GNL
Docs menu
Core · Free@gnldev/a2a

Remote agents (A2A)

Call an agent running on another server as an ordinary tool — exactly-once across the network, because the remote side replays a deterministic runId.

What it is for#

createAgentTool turns a local agent into a tool. createA2ATool does the same for an agent on another server: it posts to that server's REST endpoint and returns the answer as a tool result. From the model's point of view nothing is different.

What makes it more than an HTTP call is the runId. It is derived deterministically from the call, so a repeated POST has no second side effect — the remote runDurable replays the same run and returns the recorded result. And when the tool sits inside a parent runDurable, the parent journals it too: on a parent resume the remote call is skipped entirely.

That is the last boundary in the chain. A tool call, a sub-agent, a network router, a queued job, a cron fire and now a call to a different machine — the same guarantee holds at each of them.

Step by step#

Point it at the remote server and agent. The result carries the answer text, any interrupts, the remote agent name and the runId the remote replayed under — that last field is the exactly-once handle, and it is worth logging.

delegate to a remote agent
import { createA2ATool } from '@gnldev/a2a';

const research = createA2ATool({
  endpoint: 'https://research.internal',
  agentName: 'researcher',
  timeoutMs: 30_000,
  secret: process.env.A2A_SECRET,   // opt-in HMAC signing
});

const res = await runDurable({
  runId: 'brief-9',
  journal,
  model,
  tools: { research },
  prompt: 'Summarise the competitor landscape',
});

// the tool result carries the remote handle:
// { text, interrupts, runId: 'a2a:brief-9:call-3', remoteAgent: 'researcher' }

Where the runId comes from#

Inside runDurable the wrapper supplies an idempotencyKey that is already scoped to the parent run — {parentRunId}:{toolCallId}, or {parentRunId}:{toolName}:{hash} in args mode — and the remote runId is derived from it. That scoping is the point: it is globally unique.

Outside a durable context there is no such key, so the raw toolCallId is used instead, and it is only unique within one run. Some providers issue short ids like call_1; two unrelated runs can produce the same one, and then the remote side would replay the first run's recorded answer for the second call. Reaching this tool through runDurable avoids that; using it in a bare AI SDK loop makes id uniqueness your responsibility.

Signing and timeouts#

Signing is opt-in. Give a secret and the request goes out with x-gnl-signature — an HMAC-SHA256 over timestamp + '.' + body — plus x-gnl-timestamp, so the timestamp is part of what is signed and a captured body cannot be replayed indefinitely. The receiving server verifies it with createRestApi({ a2aSecret }). Without a secret the request is unsigned, which is the older behaviour and unchanged.

timeoutMs defaults to 30 seconds. On timeout the error is shaped like a step timeout: the wrapping durableTool writes a failed record and the model sees the real error rather than a silent hang.

createRestApi({ a2aSecret })
// on the receiving server verifies x-gnl-signature over timestamp + '.' + body
app.route('/api', createRestApi(config, { title: 'Research', a2aSecret: process.env.A2A_SECRET }));

API#

fncreateA2ATool

Builds an AI SDK tool that delegates to a remote agent. Options: endpoint, agentName, description?, headers?, fetchImpl?, timeoutMs? (default 30_000), secret?.

typeA2AResult

{ text, interrupts, runId, remoteAgent } — runId is the deterministic id the remote replayed under.

typea2aSecret

createRestApi({ a2aSecret }) on the receiving side verifies the x-gnl-signature / x-gnl-timestamp pair.

typebudgetGuard

budgetGuard — an optional hook called BEFORE the remote fetch, so a quota check can refuse the call rather than discover the overage afterwards. a2a does not embed the quota itself; the host injects it, e.g. () => assertBudget(journal, { orgId, fallback }). Without it there is no quota check at all.

typeStepTimeoutError

The error thrown on timeout: { detail: { label, timeoutMs } }. It is defined locally rather than imported, because @gnldev/durable is only a devDependency here — the shape matches so that a durableTool wrapper still recognises it and writes a failed record.

Prefer calling it from inside runDurable
That is what supplies the parent-scoped idempotency key, and it is what makes the remote runId globally unique. In a bare AI SDK loop the tool still works, but the id collision described above becomes possible and avoiding it is on you.
Unsigned by default
No secret means no signature — the same as before this option existed, and fine on a private network. On any path that crosses a trust boundary, set secret here and a2aSecret on the receiving server; otherwise anything that can reach the endpoint can start a run on it.