A2A protocol
A2A (Agent-to-Agent) is the open protocol for agents from different
frameworks and vendors to discover and call each other. Use it when
your agent should be callable by agents you don’t control, or when
your agent should delegate to someone else’s. Loomflow speaks both
sides: serve_a2a(agent) exposes an Agent as an A2A endpoint that
speaks both the v1.0 and the v0.3 JSON-RPC bindings, and A2AClient
calls a remote A2A agent (v1.0 first, falling back to v0.3), optionally
wrapped as a loomflow tool so a local agent can delegate to it.
The wire types are self-contained and implemented from the spec, with
no a2a-sdk dependency. The only optional dependency is httpx, for
the client:
pip install 'loomflow[a2a]'Server: serve_a2a(agent)
from loomflow import Agent
from loomflow.a2a import serve_a2a
agent = Agent("You research topics.", model="gpt-4.1-mini")
app = serve_a2a(
agent,
name="research-bot",
description="Researches topics and returns summaries.",
url="https://bots.example/a2a", # the card's advertised endpoint (default: the URL the card was fetched from)
)serve_a2a returns a plain ASGI-3 callable, the same framework-free
posture as loomflow.serve. It runs under
any ASGI server and mounts inside FastAPI/Starlette apps.
The discovery card
The agent card is served at the A2A v1.0 well-known path:
GET /.well-known/agent-card.json(/.well-known/agent.json, the legacy pre-1.0 path, is also accepted,
since some SDKs still probe it.) The card carries the name,
description, version and skills, and lists two JSON-RPC interfaces in
supportedInterfaces, one for v1.0 and one for v0.3, both at the
advertised URL. It keeps the v0.3 url and protocolVersion fields
for older clients. When url= is omitted, the card advertises the
address it was fetched from. skills= accepts AgentSkill instances
or plain {id, name, description, tags} mappings; a skill without
tags (which the spec requires) gets its id as its tag. When
omitted, a single catch-all run skill is advertised.
The JSON-RPC endpoint
POST / accepts JSON-RPC 2.0 requests in both bindings:
| Operation | v1.0 method | v0.3 method | Behaviour |
|---|---|---|---|
| Send a message | SendMessage | message/send | Extracts the text parts of the message, runs the agent, returns a completed Task whose output is a single text-part artifact. The message’s contextId (generated when absent) becomes the loomflow session_id; params.metadata.userId becomes user_id. |
| Send, streaming | SendStreamingMessage | message/stream | SSE stream of JSON-RPC responses: the task (working), then the run executes, then an artifact update and a final completed | failed status update. |
| Get a task | GetTask | tasks/get | Returns the last known task by id from an in-memory, bounded task table (1024 tasks; oldest evicted). |
| Cancel a task | CancelTask | tasks/cancel | Cancels a task that is still running. A finished task answers TaskNotCancelableError (-32002). |
| List tasks | ListTasks | n/a | Lists the tasks in the table, newest first, filtered by contextId / status and paged with pageSize (default 50, at most 100) and pageToken. |
The version is the request’s A2A-Version header (or query
parameter) when present; an unsupported version is answered with
-32009. Without it, the method name decides. v1.0 requests get v1.0
shapes (ProtoJSON enums such as TASK_STATE_COMPLETED, untagged
parts, {"task": ...} results); v0.3 requests get v0.3 shapes.
Subscribing to a task, push notifications and the extended card
answer with their spec error codes. This was checked against the
official a2a-sdk 1.x client and server.
Agent exceptions come back as a task in the failed state (with the
error text in status.message), not as a JSON-RPC error: execution
failure is task-level, protocol failure is RPC-level.
Current limits. Text parts only: a message with no text parts is rejected with invalid-params (file and data parts are not consumed). Streaming is coarse: one working frame, then the run executes to completion, then the result frames. There is no per-token streaming. The task table is process-local and in-memory, so restarting the server forgets tasks.
Client: A2AClient
from loomflow.a2a import A2AClient
async with A2AClient("https://bots.example/a2a") as remote:
card = await remote.fetch_card()
print(card.name, [s.id for s in card.skills])
reply = await remote.send("summarize today's tickets")
# pass the same context_id across calls to hold one remote conversation
follow_up = await remote.send("now prioritise them", context_id="ctx-1")fetch_card() tries the v1.0 well-known path first, falling back to
the legacy path on 404. send() raises A2AError on JSON-RPC
errors, malformed results, and tasks that come back failed.
The client speaks A2A v1.0 (SendMessage with an A2A-Version: 1.0
header) and, by default, falls back to v0.3 (message/send) when the
remote doesn’t know the v1.0 method or version, so it works with v1.0
servers, v0.3 servers and loomflow releases before 0.17. The
protocol_version property reports the version in use after any
fallback. Pin one when you need it:
A2AClient("https://bots.example/a2a") # "auto" (default)
A2AClient("https://bots.example/a2a", protocol_version="1.0") # never fall back
A2AClient("https://bots.example/a2a", protocol_version="0.3") # legacy servershttp= lets you supply the HTTP client: any object with
post(url, json=...) / get(url) works, such as an
httpx.AsyncClient, a wrapper adding auth headers, or an in-test
fake. When omitted, an httpx.AsyncClient is
created lazily on first use; close it with aclose() or use the
client as an async context manager.
The remote agent as a tool
as_tool() wraps send as a loomflow Tool, making the remote A2A
agent a delegate target in any local agent’s tools=[...]:
from loomflow import Agent
from loomflow.a2a import A2AClient
remote = A2AClient("https://bots.example/a2a")
coordinator = Agent(
"You coordinate. Delegate research questions to the research bot.",
model="gpt-4.1-mini",
tools=[remote.as_tool(name="research_bot")],
)
result = await coordinator.run("What changed in the EU AI Act this quarter?")The generated tool takes prompt (required) and an optional
context_id; the model can pass the same value across calls to keep
one remote conversation. Default name is a2a_delegate; the default
description embeds the remote base URL so the model knows what it’s
delegating to.
This is the cross-org / cross-framework counterpart of local delegation patterns like Supervisor: same “agent as a tool” shape, but the callee runs behind someone else’s endpoint.
A2A vs plain HTTP serving
loomflow.serve | loomflow.a2a | |
|---|---|---|
| Audience | Your own clients / frontends | Other frameworks’ agents |
| Protocol | Bespoke JSON routes + SSE | A2A v1.0 and v0.3 (JSON-RPC 2.0 + discovery card) |
| Discovery | None (you know your own routes) | GET /.well-known/agent-card.json |
| Streaming | Per-event SSE | Coarse: working → result frames |
Both are pure-ASGI factories; you can mount both surfaces of the same
Agent in one FastAPI app under different prefixes.