Design: A2A Task Delegation Transport
Context
SPEC-0010 and ADR-0010
originally scoped A2A to discovery/announcement only: personas publish Agent Cards, but cross-agent work
flows exclusively as MCP create_for calls into the durable todo queue
(SPEC-0003, ADR-0007).
That was a deliberate trade — A2A's native peer task transport bypasses human vending and produces
ephemeral, unowned RPCs — but it also meant switchboard was not a real participant in the A2A ecosystem:
an external, standards-compliant A2A client could discover a persona's card and nothing more.
ADR-0021 revisits that trade: implement the full A2A task RPC surface, but keep it gated by the exact same vended-endpoint credential SPEC-0010's friend-request-and-approval flow already mints. A2A becomes a second wire protocol for an already- authorized relationship, not a new way to acquire one. This spec is the requirements-level realization of that decision.
The substrate this spec builds on already exists in code: internal/store/todos.go (the Todo type and
its pending → claimed → done|failed lifecycle with lease/dedup/retry), internal/server (the doorbell
gate and nudgeDoorbells/PendingDoorbellTodos that drive Channels), internal/web/agentcard.go (Agent
Card projection, which today hard-codes capabilities.streaming: false and comments "no push
notifications, no state-transition history"), and internal/server/friend_intake.go /
internal/store/agents.go (VendAgentEndpoint, the credential this spec's auth checks reuse). None of
the A2A RPC handlers (SendMessage, GetTask, etc.) exist yet.
Goals / Non-Goals
Goals
- Implement
SendMessage,GetTask,ListTasks,CancelTask,SendStreamingMessage,SubscribeToTask, andGetExtendedAgentCardas a native A2A binding over the existing Todo model. - Gate every state-changing call (
SendMessage,CancelTask) behind the same vended-endpoint bearer credential MCP'screate_foralready requires — no new anonymous access path. - Extend the Todo state machine with the four A2A states it doesn't yet have (
canceled,rejected,input-required,auth-required) without breaking any existingpending/claimed/done/failedconsumer. - Rate-limit
SendMessageper vended endpoint, independent of SPEC-0010's friend-request quota, so a single already-friended peer cannot flood a queue at a rate no human ever approved. - Advertise
capabilities.streaming: trueon the Agent Card once implemented.
Non-Goals
PushNotificationConfigCRUD and webhook delivery — a separate spec (a2a-push-notifications).- Changing how a vended endpoint is acquired — SPEC-0010's friend-request-and-approval flow is unchanged.
- Renaming the
Todotype, database schema, or MCP tool names (list_todos,claim_todo,create_for) to "Task." "Task" remains A2A's wire-boundary vocabulary for the same object; the internal primitive keeps its existing name. (Considered and explicitly declined — see Decisions.) - Open, ungated peer task delegation (any A2A caller tasking any discovered agent without a prior grant) — this is exactly ADR-0010's originally-rejected option and remains rejected under ADR-0021.
Decisions
Task is a projection, not a parallel object
Choice: SendMessage creates a todo via the same store path as create_for; GetTask/ListTasks
read the todo table; there is no separate tasks table.
Rationale: One state machine, one audit trail, one lease/retry/dead-letter implementation to keep
correct. Avoids the classic "two systems of record disagree" bug class.
Alternatives considered:
- A parallel
a2a_taskstable mirrored from todos: doubles the surface to keep consistent, and risks exactly the kind of drift this decision is trying to avoid. Rejected.
Keep "Todo" as the internal name; "Task" only at the A2A boundary
Choice: No rename of the Go type, database schema, or MCP tool surface. The A2A HTTP/JSON-RPC layer
translates between A2A's Task-shaped wire format and the internal Todo model at the handler boundary
only.
Rationale: A full rename touches the Go type, DB columns, list_todos/claim_todo/create_for tool
names, ADR-0007's title, and every one of the ~17 specs that reference "todo" — and breaks any existing
MCP client calling those tool names today. The conceptual alignment ("an A2A Task is a Todo") is fully
captured by ADR-0021's framing without paying that migration cost.
Alternatives considered:
- Full rename now: real cost (breaking MCP tool interface, DB migration, doc sweep) for a naming-only gain; explicitly declined in favor of scoping it as a separate future consideration if ever pursued.
SendMessage authorization reuses the vended-endpoint credential, not a new grant type
Choice: SendMessage checks the same bearer-credential-scoped-to-queue mechanism as create_for.
Rationale: Preserves ADR-0010's core accountability property — no autonomous agent-to-agent access
grant — while still delivering the full A2A RPC surface. The alternative (open peer delegation) was
already rejected once, for the same reason, and rejecting it again here keeps the two ADRs consistent.
Alternatives considered:
- Open peer delegation matching A2A's native default: maximal interop, but reopens the exact accountability hole ADR-0010 closed. Rejected (this is ADR-0021's "Option D").
SendMessage gets its own rate limit, separate from SPEC-0010's friend-request quota
Choice: A per-vended-endpoint token bucket on SendMessage, distinct from the friend-request
quota that bounds how fast a new grant can be acquired.
Rationale: The two quotas bound different things — acquiring a grant vs. using one — and conflating
them would either make friend-request approval too permissive (if the SendMessage bucket is generous) or
choke a legitimately busy, already-approved relationship (if the friend-request quota is reused for task
volume). This gap existed for create_for before this spec too; this spec is the first to name it and
close it explicitly, rather than silently inheriting it.
Alternatives considered:
- No new rate limit, rely on general endpoint request throttling (SPEC-0007): doesn't distinguish "chatty but legitimate" from "flooding," and doesn't give operators a task-creation-specific knob. Rejected as insufficiently targeted.
Streaming reuses the existing doorbell commit hook
Choice: SendStreamingMessage/SubscribeToTask subscribe to the same store.SetTodoDoorbellHook
transition events that already drive Channels, rather than introducing a second event-emission path.
Rationale: One source of truth for "a todo just changed state." Two independent emission paths would
be a place for delivery guarantees to silently diverge between the internal doorbell and external
streaming.
Alternatives considered:
- A separate polling-based event loop for A2A streams: simpler in isolation, but duplicates the transition-detection logic the doorbell hook already provides correctly. Rejected.
Architecture
An A2A caller authenticates with the same vended-endpoint credential an MCP client would use. SendMessage
and MCP's create_for converge on the same store call; GetTask/ListTasks read the same table;
streaming and the internal Channels doorbell both subscribe to the same commit hook, diverging only in
transport (SSE to an external caller vs. an MCP notification to a connected session).
The task lifecycle, showing the four new states alongside the existing four:
Risks / Trade-offs
- New rate-limiting surface to get right. A too-tight
SendMessagebucket makes switchboard feel broken to a legitimate high-volume peer; too loose and the original "agents going nuts" worry materializes anyway. Mitigation: default conservative, make it operator-tunable, and log rejections distinctly from other 429s so the limit is diagnosable. - State machine extension touches every existing Todo consumer. Adding four new states means every
place that switches on
Todo.Stateneeds to handle them (or explicitly not, if not applicable). Mitigation: audit all existing switch/match sites onTodo.Stateas part of implementation, not just the new A2A handlers. - Two wire protocols, one behavior contract. MCP's
create_forand A2A'sSendMessagemust agree on dedup, scope enforcement, and validation, or the same logical action behaves differently depending on which protocol a caller happens to use. Mitigation: both MUST call the same internal function; the A2A handler is a thin translation layer, never a reimplementation. - Streaming is a new connection-lifecycle surface. Long-lived SSE connections need explicit backpressure and cleanup on caller disconnect, which the request/response-shaped MCP surface never had to handle. Mitigation: explicit context-cancellation propagation and connection caps (see spec's Concurrency Safety requirement).
Migration Plan
- Add the four new
Todo.Statevalues (canceled,rejected,input-required,auth-required) via a database migration; existing rows are unaffected (additive enum/constraint change). - Audit and update every existing switch/match on
Todo.State(claim logic, retry/backoff logic, UI rendering, MCP tool responses) to handle the new states or explicitly document why a given site can ignore them. - Implement the A2A HTTP/JSON-RPC handlers (
SendMessage,GetTask,ListTasks,CancelTask,SendStreamingMessage,SubscribeToTask,GetExtendedAgentCard) as thin translators onto the existing store functions (store.CreateTodo/CreateForFriend,store.ClaimTodo, etc.), reusing the same auth checkcreate_foruses. - Add the per-vended-endpoint
SendMessagerate limiter. - Wire
SubscribeToTask/SendStreamingMessageontostore.SetTodoDoorbellHook, alongside (not replacing) the existing Channels doorbell subscription. - Flip
capabilities.streamingtotrueininternal/web/agentcard.goand remove the now-stale "no push notifications, no state-transition history" comment (the push-notifications half of that comment is corrected by the companion a2a-push-notifications spec). - Update SPEC-0010's "Work Flows as Todos, Not A2A Tasks" requirement and SPEC-0009's Agent Card capability-advertisement requirement to reflect that A2A task intake is now permitted for endpoint holders (tracked as part of this spec's rollout, not a separate migration).
Open Questions
- Exact rate-limit defaults. Bucket size and refill rate for the per-endpoint
SendMessagelimiter are not fixed by this design; they need an operational starting point plus room to tune. input-required/auth-requiredtimeout policy. How long a todo may sit in an interrupt state before switchboard gives up and transitions it tofailed(or leaves it interrupted indefinitely) is unresolved.- Cursor implementation for
ListTasks. Whether the pagination cursor is an opaque encoded offset, a keyset on(created_at, id), or something else is an implementation detail to settle during coding, not an architectural choice this design needs to pin down. - Cross-protocol dedup key parity. Whether A2A's
SendMessageshould accept/require an idempotency key in the same shape MCP'screate_fordoes, or derive one from A2A's ownmessageId, needs a decision before implementation.