Skip to content

A2A Peers: Protocol, Client & Server

dazeb edited this page Sep 17, 2026 · 2 revisions

A2A Peers: Protocol, Client & Server

This slice implements Agent2Agent (A2A) messaging in two directions: an outbound client that sends a text message to a remote A2A peer over JSON-RPC 2.0 / HTTP, and an inbound loopback server that exposes this app's live agent terminal nodes as A2A agents so external peers can message them. All three files speak the same wire shape, but only two of them share code — that seam is the most important thing to know before editing.

  • Wire protocol: JSON-RPC 2.0 over HTTP, one method (message/send), plus an agent-card discovery document at /.well-known/agent-card.json.
  • Transport: plain fetch outbound (injectable), node:http inbound, bound to 127.0.0.1 only.
  • Design contract: every failure path returns a result object (ok:false), nothing throws, and every outbound request carries a mandatory timeout.

Runtime mechanism

Outbound: calling a peer

A caller (main process or renderer — protocol.ts is deliberately Electron-free and browser-safe, per its header comment) hands sendText an endpoint, text, and optional token / timeoutMs / fetch. The client builds the request body through buildMessageSend, POSTs to <endpoint>/, and interprets whatever comes back. There is exactly one place where the network is touched (request() in client.ts), and it always arms an AbortController timer — a hung peer can never hang an agent.

sequenceDiagram
  autonumber
  participant Caller as Caller (renderer or main)
  participant Client as core/a2a/client.ts
  participant Proto as core/a2a/protocol.ts
  participant Peer as Remote A2A peer

  Caller->>Client: sendText(endpoint, text, opts)
  Client->>Proto: buildMessageSend(text)
  Proto-->>Client: MessageSendBody (id 1 by default)
  Client->>Peer: POST <endpoint>/ (Authorization if token)
  Note over Client,Peer: AbortController armed for timeoutMs, default 15000
  alt transport error or timeout
    Client-->>Caller: ok:false, "A2A request failed: ..."
  else HTTP not ok
    Client->>Proto: parseRpcError(body)
    Client-->>Caller: ok:false, "RPC error code: message" or "HTTP <status>"
  else HTTP 2xx
    Client->>Proto: isRpcResponse, parseRpcError, extractResponseText
    Client-->>Caller: ok:true text / ok:true task / ok:false error
  end
Loading

Key nodes: buildMessageSend invents the messageId (tsprawl-<uuid>, via globalThis.crypto.randomUUID) while the JSON-RPC id defaults to 1 — the id is a caller concern, not a generated one. The alt branches are the entire error taxonomy: transport failures collapse into one string, HTTP failures prefer a structured RPC error and fall back to the status code, and only a well-formed JSON-RPC body reaches extractResponseText. discoverAgentCard is the same shape with a GET against <endpoint>/.well-known/agent-card.json and a stricter, simpler failure set (HTTP <status>, invalid JSON from agent card, invalid agent card).

Inbound: the app as a peer

startA2aServer is a per-boot, opt-in endpoint. It generates a 24-byte hex bearer token, listens on an OS-assigned port (listen(0, '127.0.0.1')), then writes a 0600-mode discovery file so peers can find both the port and the token. Every request — including the agent card — is token-gated; the file header records that an earlier ungated card leaked agent node titles and commands to any local process. Live nodes are re-read from agentNodes() on every request, so the card and the routing table always reflect the current canvas.

flowchart TD
  REQ["HTTP request on 127.0.0.1:port"] --> CARD{"GET /.well-known/agent-card.json ?"}
  CARD -- yes --> AUTH1{"Bearer equals per-boot token ?"}
  AUTH1 -- no --> U401["401 ok:false unauthorized (not JSON-RPC)"]
  AUTH1 -- yes --> CARDOUT["200 agent card, skills built from live nodes"]
  CARD -- no --> AUTH2{"Bearer equals per-boot token ?"}
  AUTH2 -- no --> U401
  AUTH2 -- yes --> POST{"POST / ?"}
  POST -- no --> U404["404 ok:false not found"]
  POST -- yes --> BODY["readBody, 256 KiB cap, destroy on overflow"]
  BODY --> ENV{"jsonrpc == 2.0 and method == message/send ?"}
  ENV -- no --> E301["200, -32601 method not found"]
  ENV -- yes --> TEXT{"first text part non-empty ?"}
  TEXT -- no --> E302["200, -32602 message carries no text part"]
  TEXT -- yes --> HINT["match @hint against node id, then title"]
  HINT --> FOUND{"target found ?"}
  FOUND -- no --> RESOLVE["resolveTarget fallback, else nodes 0"]
  RESOLVE --> ANY{"nodes empty ?"}
  FOUND -- yes --> DELIVER["deliverToNode(nodeId, text)"]
  ANY -- yes --> E302B["200, -32602 no live agent nodes"]
  ANY -- no --> DELIVER
  DELIVER --> THREW{"threw ?"}
  THREW -- yes --> E320["200, -32000 delivery failed: ..."]
  THREW -- no --> OK["200 result kind message, delivered to title (command)"]
Loading

Key nodes: auth is checked before routing, and the unauthorized response is a plain { ok: false, error: 'unauthorized' } at HTTP 401 — not a JSON-RPC envelope. Routing never truly fails when nodes exist: an unresolved @hint falls through to resolveTarget, which itself falls back to nodes[0], so an unaddressed or mistyped message is delivered to the first live agent. Delivery is a single await deliverToNode(...) and the reply is a synchronous ack string — this server is fire-and-forget, not request/reply.

How the halves meet

The outbound client and inbound server are not wired to each other by imports; they agree on a shape. The practical consequences worth knowing:

  • sendText always POSTs to <trimmed endpoint>/ and the server only accepts path === '/' (the '/' || '/' check is a duplicated literal — there is one route, not two). A path-prefixed endpoint works outbound but would 404 against this server.
  • The server returns all RPC-level failures at HTTP 200 with a JSON-RPC error object; only auth and unknown-route failures use non-200 statuses. So the client's parseRpcError path is the primary error channel, and its HTTP <status> fallback is mostly reached on 401/404.
  • Because the server's 401 body has error as a string, parseRpcError returns null for it and an outbound caller sees only HTTP 401, not unauthorized. If you call your own loopback server without the token, that is the error you will debug.
  • The card advertises per-node skills whose id is the node id and name is the node title; the server's address matcher tests node id first, then title. Discovery and addressing therefore close the loop: an outbound peer that reads the card can address a node by id.
  • parseAgentCard keeps only name, url, description, version, and skills — unknown card fields (e.g. capabilities) are silently dropped, so the client cannot see the server's streaming: false declaration.

File responsibilities

src/core/a2a/protocol.ts

The pure, environment-free vocabulary: types (AgentCard, MessageSendBody, A2aResponseText) and four total functions. No node imports, no I/O, never throws — junk in, null / ok:false out. It is the only file shared across process boundaries (the renderer imports it for peer testing, per its header comment), so keep it dependency-free and isomorphic if you extend it.

  • buildMessageSend(text, opts) — always succeeds and embeds whatever text it is given; the empty-text guard lives in the client, not here.
  • parseAgentCard(raw) — requires a non-empty string name; optional url/description/version must be strings if present; skills, if present, must be an array of records with optional string fields. Any violation → null.
  • extractResponseText(result) — returns { ok:true, task:{ id, state } } for kind === 'task' (state read from status.state, defaulting to 'unknown'), { ok:true, text } by joining all kind:'text' parts with newlines, else { ok:false, error:'response contained no text parts' }.
  • parseRpcError(body) / isRpcResponse(body) — the guard pair used by the client before it trusts anything: an RPC error needs a numeric code and string message; a response needs jsonrpc === '2.0' plus a result or error key.

src/core/a2a/client.ts

The outbound half, plus the transport policy. It imports the protocol module and adds exactly three things: URL normalization (trimTrailingSlash), auth/header construction (JSON content-type always; Authorization: Bearer only when a token is supplied), and the timeout wrapper. request() is the single choke point — every outbound call, card discovery included, goes through it, so a timeout cannot be forgotten by a new call site. A2ACallOpts carries both the injected fetch (test seam, mirroring the house pattern noted for core/telegram/api.ts) and the token, so any future function added here inherits auth and timeouts for free if it routes through request().

Failure mapping is deliberate and ordered: empty text short-circuits before any network call; non-2xx tries parseRpcError first; non-JSON 2xx and non-JSON-RPC bodies both become invalid JSON-RPC response; a JSON-RPC error becomes RPC error <code>: <message>; only then is result handed to extractResponseText. There are no retries — the timeout bounds one attempt, not a backoff loop.

src/main/a2a/server.ts

The inbound half — the only Node-dependent file in this slice (node:http, node:net, node:crypto, node:fs, node:path). It is also the only one that does not import the protocol module; every request/response shape, the JSON-RPC validation, and the text-part extraction are hand-written inline. Consequences: changes to MessageSendBody or the result shape in protocol.ts do not propagate here, and the server's parser differs subtly from the client's — it takes only the first text part (break), whereas extractResponseText concatenates all of them.

Its runtime surface is small and injectable: agentNodes() is called fresh per card request and per message, and deliverToNode(nodeId, text) is expected to perform the bracketed-paste into the target PTY (a throw becomes -32000). Note that PASTE_START / PASTE_END are declared at module scope but never referenced in this file — the paste wrapping is the injected implementation's job, so those constants are currently dead weight. The exported resolveTarget(text, nodes) is the pure routing rule and the natural unit-test target: @<title> anywhere in the text or a leading <title>: prefix, case-insensitive, falling back to nodes[0].

close() stops the listener and best-effort removes the discovery file so a stopped endpoint is never advertised; the in-memory token keeps existing on the returned handle after close.

Key state

Scope State Notes
Per boot (server) token 24 random bytes hex (~192 bits), in memory and in the discovery file; rotate by restarting
Per boot (server) boundPort OS-assigned via listen(0); populated before the discovery file is written
On disk (server) endpointFile <userDataPath>/a2a-agent.json, mode 0600, contents { port, token, url }; removed on close
Per request (server) live node snapshot From agentNodes(), never cached; drives both card skills and routing
Per call (client) AbortController + timer Created inside request(), cleared in finally; no session, cookie jar, or retry state

Boundary conditions

  • Request size: readBody destroys the socket once the body exceeds 256 KiB; the handler's await then has no end event, so the peer sees a reset rather than a JSON-RPC error.
  • Malformed JSON is not -32700: JSON.parse failure yields parsed = null, which is indistinguishable from a wrong method and returns -32601 method not found. Callers cannot currently tell a transport-level parse error from an unsupported method.
  • Duplicate JSON-RPC ids: buildMessageSend defaults id to 1, so concurrent in-flight sends that don't pass opts.id cannot be correlated by id.
  • Silent misrouting: a typo in @<hint> falls back to resolveTarget, then to nodes[0] — the message is delivered somewhere, not rejected.
  • startA2aServer has no failure path: the server.on('error') handler is an intentional no-op and only the listen callback resolves the setup promise, so a bind failure leaves the returned promise pending rather than rejecting. Treat that as a known rough edge if you make the port or address configurable.
  • No CORS headers are ever written, so renderer-context (browser-style) callers are subject to standard cross-origin rules; Node-side callers are not.
  • No streaming: the card declares capabilities: { streaming: false }, responses are one JSON body, and the server always answers with a kind: 'message' result — never a kind: 'task'.

Extension points

  • New JSON-RPC methods: the POST branch hard-codes message/send; there is no method table. Add one if you need a second method, and split the JSON parse failure out to -32700 at the same time.
  • Task/long-running replies: if the server ever returns kind: 'task', sendText already accepts it (extractResponseText handles the shape) — no client change required.
  • Reply capture: deliverToNode returns void, so the agent's actual answer never flows back to the peer. Adding that means widening the deps interface, not just the response body.
  • Routing policy: resolveTarget is exported and agentNodes() order defines the default target; changing addressing semantics means changing both the exported helper and the inline id/title hint match in the POST branch so they stay consistent.
  • Auth rotation: the token is per-boot and duplicated in the memory handle and the discovery file; anything that reads the file must re-read after restart.
  • Testing seams: inject fetch on the client; inject agentNodes / deliverToNode on the server. resolveTarget and all of protocol.ts are pure and directly testable.

Limits of this page

These three files define the protocol, the outbound calls, and the loopback endpoint, but not the surrounding wiring. The provided excerpts do not show who calls startA2aServer, where the agentA2aServer setting (referenced in the server's header comment, default OFF) is read, what supplies agentNodes/deliverToNode in the main process, where remote peer endpoints or tokens are stored for outbound use, or any test coverage. Treat those as out of scope rather than absent.

Sources: src/core/a2a/protocol.ts, src/core/a2a/client.ts, src/main/a2a/server.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally