-
Notifications
You must be signed in to change notification settings - Fork 0
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
fetchoutbound (injectable),node:httpinbound, bound to127.0.0.1only. -
Design contract: every failure path returns a result object (
ok:false), nothing throws, and every outbound request carries a mandatory timeout.
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
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).
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)"]
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.
The outbound client and inbound server are not wired to each other by imports; they agree on a shape. The practical consequences worth knowing:
-
sendTextalways POSTs to<trimmed endpoint>/and the server only acceptspath === '/'(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
errorobject; only auth and unknown-route failures use non-200 statuses. So the client'sparseRpcErrorpath is the primary error channel, and itsHTTP <status>fallback is mostly reached on 401/404. - Because the server's 401 body has
erroras a string,parseRpcErrorreturnsnullfor it and an outbound caller sees onlyHTTP 401, notunauthorized. If you call your own loopback server without the token, that is the error you will debug. - The card advertises per-node
skillswhoseidis the node id andnameis 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. -
parseAgentCardkeeps onlyname,url,description,version, andskills— unknown card fields (e.g.capabilities) are silently dropped, so the client cannot see the server'sstreaming: falsedeclaration.
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 stringname; optionalurl/description/versionmust 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 } }forkind === 'task'(state read fromstatus.state, defaulting to'unknown'),{ ok:true, text }by joining allkind:'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 numericcodeand stringmessage; a response needsjsonrpc === '2.0'plus aresultorerrorkey.
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.
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.
| 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 |
-
Request size:
readBodydestroys the socket once the body exceeds 256 KiB; the handler'sawaitthen has noendevent, so the peer sees a reset rather than a JSON-RPC error. -
Malformed JSON is not
-32700:JSON.parsefailure yieldsparsed = 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:
buildMessageSenddefaultsidto1, so concurrent in-flight sends that don't passopts.idcannot be correlated by id. -
Silent misrouting: a typo in
@<hint>falls back toresolveTarget, then tonodes[0]— the message is delivered somewhere, not rejected. -
startA2aServerhas no failure path: theserver.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 akind: 'message'result — never akind: 'task'.
-
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-32700at the same time. -
Task/long-running replies: if the server ever returns
kind: 'task',sendTextalready accepts it (extractResponseTexthandles the shape) — no client change required. -
Reply capture:
deliverToNodereturnsvoid, 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:
resolveTargetis exported andagentNodes()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
fetchon the client; injectagentNodes/deliverToNodeon the server.resolveTargetand all ofprotocol.tsare pure and directly testable.
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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance