Skip to content

Notification API

How external code receives events from a run. A run emits a stream of JSON events — wrap them in an envelope, deliver one per request to each configured sink, and a listener sees the agent’s progress in real time. This is the primary integration surface for the subsystem.

You declare where events go in a recipe’s spec.output.sinks (AgentDefinition CRD); this page is the wire format of what arrives. For a hands-on walkthrough, see Receive notifications.

Every event is delivered to each sink declared in spec.output.sinks. There are three sink types:

typeWhere it goes
stdoutEchoed to the pod logs. Always happens regardless of configured sinks — it is the source of truth. Carries the bare event line, not the envelope: pod logs (and the Agent.status.output capped from them) come from exactly one pod, so attribution adds nothing there and downstream status.output parsers expect the tool’s own line.
httpPOSTed to the sink’s url wrapped in the envelope below, one event per request. This is how a UI or indexer consumes runs live.
fileAppended to the sink’s path on the container filesystem, wrapped in the envelope below.

Both the init container (setup phase) and the supervisor (agent phase) emit through the same path, so the two phases’ streams look identical to a listener. A single run therefore produces one uniform event stream from start to finish.

Every event delivered to an http or file sink is wrapped in an envelope carrying the run’s identity, so a downstream workflow — where streams from many pods merge — can correlate it back to its agent and pod. The envelope is applied exactly once, at sink delivery; wrapping an already-wrapped line is refused (enforce) rather than nested:

{
"source": {
"agent": "bug-fixer-run-1",
"station": "bug-fixer-station",
"task": "task-123",
"pod": "agent-job-bug-fixer-run-1-abcde",
"namespace": "ai-agents"
},
"event": { "kind": "lifecycle", "phase": "agent", "status": "started" }
}
FieldTypeNotes
agentstringThe run’s name.
stationstringThe station the run belongs to.
taskstringExternal correlation id (the Agent’s taskId), if set.
podstringThe run pod’s name.
namespacestringThe run pod’s namespace.

Empty ids are omitted from source. A line that is not valid JSON is wrapped as a JSON string ("event": "…") rather than dropped, so a listener never has to guard against malformed bodies.

The inner event is one of:

  • a lifecycle event — owned by the subsystem, tagged "kind": "lifecycle" (below).
  • a tool-native stream-json line — the agent tool’s output, passed through verbatim (below).

Typed notifications raised by both the init container and the supervisor. Tagged "kind": "lifecycle" so a consumer can tell them apart from raw agent output.

FieldTypeNotes
kindstringAlways "lifecycle".
phaseenuminit (setup container) or agent (supervisor).
statusenumstarted, installing, running, succeeded, or failed.
toolstringOptional. The tool or package-manager involved (e.g. apt).
reasonstringOptional. A short failure slug — e.g. not-found (agent binary missing), spawn (process failed to start).
exitCodeintOptional. The agent process exit code. 0 is present and meaningful; it is not treated as empty.

Empty optional fields are omitted. Examples:

{ "kind": "lifecycle", "phase": "init", "status": "started" }
{ "kind": "lifecycle", "phase": "init", "status": "installing", "tool": "apt" }
{ "kind": "lifecycle", "phase": "agent", "status": "started" }
{ "kind": "lifecycle", "phase": "agent", "status": "succeeded", "exitCode": 0 }
{ "kind": "lifecycle", "phase": "agent", "status": "failed", "reason": "not-found" }
{ "kind": "lifecycle", "phase": "agent", "status": "failed", "exitCode": 42 }

A run that reaches the agent phase emits, at minimum, an agent/started at launch and an agent/succeeded or agent/failed (carrying exitCode) when it ends — so a hook can branch on the outcome without parsing logs.

Artifacts the recipe declared under spec.output.watch. The supervisor reads each declared path once the agent has exited and raises one event per entry, tagged "kind": "file".

This exists because the subsystem streams what an agent says: an agent whose deliverable is a file had no way to hand it back, so callers asked the model to repeat the artifact as its closing message — putting an LLM in the delivery path of a deterministic step, and silently producing nothing whenever the model summarised instead.

FieldTypeNotes
kindstringAlways "file".
eventstringThe recipe-declared event name, so one run can emit several artifacts.
pathstringThe resolved path read from. Relative paths resolve against WORKSPACE_DIR.
contentstringThe file’s contents. Absent when reason is set.
reasonstringOptional. Why there is no content: missing, too-large (>128 KiB), or unreadable.
{ "kind": "file", "event": "planning.result", "path": "/workspace/target/result.json", "content": "{\"gap\":\"found\"}" }
{ "kind": "file", "event": "planning.result", "path": "/workspace/target/result.json", "reason": "missing" }

Two guarantees a consumer can rely on:

  • File events precede the terminal event. They are raised before agent/succeeded|failed, so a consumer that treats the terminal event as end-of-stream still receives them.
  • A declared artifact always reports. A file the agent never produced still raises its event carrying reason, so a consumer learns the run delivered nothing instead of waiting on an event that never arrives.

A path that escapes WORKSPACE_DIR is refused and raises nothing — that is a recipe bug, not a run outcome.

Between the lifecycle events, the supervisor forwards each line the agent tool writes, verbatim — to pod-log stdout as-is, and to the sinks as the envelope’s event. These are produced by the tool adapter, not the subsystem — the schema below describes Claude Code’s stream-json output and may vary by model or tool. Treat the subsystem-owned contract (the envelope and lifecycle events above) as stable; treat these as the tool’s format.

Each is a JSON object with a type discriminator.

Emitted once at the start of the agent’s session with setup metadata.

FieldTypeNotes
typestring"system".
subtypestringe.g. "init".
Session metadata (session id, model, tools, working directory).

An assistant turn. message is an Anthropic Messages API message object.

FieldTypeNotes
typestring"assistant".
messageobjectMessages API message: { id, role: "assistant", model, content[], stop_reason, usage }.

message.content[] is a list of content blocks:

Block typeFieldsNotes
texttextAssistant prose.
tool_useid, name, inputA tool call. input is the tool’s parsed arguments.
thinkingthinkingPresent when extended thinking is enabled.

Tool results fed back to the agent. message.content[] carries tool_result blocks.

FieldTypeNotes
typestring"user".
messageobject{ role: "user", content[] } where each block is a tool_result { tool_use_id, content, is_error? }.

The agent’s terminal event, emitted once when the run finishes.

FieldTypeNotes
typestring"result".
subtypestringe.g. "success", "error_max_turns".
resultstringThe final result text.
is_errorboolWhether the run ended in error.
total_cost_usdnumberTotal cost of the run.
num_turnsintNumber of agentic turns.
duration_msintWall-clock duration.

Always parse tool-native payloads with a JSON parser, never by string-matching the serialized form — escaping (Unicode, forward slashes) can differ between models.

For an http sink, the subsystem expects your listener to behave as follows:

  • It POSTs to the sink’s url, one envelope per request, with a JSON body.
  • Any 2xx status means the event was delivered. The response body is ignored.
  • Use headers_secret on the sink to attach authentication headers (e.g. a bearer token) to each request.
  • A GET /healthz → ok endpoint is the convention used by the example listener — handy for readiness checks, not required by the subsystem.

HTTP delivery is best-effort but resilient. A failed POST is retried with capped exponential backoff before the event is dropped — a transient blip in your listener does not lose events, while a persistently unreachable sink never blocks or fails the run. The pod logs (stdout) remain the authoritative record.

Backoff before the retry following a failed attempt n (1-based) is min(baseMs · 2^(n-1), maxMs); with the defaults: 200 ms, 400 ms, 800 ms. Tune with these env vars on the run container; set AGENT_SINK_RETRY_ATTEMPTS=1 to restore pure fire-and-forget:

Env varDefaultMeaning
AGENT_SINK_RETRY_ATTEMPTS3Total delivery attempts per event (minimum 1).
AGENT_SINK_RETRY_BASE_MS200Base backoff, doubled each retry.
AGENT_SINK_RETRY_MAX_MS5000Cap on the backoff between retries.

The controller derives the run container’s environment from the recipe. You don’t normally set these by hand — declare spec.output.sinks and the controller injects them — but they define the runtime contract:

Env varMeaning
AGENT_SINKSJSON array of sinks, e.g. [{"type":"http","url":"http://collector/notify"}].
AGENT_NOTIFY_URLConvenience shorthand: an http sink URL, appended to the sinks.
AGENT_SINK_RETRY_*Retry tuning (see above).
AGENT_NAME, STATION_NAME, TASK_ID, POD_NAME, POD_NAMESPACEThe run identity stamped into every envelope’s source.

A recipe can filter which events reach its sinks with spec.output.select — events that don’t match are still echoed to stdout but not delivered to the sinks. See the spec.output field table in the AgentDefinition CRD reference for the selector schema.

A zero-dependency Node.js listener that prints every POSTed event lives in the repository at examples/notify-listener.mjs. Run it with node examples/notify-listener.mjs (default port 8099) and point a recipe’s http sink at it — see Receive notifications for the end-to-end walkthrough.