Skip to Content
ProductionA2A protocol

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:

Operationv1.0 methodv0.3 methodBehaviour
Send a messageSendMessagemessage/sendExtracts 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, streamingSendStreamingMessagemessage/streamSSE 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 taskGetTasktasks/getReturns the last known task by id from an in-memory, bounded task table (1024 tasks; oldest evicted).
Cancel a taskCancelTasktasks/cancelCancels a task that is still running. A finished task answers TaskNotCancelableError (-32002).
List tasksListTasksn/aLists 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 servers

http= 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.serveloomflow.a2a
AudienceYour own clients / frontendsOther frameworks’ agents
ProtocolBespoke JSON routes + SSEA2A v1.0 and v0.3 (JSON-RPC 2.0 + discovery card)
DiscoveryNone (you know your own routes)GET /.well-known/agent-card.json
StreamingPer-event SSECoarse: working → result frames

Both are pure-ASGI factories; you can mount both surfaces of the same Agent in one FastAPI app under different prefixes.

Last updated on