Reference

Events

Full catalog of lifecycle and runtime events emitted by the Routecraft context.

Namespace map

33 events / 7 namespaces
  • starting · started · stopping · stopped

  • registered · starting · started · stopping · stopped

  • started · completed · failed · dropped · restored

  • adapter · batch · split · aggregate · retry · choice · error · agent · source:parse

    Each operation emits its own started / completed / failed.

  • registered · starting · started · stopping · stopped

  • success · rejected

  • server · session · tool

    server (listening, tools:exposed), session (created, closed), tool (called, completed, failed).

Event payload

All events share the same envelope:

{
  ts: string       // ISO timestamp
  context: CraftContext
  details: {...}   // event-specific fields (see tables below)
}

Context events

EventWhen it firesDetails
context:startingBefore the context starts{}
context:startedAfter all capabilities have started{}
context:stoppingBefore shutdown begins{ reason? }
context:stoppedAfter all capabilities have stopped{}

Route events

"Route" here refers to a registered capability internally.

EventWhen it firesDetails
route:registeredCapability registered with the context{ route }
route:startingJust before a capability starts{ route }
route:startedCapability is running{ route }
route:stoppingCapability is stopping{ route, reason?, exchange? }
route:stoppedCapability has stopped{ route, exchange? }
route:source:failedA source gave up producing (e.g. a connection-backed source exhausted its reconnect attempts) and the route is about to stop{ routeId, route, adapter?, error }

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.

EventWhen it firesDetails
route:exchange:startedExchange enters the pipeline (parent or child){ routeId, exchangeId, correlationId }
route:exchange:completedExchange finished successfully (or consumed by aggregate){ routeId, exchangeId, correlationId, duration }
route:exchange:failedExchange encountered an unrecoverable error{ routeId, exchangeId, correlationId, duration, error }
route:exchange:droppedExchange intentionally removed from the pipeline{ routeId, exchangeId, correlationId, reason }
route:exchange:restoredExchange restored from cache, skipping steps{ routeId, exchangeId, correlationId, source }

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.

EventWhen it firesDetails
route:step:startedStep begins executing{ routeId, exchangeId, correlationId, operation, adapter? }
route:step:completedStep finished successfully{ routeId, exchangeId, correlationId, operation, adapter?, duration, metadata? }
route:step:failedStep threw{ routeId, exchangeId, correlationId, operation, adapter?, duration, error }
route:step:errorStep error surfaced on the route error path{ routeId, error, operation, route?, exchange? }

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

EventWhen it firesDetails
route:batch:startedBatch accumulation started{ routeId, batchId, batchSize }
route:batch:flushedBatch released for processing{ routeId, batchId, batchSize, waitTime, reason }
route:batch:stoppedBatch accumulation stopped{ routeId, batchId }

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:completed includes metadata.childCount: the number of child exchanges created
  • Aggregate step:completed includes metadata.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

EventWhen it firesDetails
route:retry:startedGuarded execution began{ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", maxAttempts }
route:retry:attemptA failed attempt will be re-attempted after backoffMsSame plus attemptNumber, backoffMs (the actual wait, factor growth and jitter applied), lastError?
route:retry:stoppedFinal success or failureSame plus attemptNumber, success, and error? (the final raw error when success is false)

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

EventWhen it firesDetails
route:delay:startedThe wait began{ routeId, exchangeId, correlationId, stepLabel, scope: "step", delayMs }
route:delay:stoppedThe wait ended; the wrapped step runs nextSame plus elapsed, cancelled

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

EventWhen it firesDetails
route:timeout:startedGuarded execution began{ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", timeoutMs }
route:timeout:stoppedThe guarded execution settled within the deadlineSame plus elapsed
route:timeout:expiredThe deadline fired first; an RC5011 throw followsSame plus elapsed

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 keeps running in the background (promises cannot be cancelled); its eventual result is discarded.

Throttle wrapper operations

EventWhen it firesDetails
route:throttle:delayedDelay mode: no token was free, the exchange will pace before admission{ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", waitMs, key?, label? }
route:throttle:passedThe exchange was admitted through the rate limiter{ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", waited, elapsed, key?, label? } (no waitMs; waited is true when it had to pace, elapsed is total time in the gate)
route:throttle:rejectedReject mode: the exchange exceeded the rate and is failed with RC5013{ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", retryAfterMs, key?, label? }

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

EventWhen it firesDetails
route:circuitBreaker:openedThe breaker tripped to open (failures reached the threshold while closed, or a probe failed while half-open){ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", failureCount, threshold, cooldownMs, label? }
route:circuitBreaker:halfOpenThe cooldown elapsed and the breaker admitted a probe call to test recovery{ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", label? }
route:circuitBreaker:closedA probe succeeded and the breaker recovered to closed{ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", label? }
route:circuitBreaker:rejectedA call was fast-failed because the breaker is open (or half-open at capacity); a fallback ran or RC5025 followed{ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", state: "open" | "half-open", retryAfterMs, label? }

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

EventWhen it firesDetails
route:concurrency:queuedAll slots were busy, so the exchange joined the wait queue (queue mode){ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", queueDepth, key?, label? }
route:concurrency:acquiredA slot was acquired and the wrapped work began{ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", waited, inUse, key?, label? }
route:concurrency:releasedThe held slot was released (work settled: success, drop, or failure){ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", heldMs, key?, label? }
route:concurrency:rejectedThe exchange was fast-failed with RC5026{ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", reason: "busy" | "queue-full", key?, label? }

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

EventWhen it firesDetails
route:operation:choice:matchedA when or otherwise branch matched{ routeId, exchangeId, correlationId, branchIndex, branchLabel }
route:operation:choice:unmatchedNo branch matched and the exchange is dropped{ routeId, exchangeId, correlationId }

branchLabel is "when" or "otherwise". branchIndex is the zero-based index of the matched branch.

Multicast operations

EventWhen it firesDetails
route:operation:multicast:startedFan-out begins, before the exchange is cloned to each path{ routeId, exchangeId, correlationId, pathCount }
route:operation:multicast:stoppedEvery path has settled and the original exchange continues{ routeId, exchangeId, correlationId, pathCount }

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

EventWhen it firesDetails
route:operation:dispatch:selectedA target was chosen to run (for failover, once per attempt){ routeId, exchangeId, correlationId, strategy, targetIndex }
route:operation:dispatch:exhaustedfailover ran out of targets and none handled the exchange{ routeId, exchangeId, correlationId, strategy: "failover", targetCount }

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

EventWhen it firesDetails
route:operation:sample:passedThe sampler admitted the exchange{ routeId, exchangeId, correlationId, mode }
route:operation:sample:droppedThe sampler dropped the exchange between samples{ routeId, exchangeId, correlationId, mode }

mode is "count" (for every) or "interval" (for intervalMs). A dropped exchange also fires route:exchange:dropped with reason "sampled".

Dedupe operations

EventWhen it firesDetails
route:operation:dedupe:passAn unseen key was reserved and the exchange continues{ routeId, exchangeId, correlationId, key }
route:operation:dedupe:duplicateA duplicate key was suppressed{ routeId, exchangeId, correlationId, key }

A suppressed duplicate also fires route:exchange:dropped with reason "duplicate". key is the derived deduplication key.

Debounce operations

EventWhen it firesDetails
route:operation:debounce:heldAn arrival is held and the quiet timer is armed or reset{ routeId, exchangeId, correlationId, key? }
route:operation:debounce:droppedA held exchange is superseded by a newer arrival in the same burst{ routeId, exchangeId, correlationId, key? }
route:operation:debounce:releasedThe trailing exchange is released downstream{ routeId, exchangeId, correlationId, key?, reason }

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

EventWhen it firesDetails
route:error-handler:invokedA .error() handler runs (route or step scope){ routeId, exchangeId, correlationId, originalError, failedOperation, scope: "route" | "step", stepLabel? }
route:error-handler:recoveredHandler returned a value; pipeline continues (step scope) or replaces body (route scope)Same plus recoveryStrategy
route:error-handler:failedHandler itself threw; rethrows for the next layer (route scope or default error path)Same

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

EventWhen it firesDetails
route:cache:hitA cached value was reused; the wrapped step (or whole pipeline, at route scope) was skipped{ routeId, exchangeId, correlationId, stepLabel, scope: "route" | "step", key }
route:cache:missNo cached value; the wrapped step ran (or was dropped)Same plus dropped?: true when the wrapped step dropped the exchange
route:cache:storedA fresh value was written to the cacheSame plus ttl?: number when a per-call TTL was set
route:cache:failedKey derivation, a provider read/write, or the wrapped step threw{ ..., stepLabel, scope: "route" | "step", phase: "key" | "get" | "inner" | "set", error, key? }

Failure phases:

  • phase: "key" - key derivation threw (no key field, since none was produced). Raised as RC5029 (not retryable).
  • phase: "get" - the provider read threw before the wrapped step ran. Non-RoutecraftError provider failures are raised as RC5028 (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 own step:failed event 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 as RC5028 (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).

EventWhen it firesDetails
route:agent:startedAgent dispatch began, before the first model call{ routeId, exchangeId, correlationId, agentName?, model, toolNames, maxTurns }
route:agent:tool:invokedAgent decided to call a tool (input validated, before guard){ routeId, exchangeId, correlationId, toolCallId, toolName, _snapshot: { input } }
route:agent:tool:resultTool handler returned a value{ routeId, exchangeId, correlationId, toolCallId, toolName, _snapshot: { output }, duration }
route:agent:tool:errorTool handler / guard / input validation threw{ routeId, exchangeId, correlationId, toolCallId, toolName, errorName, _snapshot: { error }, duration }
route:agent:block:loadedProgressive block loader returned a value to the model{ routeId, exchangeId, correlationId, toolCallId, blockName, _snapshot: { output }, duration }
route:agent:block:errorProgressive block resolver threw during load{ routeId, exchangeId, correlationId, toolCallId, blockName, errorName, _snapshot: { error }, duration }
route:agent:finishedAgent dispatch returned a consolidated result{ routeId, exchangeId, correlationId, agentName?, model, finishReason, inputTokens?, outputTokens?, totalTokens? }
route:agent:errorProvider / transport error during dispatch{ routeId, exchangeId, correlationId, agentName?, model, error }

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.

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:

EventWhen it firesDetails
agent:registeredOn context:started, once per registered agent{ agentId, description, model?, source: 'registered' }
agent:tool:registeredOn context:started, once per registered fn{ toolName, description?, tags?, source: 'registered' }

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".

EventWhen it firesDetails
route:step:started (operation: "parse")Synthetic parse step begins, before any user step{ routeId, exchangeId, correlationId, operation: "parse", adapter: "parse" }
route:step:completed (operation: "parse")Parse succeeded; user steps run next{ ..., duration }
route:step:failed (operation: "parse")Parse threw RC5016{ ..., error }

What follows depends on the adapter's onParseError mode:

  • 'fail' (default) → exchange:failed (or error:caught if a route .error() handler recovers).
  • 'abort'exchange:failed for the bad item, then the source aborts and context:error fires.
  • 'drop'exchange:dropped with reason: "parse-failed" (no step:failed fires; 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.

EventWhen it firesDetails
plugin:startingPlugin is about to start{ pluginId, pluginIndex }
plugin:startedPlugin has started{ pluginId, pluginIndex }
plugin:stoppingPlugin is about to stop{ pluginId, pluginIndex }
plugin:stoppedPlugin has stopped{ pluginId, pluginIndex }

Authentication events

Emitted by auth-enabled adapters (currently MCP HTTP) on every auth attempt. The source field identifies which adapter emitted the event.

EventWhen it firesDetails
auth:successToken validated and principal resolved{ subject, scheme, source }
auth:rejectedAuth failed (missing header, bad scheme, or invalid token){ reason, scheme, source }

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

EventWhen it firesDetails
plugin:mcp:server:listeningHTTP server is ready to accept connections{ host, port, path }
plugin:mcp:server:tools:exposedTool list logged for the first time{ tools, count }

Session events

EventWhen it firesDetails
plugin:mcp:session:createdNew HTTP client session initialized{ sessionId }
plugin:mcp:session:closedHTTP client session transport closed{ sessionId }

Tool call events

EventWhen it firesDetails
plugin:mcp:tool:calledTool invocation started{ tool, args }
plugin:mcp:tool:completedTool invocation succeeded{ tool }
plugin:mcp:tool:failedTool invocation failed{ tool, error }

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.

EventWhen it firesDetails
plugin:http:server:listeningThe HTTP server has bound its port{ port, host }
plugin:http:server:closedThe HTTP server has shut down (on context stop){}
plugin:http:request:completedA request finished (after the response is built){ method, path, status, durationMs, routeId?, principal? }

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.


Events

How to subscribe, filter by payload identity, emit custom events, and common patterns.

Configuration

Subscribe to events via craft.config.ts.

Previous
Operations