# observableChatbot (/docs/recipes/observable-chatbot)



`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 [#quickstart]

```ts
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:

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

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

## What it does [#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 [#config-reference]

```ts
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 [#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 [#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.

```ts
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](/docs/recipes/agent-instrumentation).

## See also [#see-also]

* [`@pleach/recipes` overview](/docs/recipes-pleach-recipes#observablechatbot)
  — every recipe in one page.
* [Agent instrumentation](/docs/recipes/agent-instrumentation) —
  the DIY lifecycle-subscription path with `runtime.events.on(...)`
  and the `observeSink({ destinations })` bridge.
* [`@pleach/observe`](/docs/observe) — `init`, `recordCall`,
  `subagent`, the four destinations.
* [`Observability`](/docs/observability) — the runtime-side
  observability surface.
* [`Subagents`](/docs/subagents) — the underlying
  attribution primitive.
* [`BYOK observability`](/docs/recipes/byok-observability)
  — the brownfield path for consumers who are not yet on
  `@pleach/core`.
