Skip to content

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}.

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.

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:

Terminal window
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.

Every A2A route except the well-known card runs behind the auth prologue. A peer presents one of two shapes:

Terminal window
# 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.

The authenticated catalog lists the cards the caller’s own tenant serves — the multi-agent discovery surface:

Terminal window
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.

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.

Terminal window
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": []
}
}

GetTask reads the task’s current state — projected live from the session’s event log, so it is always the session’s real state:

Terminal window
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_WORKINGTASK_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.

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):

Terminal window
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." }]
}
}
}'

CancelTask stops the session; the task settles at TASK_STATE_CANCELED:

Terminal window
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…" } }'

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:

Terminal window
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.

ListTasks returns the caller’s tasks ordered by status_timestamp descending (most-recently-updated first), keyset-paginated:

Terminal window
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).

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:

Terminal window
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" } } }'
  • CreateTaskPushNotificationConfig registers a URL for a task. The task must exist — an unknown id is TaskNotFoundError.
  • GetTaskPushNotificationConfig / ListTaskPushNotificationConfigs read 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.
  • DeleteTaskPushNotificationConfig removes 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.

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>.

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 = 300
description = "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 set rpc_url explicitly).
  • credential_ref — the name of the peer’s credential in the per-tenant credential store (convention a2a_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 sends Authorization: Bearer …, an API-key credential sends X-API-Key (override with api_key_header; apomesh peers take X-Apomesh-Api-Key) — never inferred from the secret’s bytes.
  • trustbearer_or_api_key (default) or mutual_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.

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_REQUIRED pause returns { "state": "input-required", "prompt": …, "taskId": … } — the agent (or an operator through HITL) answers by calling the tool again with that taskId, which resumes the same remote task atomically.
  • Remote progress folds onto the delegating session’s own event log as bounded io.descoped.a2a extension events (a2a.status for state transitions, a2a.artifact for artifact fragments) — inspectable live in the console like any other event. A deployment that opts into the fail-closed [[extension_vendors]] allowlist must list io.descoped.a2a for these to land; the unrestricted default admits them.
  • Cancelling the delegating dispatch — or the call timeout expiring — issues a best-effort CancelTask to the peer before the typed local outcome returns.

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 a mutual_tls peer, 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.

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 increment
token_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).

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 A2A UnsupportedOperationError (-32004) while the card advertises capabilities.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).