Reference
Events
Full catalog of lifecycle and runtime events emitted by the Routecraft context.
Namespace map
60 events / 13 namespacesstarting · started · stopping · stopped · error
registered · starting · started · stopping · stopped · source:failed · error · error:caught
started · completed · failed · dropped · restored · deferred · resumed · expired
started · completed · failed · error
batch · retry · delay · timeout · throttle · circuitBreaker · concurrency · operation · error-handler · cache · agent
Each operation has its own prefix and events, such as route:retry:attempt or route:operation:choice:matched.
applying · applied · starting · started · stopping · stopped
applying / applied bracket apply(); starting / started bracket the optional start() hook, so a plugin without one emits the applying pair only.
success · rejected
listening · failed · closed
Emitted by the named-servers listener. Every mount on a shared socket (http, mcp, ops) reports its listener lifecycle here rather than on its own plugin.
registered · tool:registered
request:completed
health:changed
server:tools:exposed · tool:called · tool:completed · tool:deferred · tool:failed · tool:declined
connection:opened · connection:closed · session:attached
Event payload
All events share the same envelope:
{
ts: string // ISO timestamp
context: CraftContext
details: {...} // event-specific fields (see tables below)
}
An event() route is not delivered the events its own exchanges cause, so an observability route cannot feed on itself.
Context events
context:stopped reports how the shutdown ended. forced is true when the
drain did not finish inside
shutdown.timeout and in-flight
execution was abandoned, and pending names the routes that still had work.
A clean stop carries forced: false and an empty pending, so a subscriber
reads the same shape either way and does not need the process exit code to
count forced shutdowns.
context:error is the catch-all an operator alarms on. It fires for boot
failures (where no exchange exists yet) and alongside route:error for an
exchange that failed unrecoverably, so a subscriber that only wants the
per-capability view watches route:error instead.
Route events
"Route" here refers to a registered capability internally.
A capability held back by its .enabled() predicate emits route:registered and route:enablement:changed, and none of the starting / started events: it is registered and known to the context, but never started.
route:source:failed is the signal to alarm on for a dead channel: unlike route:stopping, it never fires for an orderly shutdown. adapter is the adapterId of the failed source when the adapter declares one (e.g. routecraft.adapter.mail).
route:error and route:error:caught are the two outcomes of the same
moment, and exactly one of them fires per failure. route:error always
fires with context:error and route:exchange:failed; route:error:caught
fires with route:error-handler:recovered. Use the pair for an error rate
per capability, and the error handler operations
below when you need which handler ran and how it recovered.
Exchange events
Fired per exchange, scoped to the capability that owns it. routeId is the capability ID.
The exchangeId field is the exchange's own ID, not the correlation ID. Use correlationId to group related exchanges (e.g. a parent and its split children share the same correlation ID).
Lifecycle guarantee: every route:exchange:started is eventually followed by exactly one of route:exchange:completed, route:exchange:failed, route:exchange:dropped, or route:exchange:deferred.
The one exception is a forced shutdown: when shutdown.timeout elapses, in-flight exchanges are abandoned mid-step and emit no terminal event at all. A consumer reconciling starts against terminals should treat a forced shutdown as an expected gap rather than a defect; the warn line the forced stage writes names the routes that were still working.
A deferred exchange runs more than once, and the events say so. Execution one is started then deferred. When the answer arrives, resumed fires, followed by execution two's own started and its completed / failed / dropped / deferred, the last when the continuation reaches a second .defer(). A revival failure that the deferred route re-asks on (an expiry, a continuation that changed under the deferred exchange) is its own started plus terminal pair on that route. Every run carries the same exchangeId and correlationId, which is what lets a consumer stitch them together; deferralId identifies the deferral itself.
Operation events
Operation events are scoped to a capability and an operation type. They fire for individual steps in the pipeline.
Step events
Every pipeline step (transform, to, enrich, filter, and so on) emits a
generic step lifecycle. The step label is operation; the adapter's short
label, when one is involved, is adapter.
Recovery by the route error handler is signaled via route:error:caught and the route:error-handler:* events below, not a step-level event.
The metadata field on route:step:completed is populated by the adapter's getMetadata() method. For example, an LLM destination reports { model, inputTokens, outputTokens }.
Batch operations
reason is 'size' when the batch hit its size limit, 'time' when the flush interval elapsed.
Split and aggregate
Split and aggregate use standard route:step:started/route:step:completed events (not dedicated operation events). Operation-specific data is in the metadata field:
- Split
route:step:completedincludesmetadata.childCount: the number of child exchanges created - Aggregate
route:step:completedincludesmetadata.inputCount: the number of exchanges merged
After a split, each child exchange emits its own route:exchange:started. When aggregate consumes children, it emits route:exchange:completed for each child before continuing on the parent exchange.
Retry wrapper operations
scope is "route" for .retry() declared BEFORE .from() (the whole pipeline is re-run) and "step" for the wrapper attached AFTER .from(). stepLabel is the wrapped step's label, or "route" at route scope. route:retry:attempt fires once per re-attempt, so a first-attempt success emits only started and stopped.
Delay wrapper operations
cancelled: true means route shutdown cut the wait short; the wrapped step still ran. .delay() is step-scope only, so scope is always "step".
Timeout wrapper operations
A failure of the wrapped operation inside the deadline does not emit a timeout event; the error propagates unchanged and is observable via route:step:failed / the error path. The abandoned work after an expiry has its eventual result discarded (promises cannot be cancelled); the step's context AbortSignal fires on expiry so cancellation-aware IO can stop instead of running to completion in the background (see the timeout reference).
Throttle wrapper operations
scope is "route" for .throttle() declared BEFORE .from() (the whole pipeline is rate-limited) and "step" for the wrapper attached AFTER .from(). stepLabel is the wrapped step's label, or "route" at route scope. An exchange admitted from the burst (no wait) emits only route:throttle:passed with waited: false; a paced exchange emits route:throttle:delayed first, then route:throttle:passed with waited: true. In the default delay mode throttle only ever delays an exchange and never drops one; in mode: 'reject' an over-limit exchange instead emits route:throttle:rejected and is failed with RC5013. label is present when .throttle({ label }) is set, so stacked gates can be told apart.
Circuit breaker wrapper operations
scope is "route" for .circuitBreaker() declared BEFORE .from() (the whole pipeline is protected) and "step" for the wrapper attached AFTER .from(). stepLabel is the wrapped step's label, or "route" at route scope. retryAfterMs on a rejection is the time until the breaker would admit a probe (0 when half-open is at capacity). label is present when .circuitBreaker({ label }) is set. Breaker state is per route, not per exchange, so these events reflect the shared circuit.
Concurrency wrapper operations
scope is "route" for .concurrency() declared BEFORE .from() (the whole pipeline is bounded) and "step" for the wrapper attached AFTER .from(). stepLabel is the wrapped step's label, or "route" at route scope. An exchange that gets a slot immediately emits only route:concurrency:acquired with waited: false; one that has to wait emits route:concurrency:queued first, then acquired with waited: true. reason on a rejection is "busy" (reject mode, all slots in use) or "queue-full" (queue mode, the wait line reached maxQueue). key is present when .concurrency({ key }) partitions the pool; label is present when .concurrency({ label }) is set. Slot state is per route, not per exchange, so these events reflect the shared bulkhead.
Choice operations
branchLabel is "when" or "otherwise". branchIndex is the zero-based index of the matched branch.
Multicast operations
pathCount is the number of paths the exchange was fanned out to. started and stopped always pair: every started is followed by a stopped (via try/finally), even when a path fails or the multicast has zero paths (pathCount: 0).
Dispatch operations
strategy is the strategy that made the pick ("failover", "round-robin", "weighted", or "sticky") and targetIndex is the position of the selected target in the .dispatch() list. A target failure stays isolated to its own clone's error events; dispatch:exhausted is the signal that a failover chain found no healthy target.
Sample operations
mode is "count" (for every) or "interval" (for interval). A dropped exchange also fires route:exchange:dropped with reason "sampled".
Dedupe operations
A suppressed duplicate also fires route:exchange:dropped with reason "duplicate". key is the derived deduplication key.
Debounce operations
key is present only when a key selector is configured. reason on release is "quiet" (the wait window closed), "maxWait" (the maxWait cap fired during continuous activity), or "flush" (a drain / shutdown released it early). A released exchange runs the steps after .debounce() as a fresh exchange (new id, preserved correlation id) with its own route:exchange:started / :completed pair. Every arrival's own id terminates in route:exchange:dropped with reason "debounced": superseded arrivals when replaced, the absorbed trailing arrival at release time.
Error handler operations
scope is "route" for the catch-all set via .error() BEFORE .from(), and "step" for a wrapper attached AFTER .from(). stepLabel is the label of the wrapped step when scope === "step". Subscribe to the exact names and branch on scope in the payload.
Cache wrapper operations
Failure phases:
phase: "key"- key derivation threw (nokeyfield, since none was produced). Raised asRC5029(not retryable).phase: "get"- the provider read threw before the wrapped step ran. Non-RoutecraftError provider failures are raised asRC5028(retryable).phase: "inner"- the wrapped step itself threw. The original error is rethrown unchanged so outer wrappers / route-level handlers cascade as usual. This event fires alongside the wrapped step's ownroute:step:failedevent for the same exchange; they describe one failure, so do not double-count them.phase: "set"- the wrapped step succeeded but the provider write threw. The bundled in-memory provider never fails on write, so this only applies to custom providers. Step-scope rethrows asRC5028(retryable); route-scope does NOT fail the exchange (the result was already computed and returned to the source), it just emits the event for observability.
At route scope, route:cache:hit is accompanied by a route:exchange:restored event with source: "cache" (per the exchange lifecycle).
Concurrent exchanges that share one computation (stampede dedupe) currently emit route:cache:hit for the waiters at step scope, which can inflate hit-rate metrics. A distinct dedupe signal is planned and needs a provider-interface change. Route scope does not dedupe concurrent same-key callers at all in this release: each runs the pipeline once.
Agent operations
Emitted by agent() destinations. These are the coarse decision events: broadcast to every subscriber, no opt-in needed. For token-level streaming use agent({ stream: true }), or AgentOptions.onDelta for the push form; both are separate per-call channels.
agentName is present only for by-name agents (agent("id")); inline agents are identified by their routeId. model is the resolved providerId:modelName. On the session events agentName is always present and is the session's agent: the registered name, or the route id for an inline agent with session.
Tool input/output (and block-load output) ride in a _snapshot envelope. So does the thrown error on :tool:error / :block:error: error messages routinely echo the rejected input (schema validation, guards), so they are gated the same way. In-process subscribers always receive the envelope, but the SQLite telemetry sink persists it only when captureSnapshots is enabled (telemetry({ sqlite: { captureSnapshots: true } })), mirroring how exchange bodies are gated. The non-sensitive fields (toolName, toolCallId, errorName, duration) are always persisted.
route:agent:tool:denied fires before any model call, once per tool the policy refused, and carries no toolCallId because the tool was never invoked. reason is rule (a policy decided against it), rule-error (a predicate threw, so the tool was denied to fail closed), or unknown-provenance (the tool's source is missing or names a kind the policy surface does not define, which means a hand-built ResolvedTool from outside the type contract; toolKind is unknown).
Every tool-call event names the conversation the call was made in as session, when the agent was dispatched with one. The exchange identity alone cannot: a session's boundary turn runs on the exchange that deferred, which may be an earlier caller's, so a consumer attributing tool calls to a conversation (the ACP mount streaming them to an editor) reads session rather than correlationId.
route:agent:tool:refused is its call-time counterpart: the tool WAS offered, the model called it, and the tool's guard rejected that particular call. Keeping the two apart matters for what you can count. denied says an agent may not have a tool; refused says an agent asked for something its grant does not cover, which is the signal that an agent is probing the edges of what it was given. The payload is bounded to identity plus rc, the error code the guard threw. The refused input is deliberately absent even under snapshot capture: it is the input least worth trusting, and it can carry a token someone passed as an argument. The refusal also surfaces to the model as an ordinary tool error, so it can retry with a permitted form.
Synthetic block-loader invocations (_block__load__<blockName> tools) emit on the :agent:block:* channel, not :agent:tool:*. Subscribe to the right family for what you care about: :agent:tool:* covers user-declared tools only, :agent:block:* covers framework-synthesised block loads. This split keeps post-dispatch user-tool assertions (AgentResult.toolCalls) clean.
Subscribe to the exact names (route:agent:tool:invoked, route:agent:block:loaded, route:agent:finished, ...) and filter by details.routeId (or forRoute(routeId, handler)) for cross-cutting telemetry, dashboards, and TUIs.
ctx.on('route:agent:tool:invoked', ({ details }) => {
log.info({ tool: details.toolName }, 'Agent called tool');
});
ctx.on('route:agent:finished', ({ details }) => {
metrics.histogram('agent.tokens.total', details.totalTokens ?? 0);
});
The registry itself is announced separately; see agent registration events.
Source-parse operations
Parsing source adapters (json, html, csv, jsonl, mail) defer parsing
to a synthetic first pipeline step so parse failures become normal pipeline
events. The synthetic step appears in the standard step:* events with
operation: "parse".
What follows depends on the adapter's onParseError mode:
'fail'(default) →route:exchange:failed(orroute:error:caughtif a route.error()handler recovers).'abort'→route:exchange:failedfor the bad item, then the source aborts andcontext:errorfires.'drop'→route:step:failedfor the parse step, thenroute:exchange:droppedwithreason: "parse-failed";.error()is not invoked.
Subscribe by exact name and filter on the payload to count source parse failures across all routes:
ctx.on('route:step:failed', ({ details }) => {
if (details.operation === 'parse') metrics.increment('source.parse.failed');
});
ctx.on('route:exchange:dropped', ({ details }) => {
if (details.reason === 'parse-failed') metrics.increment('source.parse.dropped');
});
Plugin events
Plugin events are scoped to a plugin ID.
The event vocabulary matches the lifecycle: applying, starting, stopping. plugin:applying / plugin:applied bracket apply(), which every plugin has and which runs while the context is being built, after routes are registered and before any starts; plugin:starting / plugin:started bracket the optional start() hook, which runs after every route has signalled readiness. A plugin without a start() hook emits the applying pair only, so the applying pair never implies the starting pair and a subscriber must not expect four events per plugin. See the plugin lifecycle.
plugin:started changed meaning in 0.7
Before 0.7 plugin:starting / plugin:started bracketed apply() and fired for every plugin. They now bracket the start() hook, so they fire later than they used to and ONLY for plugins that declare one; a subscriber watching them for config-only plugins must move to plugin:applying / plugin:applied. An existing subscriber keeps compiling either way.
A failing start() hook emits context:error and no plugin:started, and the context shuts down rather than reporting itself running. context:started fires before any route or plugin start() hook has run, which makes it the wrong readiness signal: for "the context is up and its plugins have finished starting", await ctx.whenStarted() rather than watching for an event.
Authentication events
Emitted on every auth attempt by every auth-enabled surface: HTTP routes, the MCP HTTP transport, and any custom mount that resolves credentials through a named server's validator. The source field identifies which surface emitted the event: "http" for the default HTTP mount, "http:<name>" for a named HTTP mount, "mcp" for the MCP transport, or a custom mount id. Every rejection path on one surface reports the same source.
reason is a bounded vocabulary, never free text. Bearer authentication uses "expired", "infrastructure", "invalid_token", "missing_header", "unsupported_scheme", and "insufficient_scope" where applicable (an "infrastructure" rejection is answered with a 500, never a 401, so clients keep their cached token during an IdP blip). API-key authentication uses "missing api key" and "invalid api key". Webhook signatures use "missing signature header", "invalid signature", and "signature expired" with scheme: "signature".
Server events
Emitted by the named-servers listener (defineConfig({ servers })), which owns every shared HTTP socket. One event set per named server; the server field carries the name.
Before 0.7 the HTTP plugin owned its listener and emitted plugin:http:server:listening / plugin:http:server:closed. Those events no longer exist; subscribers migrate to server:listening / server:closed and read the server field to tell listeners apart.
MCP plugin events
Events emitted by the MCP plugin during server and tool lifecycle. Subscribe to the exact names (plugin:mcp:tool:called / completed / deferred / failed / declined) for broad observability, or use the catch-all "*".
MCP server events
Listener lifecycle for the MCP HTTP transport is reported by the generic server events on its named server; the MCP path comes from mcpPlugin({ path }) config.
Tool call events
deferred is deliberately not completed. A deferred run is neither finished nor failed: the real result belongs to execution two, which flows to the route's destinations when the answer arrives, so counting the deferral as a completion would publish a false receipt to every dashboard. The wire result is an ordinary isError: false answer whose structuredContent is the acknowledgment. Local routes only: a proxied call has no exchange of ours to defer.
declined is deliberately not failed. A tool whose route filters (a guard branch, a "only answer for X" predicate) declines as a matter of course, and counting those as errors would make an error-rate alert fire on ordinary traffic. The caller still receives isError: true, because MCP has no other channel to say "no result"; see AI2002. It fires for local routes only and carries no provenance fields: a proxied call has no exchange of ours to drop, and a remote error result is reported as failed.
For tools proxied from registered clients via mcpPlugin({ proxy }), the same three events fire with proxied: true, the registered client id as serverId, and the tool's name on the remote server as remoteTool (tool is the exposed, possibly renamed, name). A proxied call whose remote result carries isError: true fires plugin:mcp:tool:failed.
ACP plugin events
Emitted by acpPlugin as editors connect and open conversations. They are what tells an operator who is connected without reading a log line.
subject is who the mount's validator admitted, absent on a mount with no wall. clientName is what the editor calls itself at initialize, which is the only thing that tells two editors on one machine apart.
how says which way a conversation was taken hold of: new minted the id, load replayed the stored transcript onto a fresh connection, and resume attached to it without replaying. Closing a connection is not closing a conversation: the transcript outlives every connection, and reconnecting reaches the same one by its id.
There is no separate event for a connection that failed. A transport fault is the same lifecycle moment as a close, so it is reported as closed with fault carrying the transport's reason; a clean close carries no fault. A connection that never got as far as opened (a refused credential, a request the SDK could not parse) emits neither, since there was no connection to close.
What the agent does inside a turn is not repeated here. A prompt runs an ordinary route, so it reports itself through the agent operations and the exchange events like any other dispatch.
Agent registration events
Emitted by agentPlugin on
context:started, once per registered agent and fn, so a dashboard, the TUI
or a catalogue can list what this context can do before anything runs. They
describe the registry, not a run: a dispatch reports itself through the
agent operations above.
description is always present on agent:registered: agentPlugin refuses
to register an agent without a non-empty one, because a registered agent is
offered to other agents as a tool and a tool with no description cannot be
chosen. It is optional on agent:tool:registered because a deferred fn
(directTool, resolved at dispatch rather than eagerly) registers with its
name only. Its description and tags are resolved onto the tool at dispatch
and are not carried on any event: route:agent:tool:invoked and its
siblings identify a call by toolName and toolCallId, so a consumer that
needs the metadata reads the resolved tool rather than waiting for an
invocation to carry it.
HTTP plugin events
Events emitted by the HTTP plugin (configured via defineConfig({ http })). The listener itself belongs to the named server, so bind and close are reported by the server events above; the plugin also emits the framework's authentication events (auth:success / auth:rejected) with source: "http" when an auth strategy is configured or inherited.
plugin:http:request:completed fires for every request by default; disable it with http: { events: { perRequest: false } }. Built-in endpoints (/health, /ready, /openapi.json) do not emit it.
On a route whose respond answers before the pipeline finishes, the event reports the answer rather than the work: status is whatever the responder returned (or 503 once shutdown has begun) and durationMs measures time to answer, not time to finish. The detached pipeline's own outcome arrives on route:exchange:completed / route:exchange:failed as it does for any route. A responder that awaits finished, or returns undefined, reports the finished response as usual.
Ops plugin events
Events emitted by the ops plugin (configured via defineConfig({ ops })).
The ops surface is a mount on a named server, so its listener lifecycle is reported by server:listening and its siblings under the server's own name, not by the plugin.
plugin:ops:health:changed is what an operator alerts on instead of polling. component is context, route, or indicator, and name is the component's id (the reserved id context for the serving lifecycle). Transitions are detected per component as it changes, so a hot route costs one status comparison per exchange rather than a walk of the whole report. Staleness is the exception: an indicator going stale is a function of elapsed time rather than of any call, so it surfaces on the next read of the endpoint and not as a transition.
Related
Events
How to subscribe, filter by payload identity, emit custom events, and common patterns.
Configuration
Subscribe to events via craft.config.ts.