Skip to content

Architecture ​

The whole picture ​

A2A client                 A2A server (:41241)                 ACP adapter (subprocess)
──────────                 ───────────────────                 ───────────────────────
GET  .well-known/…  ──►    agentCardHandler
POST /             ──►     jsonRpcHandler
                             └─ DefaultRequestHandler
                                  ├─ InMemoryTaskStore
                                  └─ AgentExecutor
                                       RevisorExecutor   (src/revisor.ts — no model)
                                       AcpExecutor       (src/acp/executor.ts)
                                         │
                                         │  stdin/stdout, newline-delimited JSON-RPC
                                         ▼
                                       claude-agent-acp  or  codex-acp
                                         │
                                         ▼
                                       Claude Agent SDK / codex app-server

Both servers listen on the same port and the same interface — 127.0.0.1 unless HOST says otherwise — and serve the same protocol, so yarn client, yarn raw and yarn tap work against either without a change. That is the point of the exercise: the client cannot tell from the wire whether the work is done by twenty lines of String.split or by a coding agent, and it should not have to.

Three layers that must not be conflated ​

The A2A server side is three distinct things stacked, and mixing them up is the most common way to get lost in the SDK:

  1. AgentExecutor — the business logic. It returns nothing. It publishes events to an ExecutionEventBus through AgentEvent.task(), .statusUpdate() and .artifactUpdate().
  2. DefaultRequestHandler — routing, the task store, cancellation, push notifications. It owns the lifecycle; the executor only describes what happens inside one.
  3. The transport adapter — jsonRpcHandler from @a2a-js/sdk/server/express. The Agent Card is served separately by agentCardHandler. One request handler can be mounted on several transports at once.

The bridge's process model ​

One adapter subprocess per A2A contextId. A conversation therefore gets:

  • its own process, so conversations cannot see each other's memory;
  • its own ACP session, so a task continued after INPUT_REQUIRED lands where the first turn is remembered;
  • its own working directory, unless ACP_CWD was set (see configuration);
  • one turn at a time — Runtime.run serialises prompts, because two prompts in one session would interleave in the same update queue.

Lifetimes:

ThingCreatedDestroyed
Startup handshake adapterServer startImmediately, once initialize answers
Runtime (process + session)First task of a conversationIdle past ACP_IDLE_TIMEOUT_MS, or server shutdown
Sandbox directoryWith the runtimeNever — left behind so you can see what the agent did
A2A taskSendMessageTerminal state; the store is in memory and dies with the server

A task that is still open — including one parked in INPUT_REQUIRED — keeps its adapter alive. The alternative is worse: a follow-up turn would silently arrive in a fresh session with no memory of the question it is answering. The cost is that a conversation abandoned after the first message holds one idle process until the server stops.

Why the card is built at startup ​

src/acp/agent.ts starts an adapter once before listening, sends initialize, reads the agent's own agentInfo, and shuts it down. The Agent Card is then built from that answer rather than from a guess, and a backend that cannot start takes the server down at boot instead of on the first request. It costs one process start.

Apache License 2.0