A2A remote agents
nr-llm can hand a message to a remote agent that speaks the Agent2Agent protocol (A2A) 1.0 over JSON-RPC, and follow the task the remote agent creates. This page covers the client: a record per remote agent, a PHP API, the commands, and agent runs that delegate a step to a remote agent and wait for it (ADR-281, see Delegating a step of an agent run).
Register a remote agent
Create an A2A Remote Agent record (list module, any page or the root). The record holds:
- Identifier, title, description
- The identifier names the agent on the command line.
- Agent card URL
- The URL of the remote agent card, usually ending in
/..well- known/ agent- card. json - Credential
- Stored in nr-vault, never in the record. nr-vault adds it as
Authorization: Bearerto every request to this agent, and writes its audit row. Leave it empty for an agent without authentication. The card itself is fetched without the credential. A credential is only sent over https: a request with a credential to an http URL is refused before anything leaves (CREDENTIAL_).NEEDS_ HTTPS - Data class
- What may be sent to this agent. There is no default: a record without a data class does nothing, the card import included.
- Requires approval
- On by default. A run that calls this agent waits for a person's approval first (ADR-084).
- Enabled
- Off by default. Nothing is sent to a disabled agent.
- Limits
- Maximum response size (default 1 MiB; a larger response is not parsed.
The transfer stops at the limit when the nr-vault client streams, which
needs PHP's
curl_functions; otherwise the whole body is received and the request timeout bounds the transfer), request timeout (default 30 s), connect timeout (default 5 s; see Known limits), and the limits of a delegating run: maximum wait (default 3600 s), minimum and maximum poll interval (5 s and 300 s) and delegations per run (3).multi_*
Nothing the remote agent's card says sets the data class, the enabled flag or the approval flag: the remote does not decide its own trust.
Import the agent card
vendor/bin/typo3 nrllm:a2a:import <identifier>
The import fetches the card through the address guard, checks it against the
A2A 1.0 proto and selects the first interface with binding JSONRPC and
protocol version 1.. A card that offers no such interface is refused, and
the message names what it offers instead. When the interface names a
tenant, every request carries it.
The interface URL is where the credential goes. When it is not on the card URL's own scheme, host and port, the first import already marks the record Card changed, confirm it: a card cannot move the credential to another host, or from https to http, without an administrator seeing it.
Import again whenever the remote agent changes. If the new card has another interface URL, another tenant or other capabilities, the record shows Card changed, confirm it, and the client refuses the agent until an administrator has looked at the card, chosen Confirm: <interface URL> in Confirm the card and saved. The choice names the card the form showed: if an import changed the card while the form was open, the save confirms nothing and the record log says to review the card again. Saving the record without the choice confirms nothing, and the command-line user cannot confirm. The confirmed interface URL is shown on the record; an interface off the card URL's host is used only while it is that one.
Changing the card URL or the credential, including entering a new secret value for the same credential, drops the imported card: import it again, and confirm it if asked, before the agent is used. A secret replaced in the nr-vault module, outside this record, is not noticed.
Only administrators can open or edit remote agent records.
Which hosts the client may reach
Every request, the card import included, goes through the address guard of
ADR-202 under its own egress group a2a_
(ADR-279): http or https, no host of this installation, every
address the host resolves to public, the connection pinned to exactly the
addresses that were checked, no redirect followed, nothing retried.
The A2A client has host lists of its own in the extension configuration,
separate from those of the fetch_ tool:
a2a.client. allowed Hosts - Empty (default) allows every public host. A host on a port other than 80
or 443 needs an entry with the port, for example
agent..example. org: 8443 a2a.client. denied Hosts - Hosts and addresses that are always refused.
a2a.client. allow Via Proxy - Behind an HTTP proxy the address checks cannot reach past the proxy, so A2A requests are refused unless this is on and the allowlist is non-empty.
A remote agent on a private, loopback or link-local address cannot be reached; no list entry opens that.
Note
The address pin is nr-vault's Pinned
(nr-vault 1.5.0 and later, which nr-llm requires). If the vault's HTTP
client does not implement it, the client refuses every request to a host
name, with the reason PIN_, rather than send it unpinned. A
URL with an IP address needs no pin.
Send a message from the command line
The commands act as the backend user in the extension setting
worker.: nr-vault reads the credential as that user,
and the user owns the tasks the commands create. Without one configured the
commands refuse.
vendor/bin/typo3 nrllm:a2a:send <identifier> "Summarise the release notes"
# prints the remote task id and exits
vendor/bin/typo3 nrllm:a2a:get <identifier> <task-id>
# prints the task: state, status message, artifacts
nrllm: sends with return, so it returns as soon as
the remote agent has accepted the task. nrllm: reads it later, as
often as needed. Only tasks this installation sent can be read. Text from the
remote agent is printed as plain text: console markup is shown literally, and
control characters (C0 and C1) and format characters such as bidi overrides
are removed, the line break excepted.
What happens to a message
Before a message leaves, the client writes an intent to the table
tx_ and commits it: owner, agent, message id and the sha256
of the request. The intent then ends in exactly one state:
accepted- The remote agent created a task. Its task and context ids are stored, and the row is the local handle of the task.
answered- The remote agent answered with a message and created no task.
refused- The remote agent answered with a JSON-RPC error (code and reason are stored), or the client refused locally: the guard refused the address, the credential is missing, a redirect, an invalid response with a status other than 2xx or 5xx.
outcome_unknown - The request may have reached the remote agent and no response was read: a
timeout after sending, a lost connection, a response larger than the
record's limit (
RESPONSE_), a 5xx without a JSON-RPC body, a 2xx that is not a valid response (TOO_ LARGE INVALID_), a response of a kind A2A 1.0 does not know.AGENT_ RESPONSE
An unknown outcome is never sent again, by the client or anything else.
The remote may well have created a task, but no task id came back, so
nrllm: and list cannot find it: they know only tasks
with a local handle. Check on the remote agent's side, with its operator or
its own tools, whether the message id created a task before sending again;
a resend is a new message with a new message id.
The same owner sending the same request under the same message id again gets the first intent back and sends nothing. The same message id with a different request is refused before anything is sent.
PHP API
Netresearch\ is a public service:
$outcome = $client->sendMessage(
$agent, // A2aRemoteAgent record
$backendUserUid, // the local actor, owner of the task
A2aMessage::userText(A2aClient::newMessageId(), 'Hello'),
A2aSendMessageConfiguration::returningImmediately(),
);
if ($outcome->result?->isTask()) {
$taskId = $outcome->intent->remoteTaskId;
$task = $client->getTask($agent, $backendUserUid, $taskId);
}
send returns an A2a: the journal row, whether
this call sent anything, and the result, which is a task or a message.
A caller has to handle both. get and cancel work only on
a task the same backend user created; another user's task reads as not
found, and the remote agent is not asked. list returns only the
caller's tasks, even when several users share one remote credential, and
creates no handle.
The classes are @internal while the A2A stages land; their names and
signatures may still change.
Delegating a step of an agent run
Each enabled remote agent with an imported card offers one tool per skill on
its card, named a2a_<identifier>_<skill id>. Like an MCP tool it is off
until an administrator enables it on a configuration, it carries the record's
data class (ADR-094), it asks for approval when the record
says so, and it goes through the egress group a2a_. Listing the tools
opens no connection. Approval, data class and the enabled switch are read from
the record when the tool is called, so a change reaches a running worker.
A model turn that calls such a tool together with any other tool is refused before anything of it runs; the model is told to call the delegation alone. At most one delegation is pending per run segment.
The tool sends the message with return. A message answer, or a
task the remote agent already finished, is the tool's result and the run goes
on. Otherwise the run is suspended in the status waiting_:
it holds no lease and no worker, the inbox shows it in the section
Waiting for a remote agent, never as a decision, and
nrllm: counts it.
Schedule the poller like nrllm:, at least as often as the
shortest minimum poll interval of the records:
vendor/bin/typo3 nrllm:a2a:poll
It looks at each waiting run's remote task first at the record's minimum poll
interval after the send, then at twice the last interval up to the maximum,
with up to a quarter of jitter. A task that finished, failed or asks for input
continues the run with the remote's answer as the tool's result, as the run's
owner and in its workspace. A request that fails on the way counts as no news.
After the record's maximum wait the run continues with a "timed out" result
and the remote task is cancelled once. The poller acts as
worker. towards nr-vault, like the commands.
Cancelling a waiting run (nrllm:) cancels its remote task once,
as the owner of the delegation, and the run ends cancelled whatever the remote
answers; the answer is kept in the journal (cancel_).
The limits of a run (its round cap, token budget, and the delegations it made to each agent) carry over every wait. The delegation limit counts only what this installation sends; a remote agent that delegates further does so outside nr-llm's control.
Important
While nr_mcp_agent is installed, no delegation tool is offered to any run. nr_mcp_agent's chat cannot be told apart from other runs and does not yet understand a run that waits for a remote agent; no nr_mcp_agent release does so far.
Known limits
- Connect timeout. nr-vault takes one timeout per request. The record's
request timeout bounds the whole request; the connect timeout is bounded by
it and by TYPO3's
HTTP., not on its own.connect_ timeout - Protocol. A2A 1.0 over JSON-RPC only. A card that offers only gRPC, HTTP+JSON or protocol 0.3 cannot be imported. Fields of later protocol versions are ignored; an unknown task state never counts as completed.
- No streaming and no push yet; a task is followed by asking for it.
- An interrupted remote task (
TASK_,STATE_ INPUT_ REQUIRED TASK_) is handed to the model as such; nothing answers the remote agent yet.STATE_ AUTH_ REQUIRED - A run that cannot be suspended after its message was accepted fails;
the remote task stays in the journal as
acceptedand is not sent again.