---
title: "A2A remote agents"
manual: "LLM Foundation"
version: "main"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:administration-a2a-remote-agents@main"
source: "Administration/A2aRemoteAgents.rst"
rendered: "2026-11-12T15:41:35+00:00"
---

# A2A remote agents {#administration-a2a-remote-agents}

nr-llm can hand a message to a remote agent that speaks the [Agent2Agent
protocol](https://a2a-protocol.org/v1.0.1/specification/) (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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-281@main), see [Delegating a step of an agent run](https://docs.typo3.org/permalink/netresearch/nr-llm:administration-a2a-delegation@main)).

## Register a remote agent {#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: Bearer` to 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-084@main)).

-   **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_multi_*` 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](https://docs.typo3.org/permalink/netresearch/nr-llm:administration-a2a-remote-agents-limits@main)), 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).

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 {#import-the-agent-card}

**Import or re-import the agent card**

```bash
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.0`. 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 {#which-hosts-the-client-may-reach}

Every request, the card import included, goes through the address guard of
[ADR-202](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-202@main) under its own egress group `a2a_agent`
([ADR-279](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-279@main)): 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_external_url` tool:

-   **`a2a.client.allowedHosts`**

    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.deniedHosts`**

    Hosts and addresses that are always refused.

-   **`a2a.client.allowViaProxy`**

    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 `PinnedAddressHttpClientInterface`
> (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_UNSUPPORTED`, rather than send it unpinned. A
> URL with an IP address needs no pin.

## Send a message from the command line {#send-a-message-from-the-command-line}

The commands act as the backend user in the extension setting
`worker.technicalBeUserUid`: nr-vault reads the credential as that user,
and the user owns the tasks the commands create. Without one configured the
commands refuse.

**Send a message, then read the task**

```bash
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:a2a:send` sends with `returnImmediately`, so it returns as soon as
the remote agent has accepted the task. `nrllm:a2a:get` 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 {#what-happens-to-a-message}

Before a message leaves, the client writes an intent to the table
`tx_nrllm_a2a_send` 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_TOO_LARGE`), a 5xx without a JSON-RPC body, a
    2xx that is not a valid response (`INVALID_AGENT_RESPONSE`), a response
    of a kind A2A 1.0 does not know.

**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:a2a:get` and `listTasks()` 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 {#php-api}

`Netresearch\NrLlm\Service\Agent\A2a\Client\A2aClient` is a public service:

**Send a message and read the task from PHP**

```php
$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);
}
```

`sendMessage()` returns an `A2aSendOutcome`: the journal row, whether
this call sent anything, and the result, which is a task **or** a message.
A caller has to handle both. `getTask()` and `cancelTask()` 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. `listTasks()` 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 {#administration-a2a-delegation}

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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-094@main)), it asks for approval when the record
says so, and it goes through the egress group `a2a_agent`. 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 `returnImmediately`. 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_for_dependency`:
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:agent:status` counts it.

Schedule the poller like `nrllm:agent:work`, at least as often as the
shortest minimum poll interval of the records:

```bash
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.technicalBeUserUid` towards nr-vault, like the commands.

Cancelling a waiting run (`nrllm:agent:cancel`) 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_outcome`).

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 {#administration-a2a-remote-agents-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.connect_timeout`, not on its own.
-   **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_STATE_AUTH_REQUIRED`) is handed to the model as such; nothing
    answers the remote agent yet.
-   **A run that cannot be suspended** after its message was accepted fails;
    the remote task stays in the journal as `accepted` and is not sent again.
