Multi-Agent Composition
Single-line summary: Symmetric @x@domain addressing means any agent can call any other agent over A2A; AgentChain bounds depth, recipient_capabilities tells each hop how to dispatch siblings, and on_behalf_of carries upstream user authority.
See also: normalized-message-guide, identity-auth-guide, transport-module-guide, policy-part-guide, glossary
Type sources: packages/core/src/{message,recipient-capabilities,identity}.ts.
Symmetric Addressing
sender.address and recipient use the same @x@domain shape. Humans (@alice@example.com over email), agents (@scheduler@bots.example.com over A2A), and intermediate services share the address space.
When agent A sends to agent B over A2A, A is the sender and B is the recipient — exactly as if A were a human caller. B’s runtime applies the same trust pipeline:
- Verify the wire credential (A2A JWT or OAuth).
- Verify any forwarded
IdentityEvidencefrom A’s metadata extension. - Apply
IdentityPolicyand per-agent ACL. - Invoke
B.handle(message, ctx).
This symmetry is what makes Mentionable composable. There is no separate “agent-to-agent” trust path — it is the same path with the addresser being an agent.
AgentChain
When an agent dispatches to another agent on behalf of a chained turn, it sets recipient_capabilities.agent_chain:
type AgentChain = {
hop: number // 1-indexed; 1 = first chained turn
max_hops: number // operator-supplied ceiling
is_final: boolean // hop === max_hops
}
Carried over A2A as metadata.mentionable.agent_chain. The receiving agent’s LLM Harness reads this and adjusts: when is_final: true, the agent MUST synthesize a conclusion and MUST NOT invite another agent into the chain.
The default renderAgentChain helper produces system-prompt cues:
- Non-final:
"You are responding as hop 2 of a chain capped at 4 hops. [2 hops remaining.]" - Final:
"You are responding as the FINAL hop (4 of 4). Synthesize a conclusion — do not invite another agent."
Agents that ignore the cue still respect the wire-level max_hops enforcement: the dispatching agent (or the runtime) checks hop < max_hops before issuing the next call.
recipient_capabilities Forwarded Across Hops
When a Slack-style client dispatches an agent call over A2A, the dispatcher overrides A2A’s default mention_relay: 'none':
// Slack connector calling agent B over A2A
{
recipient_capabilities: {
mention_relay: { kind: 'inline' }, // override of A2A default
agent_chain: { hop: 1, max_hops: 4, is_final: false },
},
}
This tells agent B: “the user is on Slack — if you want to bring in agent C, type @c@domain and the platform will route.” The dispatcher (Slack Connector) carries recipient_capabilities over A2A via the same metadata.mentionable.* namespace.
Without the override, agent B would default to A2A’s none and issue an outbound A2A call to agent C — wasteful when Slack will relay an inline mention naturally.
Fan-Out Pattern
When the user (or upstream agent) asks something requiring multiple sibling agents, the orchestrator pattern is:
user "@scheduler @docs find a free Tuesday and link the runbook"
│
▼
orchestrator agent
│
├──► A2A call to @scheduler@bots.example.com (parallel)
├──► A2A call to @docs@bots.example.com (parallel)
▼
collect responses → synthesize one cohesive reply
│
▼
single reply back to user
Implementation rules:
- Issue calls in parallel (
Promise.all) with a sharedAbortSignal. - Each outbound call carries
agent_chain.hop = N,max_hopscapped,is_final: false(unless the orchestrator IS the final hop already). - Each outbound call forwards relevant
IdentityEvidence(audience-rebound to the new recipient) so the sibling agents can authorize the user. - If any sibling returns a
PolicyPart(e.g.payment_required), the orchestrator MUST surface it to the user — do not silently swallow.
Payment in Chained Calls (on_behalf_of)
When agent A pays agent B on behalf of user U, the upstream user authority travels in IdentityEvidence.on_behalf_of:
{
subject: '@agent-a@example.com',
on_behalf_of: ['mailto:u@example.com'], // immediate caller first
proof: { type: 'signed-attestation', ... },
}
The chain is ordered immediate-caller-first. Agent B authorizes against:
subject(agent A is identified)on_behalf_of[0](the chain root or immediate principal — depends on B’s policy)- B’s per-action ACL
Delegation chain for payment is F-1 future work — the v0.1 baseline supports the shape but advanced policy (delegated spending caps, per-hop attenuation) is not normative yet. See identity-auth-guide#chained-agent-calls---on_behalf_of.
Multi-Hop Cap Enforcement
When agent_chain.is_final: true, the receiving agent MUST NOT invite another agent. Implementation:
- The agent’s LLM Harness sees the rendered “FINAL hop” line in the system prompt.
- The agent’s runtime SHOULD also code-side reject outbound A2A calls when
is_final: true— the LLM compliance is best-effort, the runtime check is enforcement. - An agent that mints a new outbound A2A request MUST decrement remaining hops:
next.agent_chain = { hop: prev.hop + 1, max_hops: prev.max_hops, is_final: prev.hop + 1 === prev.max_hops }.
The runtime’s outbound A2A helper computes this automatically.
Combining With PolicyPart
A chained call may surface a PolicyPart from any hop:
user → orchestrator → @paid-search@example.com
returns payment_required (state: S)
│
▼
orchestrator surfaces payment_required to user
│
user pays → orchestrator receives policy_resolution { in_reply_to_state: S }
│
▼
orchestrator calls @paid-search again with policy_resolution forwarded
│
▼
final answer to user
The orchestrator MUST verify in_reply_to_state against @paid-search’s issuance store via the resumption pattern — typically by re-dispatching the original call with policy_resolution set, letting the receiving agent’s adapter look up the state and authorize. See policy-part-guide#resumption-patterns.
Composition Rules
- Forward
IdentityEvidenceonly when audience-rebinding makes sense. The forwarded attestation MUST list the new recipient inaudience. - Decrement chain depth at every hop. Never let an agent issue a chained call past
max_hops. - Surface PolicyParts from sub-agents to the user. Silently retrying or swallowing breaks consent and payment.
- Override
recipient_capabilities.mention_relaywhen the dispatcher knows more than A2A’s default. Slack Connector → A2A is the canonical case. - Use
Promise.all(or equivalent) with a sharedAbortSignal. Long-running fan-outs must be cancellable when the upstream user disconnects.
Common Mistakes
- Not setting
agent_chainwhen dispatching a chained call. Receiver defaults to “direct user→agent” and may invite further agents past the cap. - Reusing the same
IdentityEvidenceacross hops without rebindingaudience. Audience MUST equal the immediate recipient — anything else fails freshness/audience checks. - Swallowing a sub-agent’s
payment_required. Always surface to the user. - Treating
is_final: trueas advisory. The runtime MUST enforce it — LLM compliance is best-effort. - Using
received_viato detect “this is a chained call”. Useagent_chainpresence instead —received_viais the wire transport, not the conversation shape.