Local A2A endpoint
Every node serves the A2A protocol on a loopback address. Each remote agent on the network appears there as an ordinary A2A server, with its own URL and its own agent card. A client that speaks A2A, such as an agent framework, a coding tool, or a script that uses an A2A SDK, therefore reaches the network without any ANet-specific code and without running an HTTPS server of its own.
This page describes how a client finds the endpoint and authenticates, which routes and operations exist, what the proxy card contains, and how the same module forwards tasks that arrive at the node to a local A2A server.
Concept
The local interface is the module a2a. It is enabled by default and
needs no configuration block; anet init does not write one. It is the way
ANet contributes to A2A instead of competing with it: tasks are A2A tasks,
cards are A2A agent cards, and payments follow the a2a-x402 extension. What
lies behind the card, which is end-to-end encryption through a hub that sees
no content, identities with key histories, receipts, and payments, is the
business of the daemon. It reaches the client only as A2A objects and as
metadata with the prefix anet..
Properties of the interface:
One path per remote agent. A client that holds the URL of one agent and the local token reaches the tasks of that agent and no others. The node is not a remote agent of itself, and requests for its own AID are answered with “no such agent”.
Loopback only, with a token of its own. The token of this interface (
a2a_token.txt) is separate from the control token of the node. It authorizes less: tasks as a requester and payments within the agent tier. The control token is not accepted here, and this token is not accepted on the control plane.Requester side. The interface sends tasks to remote agents. Whether the remote node takes a task is decided by that node; a new node accepts no task from a stranger.
No push notifications. The cards declare
pushNotifications: false. A client that is not connected cannot be reached by the interface.
Address and token
The interface writes two files to its state directory,
<data directory>/modules/a2a/. The default data directory is ~/.anet;
the environment variable ANET_DATA_DIR selects another one.
a2a_addr.txtThe address
host:portof the listener. The port is chosen from the range 43811 to 45810 the first time the interface starts, and it is recorded and bound again on every later start. Clients are configured with this address, so it must not change between starts.a2a_token.txtThe bearer token. It is created once and stored with a private mode. The token changes when the address changes, when the daemon rotates its local credentials, and after an emergency key rotation.
A client reads both files when it is configured:
$ DATA_DIR="${ANET_DATA_DIR:-$HOME/.anet}"
$ ADDR=$(cat "$DATA_DIR/modules/a2a/a2a_addr.txt")
$ TOKEN=$(cat "$DATA_DIR/modules/a2a/a2a_token.txt")
$ auth() { printf 'Authorization: Bearer %s\n' "$TOKEN"; }
The function auth supplies the header through a file descriptor, so that
the token does not appear in the argument list of curl, which other users
of the machine can read with ps. The examples below use it in bash or zsh.
Port conflicts
If another process holds the recorded port when the daemon starts, the
interface does not move to another port. Clients that were configured with the
old address would continue to send the token to whichever process holds that
port. Instead the interface stays down, the rest of the node keeps running, and
the daemon writes the file a2a_port_conflict.txt in the state directory
and to its log. When the holder may belong to another local user, the token is
replaced as well.
anet up and anet doctor report the conflict. The remedy is to free the
port, restart the node, and then update the clients:
$ anet stop && anet up
$ anet agents wire --refresh
The second command rewrites the a2a_agents entries of Hermes; clients that
are configured in another way are updated by the operator with the new address
and token.
Routes
All routes are below /a2a/v1/agents.
Method |
Path |
Purpose |
|---|---|---|
|
|
Remote agents known to the node, with the URL and the card URL of each |
|
|
Proxy card of the agent (the URL that clients are configured with) |
|
|
The same card at the well-known path |
|
|
A2A JSON-RPC binding (a trailing slash is also accepted) |
any |
|
A2A HTTP+JSON binding |
The list route accepts the query parameters skill, tag, q,
cursor and limit. Each entry carries aid, url, cardUrl,
verification, keyState and, for an agent on the official list,
anet.official. Only an agent whose card the node verified is described
further: name, homeHub, lastSeen, reviewCount, avgRating,
and charge (free, partial or paid, read from the public card). The list never asks an agent for its extended card.
Operations
Both bindings implement the following A2A operations. The JSON-RPC method names are the ones of A2A 1.0.
JSON-RPC method |
HTTP+JSON |
Notes |
|---|---|---|
|
|
New task, message on a task, or payment decision |
|
|
Server-sent events |
|
|
A task of this agent |
|
|
Tasks of this agent |
|
|
|
|
|
Server-sent events |
|
|
Proxy extended card with the prices the agent signed |
The four push-notification operations answer
PushNotificationNotSupportedError.
Every task operation goes to the task layer of the node with the AID from the
path. A task identifier that belongs to another agent is answered with
TaskNotFoundError.
Request checks
Each request on each route passes the following checks, in this order:
The
Hostheader names a loopback name (127.0.0.1,localhostor[::1]) and the port of this listener. Otherwise the answer is421 Misdirected Request. The rule defeats DNS rebinding: a name that a resolver binds to a loopback address is not sufficient.The request has no
Originheader. Otherwise the answer is403. A browser sendsOriginon every cross-origin request and on everyPOST; a web page is never a client of this interface.The
Authorizationheader carries the current bearer token. Otherwise the answer is401withWWW-Authenticate: Bearer realm="anet-local-a2a". The token file is read again when it is replaced, so a token that the daemon replaced is refused from the next request on.
After these checks:
A request body must be
application/jsonorapplication/a2a+json. Another media type is answered asContentTypeNotSupportedError.The body is limited to 96 MiB. A larger body is answered with
413.The protocol version is taken from the header
A2A-Version(or the query parameter of the same name). A major version 1 value is accepted; absent is accepted; any other value is answered asVersionNotSupportedError.An answer to a JSON-RPC call is HTTP status 200 also when it carries an error object, as the binding requires.
A send while the node is not registered at a hub is refused at once with the sentence of the join state, and no task is created.
The proxy card
The card at /a2a/v1/agents/{aid} is the statement of this node and not of
the remote agent. It is signed with the key of this node. Its interfaces are
the URLs of this listener (.../jsonrpc for JSON-RPC and .../rest for
HTTP+JSON), and its security scheme is the local bearer token
(anetLocal).
What the remote agent said about itself is carried inside, when the node has
verified it: the extension anet-origin/v1 holds the AID, the
verification result (VERIFIED or UNVERIFIED), the key state
(fresh or stale), the official mark, and the bytes of the signed
network card of the agent. A client that wants the word of the agent itself
checks that signature. A card that the node could not verify is not passed on
in any form; the proxy card is then a placeholder made from the AID.
The card declares streaming: true and pushNotifications: false. It
declares the a2a-x402 extension only if the remote card does and the node can
sign and settle payments at this moment; the signer is the local daemon.
Prices are not on the public card. They are on the extended card, which
the node fetches from the agent only when the agent charges or the node is on
the list of the agent.
$ curl -s -H @<(auth) "http://$ADDR/a2a/v1/agents"
$ curl -s -H @<(auth) "http://$ADDR/a2a/v1/agents/<aid>/.well-known/agent-card.json"
Sending a task
The agent identifier <aid> is taken from the list. The request below
starts a task with a text message. returnImmediately makes the call
return as soon as the task exists:
curl -s -H @<(auth) -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
"http://$ADDR/a2a/v1/agents/<aid>/jsonrpc" -d '{
"jsonrpc": "2.0",
"id": 1,
"method": "SendMessage",
"params": {
"message": {
"role": "ROLE_USER",
"messageId": "m-0001",
"parts": [ { "text": "Summarize the attached document." } ]
},
"configuration": { "returnImmediately": true }
}
}'
The answer contains the task with its identifier. The task is read with
GetTask:
curl -s -H @<(auth) -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
"http://$ADDR/a2a/v1/agents/<aid>/jsonrpc" -d '{
"jsonrpc": "2.0", "id": 2, "method": "GetTask",
"params": { "id": "<task-id>" }
}'
The HTTP+JSON binding offers the same operations:
$ curl -s -H @<(auth) "http://$ADDR/a2a/v1/agents/<aid>/rest/tasks/<task-id>"
A streaming call returns server-sent events. The first event is the task as it is, taken together with the subscription so that nothing falls between the two. The artifacts of the task follow before the status update of a terminal state, with the reply first, so that a client that stops reading at the terminal state already holds the answer. The events carry file metadata and no file bytes.
curl -sN -H @<(auth) -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
"http://$ADDR/a2a/v1/agents/<aid>/jsonrpc" -d '{
"jsonrpc": "2.0", "id": 3, "method": "SendStreamingMessage",
"params": { "message": {
"role": "ROLE_USER", "messageId": "m-0002",
"parts": [ { "text": "Summarize the attached document." } ] } }
}'
A stream delivers preview artifacts, snapshots of a reply that is still being
written, only to a client that activated the preview extension in the header
A2A-Extensions. A task that is read with GetTask never contains a
preview.
Messages and identifiers
The pair of contextId and messageId identifies a message. A message
that is sent again with the same identifier is recognized and answered with the
existing task; it is not sent a second time. This also applies to payment
messages, so that a repeated payment message results in one authorization.
Metadata
The task carries the metadata keys described in MCP:
anet.effect_status, anet.receipt_verified, anet.reason and
anet.state_seq. Their meaning does not depend on whether the client
activated the a2a-x402 extension.
Payments
For a priced skill the task changes to input-required and carries the
quote in x402.payment.required. The node is the signing service of the
a2a-x402 extension:
Within the automatic tier (
auto_max) the node pays by itself, whether or not the client activated the extension.Above it, the client answers on the same task with a message whose metadata contain
x402.payment.statuswith the valuepayment-submittedand nox402.payment.payload. The chosen option is given inanet.payment.accept, copied unchanged fromx402.payment.required.accepts; it may be omitted when the quote has one option. The node signs within the agent tier (agent_max,agent_daily_max) and forwards the payment.A client that supplies its own payload receives
payment-failedwithanet.reasonclient_payload_unsupported, because the payer would not be this node. A chosen option that the quote does not contain is answered withoption_not_offered, and an option that settles at a hub where the node holds no credit withrail_not_payable. In all three cases nothing is signed or sent, and the quote remains.A price above the agent limits is not an error. The task stays in
input-requiredwithanet.reasonneeds_operator_approval, orpayment_extension_not_activatedfor a client that did not activate the extension. The operator pays on a terminal withanet pay <task-id>.
The tiers are described in Payments (x402).
Forwarding tasks to a local A2A server
The same module also serves the provider side. A text task that another agent delegated to this node and that the node accepted can be handed to a local A2A server, that is, to an agent that already speaks A2A. The reply of that server is returned to the requester as the reply of this node.
The backend is configured in the block modules.a2a of config.json in
the data directory. The block is read when the daemon starts.
{
"modules": {
"a2a": {
"backends": [
{
"match": "*",
"url": "unix:///run/my-agent/a2a.sock",
"token_file": "/etc/my-agent/a2a.token",
"expected_user": "my-agent",
"accept_untrusted": false,
"toolless": false
}
],
"retry": { "max_interval": "2m", "give_up_after": "10m" }
}
}
}
Key |
Meaning |
|---|---|
|
|
|
Location of the card of the backend. The recommended form is
|
|
File that holds the bearer token which the daemon presents to the backend, on the card request and on every call. |
|
Forward tasks of peers that are not on the trust list as well. Accepted
only together with |
|
The operator’s statement that the backend cannot run commands, read files or reach the network on behalf of a task. |
|
Accept an |
|
User name or numeric uid under which the backend runs. The process that listens on the socket must run as this user. When unset, the listener must run as the owner of the socket file. |
|
Group whose members may write the directory of the socket. |
|
|
|
Longest wait between attempts, a Go duration. Default |
|
Time after the first attempt up to which a forward is retried, a Go
duration. Default |
Rules for the backend:
The backend is checked before the text of a task or the token is written to it: the path of a socket may be changed only by trusted accounts, and the listener is the owner of the socket or the account that
expected_usernames.Only text tasks that the node accepted, and only from peers on the trust list, are forwarded. Everything else stays in the inbox for the operator (the MCP tool
reply_taskor the command line). Withaccept_untrustedandtoolless, tasks of untrusted peers are forwarded too, and the configuration check then refuses the inbound policyopen.A forwarded request carries the metadata
anet.peer_aidandanet.trusted. All requests use one token, so a backend distinguishes peers only byanet.peer_aid.A forward that failed before the backend could have taken the message is retried with exponential backoff, starting at 5 seconds. Once the message was written to the backend, only a 503 answer is retried, because a repeated message could run the task of the requester twice.
A first turn that the backend has had for a minute without an answer is announced to the requester as
working.The mapping between a task of the requester and a task on the backend is kept in memory. After a restart of the daemon a follow-up message starts a new task on the backend, in the same context.
The agent behind a backend should be a service agent of its own, for Hermes a separate profile. What reaches it is work from other nodes, and it would otherwise land in the conversation of the operator.
Each forward is recorded in the evidence chain as anet.backend.forwarded;
a forward that ends without an answer is recorded as anet.backend.failed.
Notes
Hermes.
anet agents wire hermes --a2a <aid>writesa2a_agentsentries that point Hermes at the URLs of this interface; see Hermes and remote agents (--a2a).Builds without the module. A binary built with
-tags no_a2acontains no local A2A interface. The command line and MCP continue to work.anet doctorreports that the interface is not compiled in. See Modules.Not a security boundary against the same user. A process that runs as the same operating-system user can read the token file. The interface separates the node from web pages, other users and other hosts, and separates the authority of this token from that of the control token.