NylorunDocsBeta
A2A

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.

VariableValue
NYLORUN_A2A_PUSH_SECRETA Standard Webhooks secret: whsec_ and the base64 of at least 24 bytes. It signs every push; receivers verify with it.
NYLORUN_A2A_PUSH_ALLOW_HOSTSComma-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 start

Give 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…"
      }
    }
  }
}
FieldRequiredMeaning
urlYesWhere the Runtime posts each update: an http or https URL the Runtime may reach.
tokenNoSent back on every delivery as X-A2A-Notification-Token, so the receiver can match a delivery to the config.
authenticationNo{ scheme, credentials }, sent on every delivery as Authorization: <scheme> <credentials>.
idNoThe 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:

BodyWhen
{ "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.
HeaderValue
Content-Typeapplication/json
X-A2A-Notification-TokenThe config's token, when it has one.
AuthorizationThe config's authentication, when it has one.
webhook-idThe delivery's id, the same on every attempt.
webhook-timestampWhen this attempt was signed, in Unix seconds.
webhook-signaturev1, 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.

OptionMeaning
secretThe push secret, whsec_….
headersThe request's headers: a Headers, node:http's IncomingHttpHeaders, or a plain record.
bodyThe raw body, a string or a Uint8Array.
toleranceSecondsHow far webhook-timestamp may be from now, either way. Default 300.
tokenThe 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.

On this page