{"id":"242a8e26-9a28-474f-9643-d336f0c4abfd","slug":"strands-acp","kind":"mcp","name":"strands-acp","summary":"","owner":"ryancormack","repoUrl":"https://github.com/ryancormack/strands-acp","license":"MIT","licenseTier":"permissive","trust":"community","stars":5,"match":null,"fewStars":false,"format":"mcp","createdAt":"2026-05-05T14:57:07.000Z","addedAt":"2026-10-21T17:18:11.346Z","logoUrl":"https://avatars.githubusercontent.com/u/1962883?u=106710528789922f9eeedce4cbbe165bc45f0810&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["utilities"],"tags":[],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-25T17:33:11.217Z","sample":false,"url":"https://conduits.dev/tools/strands-acp","language":"TypeScript","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# @ryancormack/strands-acp\n\n[![npm version](https://img.shields.io/npm/v/@ryancormack/strands-acp.svg)](https://www.npmjs.com/package/@ryancormack/strands-acp)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n\nBridge any [Strands](https://github.com/strands-agents/sdk-typescript) agent to the [Agent Client Protocol (ACP)](https://agentclientprotocol.org/).\n\n## Requirements\n\n- Node.js >= 22\n- `@strands-agents/sdk` is a peer dependency and must be installed in your project\n\n## Installation\n\n```bash\npnpm add @ryancormack/strands-acp @agentclientprotocol/sdk\n```\n\nOr using npm:\n\n```bash\nnpm install @ryancormack/strands-acp @agentclientprotocol/sdk\n```\n\nOr using yarn:\n\n```bash\nyarn add @ryancormack/strands-acp @agentclientprotocol/sdk\n```\n\n> **Note:** `@strands-agents/sdk` is a peer dependency. Make sure it is installed in your project:\n>\n> ```bash\n> pnpm add @strands-agents/sdk\n> # or: npm install @strands-agents/sdk\n> # or: yarn add @strands-agents/sdk\n> ```\n\n## Usage\n\n### Simple factory\n\nThe simplest way to expose a Strands agent over ACP is with the stdio transport:\n\n```ts\nimport { createStdioServer } from '@ryancormack/strands-acp'\nimport { agent } from '@strands-agents/sdk'\n\nfunction createAgent(_sessionId: string) {\n  return agent({ model: myModel, tools: myTools })\n}\n\ncreateStdioServer(createAgent)\n```\n\nThis starts a JSON-RPC server on stdin/stdout that any ACP-compatible client can connect to.\n\n### Config-based setup\n\nFor more control, pass an `AcpBridgeConfig` object. This lets you configure capabilities and receive session parameters in the factory:\n\n```ts\nimport { createStdioServer, type AcpBridgeConfig } from '@ryancormack/strands-acp'\nimport { agent } from '@strands-agents/sdk'\n\nconst config: AcpBridgeConfig = {\n  agentFactory: (sessionId, sessionParams) => {\n    // sessionParams includes cwd, mcpServers, additionalDirectories, etc.\n    console.log(`Creating agent for session ${sessionId} in ${sessionParams.cwd}`)\n    return agent({ model: myModel, tools: myTools })\n  },\n  capabilities: {\n    promptCapabilities: { image: true },\n  },\n}\n\ncreateStdioServer(config)\n```\n\nThe `capabilities` field lets you override the defaults advertised during initialization. Defaults are:\n\n```ts\n{\n  loadSession: true,\n  sessionCapabilities: { close: {}, list: {}, resume: {} },\n  promptCapabilities: { image: true },\n}\n```\n\n### Content block mapping\n\nThe bridge automatically maps ACP content blocks to Strands types:\n\n- **Text-only prompts** are passed as a plain string to `agent.stream(text)`.\n- **Prompts with images** (or mixed content) are mapped to a Strands `ContentBlock[]` array:\n  - ACP `text` blocks become `TextBlock` instances\n  - ACP `image` blocks (base64 data + mimeType) become `ImageBlock` instances\n\nThis means your agent can receive multimodal input when the client sends image content.\n\n### Session Management\n\nWhen `loadSession: true` is advertised (the default), clients can call `loadSession` with a known `sessionId` to restore a previous session.\n\n**How it works:**\n\n1. The `agentFactory` is called with the given `sessionId`.\n2. The resulting agent's `messages` array is streamed back to the client. Text, images, and tool calls are all replayed, so the restored transcript keeps the images the user sent and the tools the agent ran.\n\n**Session persistence pattern:**\n\nThe agent factory is responsible for restoring conversation state when given a known `sessionId`. For example, you can use a `SessionManager` plugin that loads messages from a persistent store:\n\n```ts\nconst config: AcpBridgeConfig = {\n  agentFactory: (sessionId) => {\n    // The agent restores its messages from a persistent store\n    return agent({\n      model: myModel,\n      tools: myTools,\n      sessionManager: new PersistentSessionManager(sessionId),\n    })\n  },\n}\n```\n\n### Session listing\n\n`session/list` answers \"which sessions can I resume?\". The bridge only holds live\nsessions in memory, so on its own it can answer that only for sessions the current\nprocess created: after a restart the list is empty even though the sessions are\nstill on disk and still loadable.\n\nSupply a `sessionStore` to close that. It carries the ACP-level record, which is\nthe part nothing else persists. A Strands `SessionManager` stores the\nconversation, but `cwd` and `title` are ACP concepts the agent never sees, so they\ncannot be recovered from a snapshot.\n\n```ts\nconst config: AcpBridgeConfig = {\n  agentFactory: (sessionId) => createAgent(sessionId),\n  sessionStore: {\n    async list({ cwd }) {\n      const all = await readIndex()\n      return cwd ? all.filter((s) => s.cwd === cwd) : all\n    },\n    async save(info) {\n      await writeIndexEntry(info)\n    },\n  },\n}\n```\n\n`list` receives `cwd` as a hint so a backend that can narrow the read does not\nhave to fetch everything. Ignoring it is safe: the bridge applies the same filter\nto whatever comes back, so a store cannot leak sessions from another directory by\nbeing lazy.\n\n`save` is called when a session is created, loaded, resumed, and after each\ncompleted turn. Write failures are reported on stderr and swallowed, because a\nmetadata write must never fail a user's turn. A `list` failure is not swallowed:\nit propagates as an error, since returning only the live sessions would look to\nthe client like an authoritative \"no other sessions exist\".\n\nListing merges live and stored sessions, deduplicating by id with the live entry\nwinning, and sorts most-recently-updated first.\n\nTitles come from the first prompt in a session that carries text, truncated at a\nword boundary. Nothing else in the bridge knows anything human-readable about a\nsession, and a client's session picker needs something to show. A session whose\nfirst turn was image-only stays untitled.\n\n### Tool Call Handling\n\n#### Tool kinds\n\nACP clients use a tool's `kind` to choose an icon and decide how to render\nprogress — an `edit` renders a diff, a `read` renders a file link. The kind is\ninferred from the tool name, and falls back to `other` (the ACP default) rather\nthan mislabelling everything as `execute`:\n\n| Tool name pattern | Kind |\n| --- | --- |\n| `file_read`, `cat`, `view` | `read` |\n| `file_write`, `apply_patch`, `str_replace` | `edit` |\n| `delete_file`, `rm` | `delete` |\n| `move_file`, `rename` | `move` |\n| `grep`, `glob`, `find` | `search` |\n| `web_search`, `http_request` | `fetch` |\n| `bash`, `run_command`, `python` | `execute` |\n| `think`, `plan` | `think` |\n\nOverride the inference for any tool:\n\n```ts\ncreateStdioServer({\n  agentFactory: createAgent,\n  toolKinds: { calculate_tax: 'other', my_editor: 'edit' },\n})\n```\n\n#### Locations\n\nFile-oriented tool calls (`read`, `edit`, `delete`, `move`) carry `locations`\nextracted from the tool input, so the editor can follow along with the agent.\nA `path` argument on a shell command is not reported as a file location.\n\n#### rawInput forwarding\n\nWhen a tool call begins via `beforeToolCallEvent`, the full structured input from\nthe agent is forwarded as `rawInput`, so clients can display tool parameters\nimmediately without waiting for the tool to complete.\n\n#### Deduplication\n\nWhen the agent's stream emits both a `modelContentBlockStartEvent` and a\n`beforeToolCallEvent` for the same tool call id, only one `tool_call`\nnotification is sent: the initial one with empty `rawInput`, followed by a\n`tool_call_update` carrying the complete input.\n\n#### Results and failures\n\n`afterToolCallEvent` carries an `error` when the tool threw. Failures are\nreported as `status: 'failed'`, not `completed`, and the tool result is forwarded\nas `content` so the client has something to render.\n\n### Permissions\n\nBy default the bridge does not gate tool calls, which keeps existing setups\nworking unchanged. Supply a `permissions` policy to have the agent ask the client\nbefore it acts:\n\n```ts\ncreateStdioServer({\n  agentFactory: createAgent,\n  permissions: {\n    default: 'ask',\n    tools: { file_read: 'allow', delete_file: 'deny' },\n  },\n})\n```\n\nEach decision is one of:\n\n- `allow` — run without asking.\n- `ask` — send `session/request_permission` and wait for the user.\n- `deny` — refuse without asking the client.\n\nThe client is offered the four ACP option kinds (`allow_once`, `allow_always`,\n`reject_once`, `reject_always`). An `allow_always` or `reject_always` answer is\nremembered for the rest of the session, so the user is asked once per tool.\n\nRemembered answers are held in memory for the lifetime of the live session and\nare deliberately not persisted. Resuming a session with `session/load`, or\nrestarting the agent process, starts with an empty set and asks again. The\nworkspace may have changed in between, so a decision made against the old state\nshould not silently carry over. The `permissions` policy above is the durable\nlayer: it is configuration, so it survives restarts by definition, and\n`allow_always` is a within-session convenience on top of it. A client is free to\npersist its own answers and reply without prompting, which ACP permits but does\nnot require.\n\nA rejection does not end the turn. The tool is skipped and the model receives an\nerror result for that call, so it can explain itself or try another approach. An\nunrecognised option id fails closed.\n\nThis works because `agent.stream()` is an async generator: the agent is suspended\nat the `beforeToolCallEvent` yield while the permission request is in flight, so\nthe round-trip can take as long as the user needs, and setting the event's\n`cancel` field before resuming makes the agent skip the call. No timeout of the\nbridge's own is imposed.\n\n#### Interaction with the SDK's intervention handlers\n\nThe ACP gate is not a second, competing approval system. Strands invokes an\nagent's `interventions` as hook callbacks and awaits them *before* the event is\nyielded to a consumer, so by the time this bridge sees a tool call, every\n`InterventionHandler` on that agent has already had its say.\n\nThe bridge defers to them. A call an intervention handler denied is reported to\nthe client as `failed` and the user is never asked, because the answer could not\nchange the outcome and prompting for a discarded answer looks like the editor\nignoring them. The handler's own reason is what the model receives, since it is\nmore specific than anything the bridge could supply.\n\nThat ordering is the useful one in both directions: a local policy can veto a\ncall the user would have approved, and a user's rejection cannot be overridden by\na handler that runs later, because none do. Configuring `permissions` and\ninstalling `interventions` on the same agent is therefore safe, and the two\ncompose rather than racing.\n\nThis needs no particular SDK version. On a Strands release without the\nintervention API nothing sets `cancel` before the bridge looks, and the gate\nbehaves exactly as it did.\n\n### Reasoning\n\nReasoning deltas are sent as `agent_thought_chunk` rather than mixed into the\nassistant message, so clients can collapse them separately from the answer.\n\n### Cancellation\n\n`session/cancel` aborts the in-flight prompt and releases the agent's stream, so\nthe agent's own cleanup runs rather than being left pending.\n\n## API\n\n### `createStdioServer(config)`\n\nCreates an ACP stdio server bridging a Strands Agent to the Agent Client Protocol over stdin/stdout (newline-delimited JSON-RPC).\n\n- **config** `((sessionId: string) => Agent) | AcpBridgeConfig` - Either a simple factory function or a full configuration object.\n- **Returns** `AgentConnection`\n\n### `createAgentApp(config)`\n\nBuilds the ACP v1 agent app that `createStdioServer` serves, for use with any other transport. Pass it to `app.connect(stream)`, or as the `agent` of the SDK's `AcpServer`. Every connection the app serves shares the same sessions, so a client that reconnects finds its sessions still there.\n\n- **config** - same as `createStdioServer`.\n- **Returns** `AgentApp`\n\n### `AcpBridgeConfig`\n\n```ts\ninterface AcpBridgeConfig {\n  agentFactory: (sessionId: string, sessionParams: NewSessionRequest) => Agent\n  capabilities?: Partial<AgentCapabilities>\n  toolKinds?: Record<string, ToolKind>\n  permissions?: PermissionPolicy\n  sessionStore?: SessionStore\n  sessions?: SessionRegistry\n}\n\ninterface PermissionPolicy {\n  default?: 'allow' | 'ask' | 'deny'\n  tools?: Record<string, 'allow' | 'ask' | 'deny'>\n}\n```\n\n- **agentFactory** — called for each new session and on resume. Receives the session id and the full `NewSessionRequest` params (cwd, mcpServers, etc.).\n- **capabilities** — optional partial capabilities merged with defaults and advertised in the `initialize` response.\n- **toolKinds** — explicit tool-name to ACP tool-kind mapping. Any tool not listed has its kind inferred from its name.\n- **permissions** — tool-call approval policy. Omitted means never ask.\n- **sessionStore** — durable store for ACP session metadata. Omitted means `session/list` reports only sessions this process created.\n- **sessions** — where live sessions are held. Omitted means each app keeps its own. Pass one `SessionRegistry` to several apps to share sessions between them.\n\n### `AcpAgent`\n\nThe core class, translating ACP requests into Strands agent interactions. It handles:\n\n- Session lifecycle (create, list, resume, close)\n- Prompt streaming with text, image, and tool call events\n- Content block mapping (ACP to Strands types)\n- Cancellation\n- Stop reason mapping between Strands and ACP conventions\n\nSome of these are narrower than the list suggests. See [Known gaps](#known-gaps).\n\nIt is internal. Use `createStdioServer` for the standard transport, or `createAgentApp` for any other.\n\n## Known gaps\n\nThings that are deliberately unfinished or narrower than they look. Each needs\nclosing before this becomes a first-party SDK feature.\n\n**Session modes are accepted and ignored.** `setSessionMode` returns an empty\nresponse without changing anything. No modes are advertised, so a conforming\nclient has nothing to switch to and will not call it, but if modes are ever\nadvertised the method will silently accept a mode change and do nothing. Modes\nare ACP's standardised lever for persistent policy, so the natural\nimplementation is to map a mode onto a `PermissionPolicy` and swap the active\npolicy. Until then a client cannot express \"plan mode denies every mutating\ntool\".\n\n**`authenticate` always succeeds.** It returns an empty response without\nchecking anything. No `authMethods` are advertised, so a client should never\ncall it, but the default fails open rather than closed. An agent that needs\nauthentication must not rely on this method as written.\n\n**`listSessions` needs a `sessionStore` to see past the current process.**\nWithout one it iterates the in-memory map only, so it returns an empty list after\na restart even when sessions are resumable from disk. See\n[Session listing](#session-listing). Upstream this wants a real session\nenumeration API on the SDK: the bridge cannot reach the storage its\n`agentFactory` uses, and the on-disk key layout is an SDK internal it should not\nbe parsing.\n\n**`loadSession` and `resumeSession` are not symmetric.** `loadSession` builds a\nfresh session and replays history to the client. `resumeSession` requires the\nsession to already be in memory, rebuilds the agent from the factory, and does\nnot replay. The agent therefore holds history the client's transcript may not\nshow. This may be correct, since a client resuming an in-memory session\nplausibly still has the transcript, but the difference is undocumented in ACP\nand untested here.\n\n**`loadSession` restamps timestamps.** `createdAt` and `lastUpdated` are set to\nthe load time, not the session's real times, so `listSessions` reports when a\nsession was last loaded rather than when it was last used.\n\n**`mcpServers` is forwarded, not honoured.** The bridge passes the client's MCP\nserver list to the `agentFactory` and does nothing else with it. Connecting\nthose servers is the factory's job. A client that configures MCP servers and\nexpects the agent to reach them will see nothing happen unless the factory\nimplements it.\n\n## Development\n\n```bash\n# Install dependencies\npnpm install\n\n# Build\npnpm run build\n\n# Run tests\npnpm test\n```\n\n## License\n\nMIT\n"}