Loomcycle speaks A2A. Server, client, and the INPUT_REQUIRED bridge that wasn't supposed to ship.
A week ago we wrote that the right way for loomcycle to be reachable from the Microsoft Agent Framework and Google ADK enterprise stacks was the Agent2Agent (A2A) protocol: Google's open standard, donated to the Linux Foundation, current stable v1.0.1. We named it as the lowest-risk interop bet on the table and locked an RFC (G) the same week. This week it shipped. loomcycle now speaks A2A on both sides: a served AgentCard at the well-known URI, three protocol bindings, signed cards, multi-tenant routing, and synthetic peer tools. One explicit deferral in the locked RFC got reversed during implementation, because the substrate turned out to already be there.
The scope cut we should have made at RFC lock wasn't "build less now." It was "look harder at what's already shipped."
1. What landed
Eight slices on a feature branch, built against a2a-go v2.3.1, the official Go SDK. The RFC committed to adopting the SDK as a direct dependency rather than re-implementing ~10K LOC of protocol code, and that bet paid off twice: we got three wire bindings (REST, JSON-RPC, gRPC) for free, and the SDK's real API forced careful implementation around iterator semantics and per-request context lifecycles that the RFC sketch had glossed over.
The server surface is off by default. Operators turn it on with three env vars:
LOOMCYCLE_A2A_ENABLED=1
LOOMCYCLE_A2A_SERVER_CARD=loomcycle-fleet
LOOMCYCLE_A2A_PUBLIC_BASE_URL=https://agents.example
LOOMCYCLE_A2A_TENANCY_ROUTING=none # none | host | path
Once enabled, loomcycle mounts additively - nothing about /v1/*, MCP, or /ui changes:
| Path | Binding |
|---|---|
/.well-known/agent-card.json | AgentCard discovery URI (unauthenticated) |
/a2a/v1 + subpaths | REST (HTTP+JSON) |
/a2a/jsonrpc | JSON-RPC 2.0 |
/a2a/grpc | gRPC, on loomcycle's existing gRPC server (HTTP/2 needs no path mount) |
The card advertises all three interfaces; an A2A client picks whichever transport it prefers (the reference SDK defaults to JSON-RPC). Each exposed loomcycle agent becomes one AgentCard skill; inbound messages carry a skillId in metadata that routes to the mapped agent. A request that names an unknown or unexposed skill is rejected as FAILED - a peer cannot reach an agent that isn't in exposed_agents by guessing the skill id.
Two new substrate primitives carry the configuration, content-addressed and versioned like every other substrate Def (AgentDef / SkillDef / MCPServerDef / ScheduleDef):
A2AServerCardDef- declares which loomcycle agents are exposed via A2A, the card metadata, security schemes, and the optional signing-key env var name.A2AAgentDef- registers a remote peer (either by well-known card URL or direct endpoint+binding), the auth scheme, and the expected skills. For each(peer, expected_skill)pair, loomcycle synthesizes one tool -a2a__<peer>__<skill>- at boot, exactly likemcp__<server>__<tool>. Agents reach a peer only by listing the tool (or a glob) in theirallowed_tools.
2. The deferral we reversed
The locked RFC's Decision 9 was emphatic: "TASK_STATE_INPUT_REQUIRED and TASK_STATE_AUTH_REQUIRED are out of scope for v1." The reasoning was that run-loop state machine changes for interactive task states would be 1-2 PRs of their own: plain scoping, defer to v2.
Then implementation hit the question and produced a different answer. loomcycle's Interruption tool (shipped v0.8.16) is already the human-in-the-loop primitive: a run that calls Interruption.ask parks on a channel-bus wait, surfaces the question to the operator surface (Web UI, MCP, CLI), and resumes the same run with the operator's answer. That is exactly what A2A's INPUT_REQUIRED state is asking for: a peer-facing version of "this task is waiting for more input."
So slice A2A-6 shipped the bridge instead of the deferral. A loomcycle run that parks on Interruption.ask surfaces TASK_STATE_INPUT_REQUIRED over A2A; a follow-up message on the same taskId resolves the interruption via the same notification bus the Interruption tool already waits on, and the same run resumes to its real terminal state. HTTP-side resume and A2A-side resume converge on one mechanism. A follow-up on an unknown taskId starts a fresh run instead, so the peer-facing surface stays well-defined when state has been forgotten.
AUTH_REQUIRED stays deferred: loomcycle expects peer credentials supplied up front through the per-run credentials map, not negotiated interactively. The scope split here is plain: we did the half we already had substrate for.
3. The bug the unit tests missed
Whole-feature code review caught a real correctness defect that explains why integration tests against the real SDK matter. The pattern: the a2a-go SDK creates a per-streaming-request context with requestCtx, cancel := WithCancel(ctx); defer cancel() inside its REST and JSON-RPC handlers, and hands that ctx to Execute. Our first cut of startRun drove runner.RunOnce with the request context - so the instant our iterator parked and yielded to wait for INPUT_REQUIRED, the SDK's deferred cancel() tore the run down. The Interruption tool's Bus.Wait honors ctx.Done(), which meant the parked run died with context.Canceled before any resume could arrive.
Unit tests missed it because they passed an uncancelled context.Background() rather than the per-request ctx the real SDK uses. The fix was to detach the run lifetime from the request lifetime via context.WithoutCancel + an executor-owned cancel - cancellation now flows only through Executor.Cancel (the Connector's existing cascade pathway) and explicit stream.cancel on abandon or unresumable park. A regression test (TestExecutor_RunContextDetachedFromRequestContext) pins the invariant.
Two other bugs surfaced from the end-to-end integration test that drives the real a2aclient against the mounted server: the REST binding mounted at /a2a/v1/ without prefix-strip, so the SDK's relative routes (/message:send, /tasks/{id}) all 404'd; and the executor emitted a bare status update first on a rejected brand-new message, but the SDK aggregation requires the first event to be a Task or Message, which surfaced rejections as opaque transport errors instead of terminal FAILED. Both got regression-test pins on the way out.
4. What does a signed card actually prove?
loomcycle signs its served card over RFC 8785 JSON Canonicalization Scheme with ES256 (JWS detached), when the operator names a signing-key env var that is on loomcycle's existing allowlist (the same one the scheduler and per-run credentials use). The signature embeds the matching public key as a self-contained JWS so a verifier needs no separate key fetch.
Signing is best-effort and never fails card serving. No key configured, a non-allowlisted env name, an unset var, or a malformed key: all serve the card unsigned with a single trace line for operator triage (the key value is never logged). The allowlist is a real floor: a substrate-authored A2AServerCardDef cannot name an arbitrary env var and exfiltrate it into a signature.
Inbound peer-card verification is the symmetric story but with one plain limit worth naming.
A verified signature proves the card's integrity, not the peer's identity.
What verify_signed_card: true proves, and what it does not. The JWS is self-contained: it verifies against a public key in the card's own protected header, with no external trust anchor. So a verified signature proves the card's integrity (not altered after signing), but not the peer's identity. Peer identity rests on TLS: the card is fetched over HTTPS from the peer's own well-known URI, so the transport authenticates the origin. Treat verify_signed_card as tamper-evidence on top of TLS, not as a replacement for it. Pinning a peer's key out-of-band is a future enhancement, not a current capability.
5. What is not yet wired
Two pieces of advertised functionality are accepted in config but explicitly not enforceable today, named loudly so operators don't trust them by accident:
oauth2andmtlspeer auth - config validators accept them, but the outbound bearer machinery currently wires onlyhttpandapiKeyschemes. A peer declaring oauth2 or mtls is not callable yet; the registry logs the skip at boot.- Push notifications - A2A's webhook-based async update surface stays deferred per the locked RFC's Decision 11, coordinating with RFC H's outbound-event mechanism whenever H ships (it's underway).
One latent corner worth recording: the JCS canonicalization implementation is not fully RFC 8785-faithful for fractional or very large numbers. AgentCards in practice carry only integers, so the gap is latent rather than active, but it is tracked.
The lesson: credit your own primitives at lock time. We locked the RFC with INPUT_REQUIRED deferred to v2 because we believed interactive state would need new run-loop machinery. Implementation showed the Interruption tool (shipped 18 months ago for operator-side human-in-the-loop) already was the right substrate. Bus, resume seam, take-once mechanics, all there. The scope cut we should have made at RFC lock time wasn't "build less now"; it was "look harder at what's already shipped." Next RFC: I'll do that audit first.
6. What you can do with it today
Expose a loomcycle agent:
# loomcycle.yaml
a2a_server_cards:
loomcycle-fleet:
name: loomcycle-fleet
capabilities: { streaming: true }
exposed_agents:
- { agent_name: company-researcher, skill_id: research, skill_name: Research }
$ LOOMCYCLE_A2A_ENABLED=1 \
LOOMCYCLE_A2A_SERVER_CARD=loomcycle-fleet \
LOOMCYCLE_A2A_PUBLIC_BASE_URL=https://agents.example \
./bin/loomcycle --config loomcycle.yaml
An external A2A client - Microsoft Agent Framework, Google ADK, LangGraph's /a2a/{assistant_id} endpoint, or any conformant peer - fetches https://agents.example/.well-known/agent-card.json, sees the research skill, sends a message with Message.Metadata["skillId"] = "research", and gets the run streamed back as A2A Task events ending in COMPLETED.
Call a remote peer from a loomcycle agent:
# loomcycle.yaml
a2a_agents:
partner-research:
agent_card_url: https://partner.example/.well-known/agent-card.json
auth: { scheme: http, bearer_credential_ref: partner_token }
expected_skills:
- { id: research, required: true }
agents:
orchestrator:
allowed_tools: [Read, a2a__partner-research__research]
The orchestrator agent can now call a2a__partner-research__research; loomcycle fetches the peer card (verifying the signature if configured), resolves the partner_token bearer from the run's UserCredentials via the existing RFC F channel, sends the message, and returns the peer's answer to the model.
Full operator docs in Context.help a2a-integration on any loomcycle build past this week.
Companion reading: Seven frameworks and the row that's missing (the framework-survey post that named A2A as the lowest-risk interop bet), Three MCP tokens in one run (RFC F, the per-run credentials channel that carries A2A peer bearers), and Scheduled runs at 30,000 fires (RFC E, the substrate-primitive pattern A2A's two new Defs inherit from).
We locked an RFC with a deferral and shipped the deferral anyway. Not because we changed the spec, but because the substrate underneath it had already answered the question.
That is the pattern worth keeping. The next feature's hardest work is usually not the code we are about to write. It is the code we already wrote and forgot we had.