A2A client
Send tasks to an Agent over A2A from your server, answer its approvals, read its extensions, and catch up from the change feed.
npm install @nylorun/agentsimport { createA2aClient } from "@nylorun/agents/a2a";
const a2a = createA2aClient({
url: `${process.env.NYLORUN_RUNTIME_URL}/v1/a2a/agents/support`,
key: process.env.NYLORUN_SERVER_KEY!,
subject: "a2a:billing",
});
const task = await a2a.send({
messageId: "refund:demo-123",
contextId: "ticket-4821",
parts: [{ text: "Refund order demo-123." }],
returnImmediately: true,
push: { url: "https://app.example.com/a2a/push", token: pushToken },
});createA2aClient() speaks A2A 1.0 JSON-RPC to one agent: an Agent on the
Runtime, an endpoint createA2aHandler() serves, or any other A2A 1.0 agent.
It sends A2A-Version: 1.0 and activates all five
profile v2 extensions with A2A-Extensions. It is in
@nylorun/agents 0.31.0-beta or later, from @nylorun/agents/a2a and from
@nylorun/agents. Run it on your server: it carries an application key.
Create a client
| Option | Meaning |
|---|---|
url | The agent's A2A endpoint: ${runtime}/v1/a2a/agents/${agentId} on the Runtime, or another agent's JSON-RPC URL. |
key | An application key, sent as Authorization: Bearer. |
subject | Who the tasks belong to, sent as Nylorun-Subject. The Runtime needs one with an application key: a caller sees only its own subject's contexts and tasks. |
scopes | The subject's scopes, sent as Nylorun-Scopes. Default ["sessions:own"]. |
headers | More headers for every request, such as a partner endpoint's own key. |
fetch | Your own fetch. |
extensions | The extension URIs to activate. Default: all five. A2A_EXTENSIONS names each URI. |
cardUrl | Where the Agent Card is. Default: ${url}/card for a Runtime endpoint, else ${url}/.well-known/agent-card.json. |
Keep the subject stable for one caller: it owns the contexts, and another subject's task answers as not found.
Send a task
const task = await a2a.send({
messageId: "refund:demo-123",
contextId: "ticket-4821",
parts: [{ text: "Refund order demo-123." }],
returnImmediately: true,
push: { url: "https://app.example.com/a2a/push", token: pushToken },
traceparent,
});| Field | Meaning |
|---|---|
messageId | The message's idempotency key. A retry with the same id answers with the same task. |
contextId | The conversation: one session per subject, Agent, and contextId. Omit it to start a new one; read it back from task.contextId. |
taskId | A task to continue, such as one waiting on input. |
parts | text, data, raw, and url parts. |
metadata | The message's metadata. |
extensions | Extension URIs the message's metadata uses. |
delivery | What a message to a busy context does: queue (the default) or reject. Sent as metadata.delivery. |
push | A push config: url, and optionally token, authentication, and id. See Push notifications. |
returnImmediately | Answer at once with the task, instead of waiting. |
historyLength, acceptedOutputModes | As in A2A's SendMessage configuration. |
traceparent | A W3C trace context the turn joins. See telemetry/v1. |
signal | An AbortSignal. |
send() resolves to the A2A Task. Without returnImmediately, it waits
until the task ends or pauses, up to the Runtime limit a2aWaitMs (5 minutes
by default), then answers with the task still TASK_STATE_WORKING. For long
work, pass returnImmediately and a push config.
A message to a context whose task is still running is queued: send()
answers with the task that will take it, TASK_STATE_SUBMITTED, under the
queued message's id until a turn takes it. With delivery: "reject", the
client throws A2aError with reason CONTEXT_BUSY instead.
Read, list, and cancel
const current = await a2a.get(task.id, { historyLength: 10 });
const page = await a2a.list({
contextId: "ticket-4821",
status: "TASK_STATE_WORKING",
statusTimestampAfter: "2026-11-02T09:00:00Z",
pageSize: 50,
});
for await (const each of a2a.listAll({ contextId: "ticket-4821" })) console.log(each.id, each.status.state);
await a2a.cancel(task.id);
const card = await a2a.card();| Call | A2A method | Returns |
|---|---|---|
get(taskId, { historyLength? }) | GetTask | The task, with its latest plan, telemetry once it ended, and its artifacts. history only with historyLength. |
list({ contextId?, status?, statusTimestampAfter?, pageSize?, pageToken?, historyLength? }) | ListTasks | { tasks, nextPageToken, pageSize, totalSize }: the subject's tasks on this Agent, latest status change first. pageSize is at most 100, 50 by default. Pass nextPageToken as pageToken for the next page. |
listAll(options) | ListTasks, page after page | An async iterator over every task list matches. |
cancel(taskId) | CancelTask | The task, TASK_STATE_CANCELED. |
card() | The Agent Card | The agent's card, with its extensions and capabilities. |
call(method, params) | Any | One JSON-RPC call, such as GetTaskPushNotificationConfig. |
Each is one JSON-RPC request to
POST /v1/a2a/agents/{agentId},
and the card is GET /v1/a2a/agents/{agentId}/card.
A queued message that no turn has taken yet is not a task ListTasks lists,
and it cannot be cancelled until it starts: CancelTask answers
TaskNotCancelableError. A task that already ended answers the same.
Answer an interaction
An approval, a question, or a connection request pauses the task under
interactions/v1. Answer on the same task:
import { readInteraction } from "@nylorun/agents/a2a";
const open = readInteraction(task);
if (open?.kind === "approval") {
await a2a.answer({
taskId: task.id,
contextId: task.contextId,
interactionId: open.interactionId,
approved: true,
messageId: `answer:${open.interactionId}`,
});
}answer() takes exactly one of approved, for an approval, and value,
for a question or a connection ({ connected: true }). The messageId
makes the answer safe to retry.
Read the extensions
Each reader takes whatever you hold: a push's StreamResponse, a task, a
status update, a status, a message, or an artifact. It looks in that object's
own metadata, then its status message's, and returns undefined when the
data is missing or not the profile's shape. A core A2A agent's tasks read as
plain A2A.
| Reader | Returns |
|---|---|
readInteraction(source) | interactions/v1: { interactionId, kind, prompt?, detail? } |
readProgress(source) | progress/v1: { steps } |
readTelemetry(source) | telemetry/v1: { totals?, traceId? } |
readEffects(source) | effects/v1: { uncertain? } |
readFiles(source) | files/v1: an array of { artifact, file }, one for each artifact with file data in a task, an artifact update, or one artifact. Empty when there are none. |
readExtension(source, uri) | The raw data under any extension URI. |
Verify pushes
import { readFiles, readProgress, readTelemetry, verifyA2aPush } from "@nylorun/agents/a2a";
export async function POST(request: Request) {
const update = await verifyA2aPush({
secret: process.env.A2A_PUSH_SECRET!,
headers: request.headers,
body: await request.text(),
token: pushToken,
});
const steps = readProgress(update)?.steps;
const totals = readTelemetry(update)?.totals;
for (const { file } of readFiles(update)) await saveFile(file.path, file.version, file.sha256);
await saveStatus(update, { steps, totals });
return new Response(null, { status: 204 });
}verifyA2aPush() resolves to the parsed delivery, or throws
A2aPushVerificationError with a reason. Signing, retries, and ordering are
on Push notifications.
The change feed
import { listTurnChanges } from "@nylorun/agents/a2a";
let cursor = await loadCursor(); // undefined the first time
let hasMore = true;
while (hasMore) {
const page = await listTurnChanges({
baseUrl: process.env.NYLORUN_RUNTIME_URL!,
key: process.env.NYLORUN_SERVER_KEY!,
changedAfter: cursor,
limit: 100,
});
for (const turn of page.turns) if (turn.a2a) await reconcile(turn);
cursor = page.cursor;
hasMore = page.hasMore;
await saveCursor(cursor);
}Pushes can be lost: a receiver down for more than a day, or one that refused
them. The change feed is the backstop. listTurnChanges() calls
GET /v1/turns?changedAfter=<cursor>
and returns the turns that changed after the cursor, in the order they
changed. A turn appears again each time it changes. Store cursor and pass it
as changedAfter next time; it comes back even when turns is empty. Without
changedAfter, the feed starts from the beginning. One call covers every task,
so a receiver can run it on a timer and when it starts, then read any task it
is unsure of with a2a.get().
| Option | Meaning |
|---|---|
baseUrl | The Runtime's URL. |
key | An application key. Alone, it reads the whole Tenant. |
subject, scopes | Act for a subject: only that subject's sessions' turns. |
changedAfter | The previous page's cursor. |
sessionId | One session's turns instead, oldest first. |
limit | 1 to 500. Default 100. |
It needs the Host feature turns. Each turn:
| Field | Meaning |
|---|---|
turnId, sessionId, agentId | The turn, its session, and its Agent. |
status | running, paused, completed, failed, or cancelled. |
startedAt, endedAt | ISO 8601 times. endedAt is null until the turn ends. |
changedAt | When the turn last changed. |
totals | { steps, tokens, costUsd }, or null. |
a2a | { contextId, taskId } for a turn that is an A2A task, else null. |
Errors
A JSON-RPC error from the agent throws A2aError:
| Field | Meaning |
|---|---|
code | The JSON-RPC or A2A code, such as -32001 for a task that is not found. |
message | The agent's message. |
reason | The google.rpc.ErrorInfo reason, such as TASK_NOT_FOUND or CONTEXT_BUSY. |
metadata | The ErrorInfo metadata, such as the running task's id. |
An HTTP refusal, such as 401, 404 for an unknown Agent, or 429, throws
the SDK's RuntimeError; see Errors
in the Agents SDK reference.