Skip to content

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, final removed, AgentCard → supportedInterfaces[], google.rpc.Status errors). 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 + protoResearcher repos are dead — out of scope.)
  • #750 is a symptom. Long in-process skills block message/send synchronously 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

  1. Canon exists in BOTH languages.
    • TS: @a2a-js/sdk1.0 only as 1.0.0-alpha.0 (next tag); 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) and a2a-types (1.0 types generated from the canonical a2a.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-types is the safe floor since it's proto-generated) + a thin protolabs-a2a Rust layer mirroring the same extensions/card/auth. Go/Java/.NET get official SDKs if those ever appear in the fleet.
  2. The hub migration is proven & contained. Spike feature/a2a-1.0-alpha-spike: workstacean fully migrated to @a2a-js/sdk 1.0-alpha, 948 tests pass / 0 fail, 0 tsc errors, touching only ~5 A2A-edge source files. The bus contract insulated everything else.
  3. No guaranteed 0.3↔1.0 interop (a2a-js #452 open). A mixed-version fleet may not talk even with the A2A-Version header.
  4. 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

  1. The bus is the contract. workstacean's internal bus speaks lowercase state strings ("working", "completed"). The SDK's TaskState enum must stay contained to the A2A edge (a stateToLegacyString() boundary mapper). Peers emit lowercase states at our bus boundary.
  2. Greenfield-strict. Hand-rolled handlers are deleted, not shimmed. No 0.3 fallback paths.
  3. 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.
  4. 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) and protolabs-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's AgentExecutor base + 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 }. No kind. mimeTypemediaType.
  • Custom DataPartsON 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 is metadata.mimeType (the application/vnd… MIME); the payload is the top-level data field. ⚠️ The { content: { $case: "data", value } } form is the @a2a-js/sdk in-memory (ts-proto) representation ONLY — it never goes on the wire; Part.toJSON() flattens it to data. Python a2a-sdk uses proto objects and emits the same flattened wire. Implementers/consumers parse the WIRE shape (data + metadata.mimeType), never content.$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's capabilities.extensions[] declares the URIs (https://proto-labs.ai/a2a/ext/<ext>), distinct from the part mimeType.
  • Enumsrole: ROLE_USER/ROLE_AGENT; states: TASK_STATE_*.
  • Terminal detection — terminal TaskState (COMPLETED/FAILED/CANCELED/REJECTED) + stream closure. final is gone.
  • Requests — mandatory tenant ("" single-tenant); task.status optional.
  • Streamingevent.payload.$casemessage|task|statusUpdate|artifactUpdate.
  • AgentCardsupportedInterfaces: [{ url, protocolBinding: "JSONRPC", protocolVersion: "1.0", tenant: "" }]; capabilities.extensions[]; securitySchemes/securityRequirements/signatures.
  • Push configcreateTaskPushNotificationConfig({ tenant, id, taskId, url, token, authentication }).
  • ServerServerCallContext mandatory; events via the SDK's event factory.

6. Architecture

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

  1. Hub spike — DONE, holds. feature/a2a-1.0-alpha-spike green. Becomes the reference implementation; does not merge until cutover.
  2. Build the two conventions layers. Refined (2026-06-02): the TS @protolabs/a2a stays IN this repo (packages/a2a/ — workstacean is the only TS A2A consumer, so a separate TS package buys no reuse). The Python protolabs-a2a gets a dedicated protoLabsAI/protolabs-a2a repo (4 consumers: protoAgent/roxy/ORBIS/pwnDeck), consumed via git-dependency (VCS install) — avoiding npm/PyPI publish + OIDC friction now; formalize publishing later.
  3. 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 via protolabs-a2a so the hub gets its cost/confidence/worldstate telemetry like every other agent.
  4. Flag day — deploy all five simultaneously in a low-traffic window. Smoke-test every edge (ws↔each agent, agent↔agent).
  5. 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-sdk 0.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's output_modes on the card — the same machinery as the four custom extensions. The shared layers (@protolabs/a2a TS + protolabs_a2a Py) add emitSkillResult(obj, mime) + the submit_<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_choice primitive — verified). Each runtime enforces via forced tool-calling (a submit_<skill> function whose params are the schema, tool_choice forced), validates, and repairs once, in its own LLM stack — DeepAgentExecutor (TS) here, LangChain with_structured_output in protoAgent. Not response_format (ignored by the reasoning backend); not byte-for-byte (different stacks, no A2A primitive to mirror).
  • Declaration. output_schema is not a card field — AgentSkill exposes output_modes (MIME) only. The schema lives in the skill definition (+ optional published schema URL via the card's documentation_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.

protoWorkstacean — a switchboard, not an agent.