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.txt

The address host:port of 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.txt

The 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

GET

/a2a/v1/agents

Remote agents known to the node, with the URL and the card URL of each

GET

/a2a/v1/agents/{aid}

Proxy card of the agent (the URL that clients are configured with)

GET

/a2a/v1/agents/{aid}/.well-known/agent-card.json

The same card at the well-known path

POST

/a2a/v1/agents/{aid}/jsonrpc

A2A JSON-RPC binding (a trailing slash is also accepted)

any

/a2a/v1/agents/{aid}/rest/...

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

SendMessage

POST rest/message:send

New task, message on a task, or payment decision

SendStreamingMessage

POST rest/message:stream

Server-sent events

GetTask

GET rest/tasks/{id}

A task of this agent

ListTasks

GET rest/tasks

Tasks of this agent

CancelTask

POST rest/tasks/{id}:cancel

SubscribeToTask

POST rest/tasks/{id}:subscribe

Server-sent events

GetExtendedAgentCard

GET rest/extendedAgentCard

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:

  1. The Host header names a loopback name (127.0.0.1, localhost or [::1]) and the port of this listener. Otherwise the answer is 421 Misdirected Request. The rule defeats DNS rebinding: a name that a resolver binds to a loopback address is not sufficient.

  2. The request has no Origin header. Otherwise the answer is 403. A browser sends Origin on every cross-origin request and on every POST; a web page is never a client of this interface.

  3. The Authorization header carries the current bearer token. Otherwise the answer is 401 with WWW-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/json or application/a2a+json. Another media type is answered as ContentTypeNotSupportedError.

  • 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 as VersionNotSupportedError.

  • 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.status with the value payment-submitted and no x402.payment.payload. The chosen option is given in anet.payment.accept, copied unchanged from x402.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-failed with anet.reason client_payload_unsupported, because the payer would not be this node. A chosen option that the quote does not contain is answered with option_not_offered, and an option that settles at a hub where the node holds no credit with rail_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-required with anet.reason needs_operator_approval, or payment_extension_not_activated for a client that did not activate the extension. The operator pays on a terminal with anet 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

match

"*" for every accepted text task, or a skill identifier. Required; a second backend with the same value is rejected.

url

Location of the card of the backend. The recommended form is unix:///path/to/socket. An http or https URL needs allow_tcp.

token_file

File that holds the bearer token which the daemon presents to the backend, on the card request and on every call.

accept_untrusted

Forward tasks of peers that are not on the trust list as well. Accepted only together with toolless: true.

toolless

The operator’s statement that the backend cannot run commands, read files or reach the network on behalf of a task.

allow_tcp

Accept an http or https backend URL. For a backend that can listen only on TCP; a listener on loopback must then run as the user of the daemon.

expected_user

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.

socket_group

Group whose members may write the directory of the socket.

peer_check

path-only skips the check of the listening account on platforms that cannot determine it. anet doctor reports each such backend.

retry.max_interval

Longest wait between attempts, a Go duration. Default 2m.

retry.give_up_after

Time after the first attempt up to which a forward is retried, a Go duration. Default 10m; 0 tries once. The value is to stay below the no_response_after of the requesters (15 minutes by 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_user names.

  • 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_task or the command line). With accept_untrusted and toolless, tasks of untrusted peers are forwarded too, and the configuration check then refuses the inbound policy open.

  • A forwarded request carries the metadata anet.peer_aid and anet.trusted. All requests use one token, so a backend distinguishes peers only by anet.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> writes a2a_agents entries 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_a2a contains no local A2A interface. The command line and MCP continue to work. anet doctor reports 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.

See also