Skip to main content

Signal HTTP Reference

A signal is how anything outside Waxell starts an agent run: your backend, a webhook from another service, a cron job, a CI step. You POST a JSON payload to a named signal; every agent whose manifest subscribes to that signal name runs with the payload as its input. You then follow the run through its event log, either by polling or over a Server-Sent Events (SSE) stream.

This page is the wire-level reference. The wax signals fire command wraps the same endpoints. See Your first agent with wax init.

EndpointPurpose
POST /api/v1/signals/<name>/Fire a signal
GET /api/v1/runs/<run_id>/events/Poll a run's event log
GET /api/v1/runs/<run_id>/events/stream/Stream a run's event log (SSE)

All three are served from https://api.waxell.dev. The URL is the same for every tenant, and your API key identifies the tenant.

Authentication​

Send a Waxell API key in either header:

X-Wax-Key: wax_sk_...
Authorization: Bearer wax_sk_...

The signal endpoint accepts both publishable (wax_pk_…) and secret (wax_sk_…) keys, so you can give a third-party webhook a publishable key without handing out a secret one. The run-events endpoints take the same headers.

Fire a signal​

POST /api/v1/signals/<name>/

Path​

<name> is the signal name your agent subscribes to in waxell.yaml (signals: - name: …). Names are made of letters, digits, underscores (_) and hyphens (-). Keep the trailing slash.

Body​

The request body is the payload itself: a JSON object that becomes the run's input. Send it with Content-Type: application/json. Your agent sees its fields directly. For a Claude Agent SDK agent, a prompt field (or the payload's only string field) becomes the user prompt.

{ "prompt": "Summarise yesterday's failed orders", "region": "eu-west" }

Reserved payload keys​

Keys that start with an underscore are reserved for Waxell. Keep your own field names free of a leading underscore.

KeyShapeEffect
_sub_user_identityobject, see belowRuns the agent as one of your end users. Their identity flows into policy evaluation, the per-user audit trail, per-user secrets, and the signed headers sent to your domains.
_callback_urlstring (URL)When the run finishes (success or failure), Waxell POSTs the result to this URL.
_callback_secretstringHMAC key for signing the callback body. Use it together with _callback_url.
_agent_versionstring (exact semver, e.g. "1.4.0")Runs that version of the subscribing agent instead of the routed one. Use it to exercise a candidate version before you promote it.

_sub_user_identity​

{
"_sub_user_identity": {
"sub_user_id": "alice",
"email": "alice@acme.com",
"display_name": "Alice Ng",
"attributes": { "plan": "enterprise", "region": "eu" },
"roles": ["approver"],
"permissions": ["orders:read", "orders:refund"]
}
}
FieldAliasNotes
sub_user_ididRequired. Your stable identifier for the end user. Becomes the sub claim of the end-user token.
emailOptional.
display_namenameOptional.
attributesattrsOptional free-form object.
rolesOptional list of strings.
permissionsOptional list of strings (perms claim on the token).

The first time Waxell sees a sub_user_id, it registers that end user in your workspace. Every signal carrying the identity is then recorded on that user's activity stream.

Run-completion callback​

With _callback_url set, Waxell sends one POST when the run reaches a terminal state:

POST <your _callback_url>
Content-Type: application/json
User-Agent: Waxell-RunCallback/1.0
X-Wax-Run-Id: 4821
X-Wax-Signal-Id: 6f1d2c1e-...
X-Wax-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with _callback_secret>
{
"run_id": "4821",
"workflow_id": "…",
"agent_name": "order_triage",
"status": "completed",
"result": { "…": "…" },
"duration": 14.2,
"started_at": "2026-10-13T09:00:01.120000+00:00",
"completed_at": "2026-10-13T09:00:15.322000+00:00",
"trace_id": "…",
"signal_id": "6f1d2c1e-..."
}

Verify the signature over the exact bytes you received, with a constant-time comparison (hmac.compare_digest in Python, crypto.timingSafeEqual in Node). Waxell waits 5 seconds for a response and retries once on a network error or a 5xx. A 4xx is treated as final. De-duplicate on run_id.

Optional headers​

HeaderPurpose
X-Idempotency-KeyExactly-once delivery. Your own key for this event (a webhook delivery id, an order number). A second POST to the same signal name with the same key within 24 hours starts nothing and returns 200 with "status": "duplicate" and the run_id of the first delivery. Keys are scoped per signal name and per workspace; 200 characters maximum. Idempotency-Key is accepted as an alias. With wax: wax signals fire <name> --payload … --idempotency-key <key>.
X-Correlation-IDYour own identifier for this request. Stored on the signal envelope for correlation in traces and logs. Not used for de-duplication.
X-Waxell-Workflow-IdLineage. When your endpoint fires a signal while handling a domain call from a Waxell run, echo the X-Waxell-Workflow-Id header Waxell sent you. The new run is then linked to the run that caused it.
X-Waxell-Agent-NameLineage. Echo alongside X-Waxell-Workflow-Id, as above.

Response — 202 Accepted​

{
"signal_id": "6f1d2c1e-8a0b-4f7e-9d55-0c3a1b2e7f10",
"status": "matched",
"signal_name": "run",
"tenant": "acme",
"run_id": "4821",
"poll_url": "/api/v1/observe/query/traces/detail/?trace_id=4821",
"events_url": "/api/v1/runs/4821/events/",
"matched_agent": "…",
"matched_framework": "claude_agent_sdk",
"matched_workflow": ""
}
FieldAlways presentMeaning
signal_idyesUnique id of this signal delivery.
statusyesmatched: an agent subscribed and a run was started (the fields below are present). accepted or queued: the signal was received, and no run id is returned, for example because no agent subscribes to this name. Check the name against your manifest's signals:. duplicate (HTTP 200): the X-Idempotency-Key was already used for this signal within 24 hours; signal_id, run_id and events_url describe the first delivery.
idempotency_keywhen sentEcho of your X-Idempotency-Key.
signal_nameyesEcho of <name>.
tenantyesSlug of the workspace the key belongs to.
run_idwhen matchedId of the run. Use it with the run-events endpoints below.
events_urlwhen matchedPath of the polling event log for this run.
poll_urlwhen matchedPath of the full run detail (run row, spans and result).
matched_agentwhen matchedCanonical id of the agent version that is running.
matched_frameworkwhen matchedThe agent's framework: value. See Framework support.
matched_workflowwhen matchedWorkflow the subscription triggers, if any.

When several agents subscribe to the same signal name, all of them run. The response describes the run you can follow.

Errors​

StatusBodyCause
401{"error": "API key required. …"}No X-Wax-Key or Authorization: Bearer header.
401{"error": "Invalid or expired API key"}Key not recognised, revoked or expired.
400{"error": "Invalid signal name"}<name> contains characters other than letters, digits, _ and -.
403{"error": "tenant_mismatch", "detail": "…"}You called a tenant-scoped host (for example acme.api.waxell.dev) with a key from a different workspace. Use https://api.waxell.dev, or the host that matches your key.
500{"error": "Failed to dispatch signal", "signal_id": "…"}The signal could not be dispatched. Safe to retry.

Example​

curl -sS -X POST "https://api.waxell.dev/api/v1/signals/run/" \
-H "X-Wax-Key: $WAXELL_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: zendesk-delivery-9f3c2a" \
-H "X-Correlation-ID: support-ticket-88123" \
-d '{
"prompt": "Draft a reply to ticket 88123 and flag anything that needs a refund.",
"ticket_id": "88123",
"_sub_user_identity": {
"sub_user_id": "alice",
"email": "alice@acme.com",
"display_name": "Alice Ng",
"roles": ["support_agent"]
},
"_callback_url": "https://app.acme.com/hooks/waxell-run-complete",
"_callback_secret": "'"$WAXELL_CALLBACK_SECRET"'"
}'

Poll a run's events​

GET /api/v1/runs/<run_id>/events/?since=<cursor>&limit=<n>

Each event is one span of the run: the agent itself, an LLM call, a tool call, or a progress message the agent emitted. Poll with the cursor from the previous response to receive only what's new.

Query paramDefaultNotes
since0Return completed events with seq greater than this. Pass the previous response's next_cursor.
limit200Maximum completed events per response. Values above 1000 are capped at 1000.

Response — 200 OK​

{
"run_id": "4821",
"status": "running",
"events": [
{
"id": "b7f0…",
"seq": 3,
"ts": "2026-10-13T09:00:02.481000+00:00",
"kind": "llm",
"name": "anthropic.messages",
"status": "ok",
"duration_ms": 1840,
"completed": true,
"message": null,
"user_facing": false,
"attributes": { "…": "…" },
"parent_id": "a12c…"
}
],
"in_flight": [
{
"id": "c9e4…",
"seq": 4,
"ts": "2026-10-13T09:00:04.310000+00:00",
"kind": "tool",
"name": "orders__lookup",
"status": "running",
"duration_ms": null,
"completed": false,
"message": null,
"user_facing": false,
"attributes": { "…": "…" },
"parent_id": "a12c…"
}
],
"next_cursor": 3,
"terminal": false
}
FieldMeaning
run_idThe run.
statusCurrent run status.
eventsNewly completed events after since, in seq order.
in_flightEvents that have started and not finished: a snapshot of what's running right now. An in-flight event appears on every poll until it completes, then moves to events.
next_cursorHighest seq in events. Pass it as since on the next poll.
terminaltrue once the run has finished (completed, success, failed, error or cancelled). Stop polling.

Event fields:

FieldMeaning
idSpan id.
seqPosition in the run, used for the cursor.
tsStart time (ISO 8601).
kindSpan kind, for example agent, llm, tool, governance, or custom for progress messages.
nameSpan name: the model call, or the tool name (domain tools are <domain>__<action>).
statusSpan status.
duration_msDuration once completed.
completedtrue when the span has ended.
messageHuman-readable progress text, when the span carries one.
user_facingtrue for progress messages meant to be shown to an end user.
attributesFull span attributes.
parent_idParent span id, for building the tree.

A run id that doesn't exist in your workspace returns 404 {"detail": "Not found"}.

Example​

RUN_ID=4821
CURSOR=0
while :; do
RESP=$(curl -sS "https://api.waxell.dev/api/v1/runs/$RUN_ID/events/?since=$CURSOR&limit=200" \
-H "X-Wax-Key: $WAXELL_API_KEY")
echo "$RESP" | jq -r '.events[] | "\(.seq)\t\(.kind)\t\(.name)\t\(.message // "")"'
CURSOR=$(echo "$RESP" | jq '.next_cursor')
[ "$(echo "$RESP" | jq '.terminal')" = "true" ] && break
sleep 2
done

Stream a run's events (SSE)​

GET /api/v1/runs/<run_id>/events/stream/

The same events, pushed to you as Server-Sent Events so you don't have to poll. The response is Content-Type: text/event-stream.

FrameWhendata
event: snapshotWhenever there are new completed events or open spans{"events": [...], "in_flight": [...], "next_cursor": 12, "terminal": false, "status": "running"}. events holds only what's new since the previous snapshot.
event: terminalThe run finished{"status": "completed"}. The stream then closes.
: keepalive (comment)Every 15 secondsNo data. Keeps proxies from closing the connection.
event: timeoutThe stream has been open for 10 minutes{"reason": "max_stream_duration"}. The stream then closes.

A stream is capped at 10 minutes. For a longer run, reconnect, or switch to the polling endpoint and pass the last next_cursor you received as since.

Example​

curl -sS -N "https://api.waxell.dev/api/v1/runs/4821/events/stream/" \
-H "X-Wax-Key: $WAXELL_API_KEY" \
-H "Accept: text/event-stream"
event: snapshot
data: {"events": [], "in_flight": [{"id": "a12c…", "seq": 1, "kind": "agent", "name": "order_triage", "completed": false, …}], "next_cursor": 0, "terminal": false, "status": "running"}

event: snapshot
data: {"events": [{"id": "b7f0…", "seq": 3, "kind": "llm", …}], "in_flight": [{"id": "c9e4…", "seq": 4, "kind": "tool", "name": "orders__lookup", …}], "next_cursor": 3, "terminal": false, "status": "running"}

: keepalive

event: terminal
data: {"status": "completed"}