Reference
Events
Full catalog of lifecycle and runtime events emitted by the Routecraft context.
Namespace map
48 events / 9 namespacesstarting · started · stopping · stopped · error
registered · starting · started · stopping · stopped · source:failed · error · error:caught
started · completed · failed · dropped · restored · suspended · resumed · expired
adapter · batch · split · aggregate · retry · choice · error · agent · source:parse
Each operation emits its own started / completed / failed.
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.
request:completed
server:tools:exposed · tool:called · tool:completed · tool:suspended · tool:failed · tool:declined
Event payload
All events share the same envelope:
{
ts: string // ISO timestamp
context: CraftContext
details: {...} // event-specific fields (see tables below)
}
Context events
Route events
"Route" here refers to a registered capability internally.
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).
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 exchange:started is eventually followed by exactly one of completed, failed, or dropped.
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 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 step:started/step:completed events (not dedicated operation events). Operation-specific data is in the metadata field:
- Split
step:completedincludesmetadata.childCount: the number of child exchanges created - Aggregate
step:completedincludesmetadata.inputCount: the number of exchanges merged
After a split, each child exchange emits its own exchange:started. When aggregate consumes children, it emits 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 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 intervalMs). 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 waitMs window closed), "maxWait" (the maxWaitMs 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 ownstep: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, cache:hit is accompanied by an exchange:restored event with source: "cache" (per the exchange lifecycle).
Concurrent exchanges that share one computation (stampede dedupe) currently emit 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 AgentOptions.onDelta instead (a separate per-call channel).
agentName is present only for by-name agents (agent("id")); inline agents are identified by their routeId. model is the resolved providerId:modelName.
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).
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);
});
When the context starts, agentPlugin announces the agents and fns it registered so dashboards and the TUI can list them before they run:
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) →exchange:failed(orerror:caughtif a route.error()handler recovers).'abort'→exchange:failedfor the bad item, then the source aborts andcontext:errorfires.'drop'→exchange:droppedwithreason: "parse-failed"(nostep:failedfires; the parse step catches and drops cleanly).
Subscribe with a glob 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.
Authentication events
Emitted by auth-enabled adapters (currently MCP HTTP) on every auth attempt. The source field identifies which adapter emitted the event.
reason is one of "missing_header", "unsupported_scheme", or "invalid_token".
MCP plugin events
Events emitted by the MCP plugin during server and tool lifecycle. Subscribe to the exact names (plugin:mcp:tool:called / completed / failed) for broad observability, or use the catch-all "*".
Server events
Tool call events
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.
HTTP plugin events
Events emitted by the HTTP plugin (configured via defineConfig({ http })). The plugin also emits the framework's authentication events (auth:success / auth:rejected) with source: "http" when an auth strategy is configured.
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.
Related
Events
How to subscribe, filter by payload identity, emit custom events, and common patterns.
Configuration
Subscribe to events via craft.config.ts.