← Back to list

ACP and Strands — an open source match

The Agent Client Protocol is an open standard designed primarily to allow clients, like IDEs, to invoke coding agents, like Kiro and…

Ryan Cormack · 2026-05-16 22:33 · 5 claps · 6.6 min read
#strands-agents #aws #amazon-quick-suite #acp
Open on Medium ↗
Wiki topics: AGT · AI Agents 💻 · Programming ☁️ · DevOps & Cloud 🔓 · Open Source

ACP and Strands — an open source match

The Agent Client Protocol is an open standard designed primarily to allow clients, like IDEs, to invoke coding agents, like Kiro and Copilot. The Strands framework is an open source agent building framework, initially designed to make working with the agentic loop much easier. The Typescript version of Strands has recently hit its 1.0 release milestone and it’s probably my favourite framework for building agents. It event comes with a huge amount of vended tools making building agents incredibly easy.

In this blog post I’ll walk through how you can use a Strands Agent as an ACP Agent and why I think this is a great approach for building agents that you can use with a variety of clients. I have a standalone example of an interface between Strands and ACP here, as well as a working implementation of it here showing an agent that helps you explore the Amazon Leadership Principles.

ACP was designed around coding agents, and whilst Strands is a great option for building your own coding agent, it’s also a great framework for building non-coding agents.

The ACP Protocol

At its core, ACP is a JSON-RPC protocol over stdio (or other transports). A client sends structured requests and the agent responds. The lifecycle looks like this:

  1. Initialize — client and agent exchange capabilities
  2. New/Load Session — create a fresh conversation or restore an existing one
  3. Prompt — client sends user messages, agent streams back responses and tool calls
  4. Cancel — client can interrupt an in-progress generation
  5. Close Session — tear down when done

The ACP SDK gives you an Agent interface to implement:

interface Agent {
  initialize(params: InitializeRequest): Promise<InitializeResponse>
  newSession(params: NewSessionRequest): Promise<NewSessionResponse>
  loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse>
  resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse>
  prompt(params: PromptRequest): Promise<PromptResponse>
  cancel(params: CancelNotification): Promise<void>
  closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse>
}

The key thing here is that ACP doesn’t care how your agent works. It only cares about the protocol boundary. Any framework that can take a string input and produce streaming text and tool calls can sit behind it.

The Bridge

I’ve published the bridge as strands-acp. It's a single package that implements the ACP Agent interface and delegates everything to a Strands Agent. The only thing it needs from you is a factory function that creates a Strands Agent given a session ID.

The simplest usage:

import { createStdioServer } from 'strands-acp'
import { agent } from '@strands-agents/sdk'

function createAgent(_sessionId: string) {
  return agent({ model: myModel, tools: myTools })
}

createStdioServer(createAgent)

The createStdioServer function sets up stdin/stdout as a newline-delimited JSON-RPC transport and creates an AcpAgent instance with your factory.

For more control you can pass an AcpBridgeConfig object. This gives you access to session parameters (like the client's working directory) and lets you configure capabilities:

import { createStdioServer, type AcpBridgeConfig } from 'strands-acp'
import { agent } from '@strands-agents/sdk'

const config: AcpBridgeConfig = {
  agentFactory: (sessionId, sessionParams) => {
    console.log(`Creating agent for session ${sessionId} in ${sessionParams.cwd}`)
    return agent({ model: myModel, tools: myTools })
  },
  capabilities: {
    promptCapabilities: { image: true },
  },
}

createStdioServer(config)

The capabilities field lets you override the defaults advertised during ACP initialization. By default the bridge advertises session load/resume/close/list support and image prompts.

Under the hood, createStdioServer does something fairly minimal:

export function createStdioServer(config): AgentSideConnection {
  const input = Writable.toWeb(process.stdout)
  const output = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>
  const stream = acp.ndJsonStream(input, output)
  return new acp.AgentSideConnection((conn) => new AcpAgent(conn, config), stream)
}

It wraps stdin/out into a JSON-RPC stream and hands it to the ACP SDK along with the AcpAgent class. The AcpAgent is where all the mapping happens.

Session Management

ACP defines three session-related methods. Each one maps neatly to how Strands’ SessionManager works.

Starting a New Session

When the client calls newSession, the bridge generates a session ID, calls your factory, and stores the resulting agent:

async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
  const sessionId = generateSessionId()
  this.sessions.set(sessionId, {
    agent: this.agentFactory(sessionId, params),
    abortController: null,
    cwd: params.cwd,
    createdAt: new Date(),
    lastUpdated: new Date(),
    title: null,
  })
  return { sessionId }
}

The agent starts with an empty conversation. If you’re using a SessionManager with persistent storage, there's no prior state for this ID so nothing gets restored.

Loading a Previous Session

The client passes a session ID it received previously:

async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
  const agent = this.agentFactory(params.sessionId, params)
  this.sessions.set(params.sessionId, {
    agent,
    abortController: null,
    cwd: params.cwd,
    createdAt: new Date(),
    lastUpdated: new Date(),
    title: null,
  })

  // Stream conversation history back to the client
  for (const message of agent.messages) {
    const updateType = message.role === 'user' ? 'user_message_chunk' : 'agent_message_chunk'
    for (const block of message.content) {
      if (block.type === 'textBlock') {
        await this.connection.sessionUpdate({
          sessionId: params.sessionId,
          update: { sessionUpdate: updateType, content: { type: 'text', text: block.text } },
        })
      }
    }
  }

  return {}
}

The factory is called with the same session ID, and if your agent uses a SessionManager with persistent storage (like FileStorage), it automatically restores the full conversation history on construction. The bridge then iterates agent.messages and replays them to the client as session updates. The client renders the history in its UI, and the Strands agent has the full context in memory. Both sides are in sync.

This is the pattern I use in my Leadership Principles agent:

export function createAgent(sessionId: string): Agent {
  const sessionManager = new SessionManager({
    sessionId,
    storage: { snapshot: new FileStorage(join(homedir(), '.strands-lp')) },
  })

  return new Agent({
    model: new BedrockModel({ modelId: 'us.amazon.nova-pro-v1:0' }),
    printer: false,
    systemPrompt: SYSTEM_PROMPT,
    sessionManager,
  })
}

The SessionManager handles all the persistence. The bridge doesn't need to know anything about how or where your agent stores state.

Resuming a session

The SessionManager will auto-restore state on the next prompt() call.

async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
  const session = this.sessions.get(params.sessionId)
  if (!session) throw acp.RequestError.resourceNotFound(params.sessionId)
  session.agent = this.agentFactory(params.sessionId, params)
  return {}
}

Handling Prompts and Tool Calls

When the client sends a prompt(), the bridge maps ACP content blocks to Strands types and feeds them to the agent's streaming generator.

For text-only prompts, the text is joined and passed as a string:

const gen = session.agent.stream(text)

For prompts with images (or mixed content), the bridge maps to Strands’ ContentBlock[] array. ACP text blocks become TextBlock instances, ACP image blocks (base64 data + mimeType) become ImageBlock instances. This means your agent can receive multimodal input when the client sends image content.

Text deltas from the model become agent_message_chunk updates:

if (inner.type === 'modelContentBlockDeltaEvent' && inner.delta.type === 'textDelta') {
  await this.connection.sessionUpdate({
    sessionId: params.sessionId,
    update: {
      sessionUpdate: 'agent_message_chunk',
      content: { type: 'text', text: inner.delta.text },
    },
  })
}

Each delta is forwarded immediately, so the client renders responses progressively.

Tool Calls

Strands tells you about tool calls at two different layers:

  1. Model streaming -modelContentBlockStartEvent with toolUseStart gives you the tool name and ID early, before the input is fully streamed
  2. Lifecycle hookbeforeToolCallEvent fires just before execution with the full input

The bridge handles both without sending duplicate notifications to the client. It tracks a currentToolCallId to coordinate:

// From model streaming — announce the tool call (input not yet available)
if (inner.start?.type === 'toolUseStart') {
  currentToolCallId = inner.start.toolUseId
  await this.connection.sessionUpdate({
    sessionId: params.sessionId,
    update: {
      sessionUpdate: 'tool_call',
      toolCallId: inner.start.toolUseId,
      title: inner.start.name,
      kind: 'execute',
      status: 'in_progress',
      rawInput: {},
    },
  })
}

// From lifecycle hook — send the full input as an update
case 'beforeToolCallEvent': {
  if (currentToolCallId === event.toolUse.toolUseId) {
    await this.connection.sessionUpdate({
      sessionId: params.sessionId,
      update: {
        sessionUpdate: 'tool_call_update',
        toolCallId: event.toolUse.toolUseId,
        rawInput: event.toolUse.input,
      },
    })
  }
  break
}

When the tool completes:

case 'afterToolCallEvent': {
  if (currentToolCallId) {
    await this.connection.sessionUpdate({
      sessionId: params.sessionId,
      update: {
        sessionUpdate: 'tool_call_update',
        toolCallId: currentToolCallId,
        status: 'completed',
      },
    })
    currentToolCallId = undefined
  }
}

From the client’s perspective, a tool call looks like:

← tool_call        { id: "xyz", title: "read_file", status: "in_progress" }
← tool_call_update { id: "xyz", rawInput: { path: "/src/main.ts" } }
← tool_call_update { id: "xyz", status: "completed" }
← agent_message_chunk { text: "The file contains..." }

The client gets one tool_call per invocation, a subsequent update with the full parameters, and then a completion signal. This lets it render progress indicators and show what the agent is doing.

Cancellation

ACP supports cancellation, and Strands does too. The bridge triggers both:

async cancel(params: CancelNotification): Promise<void> {
  const session = this.sessions.get(params.sessionId)
  if (session) {
    session.agent.cancel()
    session.abortController?.abort()
  }
}

agent.cancel() tells Strands to stop the agentic loop. The AbortController breaks out of the generator iteration in the prompt() method.

Why Strands and ACP works so well

Strands is such a quick and easy way to build an agent. It comes with so many built in features and it is easily mapped to so much of the ACP feature set. You can change the model, system prompt, or tools without touching the protocol layer. Strands’ SessionManager gives you persistence for free. And the factory pattern means each session gets its own isolated Agent instance.

For a coding agent you’d use the file editing and bash tools that Strands provides. In my example there are no tools, just multi-turn conversation. However, over time, I can easily start to add more tools and features to my agent and any ACP Client would continue to work. The bridge handles this because ACP doesn’t know or care what your agent does.

Any ACP-compatible client can invoke your agent without knowing that Strands is under the hood. You get the protocol compliance and client ecosystem, and you get to build your agent the way you want.

ACP is being increasingly adopted across a wide range of clients including the Zed IDE, Jetbrains products and most recently Amazon Quick Desktop. It was Quick Desktop that initially got me looking at how I could integrate my own custom Strands Agents into Quick and with this Bridge I’m able to integrate my agents into my Quick Desktop workflows.

An example of calling a Strands Agent over ACP in Quick Desktop

An example of calling a Strands Agent over ACP in Quick Desktop

How are you using Strands and ACP?


메타데이터
post_id
bc3a2d8cd3b9
slug
acp-and-strands-an-open-source-match-bc3a2d8cd3b9
url
https://medium.com/@ryancormack/acp-and-strands-an-open-source-match-bc3a2d8cd3b9
canonical_url
https://medium.com/@ryancormack/acp-and-strands-an-open-source-match-bc3a2d8cd3b9
author_url
https://medium.com/@ryancormack
status
ok
fetched_at
2026-06-10 08:17:25