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 anyask(). A secondinit(...)call throws, so call it exactly once. The recipe does NOT callinititself — without it, thesubagent(...).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 outersubagent("tenant-abc").run(...), the attribution path becomes["tenant-abc", "my-app-chatbot"]— the recipe's inner scope appends, it does not overwrite. otlpEndpointandsampleRatioare forward-looking config. Accepted on the type today, but inert at runtime. Wireinit({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/observeis an optional peer. Install it alongside@pleach/recipeswhen 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
@pleach/recipesoverview — every recipe in one page.- Agent instrumentation —
the DIY lifecycle-subscription path with
runtime.events.on(...)and theobserveSink({ destinations })bridge. @pleach/observe—init,recordCall,subagent, the four destinations.Observability— the runtime-side observability surface.Subagents— the underlying attribution primitive.BYOK observability— the brownfield path for consumers who are not yet on@pleach/core.
ragChatbot
Chatbot plus a retrieval stage. You supply the Retriever — the recipe is storage-agnostic on purpose. Per turn, top-K chunks are spliced into the prompt as numbered context before delegating to simpleChatbot.
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.