A2A protocol
A2A (Agent-to-Agent) is the open protocol for agents from different
frameworks and vendors to discover and call each other. Loomflow
speaks both sides: serve_a2a(agent) exposes an Agent as an A2A
v1.0 endpoint, and A2AClient calls a remote A2A agent — optionally
wrapped as a loomflow tool so a local agent can delegate to it.
The wire types are self-contained, implemented from the spec — 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
)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
— some SDKs still probe it.) The card carries the name, description,
advertised URL, version, and skills. skills= accepts AgentSkill
instances or plain {id, name, description} mappings; when omitted a
single catch-all run skill is advertised.
The JSON-RPC endpoint
POST / accepts JSON-RPC 2.0 requests. Methods:
| Method | Behaviour |
|---|---|
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. |
tasks/get | Returns the last known task by id from an in-memory, bounded task table (1024 tasks; oldest evicted). |
message/stream | SSE stream of JSON-RPC responses: a working status update, then the run executes, then an artifact update and a final completed | failed status update. |
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.
v1 bounds. Text parts only — a message with no text parts is
rejected with invalid-params (file/data parts are not yet consumed).
message/stream is coarse: one working frame, then the run
executes to completion, then the result frames — per-token
streaming via agent.stream is future work. The task table is
process-local and in-memory; 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.
http= is the injection seam: any object with post(url, json=...)
/ get(url) works — 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 (JSON-RPC 2.0 + discovery card) |
| Discovery | None (you know your own routes) | GET /.well-known/agent-card.json |
| Streaming | Per-event SSE | Coarse (v1): working → result frames |
Both are pure-ASGI factories; you can mount both surfaces of the same
Agent in one FastAPI app under different prefixes.