Umbilical Event Taxonomy
Reference for every umbilical event type: envelope, per-namespace payloads, and stability tiers
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.
| Event | Payload |
|---|---|
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.startedand exactly onetool.completed/tool.failedper invocation, including unknown tools, schema-validation failures, and calls that fail by throwing. idis the LLMtool_use.id. It is absent on code-driven and shell-driven calls, which have no such id — never key on it being present.inputhas the runtime’s internal flags (_authorized,_protection_override,_full,_async) stripped.result.contentis 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
| Event | Payload |
|---|---|
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
| Event | Payload |
|---|---|
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
| Event | Payload |
|---|---|
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.
| Event | Payload |
|---|---|
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.failed | Same 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.
| Event | Payload |
|---|---|
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_idsystem_scope—trigger(the trigger name that fired this lambda)tap—tap(the tap name)sys_code— nolambda_path/function_name: inline sandboxed code has no backing file. Correlate via the enclosingtool.*pair forsys_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.
| Event | Payload |
|---|---|
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.
| Event | Payload |
|---|---|
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.
| Event | Payload |
|---|---|
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
| Event | Payload |
|---|---|
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:
reason | Meaning |
|---|---|
interval | Rate-limited away by a target’s interval_ms window |
superseded | A queued latest-wins trigger (inbox, file_change) was evicted by a newer one. Owner inbox messages are never evicted |
hibernate | The 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.
| Event | Payload |
|---|---|
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.
| Event | Payload |
|---|---|
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
| Event | Payload |
|---|---|
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.
| Event | Payload |
|---|---|
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:
reason | Raised by |
|---|---|
restricted | A tool declared restricted: true in the agent config |
protection | A data-protection denial (locked file, meta key, or config field) that a human may override |
shell_gate | A 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.
| Event | Payload |
|---|---|
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.
| Event | Payload |
|---|---|
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.
| Event | Payload |
|---|---|
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.
| Event | Payload |
|---|---|
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 byfile.written. They fire only onfs_write, which writes throughworkspace.writeFileand emitsfile.writtenfrom the workspace choke point.inter_agent_message— covered bymessage.received/message.sent/message.queuedfrom the inbox/outbox choke point. The executor type is a UI-only renderer notification.trigger_message— the turn it initiates is observable viaturn.completed; message-driven triggers are additionally covered bymessage.received.chat_updated,autosaved,response_metadata— UI-only. Model-call metadata (model, token usage, cost) is carried byllm.completed. Streamingtext_delta/thinking_deltabatches are high-volume UI noise, available opt-in viaturn.delta(umbilical.stream_deltas).
loop.* — stable
Conversation-loop lifecycle. These are the events that explain a discontinuity in an agent’s history.
| Event | Payload |
|---|---|
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.
| Event | Payload |
|---|---|
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.
| Event | Payload |
|---|---|
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/bare summarised endpoint descriptors, not live handles.originisdeclarative(fromstream_bindingsin the ADF) orimperative(from a runtimestream_bindcall). Only declarative bindings emitbinding.materialized,binding.pending, andbinding.reconnecting.binding.errorcarriesendpoint: 'a' | 'b'for endpoint-level failures anddirection: 'a_to_b' | 'b_to_a'for copy failures — never both.thresholdonbinding.threshold_exceedednames which limit tripped (max_bytes,max_duration_ms,idle_timeout_ms), withobservedvslimit.binding.flow_summaryfires on theflow_summary_interval_mstimer — 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.
| Event | Payload |
|---|---|
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.
| Type | Phase | Intent |
|---|---|---|
ws.reconnecting | 2 | WebSocket 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.