pleach
Cookbook

observableChatbot

Chatbot whose ask() calls inherit a @pleach/observe sub-agent attribution scope. Every recordCall(...) downstream of the runtime turn carries [serviceName, ...] without threading a recorder argument through call sites.

observableChatbot wraps simpleChatbot with an @pleach/observe subagent(serviceName).run(...) scope. Every recordCall(...) issued downstream of runtime.executeMessage(...) — by the runtime, by a plugin observer, by the consumer's outer loop — inherits the [serviceName, ...] attribution path automatically. No recorder argument threaded through call sites.

Best fit: a SaaS adding chat (the production upgrade once OTel is wired) and an AI consultancy (one runtime per tenant; the dashboard rows are tagged by the consuming app's service name).

Quickstart

import { init } from "@pleach/observe";
import { memory } from "@pleach/observe/destinations";
import { observableChatbot } from "@pleach/recipes/observability";

// Once at app boot — the SDK is a process-singleton.
const dest = memory();
init({ destination: dest });

const bot = observableChatbot({
  serviceName: "my-app-chatbot",
});

await bot.ask("hello"); // rows on `dest` carry subagent: "my-app-chatbot"

Compose freely with outer scopes — the recipe's inner subagent(serviceName) nests beneath any outer scope the consumer wraps around the ask() call:

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

await subagent("tenant-abc").run(async () => {
  await bot.ask("..."); // attribution path: ["tenant-abc", "my-app-chatbot"]
});

What it does

The recipe constructs a simpleChatbot and replaces its ask() with one wrapped in subagent(serviceName).run(...) from @pleach/observe. The runtime, plugins, and any code the runtime call reaches all sit inside that AsyncLocalStorage scope, so recordCall(...) invocations issued downstream inherit the attribution path without an explicit recorder argument.

The wrap is a no-op pass-through when the consumer has not called init({destination}). The recipe does NOT call init itself — that would clash with hosts that already initialized observe in the parent process.

Config reference

interface ObservableChatbotConfig extends SimpleChatbotConfig {
  /**
   * Sub-agent attribution label applied to all rows recorded
   * inside each ask(...) call. Defaults to "pleach-chatbot".
   * Set to false to disable the ALS scope (useful when the
   * consumer is composing the recipe inside their own outer
   * subagent(...).run(...) scope).
   */
  serviceName?: string | false;

  /**
   * Forward-looking — currently inert. Documented so the
   * config shape stabilizes before a future recipe version
   * takes ownership of init for hosts that don't bring
   * their own.
   */
  otlpEndpoint?: string;
  sampleRatio?: number;
}

The returned shape is the same Chatbot interface that simpleChatbot returns — runtime, ask, newSession, reset. The runtime escape hatch is unchanged.

Common gotchas

  • init({destination}) is a process-singleton. Call it once at app boot, before any ask(). A second init(...) call throws, so call it exactly once. The recipe does NOT call init itself — without it, the subagent(...).run(...) wrap is a no-op pass-through and no rows land on a destination.
  • Outer scopes nest, they do not replace. When the consumer wraps bot.ask(...) inside an outer subagent("tenant-abc").run(...), the attribution path becomes ["tenant-abc", "my-app-chatbot"] — the recipe's inner scope appends, it does not overwrite.
  • otlpEndpoint and sampleRatio are forward-looking config. Accepted on the type today, but inert at runtime. Wire init({destination, sampling}) directly for sampling and destination control in v0.2.
  • Subpath import is load-bearing. Import from @pleach/recipes/observability, not the root barrel.
  • @pleach/observe is an optional peer. Install it alongside @pleach/recipes when using this recipe. Without the install, the subpath import fails — the documented missing-peer signal.

Live lifecycle events

The recipe gives you per-call attribution rows. If you also want the per-turn lifecycle — stage transitions, recovery dispatches, retries, stream timing — subscribe to those directly off the runtime escape hatch. runtime.events.on("model.called", ...) carries the per-call cost signal on the durable bus; the stage.* / turn.* / recovery.fired / retry.attempted / stream.* kinds arrive on the StreamEvent iterator.

const bot = observableChatbot({ serviceName: "my-app-chatbot" });

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

That bare-subscription path — no recipe wrapper, plus the observeSink one-liner that bridges model.called straight to a destination — is the focus of Agent instrumentation.

See also

On this page