A2A¶
Available · pip install "agent-adapter[a2a]" · a2a-sdk[http-server] 1.3+ (A2A protocol 1.0)
Serve registry tools as an A2A agent, and import a remote A2A agent's skills as tools. Every call runs through the executor, so hooks, approval, limits, cache and the ledger apply. IBM's ACP merged into A2A, so this covers ACP agents too.
Functions¶
Function (agent_adapter.integrations.a2a) |
Returns |
|---|---|
a2a_app(registry, *, url, executor=None, authenticate=None, authorize=None, allow_anonymous=False, host="127.0.0.1", allowed_hosts=None, public_card=False, client_approval=False, name="agent-adapter", description=, version=, max_body_bytes=1 MiB, max_data_bytes=256 KiB, task_store=None) |
a Starlette app: the card at /.well-known/agent-card.json, JSON-RPC at / |
a2a_executor(registry, *, executor=None, client_approval=False, authorize=None, max_data_bytes=256 KiB) |
an SDK AgentExecutor, for your own DefaultRequestHandler |
agent_card(registry, *, url, name=, description=, version=) |
an AgentCard: one AgentSkill per tool exported to a2a |
bounded_task_store(*, ttl=24 h, max_tasks=10 000, max_per_owner=1 000) |
the default SDK TaskStore of a2a_app (in memory, bounded) |
static_bearer({token: caller_id}) |
an authenticate hook for Authorization: Bearer |
await connect_a2a(url, *, headers=None, auth=None, allow_private=False, trusted_hosts=(), allow_cross_origin=False, timeout_s=30, max_response_bytes=4 MiB, http_transport=None) |
an open A2AConnection (card + SDK client); close it, or use async with |
await import_a2a(registry, connection, *, prefix="", names=None, task_timeout_s=300, poll_interval_s=1, **policy) |
the registered ToolSpecs, one per remote skill |
INTEGRATION |
export_tools(registry) → AgentSkills; bind_context(request_context) (call id = task id) |
Serve¶
tools = Registry()
@tools.tool(effects="read_only")
async def shipping_rate(region: str) -> float:
"""Shipping rate for a region, in percent of the order value."""
return {"eu": 5.0, "us": 18.0}.get(region, 12.0)
@tools.tool(confirm=True)
async def send_invoice(customer_id: str, amount: float) -> str:
"""Send an invoice to a customer (needs approval)."""
return f"invoiced {customer_id} for {amount:.2f} USD"
store = MemoryApprovalStore() # reviewers decide here (or through the HTTP server's API)
app = a2a_app(
tools,
url="https://tools.internal.example/a2a/", # the public JSON-RPC URL, written in the card
executor=Executor(approval=DeferredApproval(store)),
authenticate=static_bearer({"s3cret-token": "agent-team"}), # token -> caller id
host="0.0.0.0",
allowed_hosts=["tools.internal.example"], # Host header allow-list
)
# uvicorn examples.a2a_server:app --host 0.0.0.0 (behind TLS)
A client sends one message per call: a data part {"tool": "<skill id>", "arguments": {…}}.
The result is an artifact with one application/json text part (exact JSON; a data part
would turn every int into a float).
create_app does not mount A2A. To serve both, mount a2a_app(...) beside it in your own
Starlette app, with its own authenticate, and run its lifespan.
sequenceDiagram
participant C as A2A client
participant G as Gate (Host, body, auth)
participant A as ToolAgent
participant E as Executor
C->>G: SendMessage {tool, arguments}
G->>A: principal → task owner
A->>E: run_spec(spec, args, call_id = task id)
alt done
A-->>C: Task completed, artifact (JSON)
else approval pending
A-->>C: Task input-required {approval_id}
C->>A: any message, same task id
A->>E: run_spec again (the port decides)
end
| Outcome | Task state | Status message |
|---|---|---|
| value | completed |
(artifact result) |
| tool error, invalid args, denial, timeout | failed |
text kind: message, data {"error": {kind, message}} |
bad message, unknown tool, authorize says no |
rejected |
same shape; kind bad-request, tool-not-found, forbidden |
| approval pending | input-required |
text approval required: <id>, data {kind, approval_id, tool, answer} |
Approval¶
A pending call is parked on the server: tool, arguments, run id and approval id. The next message on that task re-runs exactly that call. What the message says never changes it.
| Mode | Who decides | What the follow-up message does |
|---|---|---|
| default | the executor's port (DeferredApproval: reviewers decide in its store) |
only asks to try again; a client "approved" is ignored |
client_approval=True (opt-in, warns) |
the A2A client | {"approval_id": "<id>", "approved": true} becomes a one-use token bound to run, task id, tool and argument digest |
With client_approval, anything but an exact approval_id and a JSON true re-asks. A
false denies. A replayed answer finds a finished task (the SDK refuses it). Another
caller cannot see the task at all.
Security¶
| Guard | Default |
|---|---|
| Auth | authenticate(request) -> principal (anything with a string .id, such as the HTTP server's Principal); none → 401 |
No authenticate |
local host: loopback peers only (403 non-local-client); public host: refused unless allow_anonymous=True |
| Owner scope | the SDK task store, our parked calls and run ids are keyed by principal.id |
| Host header | must be in allowed_hosts (local names by default): no DNS rebinding; else 421 |
| Body | max_body_bytes (413), read within 30 s (408); a call's data part ≤ max_data_bytes, depth ≤ 32 |
| Card | needs auth too, unless public_card=True |
| Call context | keeps only the A2A-Version and extension headers, never Authorization |
| Push notifications, streaming | off (no outbound requests from the server) |
authorize(principal, spec, arguments) |
must return True; a raising hook refuses |
| Task store | bounded_task_store(): a task expires 24 h after its last update; over 1 000 per owner the owner's own oldest goes, over 10 000 the oldest finished task goes; a rejected task keeps no history. Pass task_store= for a durable SDK store |
| Parked calls | 100 per owner (more are rejected, kind too-many-pending), 10 000 in all, 24 h |
Import¶
connection = await connect_a2a(
URL, # the card is read from URL + "/.well-known/agent-card.json"
headers={"Authorization": "Bearer s3cret-token"}, # same origin only
trusted_hosts=["tools.internal.example"], # a private host you trust
http_transport=offline, # omit in real use
)
async with connection:
tools = Registry()
await import_a2a(tools, connection, prefix="shop_")
print(tools.names()) # ['shop_shipping_rate', 'shop_send_invoice']
executor = Executor(tools)
print(await executor.run("shop_shipping_rate", {"region": "eu"})) # 5.0
try:
await executor.run("shop_send_invoice", {"customer_id": "cus_1024", "amount": 49.5})
except ToolError as exc: # the remote task waits for the remote reviewers
print(exc.details["state"]) # input-required
| Remote skill | Local tool |
|---|---|
described in the card's urn:agent-adapter:a2a:tools:v1 extension (our servers) |
typed arguments, sent as {tool, arguments} |
| any other skill | {"message": str}, sent as text |
| Remote task ends | Tool result |
|---|---|
completed |
the one part's value (JSON text parsed), else a list of values |
| a direct message | the same, from the message parts |
failed, rejected, canceled, input-required, auth-required |
ToolError; details hold task_id, context_id, state |
still running after task_timeout_s |
ToolError (polls with GetTask) |
A remote input-required never approves anything locally. Approve it on the remote side.
| Outbound guard | Default |
|---|---|
Private, loopback, link-local, multicast, CGNAT, site-local, Teredo hosts, and IPv6 forms that embed a private IPv4 (NAT64 64:ff9b::/96, ::a.b.c.d, 6to4) |
refused (HostRefused) unless allow_private=True or in trusted_hosts |
| Interfaces in the card on another origin | dropped; none left → IntegrationError (headers / auth never leave the origin) |
| Redirects | not followed |
| Response size | max_response_bytes per response |
The host is resolved once per connection; every answer is checked and the socket goes to a
checked address (no second lookup, so DNS rebinding cannot swap it). TLS still checks the
certificate against the host name. A custom http_transport (tests, ASGI) is checked per
request but makes its own connections. Card errors and logs show only scheme://host:port.
Files¶
| File | Role |
|---|---|
integrations/a2a/__init__.py |
A2AIntegration, INTEGRATION, the public names |
integrations/a2a/server.py |
a2a_app, the gate (Host, body, auth), static_bearer |
integrations/a2a/agent.py |
ToolAgent: run, park, resume; a2a_executor |
integrations/a2a/card.py |
agent_card, agent_skill |
integrations/a2a/client.py |
connect_a2a, import_a2a, call_remote |
integrations/a2a/_wire.py, _pending.py |
message format and bounds; parked calls |
integrations/a2a/_guard.py, _pinned.py |
outbound host policy; the transport that connects to checked IPs |
integrations/a2a/_task_store.py, _owned.py |
bounded_task_store; the per-owner expiring table both use |
Limits: parked calls live in one process (route a task's follow-ups to the same replica,
as the SDK's own ActiveTaskRegistry needs). Numbers in arguments arrive as doubles; whole
values become ints before validation.