Push notifications
Receive each A2A task update as a signed delivery, verify it, and catch up on missed ones.
import { A2aPushVerificationError, verifyA2aPush } from "@nylorun/agents/a2a";
export async function POST(request: Request) {
try {
const update = await verifyA2aPush({
secret: process.env.A2A_PUSH_SECRET!, // whsec_…, the Runtime's push secret
headers: request.headers,
body: await request.text(),
});
await apply(update); // idempotent: deliveries can repeat
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof A2aPushVerificationError) return new Response(null, { status: 401 });
throw error;
}
}An Agent does not stream: its card says streaming: false. Instead, the
Runtime posts each status and artifact update of a task to a URL the caller
gives, signed with Standard Webhooks.
Deliveries are at least once, retried for up to 24 hours. The
change feed covers anything a receiver
missed.
Turn push on
The operator gives the Runtime a push secret. Without one, the card says
pushNotifications: false, and a push config is refused with
PushNotificationNotSupportedError.
| Variable | Value |
|---|---|
NYLORUN_A2A_PUSH_SECRET | A Standard Webhooks secret: whsec_ and the base64 of at least 24 bytes. It signs every push; receivers verify with it. |
NYLORUN_A2A_PUSH_ALLOW_HOSTS | Comma-separated host:port entries, or host:* for any port, that a push may reach although they are private, loopback, or link-local. See Allowed hosts. |
npx nylorun start passes both from its shell to the Runtime:
NYLORUN_A2A_PUSH_SECRET="whsec_$(openssl rand -base64 32)" npx nylorun startGive each receiver the same secret, and keep it on the receiver's server.
Give a push config
Send the config with the message, in configuration:
{
"jsonrpc": "2.0",
"id": 1,
"method": "SendMessage",
"params": {
"message": {
"role": "ROLE_USER",
"messageId": "m-1",
"contextId": "ticket-4821",
"parts": [{ "text": "Refund order demo-123." }]
},
"configuration": {
"returnImmediately": true,
"taskPushNotificationConfig": {
"url": "https://app.example.com/a2a/push",
"token": "c2b1…"
}
}
}
}| Field | Required | Meaning |
|---|---|---|
url | Yes | Where the Runtime posts each update: an http or https URL the Runtime may reach. |
token | No | Sent back on every delivery as X-A2A-Notification-Token, so the receiver can match a delivery to the config. |
authentication | No | { scheme, credentials }, sent on every delivery as Authorization: <scheme> <credentials>. |
id | No | The config's id within its task. The Runtime makes one when it is absent. |
A URL the Runtime will not call is refused when the config is given, with
InvalidParamsError. A task keeps at most 10 configs. The four config
methods, CreateTaskPushNotificationConfig, GetTaskPushNotificationConfig,
ListTaskPushNotificationConfigs, and DeleteTaskPushNotificationConfig, add,
read, and remove them on a task that exists. A finished task's configs are
kept for 7 days. Each method is a JSON-RPC request to
POST /v1/a2a/agents/{agentId}.
With the SDK, pass push to a2a.send(), or use a2a.call() for the config
methods; see
A2A client.
A config carries the data of the extensions its request activated with
A2A-Extensions, or of all five when the request named none.
Deliveries
Each delivery is an HTTP POST to the config's url. The body is one A2A
StreamResponse object:
| Body | When |
|---|---|
{ "statusUpdate": … } | The task's state changed, the agent wrote step text, or it carries new extension data, such as a plan from progress/v1. |
{ "artifactUpdate": … } | The turn wrote a file, under files/v1, or ended with its output. |
| Header | Value |
|---|---|
Content-Type | application/json |
X-A2A-Notification-Token | The config's token, when it has one. |
Authorization | The config's authentication, when it has one. |
webhook-id | The delivery's id, the same on every attempt. |
webhook-timestamp | When this attempt was signed, in Unix seconds. |
webhook-signature | v1, and the base64 of HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}, keyed with the push secret's bytes. |
Every statusUpdate carries status.timestamp, the time of the event it
reports. Deliveries can repeat and can arrive out of order: drop a
webhook-id you have applied, order a task's updates by status.timestamp,
and let an update older than the one you hold change nothing.
Verify a delivery
verifyA2aPush() checks the signature and the timestamp, and resolves to the
parsed body. It is async. Pass the body exactly as received, as a string or
bytes, before any JSON parsing.
| Option | Meaning |
|---|---|
secret | The push secret, whsec_…. |
headers | The request's headers: a Headers, node:http's IncomingHttpHeaders, or a plain record. |
body | The raw body, a string or a Uint8Array. |
toleranceSeconds | How far webhook-timestamp may be from now, either way. Default 300. |
token | The config's token, when you gave one: X-A2A-Notification-Token must match it. |
A delivery that fails a check throws A2aPushVerificationError, whose
reason is missing_headers, invalid_secret, invalid_timestamp,
timestamp_out_of_range, invalid_signature, invalid_token, or
invalid_body. Answer it with 401 and drop it.
With Node's own crypto:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyPush(secret: string, headers: Headers, body: string): unknown {
const id = headers.get("webhook-id");
const timestamp = headers.get("webhook-timestamp");
const signatures = headers.get("webhook-signature");
if (!id || !timestamp || !signatures) throw new Error("unsigned delivery");
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error("stale delivery");
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
const valid = signatures.split(" ").some((entry) => {
const [version, value] = entry.split(",");
if (version !== "v1" || !value) return false;
const given = Buffer.from(value, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
if (!valid) throw new Error("bad signature");
return JSON.parse(body);
}webhook-signature can hold several space-separated v1,… entries; one that
matches verifies the delivery. Compare in constant time, as above.
Retries
A delivery ends when the receiver answers 2xx, or 410 to say it is gone.
Any other answer, a timeout of 10 seconds, or a failed connection is sent
again, with the same webhook-id and body. The wait starts at 5 seconds and
doubles after each failure, up to 10 minutes, and the Runtime drops the
delivery once it is 24 hours old. A delivery whose URL has become refused is
dropped at once.
Answer quickly and do the work after. A receiver that was down for longer than
24 hours, or that refused deliveries, catches up from the
change feed and GetTask.
Allowed hosts
The Runtime sends pushes through its address checks, as it calls HTTP tools,
with one difference: a push never goes to a private, loopback, or link-local
address unless that host:port is in NYLORUN_A2A_PUSH_ALLOW_HOSTS, whatever
NYLORUN_ENDPOINT_PRIVATE says. Plain http follows
NYLORUN_ENDPOINT_HTTP; see Private addresses.
NYLORUN_A2A_PUSH_ALLOW_HOSTS=172.17.0.1:18787,receiver.internal:*The same list governs the files the Runtime fetches from a message's url
parts.