Agent2Agent (A2A)
apomesh speaks the Agent2Agent (A2A) v1.0 protocol in both directions.
Inbound, it is a server: a remote peer discovers a published agent through its
card and drives it with standard A2A JSON-RPC messages — no apomesh SDK on the
caller’s side. Outbound, a configured remote A2A agent becomes a tool a
session dispatches to (a2a:<peer-id>) — the agent-as-tool delegate path. This
guide shows an operator/integrator both: expose an agent, drive it as a peer,
and delegate outbound. For why it works
this way — the translate-and-delegate design, and how an A2A Task is really a
substrate session — read
Concepts → Agent federation.
The A2A routes are merged onto the same listener as the REST
surface; in the local stack that is port 50052. Unlike the
REST routes (under /api/v1), the A2A routes sit at the listener root:
/.well-known/agent-card.json, /a2a/catalog, and the per-agent endpoint
/a2a/agents/{namespace}/{slug}/{version}.
Prerequisite: a published agent
Section titled “Prerequisite: a published agent”A card projects from a published agent — a locked, versioned registry entry. Publish one first (see Published agents); its bundle skills become the card’s advertised skills. A2A never exposes an unpublished or non-served shape.
Expose the well-known card
Section titled “Expose the well-known card”The public well-known route serves exactly one card — the operator-designated
serving agent. Designate it with an [a2a] section in the active profile config
(deploy/local/config.<profile>.toml):
[a2a]# Both must be set for the well-known route to serve a card; either absent# and the well-known 404s (the authenticated catalog still works).serving_tenant = "acme"serving_agent = "io.acme/researcher"# Optional version pin. Absent → serve iff exactly one served version exists.serving_agent_version = "1.0.0"Restart the stack, then fetch the card — the well-known route is public, so no credentials:
curl -s http://localhost:50052/.well-known/agent-card.json{ "name": "researcher", "description": "Researches a question and returns a sourced brief.", "version": "1.0.0", "supportedInterfaces": [ { "url": "http://localhost:50052/a2a/agents/io.acme/researcher/1.0.0", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" } ], "capabilities": { "streaming": true, "pushNotifications": true, "extendedAgentCard": false }, "defaultInputModes": ["text/plain"], "defaultOutputModes": ["text/plain", "application/json"], "skills": [ { "id": "web_research", "name": "Web research", "description": "…", "tags": ["research"] } ], "securitySchemes": { "bearer": { "httpAuthSecurityScheme": { "scheme": "bearer" } } }, "securityRequirements": [{ "schemes": { "bearer": { "list": [] } } }]}The address rides supportedInterfaces[0].url is the agent’s address: a
peer sends its task to exactly that endpoint over the JSONRPC binding (the
v1.0 card has no top-level url/preferredTransport; the protocol version
rides the interface). capabilities.streaming is true — the streaming
methods are live. Each securitySchemes value is the authoritative A2A v1.0
ProtoJSON member-wrapper ({"httpAuthSecurityScheme": …}, not the OpenAPI
{"type": …} form), and securityRequirements names each honored scheme as an
independent (OR) way to authenticate. The schemes reflect the deployment’s
active auth backends — bearer always, plus apiKey when the API-key backend
is active.
Authenticate
Section titled “Authenticate”Every A2A route except the well-known card runs behind the auth prologue. A peer presents one of two shapes:
# A Bearer token — an OAuth JWT or a scope-bound runtime token.-H "Authorization: Bearer $A2A_TOKEN"
# Or, when the deployment runs the API-key backend, the api-key header:-H "X-Apomesh-Api-Key: $A2A_API_KEY"The token’s scopes gate each method: SendMessage needs session-start
authority, GetTask session-read, CancelTask session-control, and the
input-required continuation needs HITL-respond authority. A read denial renders
as task-not-found (indistinguishable from a task that does not exist — no
existence oracle); a write/act denial renders as an A2A permission-denied.
Discover the tenant’s catalog
Section titled “Discover the tenant’s catalog”The authenticated catalog lists the cards the caller’s own tenant serves — the multi-agent discovery surface:
curl -s http://localhost:50052/a2a/catalog \ -H "Authorization: Bearer $A2A_TOKEN"It returns a JSON array of the same AgentCard shape, each with its own
per-agent url.
Send a task
Section titled “Send a task”SendMessage starts a session from the served agent. The message’s text parts
become the goal; only the goal and budget cross the protocol boundary. A2A parts
use member-presence (a text part is { "text": "…" } — no kind tag).
A message must carry a text part. The card advertises text/plain as the
only input mode, and that asymmetry with the output modes is deliberate rather
than an oversight: the send path folds text parts and nothing else, so
advertising application/json on the way in would be a card promising a peer
something the server refuses. application/json stays truthful on the way
out, where a terminal value that is not a string really is rendered as JSON.
A card is a contract with another organisation; it does not over-promise.
curl -s http://localhost:50052/a2a/agents/io.acme/researcher/1.0.0 \ -H "Authorization: Bearer $A2A_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": { "message": { "messageId": "msg-1", "role": "ROLE_USER", "parts": [{ "text": "Summarize Q3 revenue drivers with sources." }] } } }'The result is a Task whose id is the session id and whose status.state
starts at TASK_STATE_SUBMITTED:
{ "jsonrpc": "2.0", "id": 1, "result": { "id": "sess-7f3a…", "contextId": "ctx-sess-7f3a…", "status": { "state": "TASK_STATE_SUBMITTED" }, "artifacts": [], "history": [] }}Poll the task
Section titled “Poll the task”GetTask reads the task’s current state — projected live from the session’s
event log, so it is always the session’s real state:
curl -s http://localhost:50052/a2a/agents/io.acme/researcher/1.0.0 \ -H "Authorization: Bearer $A2A_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "GetTask", "params": { "id": "sess-7f3a…" } }'State moves TASK_STATE_WORKING → TASK_STATE_COMPLETED; a completed task
carries its result in artifacts. A refused or unresolvable start returns a task
in TASK_STATE_REJECTED; a failure, TASK_STATE_FAILED.
Answer an input-required task
Section titled “Answer an input-required task”If the agent pauses on a stall for human input, the task reads
TASK_STATE_INPUT_REQUIRED. A peer resumes it with another SendMessage
carrying the task’s id — the presence of a taskId turns the send into the
continuation, which resumes the paused session (a plain message resumes with a
minimal grant; it never terminates the task):
curl -s http://localhost:50052/a2a/agents/io.acme/researcher/1.0.0 \ -H "Authorization: Bearer $A2A_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "SendMessage", "params": { "message": { "messageId": "msg-2", "taskId": "sess-7f3a…", "role": "ROLE_USER", "parts": [{ "text": "Yes, proceed with the public filings." }] } } }'Cancel a task
Section titled “Cancel a task”CancelTask stops the session; the task settles at TASK_STATE_CANCELED:
curl -s http://localhost:50052/a2a/agents/io.acme/researcher/1.0.0 \ -H "Authorization: Bearer $A2A_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": 4, "method": "CancelTask", "params": { "id": "sess-7f3a…" } }'Stream a task
Section titled “Stream a task”SendStreamingMessage (a fresh task) and SubscribeToTask (an existing one)
return Server-Sent Events over the same endpoint. The first frame is always
the Task snapshot (state + any artifacts-so-far); subsequent frames carry
status transitions and artifact fragments until the task reaches a terminal or
input-required state:
curl -sN http://localhost:50052/a2a/agents/io.acme/researcher/1.0.0 \ -H "Authorization: Bearer $A2A_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": 5, "method": "SendStreamingMessage", "params": { "message": { "messageId": "m2", "role": "ROLE_USER", "parts": [{ "text": "Research the filing." }] } } }'data: {"jsonrpc":"2.0","id":5,"result":{"task":{"id":"sess-…","status":{"state":"TASK_STATE_WORKING"}}}}data: {"jsonrpc":"2.0","id":5,"result":{"artifactUpdate":{"taskId":"sess-…","artifact":{…},"append":true,"lastChunk":false}}}data: {"jsonrpc":"2.0","id":5,"result":{"statusUpdate":{"taskId":"sess-…","status":{"state":"TASK_STATE_COMPLETED"}}}}Each result is the A2A StreamResponse oneof (task | statusUpdate |
artifactUpdate | message), member-wrapped per the v1.0 wire.
List tasks
Section titled “List tasks”ListTasks returns the caller’s tasks ordered by status_timestamp
descending (most-recently-updated first), keyset-paginated:
curl -s http://localhost:50052/a2a/agents/io.acme/researcher/1.0.0 \ -H "Authorization: Bearer $A2A_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": 6, "method": "ListTasks", "params": { "pageSize": 20 } }'The result carries tasks, a nextPageToken (empty when the last page is
reached), and totalSize (0 — the count is unknown ahead of full pagination,
per AIP-158).
Get pushed instead of polling
Section titled “Get pushed instead of polling”Register a webhook for a task and the daemon POSTs the Task to it on each
peer-visible transition — the alternative to holding a stream open or polling
GetTask. Four methods manage the registrations:
curl -s http://localhost:50052/a2a/agents/io.acme/researcher/1.0.0 \ -H "Authorization: Bearer $A2A_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": 7, "method": "CreateTaskPushNotificationConfig", "params": { "taskId": "sess-…", "pushNotificationConfig": { "url": "https://caller.example/hooks/apomesh", "token": "opaque-correlation-token" } } }'CreateTaskPushNotificationConfigregisters a URL for a task. The task must exist — an unknown id isTaskNotFoundError.GetTaskPushNotificationConfig/ListTaskPushNotificationConfigsread the registrations back; a read you are not permitted to see renders as absence rather than a denial, so the surface is not an existence oracle.DeleteTaskPushNotificationConfigremoves one.
Registrations are durable: they survive a daemon restart, and the daemon reconciles on boot so a task that reached a terminal state while it was down still gets its delivery.
The optional token is echoed back to your webhook, which is how you correlate a
delivery to a registration. Delivery URLs are vetted against egress policy both at
registration and again at connect time, so a URL that resolves into the
deployment’s own network is refused rather than fetched.
Delegate outbound to A2A peers
Section titled “Delegate outbound to A2A peers”The outbound direction inverts the roles: a remote A2A agent — an apomesh
deployment or any other A2A v1.0 server — is configured as a peer, and
every session of the tenant sees it as the delegate tool a2a:<peer-id>.
Configure a peer
Section titled “Configure a peer”Peers seed from [[a2a_peers]] entries in the active profile config — the A2A
sibling of [[mcp_servers]], imported on first boot into the per-tenant peer
store (store-authoritative thereafter):
[[a2a_peers]]peer_id = "research"card_url = "https://peer.example/.well-known/agent-card.json"credential_ref = "a2a_peer/research"trust = "bearer_or_api_key"call_timeout_secs = 300description = "remote deep-research agent"peer_id— unique per daemon; names the tool (a2a:research).card_url— the peer’s agent-card URL; the JSON-RPC endpoint derives from the fetched card (or setrpc_urlexplicitly).credential_ref— the name of the peer’s credential in the per-tenant credential store (conventiona2a_peer/<peer-id>). The section is secrets-free by design: a credential value never appears in a peer row, on the event log, or in agent-readable content. The credential’s typed kind chooses the auth header — an OAuth bearer credential sendsAuthorization: Bearer …, an API-key credential sendsX-API-Key(override withapi_key_header; apomesh peers takeX-Apomesh-Api-Key) — never inferred from the secret’s bytes.trust—bearer_or_api_key(default) ormutual_tls(see Mutual-TLS peers below). An unknown value is a parse-time config error, never a silent default.call_timeout_secs— per-call wall clock; omitted = 300 seconds.
At session start the daemon fetches each peer’s card (a failing peer degrades soft — that peer’s tool is absent, the session proceeds; MCP and A2A admission degrade independently). Outbound targets pass an SSRF guard that rejects private / special-use ranges by default.
Call the peer from a session
Section titled “Call the peer from a session”The projected tool takes { "message": "…" } — plus an optional taskId to
resume a paused remote task:
- A completed remote task returns
{ "state": "completed", "taskId": …, "artifacts": […] }as the tool result. - A remote
TASK_STATE_INPUT_REQUIREDpause returns{ "state": "input-required", "prompt": …, "taskId": … }— the agent (or an operator through HITL) answers by calling the tool again with thattaskId, which resumes the same remote task atomically. - Remote progress folds onto the delegating session’s own event log as
bounded
io.descoped.a2aextension events (a2a.statusfor state transitions,a2a.artifactfor artifact fragments) — inspectable live in the console like any other event. A deployment that opts into the fail-closed[[extension_vendors]]allowlist must listio.descoped.a2afor these to land; the unrestricted default admits them. - Cancelling the delegating dispatch — or the call timeout expiring — issues
a best-effort
CancelTaskto the peer before the typed local outcome returns.
Mutual-TLS peers
Section titled “Mutual-TLS peers”A trust = "mutual_tls" peer authenticates the two platforms to each
other at the transport, not with a header credential. Two extra fields are
required on the row (both public material — a CA bundle and an identity
name, never a secret):
[[a2a_peers]]peer_id = "acme-research"card_url = "https://acme.example/.well-known/agent-card.json"credential_ref = "a2a_peer/acme-research"trust = "mutual_tls"trust_anchor_pem = """-----BEGIN CERTIFICATE-----…the peer org's CA bundle (public)…-----END CERTIFICATE-----"""expected_identity = "spiffe://acme.example/node/researcher"trust_anchor_pem— the peer org’s CA bundle. For amutual_tlspeer, the peer’s server cert is verified against this anchor, not the web-PKI roots — org-to-org trust is explicit and pinned.expected_identity— the SPIFFE-style URI (or DNS name) the peer’s presented cert must carry. A mismatch is a typed rejection, decoupled from the dialed host.
The client identity apomesh presents is per-tenant: the org CA
(configured via [a2a].identity_dir, from the
PKI plane) mints a
spiffe://<trust-domain>/tenant/<tenant-id> client cert on first use and
stores it as a mutual_tls_identity credential in the tenant’s credential
store — so two tenants delegating to the same peer present distinct
identities, and the private key never reaches an observable surface. No org
CA configured means a mutual_tls peer fails closed (not admitted).
Serving peers over mutual TLS (the federation listener)
Section titled “Serving peers over mutual TLS (the federation listener)”The public REST/A2A listener stays server-auth-only. To accept mutual-TLS peers, run the dedicated federation listener — a second port that requires a client cert:
[a2a.federation]listen_addr = "0.0.0.0:50055"server_dns_names = ["your-node.example", "localhost"]peer_ca_paths = ["/etc/apomesh/peers/acme-ca.pem"]It requires worker_backend-style mTLS ([auth] backend mtls) and the
[a2a].identity_dir org CA; a misconfiguration fails boot, never a silent
plaintext listener. A caller authenticated here resolves to a federated
peer principal (distinct from an in-tenant service account), and a card
served via this listener advertises the mtlsSecurityScheme — while the
shared listener’s card never does.
Edge-worker enrollment
Section titled “Edge-worker enrollment”Workers join over mutual TLS without hand-provisioned certs. An operator seeds a one-time bootstrap token; the worker exchanges it for a PKI-minted node identity, then connects mTLS:
[[enrollment.bootstrap_tokens]]node_id = "worker-1"allowed_tenants = "any" # recorded; enforcement is a later incrementtoken_env = "APOMESH_ENROLL_TOKEN_WORKER_1"The worker boots with --enroll-token <abt_…> --enroll-url https://your-node.example:50052 --enroll-ca <org-ca.pem> --identity-dir <dir>: it POSTs the token to /api/v1/enroll, writes the obtained
cert/key(0600)/CA bundle, and connects. The token is single-use (a reuse
is rejected) and expiring; an invalid enroll aborts startup, never a
plaintext fallback. In the local stack, just mint-enroll mints the org CA
and prints a fresh dev token (see the deploy/local README).
Not yet available
Section titled “Not yet available”Both directions now ship their core surface: inbound discovery plus the full
task surface (SendMessage / GetTask / CancelTask, the input-required
continuation, SendStreamingMessage / SubscribeToTask streaming, ListTasks,
push-notification webhooks), and the outbound agent-as-tool delegate path. Still
deferred:
GetExtendedAgentCard— served, but returns the A2AUnsupportedOperationError(-32004) while the card advertisescapabilities.extendedAgentCard: false; the caller-facing disclosure gradient (a distinct extended card) is a later increment.- Authenticated push delivery — the daemon presents no credential to a webhook, and a caller-supplied one is refused (see the caution above).
- The A2A gRPC binding (apomesh’s own gRPC is not the A2A gRPC binding).
Related
Section titled “Related”- Concepts → Agent federation — the design and the Task-is-a-session model.
apomesh-a2a— the inbound adapter crate.apomesh-a2a-client— the outbound client crate.apomesh-a2a-types— the shared wire vocabulary.- REST + SSE — the other language-agnostic transport on the same listener.