ADR-0006: A2A 1.0 — Canonical SDKs + a Thin protoLabs Conventions Layer
- Status: Proposed — 2026-06-01
- Deciders: Josh (operator)
- Related: ADR-0004 (A2A agents are an extension tier); ADR-0005 (out-of-process tool tier — sibling "use the canonical protocol" decision); spike branch
feature/a2a-1.0-alpha-spike;docs/explanation/a2a-1.0-spike-findings.md+a2a-fleet-census.md - Tags: architecture, a2a, protocol, sdk, fleet, interop, extensions, migration
A2A is the spine of the fleet: workstacean (hub) dispatches to protoAgent, roxy, ORBIS, and pwnDeck over it. The fleet speaks A2A 0.3; the spec shipped 1.0 (2026-03). A census found the hub used the canonical JS SDK while four Python agents each hand-rolled ~1–2k lines of A2A handler (protoAgent ≈ roxy, a fork). The directive: use the spec to its fullest and be a reference-grade implementation (and seed a teaching blog). This ADR decides how the whole fleet gets to 1.0 and stays clean: adopt the canonical SDK in each language, and own only a thin shared conventions layer on top — deleting the hand-rolled handlers entirely.
1. Context & problem
- The fleet is on 0.3; the spec is 1.0. 1.0 is a breaking rewrite (methods renamed, Parts member-discriminated,
TASK_STATE_*enums,finalremoved, AgentCard →supportedInterfaces[],google.rpc.Statuserrors). Full delta:docs/explanation/a2a-1.0-spike-findings.md. - Massive duplication on the Python side. Census (
a2a-fleet-census.md): only protoWorkstacean used an SDK (@a2a-js/sdk). protoAgent (~2059 LOC), roxy (~2069, a protoAgent fork), ORBIS (server+client), pwnDeck (~925) each hand-roll the JSON-RPC/SSE/card/Part wire. A protocol bump means editing N near-identical copies by hand. (quinn+protoResearcherrepos are dead — out of scope.) - #750 is a symptom. Long in-process skills block
message/sendsynchronously because the task lifecycle isn't first-class for in-process executors — one of several spec features we under-use even at 0.3. - Goal beyond fixing bugs. Be exemplary, and produce a teaching artifact (blog). That argues for canonical, self-describing, minimal — not bespoke.
2. Findings that constrain the decision
- Canon exists in BOTH languages.
- TS:
@a2a-js/sdk— 1.0 only as1.0.0-alpha.0(nexttag); stable line is still 0.3.13. - Python:
a2a-sdk(official, a2aproject) — stable 1.0 since 2026-04-20, now 1.1.0, "implements A2A Spec 1.0 with compatibility mode for 0.3." Same shapes as JS (AgentExecutor.execute/cancel,DefaultRequestHandler,A2AStarletteApplication), with an official v1.0 migration guide. - Asymmetry: the Python agents can run stable 1.0/1.1; the hub runs an alpha. The bleeding-edge risk sits on the hub, not the fleet.
- Official language coverage: Python, Go, Java, JS/TS, C#/.NET. Rust is NOT official — community crates only:
tomtom215/a2a-rust(claims 1.0, quad-transport, card signing, SSRF/TLS hardening) anda2a-types(1.0 types generated from the canonicala2a.proto). No fleet node is Rust today (the census set is 1×TS + 4×Python), so Rust does not gate this migration. If a Rust A2A node ever lands, the §4 pattern still holds: adopt the best community crate as canon-equivalent (vet it — it's not LF-maintained;a2a-typesis the safe floor since it's proto-generated) + a thinprotolabs-a2aRust layer mirroring the same extensions/card/auth. Go/Java/.NET get official SDKs if those ever appear in the fleet.
- TS:
- The hub migration is proven & contained. Spike
feature/a2a-1.0-alpha-spike: workstacean fully migrated to@a2a-js/sdk1.0-alpha, 948 tests pass / 0 fail, 0 tsc errors, touching only ~5 A2A-edge source files. The bus contract insulated everything else. - No guaranteed 0.3↔1.0 interop (a2a-js #452 open). A mixed-version fleet may not talk even with the
A2A-Versionheader. - Our extensions are half-formalized already. The Python cards declare extension URIs (
https://proto-labs.ai/a2a/ext/cost-v1,…/confidence-v1,…/worldstate-delta-v1,…/tool-call-v1). workstacean consumes them but doesn't declare them.
3. Constraints we must honor
- The bus is the contract. workstacean's internal bus speaks lowercase state strings (
"working","completed"). The SDK'sTaskStateenum must stay contained to the A2A edge (astateToLegacyString()boundary mapper). Peers emit lowercase states at our bus boundary. - Greenfield-strict. Hand-rolled handlers are deleted, not shimmed. No 0.3 fallback paths.
- One wire contract, fleet-wide. Exactly one definition of the 1.0 shapes + our extensions (§5), conformed to by both language layers and any peer.
- Don't reinvent the protocol. The canonical SDK owns the protocol mechanics; we own only conventions.
4. Decision
Adopt the canonical A2A SDK in each language (@a2a-js/sdk for TS, a2a-sdk for Python) and own a single thin shared "conventions layer" per language (@protolabs/a2a, protolabs-a2a) that encodes only protoLabs specifics. Delete every hand-rolled handler. Cut the whole fleet over to 1.0 in one flag-day.
Resolved forks:
- D1 — Canon, not hand-rolled, not own-from-scratch. Both languages have an official 1.0 SDK. Every A2A node imports it for protocol mechanics. ~6k LOC of hand-rolled Python handler is deleted.
- D2 — Own a thin conventions layer, not a handler.
@protolabs/a2a(TS) andprotolabs-a2a(Python) are small packages wrapping the canonical SDK with ONLY: (a) the four custom extensions (declare + emit/parse helpers), (b) agent-card defaults (provider, security scheme, conventions), (c) the auth scheme (API key / bearer), (d) the bus-boundary state convention (TS only). Agents subclass the layer'sAgentExecutorbase + call its card builder. This is "a handler we own and share out" — minimized to just our glue. - D3 — Formalize the extensions as declared
AgentExtensions. The four MIME contracts (cost-v1,confidence-v1,worldstate-delta-v1,tool-call-v1) become first-class declared extensions with stable URIs + payload schemas (§5). The conventions layer is the single source for emit + parse in both languages, so the contract can't drift between a hand-edited Python copy and the hub. - D4 — Flag-day cutover, no dual-stack. All five live nodes (ws + protoAgent + roxy + ORBIS + pwnDeck) flip to 1.0 in one coordinated window. We do not build dual-stack or 0.3↔1.0 adapters.
a2a-sdk's 0.3 compatibility mode is a safety net during cutover, not an architecture we maintain. The ws spike branch holds — merging it auto-deploys via watchtower and would break the still-0.3 fleet. - D5 — Fold #750 + the under-used 1.0 features into post-cutover themes. First-class task lifecycle for in-process executors (honor
returnImmediately, unify in-process ≡ A2A tracking), real Artifacts,input-required↔HITL wiring,referenceTaskIds,tasks/list, conformance testing (§7). These are workstacean-internal or additive; they don't gate the wire cutover.
5. The wire contract (what the layer encodes, what peers produce)
The canonical 1.0 shapes the fleet conforms to (grounded in the green spike):
- Parts — member-discriminated:
{ content: { $case: "text"|"data"|"raw"|"url", value }, metadata, mediaType }. Nokind.mimeType→mediaType. - Custom DataParts — ON THE WIRE (canonical proto3-JSON, what BOTH SDKs emit; verified 2026-06-02 via
Part.toJSON()):{ "data": <payload>, "metadata": { "mimeType": "application/vnd.protolabs.<ext>+json" }, "mediaType": "application/json" }The discriminator ismetadata.mimeType(theapplication/vnd…MIME); the payload is the top-leveldatafield. ⚠️ The{ content: { $case: "data", value } }form is the@a2a-js/sdkin-memory (ts-proto) representation ONLY — it never goes on the wire;Part.toJSON()flattens it todata. Pythona2a-sdkuses proto objects and emits the same flattened wire. Implementers/consumers parse the WIRE shape (data+metadata.mimeType), nevercontent.$case. Payloads:cost-v1(usage/costUsd/durationMs — note Python proto3-JSON may render integer token counts as floats; parse numbers loosely),confidence-v1(confidence/explanation),worldstate-delta-v1,tool-call-v1. The card'scapabilities.extensions[]declares the URIs (https://proto-labs.ai/a2a/ext/<ext>), distinct from the partmimeType. - Enums —
role:ROLE_USER/ROLE_AGENT; states:TASK_STATE_*. - Terminal detection — terminal
TaskState(COMPLETED/FAILED/CANCELED/REJECTED) + stream closure.finalis gone. - Requests — mandatory
tenant(""single-tenant);task.statusoptional. - Streaming —
event.payload.$case∈message|task|statusUpdate|artifactUpdate. - AgentCard —
supportedInterfaces: [{ url, protocolBinding: "JSONRPC", protocolVersion: "1.0", tenant: "" }];capabilities.extensions[];securitySchemes/securityRequirements/signatures. - Push config —
createTaskPushNotificationConfig({ tenant, id, taskId, url, token, authentication }). - Server —
ServerCallContextmandatory; events via the SDK's event factory.
6. Architecture
agent code ──▶ @protolabs/a2a (TS) protolabs-a2a (Python) ◀── agent code
│ • extensions (declare/emit/parse) │
│ • AgentCard defaults + auth scheme │
│ • AgentExecutor base / bus-state boundary │
▼ ▼
@a2a-js/sdk 1.0-alpha a2a-sdk 1.1 (stable)
│ protocol mechanics (JSON-RPC/SSE/card/task) │
▼ ▼
════════════════ A2A 1.0 wire (§5) ════════════════Both layers mirror each other: same extension URIs, same card conventions, same auth. The protocol is the SDK's job; the layer is only protoLabs glue.
7. Rollout (flag-day)
- Hub spike — DONE, holds.
feature/a2a-1.0-alpha-spikegreen. Becomes the reference implementation; does not merge until cutover. - Build the two conventions layers. Refined (2026-06-02): the TS
@protolabs/a2astays IN this repo (packages/a2a/— workstacean is the only TS A2A consumer, so a separate TS package buys no reuse). The Pythonprotolabs-a2agets a dedicatedprotoLabsAI/protolabs-a2arepo (4 consumers: protoAgent/roxy/ORBIS/pwnDeck), consumed via git-dependency (VCS install) — avoiding npm/PyPI publish + OIDC friction now; formalize publishing later. - Migrate the 4 Python repos — delete hand-rolled handler → adopt
a2a-sdk+protolabs-a2a. protoAgent first (roxy inherits the fork), then ORBIS, then pwnDeck. Each on a ready branch, not deployed. Locked (2026-06-01): ORBIS ADOPTS all four extensions (cost-v1/confidence-v1/worldstate-delta-v1/tool-call-v1) — today it declares none; on migration it emits them viaprotolabs-a2aso the hub gets its cost/confidence/worldstate telemetry like every other agent. - Flag day — deploy all five simultaneously in a low-traffic window. Smoke-test every edge (ws↔each agent, agent↔agent).
- Post-cutover themes (the "fullest" of 1.0): in-process task lifecycle /
returnImmediately(#750), real Artifacts,input-required↔HITL,referenceTaskIds,tasks/list, and conformance via the A2A Inspector + TCK.
8. Consequences
- Delete ~6k LOC of hand-rolled Python handler; one contract, two thin layers.
- Self-describing & exemplary — declared extensions, advertised security schemes, canonical SDKs. This is the blog's spine.
- Risk concentrates on the hub (alpha JS SDK); the Python fleet runs stable 1.0/1.1. Mitigation: the hub spike is already green and pinned to
1.0.0-alpha.0; bump deliberately. - Flag-day blast radius is accepted (low-traffic fleet); the
a2a-sdk0.3-compat mode is a fallback if a node lags. - Lock-in to canon — if an SDK diverges from the spec, we carry it in the thin layer, not the agents.
Addendum (2026-06-02): Structured skill outputs as schema-enforced DataParts
- Status: Accepted. (Design: protoAgent#476; TS companion: protoWorkstacean#756.)
Problem. A2A skills return free-form LLM text — fine for humans/agents, broken when a program must parse the result (protoMaker's parseReviewXml for jon's market_review; the pr-remediator reading diagnose_pr_stuck). "Make it structured" is attempted by prompting, a soft constraint the model ignores. Empirically (gateway probes, 2026-06-02) response_format: json_schema is ignored by protolabs/reasoning→minimax-m2.7 even called directly; LiteLLM also drops it. Forced tool-calling works on every backend in our zoo.
Decision. A skill MAY declare an output_schema (JSON Schema) + result_mime. Split by the A2A spec's own boundary:
- Transport — A2A-native, shared/mirrored. Emit the validated object as a DataPart (
data+metadata.mimeType/media_type=result_mime), advertised via the skill'soutput_modeson the card — the same machinery as the four custom extensions. The shared layers (@protolabs/a2aTS +protolabs_a2aPy) addemitSkillResult(obj, mime)+ thesubmit_<skill>tool-name convention, mirrored byte-for-byte (wire only, no LLM dependency). - Enforcement — runtime-local, NOT mirrored. Generation is out of A2A's scope (the SDK has no
output_schema/response_format/tool_choiceprimitive — verified). Each runtime enforces via forced tool-calling (asubmit_<skill>function whose params are the schema,tool_choiceforced), validates, and repairs once, in its own LLM stack —DeepAgentExecutor(TS) here, LangChainwith_structured_outputin protoAgent. Notresponse_format(ignored by the reasoning backend); not byte-for-byte (different stacks, no A2A primitive to mirror). - Declaration.
output_schemais not a card field —AgentSkillexposesoutput_modes(MIME) only. The schema lives in the skill definition (+ optional published schema URL via the card'sdocumentation_url).
Rationale. A2A natively defines the structured transport (DataPart + output_modes) but no generation primitive — so transport is shared/mirrored, enforcement is runtime-local. Tool-calling is the provider-agnostic generation lever; response_format is unreliable across our heterogeneous model zoo.
Consequences. Shared layers add emitSkillResult + the tool-name convention (wire only); each runtime adds forced-tool-call enforcement in its executor; skills opt in per-skill (free-text default, untouched); parseReviewXml (and any regex-on-text consumer) → typed DataPart parsing.
TS-side rollout (#756): (1) this addendum [done]; (2) @protolabs/a2a emitSkillResult + submit_<skill> convention, in lockstep with protolabs_a2a; (3) DeepAgentExecutor forced-tool-call enforcement + zod validate + 1 repair → emitSkillResult; (4) diagnose_pr_stuck declares its verdict schema → the pr-remediator reads the DataPart.