Skip to content

Agent federation (A2A)

The Agent2Agent (A2A) protocol is an open, transport-level contract for one agent (or agent platform) to discover and task another across an organizational boundary. apomesh speaks it in both directions. Inbound, it is a third transport adapter — beside the gRPC worker wire and the REST/SSE surface — so a remote peer can call an apomesh-hosted agent using standard A2A messages, with no apomesh SDK on the other side. Outbound, a configured remote A2A agent is a delegate tool a session dispatches to — the agent-as-tool path.

Inbound, apomesh is an A2A server: a peer fetches an agent’s card, sends it a task, polls the task, and cancels it — and everything the substrate already provides (the coordination-loop strategy, supervision, human-in-the-loop pauses, and tenant isolation) applies to that task unchanged, because the task is a substrate session.

The adapter carries no orchestration logic of its own. It translates an A2A message into a call on an existing substrate workflow and delegates. An inbound SendMessage against a served agent maps onto the published-agent session-start path — the same create_session_workflow the console, the SDK, and REST call. Only the goal and the budget cross the protocol boundary; nothing shape-internal does.

Inbound A2A: a peer's Task maps onto a substrate session; the adapter translates, the substrate runs the work

The substrate stays protocol-blind: there is no A2A Payload variant, no A2A LifecyclePhase, no manifest field that absorbs an A2A shape. Every A2A concept — the wire types, the task-state projection, the auth prologue, the card projection, the discovery routes, and the JSON-RPC surface — lives in one crate, apomesh-a2a, so the substrate remains a runtime the adapter sits beside, never one the protocol reaches into.

A2A models work as a Task with a lifecycle state. apomesh does not keep a parallel task store: an A2A Task is a substrate session viewed through the protocol’s vocabulary. The Task’s id is the session’s id; the Task’s state is projected from the session’s events through one pure, exhaustive mapping — the substrate’s own lifecycle phases, exit conditions, and terminal output fold into the A2A TaskState values:

A2A TaskStateComes from
SUBMITTEDaccepted, session not yet created
WORKINGthe session running (created / running / resuming)
INPUT_REQUIREDa live on-stall HITL pause (the only pause a peer answers)
COMPLETEDthe session reached its goal (or an accepted-partial result)
FAILEDa failed or coverage/replan-exhausted terminal
CANCELEDthe session was cancelled
REJECTEDa pre-session refusal — authenticated but the send could not start a session

Because the projection reads the event log — the single source of truth — a Task’s state is always the session’s real state, reconstructable and auditable, never a separately-tracked copy that can drift.

The card is a projection of a published agent

Section titled “The card is a projection of a published agent”

A2A discovery is an Agent Card — a JSON document describing an agent’s name, skills, capabilities, and how to reach it. In apomesh a card is not authored separately; it is projected from a published agent (the M28 registry entry). The card’s skills come from the published agent’s sealed bundle — id, name, description, and tags only, never the prompt fragments, tool internals, or rubrics that make the agent work. The card’s endpoint URL is the agent’s address: each card points at its own per-shape JSON-RPC endpoint, so a peer that reads a card knows exactly where to send its task.

Two discovery surfaces sit side by side:

  • The well-known card (/.well-known/agent-card.json) is deployment-public and serves exactly one card — the operator-designated serving agent — as the A2A v1.0 well-known URI requires (a single card object, not a list).
  • The authenticated catalog (/a2a/catalog) serves the array of cards the calling peer’s own tenant has published as served — the multi-agent discovery surface, behind the A2A auth prologue.

Neither surface ever leaks a non-served or cross-tenant agent, and no card carries tenant-visibility metadata — the tenant-isolation opacity that governs every other read applies here too.

The auth prologue is the third trust boundary

Section titled “The auth prologue is the third trust boundary”

Every A2A request passes through a trust-boundary prologue — the A2A counterpart to the gRPC and REST auth prologues — over the same SessionAuth backend the rest of the platform uses. It authenticates the peer, resolves its principal, and enforces scopes fail-closed before any substrate work runs. A peer authenticates with a Bearer token (an OAuth JWT or a scope-bound runtime token — both ride Authorization: Bearer under every backend) or, when the deployment runs the API-key backend, an API key header. Peer-certificate mTLS is a named, deferred shape — the current serving listener presents a server certificate only, so mTLS resolves as a typed, fail-closed unsupported rather than a silent fallthrough.

Beyond identity, the prologue applies the skill-grain claim the authorization plane reserved for federation: a peer’s grant can restrict it to invoking only agents whose bundle skills fall within the peer’s claimed set — so a federated caller is bounded to exactly the capabilities it was granted, not the whole catalog.

The outbound direction mirrors the inbound design at every joint. Where the inbound adapter makes an A2A Task be a session, the outbound path makes a remote A2A agent be a tool: an operator configures a peer (identity, card URL, a credential reference, a trust profile), and the peer projects into the tenant’s sessions as the delegate tool a2a:<peer-id> — the same delegation-advertisement mechanism MCP servers use, one namespace-carrying projection with two namespace owners.

Outbound A2A: a session's tool call traverses the delegation spine to the remote peer; progress folds back onto the session's own event log

The same invariants carry over, inverted:

  • The substrate stays protocol-blind. The worker-side tool is a thin delegate with no A2A vocabulary; execution lives orchestrator-side, and the A2A wire types stay in the apomesh-a2a-types / apomesh-a2a-client leaves.
  • The event log stays the single source of truth. Remote status transitions and artifact fragments fold onto the delegating session’s log as bounded io.descoped.a2a extension events — a delegation is as inspectable and replayable as any local dispatch.
  • The credential plane stays sealed. A peer row carries a credential reference into the per-tenant credential store, never a value; the resolved secret rides the transient substrate channel and never reaches the log, a checkpoint, or agent-readable content.
  • The remote state table is the inverse projection. The client reads the peer’s TaskState back through a pure, wildcard-free mapping — the exact inverse of the inbound table above, canary-twinned so a new protocol state cannot land on one side only. A remote INPUT_REQUIRED pause surfaces as a typed tool result carrying the peer’s prompt and task id; sending again with that id resumes the same remote task.
  • Cancellation propagates. Cancelling the delegating dispatch — or the per-call timeout expiring — issues a best-effort CancelTask to the peer before the typed local outcome returns.
  • A peer’s response is bounded, on every path. The peer chooses the size of what it returns, so every artifact is capped — per part body, per part count, and per artifact, with the success path bounded exactly as the error paths are. An unbounded 50 MB reply would otherwise either blow the delegating agent’s context or bill the tenant for the whole thing, with no budget check in between. A Data part clipped past the bound becomes text, because a truncated JSON value is not a JSON value; the result reports what it dropped, and says nothing when it dropped nothing.

Because both directions ship, apomesh federates with itself: the flagship end-to-end test runs one daemon that serves a published agent inbound while a session on the same daemon delegates to it outbound through real HTTP, gRPC, and worker wires — the full circle, with the peer credential asserted absent from every envelope of both sessions’ logs.

Federation is not just two-way, it is mutually authenticated. A deployment has an org-grain trust domain with its own issuing CA (the PKI plane), implemented natively in the SPIFFE model: spiffe://<trust-domain>/node/<id> and .../tenant/<id> identity URIs in cert SANs, short-lived leaves, and trust bundles exchanged between domains.

The same translate-and-delegate symmetry carries into the trust plane:

  • Outbound, a mutual_tls peer’s server cert is verified against the peer org’s pinned trust anchor (not the web-PKI roots — org-to-org trust is explicit), with a SPIFFE expected-identity assertion. The client identity apomesh presents is per-tenant: the org CA mints a tenant-grain client cert on first use, stored as a sealed credential in that tenant’s credential store — so two tenants federating to the same peer present distinct identities, and the private key never reaches the event log. The DNS-rebinding window is closed at the connector (the vetted address is the connected address).
  • Inbound, a dedicated federation listener terminates client-cert TLS (the shared listener stays server-auth-only), validates the peer chain against the configured bundles, and resolves the caller to a federated peer principal — kept distinct from an in-tenant service account so audit and authorization can tell a cross-protocol peer apart. A card served there honestly advertises the mtlsSecurityScheme.
  • Edge workers join the same trust domain by bootstrap-token enrollment: a one-time, single-use token is exchanged (pre-cert, over the public REST listener) for a PKI-minted node identity, retiring hand-provisioned worker certs. Enrollment also binds the node’s tenant scope, durably, so the scope survives a daemon restart. That binding is enforced at two points: the forward dispatch-routing gate (an out-of-scope worker is not an eligible target) and — the load-bearing one — the reverse delegation gate, where a worker naming a session it should not reach is refused. A worker with no enrollment record resolves to the unscoped default, which keeps statically-provisioned workers working.

Tenant data and secret isolation is unaffected by any of this — it is enforced by the per-tenant credential plane keyed by tenant context, independent of a worker’s or peer’s transport identity. Per-worker scoping is defense in depth on top of that, not the mechanism that provides it.

Inbound: the discovery surfaces and the full task surfaceSendMessage, GetTask, CancelTask, the on-stall HITL continuation, the streaming methods (SendStreamingMessage / SubscribeToTask, as Server-Sent Events with a Task-first frame), ListTasks, and push-notification webhooks (durable registrations, reconciled on boot, delivered to egress-vetted URLs). Outbound: the agent-as-tool delegate path and mutual-TLS peer trust. The PKI plane (org CA, issuance, trust bundles), the inbound federation listener, edge-worker enrollment, and the durable per-worker tenant scope all ship.

Next on the federation vector: the disclosure gradient (GetExtendedAgentCard is served but unsupported while cards advertise extendedAgentCard: false), authenticated push delivery (today the daemon presents no credential to a webhook, and a caller-supplied one is refused rather than ignored), and the remaining protocol bindings. Each waits on a real consumer rather than a schedule.