Documentation — A2A — the inbound channel
How it works

A2A — the inbound channel

MCP is how agentd reaches outward for capability — tools and events arrive from servers you name. A2A is the opposite direction: it is how something reaches in. A parent agent delegating work, a peer in a mesh, a human driving the daemon from a terminal, and the web UI in a browser all speak the same protocol to the same listener.

The two are easy to confuse because both are JSON-RPC over HTTPS. The distinction worth holding onto is direction and ownership:

MCPA2A
Directionagentd calls outsomething calls in
agentd isthe clientthe server
Carriestools, resources, subscriptionsmessages, tasks, commands, control
Configured bymcp.serversa2a.listen
Absent by defaultno servers, no toolsno listener, no external access

An agentd with neither is a closed box that can still think and answer on stdout. Adding MCP gives it hands; adding A2A gives other people a door.

Turning it on

a2a:
  listen: https://0.0.0.0:8443
  tls:
    cert: /etc/agentd/tls/server.crt
    key: /etc/agentd/tls/server.key
    client_ca: /etc/agentd/tls/clients-ca.crt   # mTLS: who may connect at all

A listener makes the instance long-lived, which means it needs a durable store — a daemon that forgets its tasks on restart is worse than one that never accepted them. Validation enforces this rather than letting you discover it after a crash.

Trust is per request, never the transport. A non-loopback bind must configure mTLS and/or a bearer; an unauthenticated non-loopback listener is a startup error, not a warning. A loopback http:// bind with no credential is allowed for local development only — there, being on the machine is the authorization.

What a caller can do

Every request resolves to a principal (from the mTLS certificate or the bearer) and is authorized against a role matrix before anything runs.

MethodWhat it does
SendMessageNatural language becomes a conversation turn; a command DataPart becomes a registry action (status, workflow.run, config, …)
SendStreamingMessageThe same, answered as an SSE stream of status and artifact updates
GetTask / ListTasksRead a durable task, or enumerate them
CancelTaskStop one in flight
SubscribeToTaskFollow one task's transitions
SubscribeToEventsThe instance-wide observation feed the display clients render (needs interface.enabled)
GetAgentCardDiscovery: identity, transport, and the capabilities this instance actually implements. Also served unauthenticated at /.well-known/agent-card.json
GetExtendedAgentCardThe authenticated card: the same document, with the skills this caller may actually run
CreateTaskPushNotificationConfig etc.Register a webhook for a task's updates instead of holding a stream open (see below)

Errors use the codes the spec assigns, because peers branch on them: -32601 for a method that does not exist, -32001 for a task that does not. A peer should never have to string-match an error message.

The wire is proto3 JSON

A2A is defined in protocol buffers, and its JSON binding is proto3 JSON — which is stricter than "some JSON with these field names". Three consequences are worth stating, because getting them wrong fails silently in the peer:

  • Enums are the proto value names. TASK_STATE_COMPLETED, not completed; ROLE_AGENT, not agent.
  • Timestamps are RFC 3339 strings. status.timestamp is a google.protobuf.Timestamp, so "2026-08-17T13:41:27.824Z" — not epoch milliseconds.
  • Every task is a Task. ListTasks returns the same object as GetTask (minus the artifacts a listing does not resolve), so the state is always at status.state. The result carries totalSize, pageSize and nextPageToken; agentd answers in a single page.

Being told instead of watching

Streaming assumes the caller can hold a connection open for as long as the work takes. A caller that cannot — a serverless function, a queue consumer — registers a webhook, and agentd POSTs each of that task's updates to it. The body is the Task, exactly as a streaming caller would have seen it, so one handler serves both ways of being told.

a2a:
  push:
    enabled: true         # default OFF
    allow_private: false  # default OFF

Both defaults are off, and the reason is that the URL comes from the caller. Every delivery is an outbound request to an address a peer chose, which is the shape of an SSRF: pointed at a cloud metadata endpoint, agentd fetches credentials on the caller's behalf. So enabled says you are willing to make that request at all, and allow_private — a separate, larger decision — says you are willing to make it to a private or loopback address. A target is checked twice: at registration, where the caller is present to be told why it was refused, and again at delivery, because a name can resolve somewhere new in between.

The receiver gets X-A2A-Notification-Token echoed back from the config, so it can tell a real delivery from a stray POST at a URL somebody guessed. A bearer agentd should present goes in authentication.credentials with a Bearer scheme, and is never read back out.

Delivery is best-effort by design: a webhook that is down must not fail the task it was reporting on. Failures are logged as a2a.push.failed and dropped.

Anything agentd wants to say that the spec has no field for goes under metadata, namespaced: agentd/principal, agentd/link, agentd/statusHistory. That is what proto3 leaves open for extensions, and it means a strict peer can ignore all of it.

This is verified two ways. agentd-conformance asserts the shapes on every path that emits a task; and crates/a2a-oracle — excluded from the default build — boots the real daemon and parses its responses with a2a-rs, an unrelated implementation of the same specification, so a misreading on our side has to survive a second reader before it reaches anyone.

Roles, and what each may call

RoleMay call
operatoreverything, unconditionally
userworkflow.run / status / cancel, subagent.send / status, plan.get, ask_human, conversation.get, run.get
agentworkflow.run, workflow.status
anonymousnothing — denied at every layer, and an explicit grants: ["*"] does not rescue it

Principals are matched in order, so the most specific rule wins:

a2a:
  principals:
    - { match: { san: "spiffe://ops/*" },  role: operator }
    - { match: { san: "spiffe://team/*" }, role: user, grants: [workflow.*] }

The card is a promise

GetAgentCard advertises what this build can do — and only that. If the card says streaming: true, a streaming send really produces update frames; if it says pushNotifications: false, asking for one is refused with a proper error rather than half-served. Both directions are covered by the conformance suite, because a peer that believes the card and builds against something absent fails expensively and late.

Check what a given instance offers before wiring against it:

$ curl -s -X POST https://agent.internal:8443/ \
    -H 'content-type: application/json' \
    -d '{"jsonrpc":"2.0","id":1,"method":"GetAgentCard","params":{}}' | jq .result.capabilities

One agent driving another

Composition needs no new protocol: the channel a parent dials is the channel a worker serves. Deploy a worker that exposes A2A, and point a parent at it as a peer — the parent delegates as spec-conformant Tasks and gets artifacts back.

# the parent: delegate to a worker that speaks A2A
a2a:
  peers:
    - name: reviewer
      endpoint: https://reviewer.internal:8443

A workflow step (a2a.delegate) or the agent itself can then hand work to reviewer and wait for its result. The worker is an ordinary agentd: its own instruction, its own tools, its own budget — and its own fence.

Co-located peers: the unix-socket fast lane

Two instances on the same host (or the same pod) can skip TCP and TLS entirely: one listens on a unix domain socket, the other names that socket as its peer endpoint. Same protocol, same tasks and artifacts — only the transport and the authenticator change.

# instance B — the worker
a2a:
  listen: unix:///run/agentd/bee.sock

# instance A — the delegator
a2a:
  peers:
    - name: bee
      endpoint: unix:///run/agentd/bee.sock

Why this is the fast lane: no TLS handshake per dial, no TCP stack, and data moves through the kernel's socket buffer — the cheapest IPC that still keeps the A2A contract (so moving the peer to another host later is a one-line endpoint change back to https://).

Authentication changes shape rather than disappearing. TLS material is refused on a unix listener; the kernel authenticates instead: the socket file is created 0600, and every connection's SO_PEERCRED uid must be the daemon's own user (or root) — a different local user is dropped before HTTP, logged as a2a.unix.denied. That is strictly stronger than loopback TCP, which any local user can dial; a connection that passes gets the loopback trust posture (operator in the single-user setup), and a configured a2a.bearer still applies on top. Webhooks deliberately do NOT take unix:// — they are an external surface.

Two instances can also hold sockets in both directions (each listens, each names the other as a peer) — then either side can a2a.send/a2a.delegate at any time, which is the "two agents agree to connect" pattern: the agreement is the pair of socket paths in their configs, and the filesystem's permissions are the contract.

Where the display clients fit

The TUI and web UI are A2A clients. They use the same listener, the same principals, and the same task surface a peer would; nothing about them is privileged except that a loopback connection resolves to operator. That is why several surfaces can watch one session at once, and why a client can be attached from another machine with a rotating pairing code instead of a copied bearer.

See interface.md for the client surface.

See also

  • mcp.md — the other direction: where tools and events come from.
  • security.md — principals, the trifecta rule, and what the listener does not protect you from.
  • operations.md — driving a live daemon: drain, pause, reload.