On this page

Reference for every event type the runtime publishes to the umbilical. Events flow to agent taps via the per-agent UmbilicalBus and to external /events SSE subscribers via the daemon bus.

Every event has this envelope. It is the same object on the per-agent bus, on the daemon bus, and on the /events wire — there is no second, daemon-specific shape:

interface UmbilicalEvent {
  seq: number              // monotonic per-agent (0 when there is no owning agent bus)
  event_type: string       // dotted path, see below
  timestamp: number        // epoch ms
  source: string           // agent:<turn>, lambda:<file>:<fn>, system:<subsystem>
  agent_id?: string | null // owning agent; null for daemon-scope events
  loop?: string            // inner cognition loop that produced it; absent = main / not loop-scoped
  turn_id?: string         // the turn that produced it; absent outside a turn
  payload: Record<string, unknown>
  sig?: string             // reserved: detached signature over the envelope
}

source is a first-class envelope field. It is not folded into payload on any transport.

loop names the inner cognition loop (a side loop declared in AgentConfig.loops, e.g. consolidator) whose executor produced the event: its turns, tool calls, model calls, state changes, HIL requests and asks. Work a loop’s turn causes (a lambda it calls, a tool it runs) inherits the stamp. It is absent for the main loop and for events that are not loop-scoped (daemon, adapters, system-scope timers), so a pre-loops consumer sees exactly what it always did. agent.state.changed with a loop is that loop’s executor state, not the agent’s. timer.fired and trigger.fired carry the loop they wake (a timer’s loop, a trigger target’s loop), so a consumer can file a scheduled wake under the side loop it starts.

turn_id names the turn whose work produced the event: the turnId that POST /agents/:id/chat or /trigger answered with (kept when an interrupting chat is replayed after the turn it cut short), else the runtime’s own turn id (the one in source: agent:<id>). Lambdas and tools a turn runs inherit it. A chat delivered into a turn other than its own (queued behind a busy loop, see chat.* below), and the request of a turn a chat cut short, are listed on the next non-interrupted turn.completed / agent.error payload as absorbed_turn_ids.

GET /events wraps this envelope in a transport frame carrying a resume cursor: { cursor, event }. cursor is a per-daemon-process counter used only for ?since= / Last-Event-ID replay (the stream’s stream.hello epoch says which process); per-agent ordering lives on event.seq. See http-api.md.

Typed registry

src/shared/types/umbilical-events.ts is the machine-readable counterpart to this document: UMBILICAL_EVENT_TYPES lists every type the runtime emits, and isKnownUmbilicalEventType() also accepts the open custom.* namespace.

A CI guard (tests/unit/umbilical-event-registry.test.ts) fails the build if a runtime emit site uses a type that is not in the registry. Tap and stream-binding filters naming an unknown type produce a warning, never a validation error — filters must stay forward-compatible with newer runtimes.

Stability

Event types in the tables below labelled stable commit to their payload field names and semantics — taps that filter on these will not break across minor versions. Events labelled provisional may refine their payload shape as taps shake out real-world use.

Adding a new field to a stable payload is non-breaking. Removing or renaming one requires a major version bump in the umbilical contract.


tool.* — stable

Every built-in and MCP tool invocation, from every caller.

EventPayload
tool.started{ filePath, name, id?, input }
tool.completed{ filePath, name, id?, result, isError: false }
tool.failed{ filePath, name, id?, result, isError: true }

Emitted from ToolRegistry.executeTool, the single choke point every caller funnels through — the LLM tool loop, sandboxed code (adf.*), and the shell pipeline all emit identically. source distinguishes them: LLM-driven calls carry source: "agent:<turn_id>", code-driven calls source: "lambda:<file>:<fn>".

Guarantees:

  • Exactly one tool.started and exactly one tool.completed/tool.failed per invocation, including unknown tools, schema-validation failures, and calls that fail by throwing.
  • id is the LLM tool_use.id. It is absent on code-driven and shell-driven calls, which have no such id — never key on it being present.
  • input has the runtime’s internal flags (_authorized, _protection_override, _full, _async) stripped.
  • result.content is truncated to ~16 KB. Taps are observers, not sinks — read the tool’s real output from the loop if you need all of it.
  • A protection denial that a human overrides produces two pairs for one tool_use.id: the denied execution and the approved re-execution. Two executions really did happen.

Tool outcomes the loop synthesizes without ever calling a tool — the ask intercept, a disabled-tool rejection, a HIL denial, and the task reference returned by an _async call — also emit a pair, so every result the model sees is observable.

turn.* — stable

EventPayload
turn.completed{ filePath, content, targetState, llm_call? }
turn.delta{ kind: 'text' | 'thinking', text } — opt-in, see below

turn.completed fires when the LLM loop finishes a turn (end-of-turn signal or tool-driven stop). content is the final assistant text for this turn. interrupted: true marks a turn a chat or the owner cut short. absorbed_turn_ids lists the chats this turn answered besides its own.

chat.* — stable

EventPayload
chat.delivered{ delivery: 'turn_start' | 'next_step', count, turn_ids }
chat.discarded{ reason: 'stopped' | 'off', count, turn_ids, unanswered_turn_ids? }

Owner chats that arrive while a loop is busy queue in arrival order; nothing is dropped silently. The first one interrupts the running turn, and the oldest queued chat runs next under its own turn_id. chat.delivered fires inside that turn (carrying its turn_id) when the rest of the queue joins it as consecutive user rows: turn_ids are those chats’ request ids (count also covers chats sent without one, e.g. from Studio). They complete with the turn (absorbed_turn_ids).

chat.discarded fires when a stop, unload or off transition drops queued chats: turn_ids were never delivered; unanswered_turn_ids were delivered but their turn was cut off. A System notice quoting the dropped messages is also written to the loop and adf_logs (chat_discarded).

turn.delta is off by default — streaming every flushed batch is high volume and most taps only want finished output. Enable it per agent:

umbilical:
  stream_deltas: true

One event fires per flushed delta batch (not per token), with text being the concatenated deltas in that batch. Adjacent same-kind deltas are coalesced; mixed [thinking, text, thinking] stays three ordered events.

agent.* — stable

EventPayload
agent.state.changed{ filePath, state }
agent.error{ filePath, event }
agent.loaded{ filePath, name, handle, autostart }
agent.unloaded{ filePath }
agent.recovered{ reason: 'auth', state: 'idle', notice }
agent.credentials.unlocked{ filePath, reason, adaptersRestarted, mcpRestartNeeded, message }

agent.recovered fires per loop (loop set for side loops) when a subscription sign-in completes on the daemon (adf auth login chatgpt|grok, loopback or relay) and that loop sat in error on an auth failure from a provider of the same type. The loop returns to idle; the failed turn is not re-run — the next trigger works normally. Other error reasons are untouched.

agent.credentials.unlocked fires when a loaded agent that was degraded on sealed credential envelopes (CREDENTIALS_LOCKED) unlocks without a reload — on the owner identity becoming ready (adf identity new|restore|unlock, or a phrase Studio put in the shared keychain) or on the daemon’s once-a-minute re-check while any agent is degraded. degraded is cleared, adapters that were held by the locked-credentials stub are restarted, and mcpRestartNeeded names MCP servers with sealed per-agent credentials that connected without them (restart the agent to reconnect those).

agent.loaded / agent.unloaded come from the shared lifecycle resource in src/main/runtime/umbilical-lifecycle.ts, so the daemon, Studio background, and Studio foreground all announce agents identically. agent.loaded fires after the agent’s umbilical taps register, so a tap sees its own agent’s load event; agent.unloaded fires before the per-agent bus is destroyed.

llm.* — stable

Every completed model call, including regular turns, compaction calls, and adf.model_invoke.

EventPayload
llm.completed{ provider, model, input_tokens, output_tokens, cache_read_tokens?, cache_write_tokens?, reasoning_tokens?, duration_ms, stop_reason, cost_usd?, turn_id?, call_source }
llm.failedSame payload, with stop_reason: "error"

call_source is one of turn, compaction, model_invoke, or another runtime source label. The event envelope’s source remains the provenance that caused the call (agent:<turn>, lambda:<file>:<fn>, etc.).

lambda.* — stable

Every lambda invocation: WS handlers, sys_lambda, middleware, API routes, system-scope trigger/timer lambdas, and tap lambdas.

EventPayload
lambda.started{ lambda_path, function_name, kind, ...kind-specific }
lambda.completed{ lambda_path, function_name, kind, duration_ms, ...kind-specific }
lambda.failed{ lambda_path, function_name, kind, duration_ms?, error, ...kind-specific }

kind is one of ws, sys_lambda, sys_code, middleware, api_route, system_scope, tap.

Kind-specific fields:

  • ws — connection_id
  • system_scope — trigger (the trigger name that fired this lambda)
  • tap — tap (the tap name)
  • sys_code — no lambda_path / function_name: inline sandboxed code has no backing file. Correlate via the enclosing tool.* pair for sys_code.

db.* — stable

Every read/write through the db_query / db_execute tools — which is every agent-driven SQL path, since sandboxed adf.db_query(...) calls and shell select/sql commands all route through those same tools.

Emission stays at the tool layer rather than moving to AdfWorkspace.executeSQL / querySQL, because those methods are also called by Studio’s table browser and by workspace clone/migration plumbing. Moving emission down would fire db.read on every UI table click and every migration DROP TABLE — noise from callers that are not the agent operating on its data.

EventPayload
db.read{ sql, params, row_count }
db.write{ sql, params, changes }

No table field. Parsing a table name from arbitrary SQL via regex silently lies on edge cases (subqueries, joins, CTEs). Taps filter by SQL substring when they need table-level granularity:

when: "event.payload.sql.includes('local_orders')"

Agents that need precise table parsing can do it properly inside the tap lambda.

file.* — stable

Changes to the agent’s adf_files table.

EventPayload
file.read{ path, bytes }
file.written{ path, bytes }
file.deleted{ path }

file.written and file.deleted are emitted from AdfWorkspace, so they cover every writer — the fs_write/fs_delete tools, shell redirects, and sandboxed code alike — including writes to the README.md / document.md / mind.md aliases, which route through the same choke point.

file.read is emitted by the fs_read tool only, not from AdfWorkspace.readFile. That method also backs internal machinery (lambda source loading, prompt file injection), and emitting there would drown taps in reads the agent never asked for.

message.* — stable

Inbox and outbox lifecycle.

EventPayload
message.received{ message_id, from, content_type, size }
message.queued{ message_id, to }
message.sent{ message_id, status_code }
message.delivery_failed{ message_id, status_code }

message.queued fires when a message is accepted into the outbox, before any delivery attempt. message.sent fires on terminal success from any transport (local, WS, HTTP, adapter). message.delivery_failed fires on failure.

Delivery is a single attempt — there are no retries. The outbox row moves pending → delivered | failed in one shot; a failed message stays failed until something re-sends it. (WS failure does fall back to HTTP within the same attempt, but that is one delivery try across transports, not a retry loop.)

trigger.* — stable

EventPayload
trigger.fired{ trigger_type, scope, target_lambda }
trigger.dropped{ trigger_type?, reason, dropped? }

trigger.dropped makes discarded work visible — the agent never sees these, so without the event they are invisible. reason is one of:

reasonMeaning
intervalRate-limited away by a target’s interval_ms window
supersededA queued latest-wins trigger (inbox, file_change) was evicted by a newer one. Owner inbox messages are never evicted
hibernateThe queued backlog was discarded on a non-idle state transition. Carries dropped (how many) instead of trigger_type

provider.* — stable

Automatic recovery from transient provider errors (rate limits, overload, network failures), governed by config.recovery. Enabled by default; auth/billing errors never retry.

EventPayload
provider.retry_scheduled{ attempt, max_attempts, delay_ms, next_retry_at }
provider.retry_started{ attempt, max_attempts }
provider.retry_cancelled{ reason: 'superseded' | 'abort' | 'hibernate' | 'state_transition' | 'disabled' | 'agent_state' }

retry_cancelled with superseded means fresh work (a user message or new trigger) took over before the backoff elapsed — that turn resumes from the same loop history, so the failed work is not lost.

error.* — stable

Recovery from the terminal error state. Non-auth errors (tool_mismatch, turn_error) let the next incoming agent-scope trigger run as a recovery attempt instead of being dropped; auth errors stay parked until fixed.

EventPayload
error.recovery_trigger{ reason: 'auth' | 'tool_mismatch' | 'turn_error', trigger, after_cooldown }
error.recovery_suppressed{ reason, trigger, attempts, max_attempts }

recovery_trigger fires when a trigger is spent as a recovery turn; trigger is the trigger type that woke the agent, and after_cooldown is true when the attempt cap had been hit but the cooldown window had elapsed. recovery_suppressed fires once per error episode when the cap is hit and the cooldown has not elapsed — every agent-scope trigger is dropped until a chat message arrives or the cooldown passes.

timer.* — stable

EventPayload
timer.fired{ timer_id, scope, run_count, scheduled_at }

hil.* — stable

Human-in-the-loop approvals. request_id and task_id are the same value (the adf_tasks row id) — both are present so consumers can key on either.

EventPayload
hil.requested{ request_id, task_id, tool, reason, input, can_always_approve, always_approve_blocked_reason? }
hil.resolved{ request_id, task_id, approved, feedback?, timed_out?, orphaned? }

reason is one of:

reasonRaised by
restrictedA tool declared restricted: true in the agent config
protectionA data-protection denial (locked file, meta key, or config field) that a human may override
shell_gateA tool gated inside a shell pipeline preflight

input has internal flags stripped, same as tool.*.

timed_out: true marks an auto-deny: no human decided within the timeout. approved is then always false. “Approve all” emits one hil.resolved per approved task; it never batches protection overrides, which stay individual.

Every hil.requested is followed by exactly one hil.resolved, including the non-blocking _async approval flows where the request and the resolution are turns apart.

orphaned: true marks the load-time reconciliation case: an executor-managed pending_approval task left in flight by a crash or hard shutdown is cancelled at the next load, and a synthetic hil.resolved { approved: false, orphaned: true } is emitted so the earlier hil.requested from the dead process is still closed out. No human decided, so approved is always false.

ask.* — stable

The ask tool: the agent asking its human a question and blocking on the answer.

EventPayload
ask.requested{ request_id, question }
ask.resolved{ request_id, has_response, response_length, preview? }

The human’s answer is not put on the wire in full. Taps get its shape — has_response, response_length, and a preview truncated to 200 characters (absent on an empty answer). A tap that legitimately needs the whole answer reads it from the loop.

suspend.* — stable

The owner decision raised when an agent hits limits.max_active_turns.

EventPayload
suspend.requested{ reason }
suspend.resolved{ resumed, timed_out? }

resumed: true means resume the agent; false means shut down. A suspend that nobody answers before limits.suspend_timeout_ms resolves { resumed: false, timed_out: true }.

config.changed — stable

Fires whenever the agent config is written back to the workspace.

EventPayload
config.changed{ updated_at, changed_keys }

changed_keys lists the names of top-level AgentConfig keys whose JSON representation differs from the previous config (a shallow diff — nested edits surface as the containing top-level key).

Values are never included. Config holds provider API keys, adapter tokens, and MCP credentials; a tap or an external /events subscriber learns that providers changed, never to what.

context.injected — stable

Fires when content is injected into the conversation loop out-of-band: a system prompt refresh, per-turn dynamic instructions, an auto-compaction notice, or an agent loop_inject call.

EventPayload
context.injected{ category, origin?, key?, delivery?, bytes? }

category names the kind of injection (system_prompt, dynamic_instructions, System, or a loop_inject category). bytes is the UTF-8 size of the injected text.

The injected text itself is never included. It can hold the full system prompt or arbitrary user-supplied content, so — like config.changed — only its category and size reach a tap or an external /events subscriber.

Deliberately not on the umbilical

Some executor-internal notifications used to reach daemon consumers through the now-retired raw agent.event envelope. They are intentionally NOT mapped to typed umbilical events:

  • document_updated / file_updated — already covered by file.written. They fire only on fs_write, which writes through workspace.writeFile and emits file.written from the workspace choke point.
  • inter_agent_message — covered by message.received / message.sent / message.queued from the inbox/outbox choke point. The executor type is a UI-only renderer notification.
  • trigger_message — the turn it initiates is observable via turn.completed; message-driven triggers are additionally covered by message.received.
  • chat_updated, autosaved, response_metadata — UI-only. Model-call metadata (model, token usage, cost) is carried by llm.completed. Streaming text_delta / thinking_delta batches are high-volume UI noise, available opt-in via turn.delta (umbilical.stream_deltas).

loop.* — stable

Conversation-loop lifecycle. These are the events that explain a discontinuity in an agent’s history.

EventPayload
loop.compacted{ reason, new_token_count }
loop.compaction_failed{ reason, trigger }
loop.compaction_superseded{ reason, detail, new_token_count }
loop.cleared{ method: 'clear' | 'replace' }
loop.recovered{ reason: 'stale_checkpoint' | 'malformed_checkpoint' }, or { reason: 'orphaned_tasks', running, awaiting_approval }

loop.compacted fires after a successful compaction — from the automatic top-of-loop guard, the pre-flight context guard, or the voluntary loop_compact tool. reason is the human-readable trigger.

loop.compaction_failed fires when the summarizer call errored or returned no text. The loop is NOT compacted — history is preserved, an error note is appended to the loop, an adf_logs row (event: compaction_failed) records the detail, and compaction retries at the next threshold check. reason is the failure detail, trigger the compaction trigger (auto / preflight / voluntary).

loop.compaction_superseded fires when a compaction finished summarizing but another turn had already compacted (or cleared) the same loop underneath it. The late summary is discarded rather than applied twice; the session reloads from the loop as it now stands and new_token_count reports the result. reason is the compaction trigger, detail says what changed underneath.

loop.cleared distinguishes a wipe (clear) from an atomic rewrite (replace, e.g. stripping provider-incompatible blocks from history).

loop.recovered fires at load time when a durable turn checkpoint was left in_progress by a crash, reload, or hard shutdown. The trigger is deliberately not replayed — duplicate timer and tool side effects are unsafe — so this event marks a turn that was cut off, not one that resumed. reason is stale_checkpoint (a checkpoint recovered on load) or malformed_checkpoint (a checkpoint that could not be parsed and was discarded).

The same event also fires with reason: 'orphaned_tasks' when load-time task reconciliation sweeps in-flight rows: running tasks left by the dead process become failed, and executor-managed pending_approval tasks become cancelled. It carries running and awaiting_approval counts of what was swept, and fires only when at least one task was reconciled. (Each cancelled approval additionally emits its own hil.resolved { orphaned: true }.)

ws.* — provisional

WebSocket lifecycle. Per-frame events are intentionally not emitted — use tool.completed filtered on tool === 'ws_send' for outbound frame observability.

EventPayload
ws.opened{ connection_id, direction, remote_did, url_params }
ws.closed{ connection_id, direction, remote_did, code, reason, duration_ms }

binding.* — provisional

Stream-binding lifecycle, emitted by the stream binding manager with source: "system:stream_bind". Payload shapes may still gain fields as declarative bindings mature.

EventPayload
binding.created{ binding_id, a, b, bidirectional, origin, declaration_id, options }
binding.materialized{ binding_id, declaration_id }
binding.pending{ binding_id, declaration_id, a, b, reason }
binding.reconnecting{ binding_id, declaration_id, reason }
binding.error{ binding_id, endpoint?, direction?, error }
binding.threshold_exceeded{ binding_id, threshold, observed, limit }
binding.flow_summary{ binding_id, bytes_a_to_b, bytes_b_to_a, interval_ms, status }
binding.terminated{ binding_id, reason, origin, declaration_id }

Notes:

  • a / b are summarised endpoint descriptors, not live handles.
  • origin is declarative (from stream_bindings in the ADF) or imperative (from a runtime stream_bind call). Only declarative bindings emit binding.materialized, binding.pending, and binding.reconnecting.
  • binding.error carries endpoint: 'a' | 'b' for endpoint-level failures and direction: 'a_to_b' | 'b_to_a' for copy failures — never both.
  • threshold on binding.threshold_exceeded names which limit tripped (max_bytes, max_duration_ms, idle_timeout_ms), with observed vs limit.
  • binding.flow_summary fires on the flow_summary_interval_ms timer — the first tick always, later ticks only when bytes, drops or status changed since the last summary — and once more immediately before termination.

adapter.* / mcp.* — stable

Forwarded from channel-adapter and MCP server managers. See http-api.md for payload shapes.

daemon.* — stable

Runtime startup events.

EventPayload
daemon.started{ host, port, settingsPath }
daemon.autostart.report{ report }

custom.* — agent-defined

Anything emitted by agent code via adf.emit_event. The custom. prefix is reserved for agent-authored events — the runtime will never emit a custom.* event. This namespacing prevents agents from spoofing runtime-emitted events.

See umbilical.md for the emission API.

Reserved types

These types are declared in the registry but are not emitted yet — they are reserved by later phases of the umbilical overhaul. Filters may name them today without triggering an unknown-type warning; taps simply never fire until the emitting phase lands.

TypePhaseIntent
ws.reconnecting2WebSocket connection entering reconnect backoff
umbilical.checkpoint—Signed epoch/checkpoint marker over the agent’s event history. Parked — nothing emits it today. The attested-checkpoint work was reverted along with the durable event log; see ../design/sealed-epochs.md for the deferred design

Payload shapes for reserved types are defined by the phase that starts emitting them; do not depend on speculative fields.