pleach
Cookbook

agentInstrumentation

Instrument any @pleach/core agent with bare lifecycle subscriptions — runtime.events.on(kind, ...) for stage, turn, recovery, retry, and model.called signals — then bridge model.called straight to a destination with observeSink({ destinations }). No recipe wrapper, no recorder threaded through call sites.

This is the do-it-yourself instrumentation path. You hold a SessionRuntime and you want to watch how each turn runs — stage transitions, recovery dispatches, retries, per-LLM-call cost — without adopting a recipe wrapper or threading a recorder argument through your code.

Two surfaces carry the signal. The durable runtime.events bus carries lifecycle and per-call events for cross-cutting subscribers. The per-turn StreamEvent iterator carries the same lifecycle kinds inline with the turn, for UIs that render from the stream. Pick the one that matches where your subscriber lives.

Best fit: any @pleach/core consumer who wants metrics or audit rows from an existing runtime without rewriting the turn loop. For the wrapped-chatbot version of this story, see observableChatbot. For the no-@pleach/core brownfield version, see BYOK observability.

Subscribe to the runtime bus

runtime.events.on(kind, handler) registers a handler for one event kind on the durable bus. It returns an unsubscribe function. The bus is the right surface for a metrics exporter, a cost meter, or an alerting hook — anything that outlives a single turn.

const offModel = runtime.events.on("model.called", (e) => {
  // { provider, model, callClass, inputTokens, outputTokens, costUSD, latencyMs }
  meter.record(e.model, e.latencyMs);
});

const offError = runtime.events.on("error", (e) => {
  // { error, context }
  alert(e.error);
});

// later, on teardown:
offModel();
offError();

model.called fires once per LLM call. As of the seam-path landing it fires for both call sites in a turn:

  • the main agentic turn (the converse / synthesize loop), and
  • each seam call — the dedicated synthesize, reasoning, and utility seam invocations the graph makes around the main turn.

That distinction matters for cost. See What fires today below before you sum costUSD across rows.

Subscribe inline with the turn

When your subscriber already owns the turn loop — a React UI, an SSE forwarder — read the same lifecycle kinds off the StreamEvent iterator instead. @pleach/react's useChat exposes them through onEvent:

useChat({
  onEvent: (e) => {
    if (e.type === "stage.transition") {
      // { from: StageId | null, to: StageId }
      track("stage", e.to);
    }
    if (e.type === "turn.completed") {
      // { outcome: "ok" | "error" | "interrupted", durationMs }
      track("turn", e.outcome, e.durationMs);
    }
  },
});

The same kinds arrive on the raw iterator if you do not use useChat:

for await (const e of runtime.executeMessage(sessionId, prompt)) {
  if (e.type === "recovery.fired") {
    // { arm: RecoveryDispatchArm }
    track("recovery", e.arm);
  }
}

These are StreamEvent members, not runtime.events bus events. The full catalog — payload shapes and when each fires — is on Stream events → Execution lifecycle. model.called is the exception: it lives on the durable bus, not the stream, because cost attribution is a long-lived concern.

Bridge model.called to a destination

observeSink turns the runtime.events.on("model.called", ...) subscription into audit rows with one line. It is a handler factory: observeSink({ destinations }) returns a model.called handler that maps each event to an ObserveRow and writes it to every destination you pass.

import { observeSink } from "@pleach/observe";
import { memory, postgres } from "@pleach/observe/destinations";

const store = memory();

runtime.events.on(
  "model.called",
  observeSink({
    destinations: [store, postgres({ pgClient, tableName: "pleach_observe_calls" })],
  }),
);

// every LLM call now writes one ObserveRow to both destinations

The mapping is runtimeEventToObserveRow — a pure function from a model.called event to an ObserveRow. observeSink calls it for you; import it directly if you want the row without the write (to enrich tags before handing it to your own sink, say):

import { runtimeEventToObserveRow } from "@pleach/observe";

runtime.events.on("model.called", (e) => {
  const row = runtimeEventToObserveRow(e);
  myKafkaTopic.publish({ ...row, tags: { region: "us-east-1" } });
});

ObserveRow is the same shape the brownfield recordCall path writes — a strict subset of @pleach/core's AuditableCall v13 record. See @pleach/observe for the row fields, the four destinations, redaction, and sampling.

What fires today

model.called fires for both the main turn and the seam calls, but the two carry cost differently.

SourceWhen it firesTokens + latencycostUSD
Seam calls (synthesize / reasoning / utility seams)Every turnRealReal — computed from the family's per-1M-token rates
Main agentic turn (converse / synthesize loop)Every turnReal0 sentinel — derive downstream from the llm.turn token counts

The seam-call rows are the ones with real costUSD. The main-turn row reports tokens and latency but carries 0 for costUSD by design — cost for the main turn is derived downstream from llm.turn token metadata, not stamped on the model.called event. If you sum costUSD naively across all model.called rows in a turn, the main-turn row contributes nothing; that is expected, not a dropped row.

The lifecycle stream events fire every turn regardless of the above:

  • turn.started / turn.completed
  • stage.transition (and stage.lattice_violation when a transition leaves the advertised lattice)
  • recovery.firedarm"zeroToolRecovery" | "allToolsFailedMissingParams" | "maxStepsHit"
  • retry.attempted{ reason, attempt }
  • stream.first_chunk / stream.completed — TTFB and total stream duration

So a UI can reconstruct the per-turn lattice walk and report TTFB and total latency from the stream alone, with no separate metrics pipeline.

Common gotchas

  • model.called is a bus event, not a stream event. Subscribe with runtime.events.on("model.called", ...). It does not arrive in the executeMessage iterator or in useChat({ onEvent }) — those carry the lifecycle stream kinds (stage.*, turn.*, recovery.fired, retry.attempted, stream.*), not the per-call cost signal.
  • The main-turn costUSD is a 0 sentinel. Real cost on the main turn is derived from llm.turn token counts downstream; the seam-call rows are the ones carrying computed costUSD. Don't treat a 0-cost main-turn row as a bug.
  • observeSink needs no init. Unlike the brownfield recordCall path, observeSink({ destinations }) writes straight to the destinations you pass — there is no process-singleton to initialize first.
  • runtime.events.on returns an unsubscribe function. Call it on teardown for long-lived processes; a leaked handler keeps the destination write alive past the runtime you meant to retire.

See also

  • Stream events — the full StreamEvent catalog, including the execution-lifecycle kinds this page subscribes to.
  • @pleach/observeobserveSink, runtimeEventToObserveRow, the ObserveRow shape, and the four destinations.
  • observableChatbot — the same observability story as a chatbot recipe wrapper.
  • BYOK observability — the brownfield path for loops not yet on @pleach/core.
  • Observability — the runtime-side observability surface.

On this page