Reference

Events

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

Namespace map

60 events / 13 namespaces
  • starting · 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

EventWhen it firesDetails
context:startingBefore the context starts{}
context:startedAfter context initialization, BEFORE any route or plugin start() hook has run. For readiness, await ctx.whenStarted(){}
context:stoppingBefore shutdown begins{ reason? }
context:stoppedAfter all capabilities have stopped{ forced, pending }
context:errorSomething failed at context level: a plugin's apply() or start() threw, a capability failed to start, the config was refused, or an exchange failed with no handler left to catch it{ error, route?, exchange? }

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.

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 ACCEPTING work. In-flight exchanges may still be draining: shutdown fires this when intake closes, not when the drain completes{ 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:enablement:changedA capability's .enabled() verdict changed (or was reached for the first time). reason is present only when enabled is false{ routeId, route, enabled, reason? }
route:errorAn exchange failed unrecoverably on this capability: no route-scope .error() handler, or the handler itself rethrew{ routeId, error, route?, exchange? }
route:error:caughtA route-scope .error() handler recovered the exchange, so the pipeline continues{ routeId, error, route?, exchange? }

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.

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 }
route:exchange:deferredExchange deferred at a .defer(); execution one ends here{ routeId, exchangeId, correlationId, deferralId, position, expiresAt? }
route:exchange:resumedA deferred exchange was revived and its continuation is about to run{ routeId, exchangeId, correlationId, deferralId, position, resumedBy? }
route:exchange:expiredA deferral stopped being resumable because its ttl elapsed{ routeId, exchangeId, correlationId, deferralId, expiresAt }

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.

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 route: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 route:step:started/route:step:completed events (not dedicated operation events). Operation-specific data is in the metadata field:

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

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

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 interval). 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 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

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 route: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, 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.

EventWhen it firesDetails
route:agent:startedAgent dispatch began, before the first model call{ routeId, exchangeId, correlationId, agentName?, model, toolNames, maxTurns }
route:agent:tool:deniedtoolPolicy refused a tool admission, so it was never offered to the model{ routeId, exchangeId, correlationId, agentName?, toolName, toolKind, reason }
route:agent:tool:invokedAgent decided to call a tool (input validated, before guard){ routeId, exchangeId, correlationId, session?, toolCallId, toolName, _snapshot: { input } }
route:agent:tool:resultTool handler returned a value{ routeId, exchangeId, correlationId, session?, toolCallId, toolName, _snapshot: { output }, duration }
route:agent:tool:errorTool handler / guard / input validation threw{ routeId, exchangeId, correlationId, session?, toolCallId, toolName, errorName, _snapshot: { error }, duration }
route:agent:tool:refusedA tool's guard rejected a call the model made, on a tool it was allowed to attempt{ routeId, exchangeId, correlationId, session?, toolCallId, toolName, rc? }
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 }
route:agent:session:queuedA message arrived for a session whose turn is running and was queued for the next turn boundary{ routeId, exchangeId, correlationId, agentName, session, depth, interrupt }
route:agent:session:interruptedA running turn was cancelled by a later message with interrupt: true{ routeId, exchangeId, correlationId, agentName, session }
route:agent:session:restoredA turn started on a session whose previous turn a restart cut short: its partial transcript was kept and each background call it was waiting on was reported lost{ routeId, exchangeId, correlationId, agentName, session, lostBackground }
route:agent:session:deferredA turn ended with work outstanding (background calls running, or messages queued), so its exchange's continuation was stored for a completion, the queued messages, or a boot to revive{ routeId, exchangeId, correlationId, agentName, session, deferralId, inbox, background }
route:agent:session:revivedA stored continuation was revived and this turn is the one it runs: the inbox is its user message and the route's downstream steps follow. Emitted on the revived exchange, after core's route:exchange:resumed{ routeId, exchangeId, correlationId, agentName, session, deferralId }
route:agent:session:background:startedA background tool dispatched its route and returned a handle to the model{ routeId, exchangeId, correlationId, agentName, session, handle, toolName }
route:agent:session:background:completedA background tool's route finished and its result was posted to the session inbox{ routeId, exchangeId, correlationId, agentName, session, handle, toolName, duration }
route:agent:session:background:failedA background tool's route failed and the failure was posted to the session inbox{ routeId, exchangeId, correlationId, agentName, session, handle, toolName, errorName, duration }

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

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

EventWhen it firesDetails
plugin:applyingThe plugin's apply() hook is about to run, while the context is being built{ pluginId, pluginIndex }
plugin:appliedThe plugin's apply() hook returned{ pluginId, pluginIndex }
plugin:startingThe plugin's start() hook is about to run, after every route is up{ pluginId, pluginIndex }
plugin:startedThe plugin's start() hook resolved{ pluginId, pluginIndex }
plugin:stoppingPlugin is about to stop{ pluginId, pluginIndex }
plugin:stoppedPlugin has stopped{ pluginId, pluginIndex }

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.

Warning

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.

EventWhen it firesDetails
auth:successCredential validated and principal resolved{ subject, scheme, source }
auth:rejectedAuth failed{ reason, scheme, 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.

EventWhen it firesDetails
server:listeningThe named listener bound its port (during context start()){ server, host, port }
server:failedThe listener could not bind; the context fails to start{ server, error }
server:closedThe listener shut down (on context stop){ server }
server:request:rejectedShared protocol ingress refused Host or browser Origin before auth or dispatch{ server, mount, reason: "host" | "origin" }

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.

EventWhen it firesDetails
plugin:mcp:server:tools:exposedTool list logged for the first time{ tools, count }

Tool call events

EventWhen it firesDetails
plugin:mcp:tool:calledTool invocation started{ tool, args, proxied?, serverId?, remoteTool? }
plugin:mcp:tool:completedTool invocation succeeded{ tool, proxied?, serverId?, remoteTool? }
plugin:mcp:tool:deferredThe route deferred at a durable deferral; execution one answered with the Deferred acknowledgment{ tool, deferralId }
plugin:mcp:tool:failedTool invocation failed{ tool, error, proxied?, serverId?, remoteTool? }
plugin:mcp:tool:declinedThe route ran and dropped the exchange instead of producing a result{ tool, reason }

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.

EventWhen it firesDetails
plugin:acp:connection:openedAn editor opened a connection and initialized it{ connectionId, subject?, clientName? }
plugin:acp:connection:closedThe connection went away, cleanly or not{ connectionId, fault? }
plugin:acp:session:attachedA conversation was opened, loaded or resumed on a connection{ connectionId, sessionId, agentName, how }

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.

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

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.

EventWhen it firesDetails
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.

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 })).

EventWhen it firesDetails
plugin:ops:health:changedA health component moved to a different status{ component, name, from, to }

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.


Events

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

Configuration

Subscribe to events via craft.config.ts.

Previous
Operations