@cool-ai/beach-inspect
v1.2.2
Published
Local developer tool for inspecting the router's event log — sessions, turns, and missives.
Readme
@cool-ai/beach-inspect
Optional local-only development UI that reads the event router's stream and renders sessions, envelopes, and tool calls.
Home: cool-ai.org · Documentation: cool-ai.org/docs
Install
npm install --save-dev @cool-ai/beach-inspectFor Redis-backed stores, install the optional peer:
npm install ioredisCLI usage
# Inspect a JSON missive store
npx beach-inspect --store ./data/missives.ndjson
# Inspect a Redis store
npx beach-inspect --store redis://localhost:6379
# Use a non-default port
npx beach-inspect --store ./data/missives.ndjson --port 9090The CLI starts a local HTTP server and opens the browser. Press Ctrl+C to exit.
Programmatic use
import {
InspectReader,
InspectorRegistry,
defaultRegistry,
createServer,
ManifestEventStore,
} from '@cool-ai/beach-inspect';
import { ManifestHandler } from '@cool-ai/beach-core';
import { JSONMissiveStore } from '@cool-ai/beach-missives/stores';
// Wire the manifest observer before creating the manifest handler
const manifests = new ManifestEventStore();
const manifestHandler = new ManifestHandler({ observer: manifests });
const store = new JSONMissiveStore('./data/missives.ndjson');
const reader = new InspectReader(store, {
registry: defaultRegistry({ store }), // required — see "Inspector registry" below
listAll: () => store.listAll(), // enables session listing
manifests, // enables manifest context in the Manifest side-panel section + legacy Manifests tab
});
const server = createServer(reader);
server.listen(9090);ManifestEventStore implements ManifestObserver from @cool-ai/beach-core. It subscribes to manifest lifecycle events in-process and surfaces them in three places:
- Manifest side-panel section (default sequence-diagram view, CAIB-275) — clicking any arrow that carries a
manifestIdopens the full debugging context: identity & lifecycle, trigger event payload, every dispatched event the router fired on the manifest's behalf, per-slot raw / projected / missive drill-downs, parent, children. Backed byInspectReader.getManifestContext(manifestId)andGET /api/manifests/:id/context. - Manifests tab (legacy four-tab view) — each manifest's status, slot fill state, delivery timestamps, and duration. Slots filled via the orphan queue are annotated
(via orphan claim). - Orphans tab (process-wide, legacy four-tab view) — live orphan queue with TTL remaining, and a complete orphan event history (queued → claimed/evicted).
If manifests is not provided, the Manifest side-panel section is suppressed and the legacy tabs return empty data rather than erroring.
Default view — sequence diagram
The bundled UI's right panel renders the selected session as a vertical sequence diagram. Lifelines run top-to-bottom for each component the session touched. Arrows represent events flowing between lifelines in chronological order, and an icon at each arrow's destination distinguishes the dispatch type.
A routed event produces N+1 arrows: one inbound leg from the source to the EventRouter lifeline (the router brokers every dispatch), and one outbound leg per target in the rule's to[] list. Targets are equal peers; consumers reading the sequence reconstruct the dispatch-to-each-target from those outbound arrows in firing order. Unmatched routing decisions stay a single arrow from source to the __unmatched lane.
The diagram also surfaces two synthesised arrow categories. manifest-created arrows flow from the triggering component to the ManifestHandler lane at the point a manifest is registered against a routed event. recorded-outbound arrows surface side-channel deliveries the consumer has logged via InspectorRegistry.recordOutbound(...) — useful for showing arrows into systems Beach does not itself dispatch to.
Clicking an arrow opens a side panel with three built-in sections, top-to-bottom:
- Routing — dispatch type, source → target list, event type, and (for unmatched arrows) the routing decision's
reason. Backed byGET /api/events/:id/routing. - Payload — each missive part rendered via the registered payload renderers (see below); falls back to a JSON tree. Beach-internal parts are filtered out:
inspect:routing-decisionis metadata for the Routing section, not payload, and the originating event's data (carried asinspect:event) renders first ahead of any consumer-attached parts (CAIB-275). - Causal context — the event's parent eventId, direct-child count, and a chain-depth badge (
N upstream / M downstream). The diagram simultaneously highlights the full causal chain — every arrow upstream of the click back to the session root, and every arrow downstream by transitive parentage. Non-chain arrows dim to ~30% opacity so the chain stands out without losing surrounding context. PressJ/Kto walk the chain forwards / backwards in temporal order;Esc(or the Clear chain button) clears the highlight. Backed byparentEventIdon everySequenceArrow.
Below the built-ins, two Beach-supplied arrow-section plugins surface manifest context:
- Manifest — rendered on any arrow whose context carries a
manifestId(today:manifest-createdarrows). The section shows the manifest's full debugging context — identity & lifecycle, the triggering event (with its payload), each dispatched event the router fired on this manifest's behalf, every slot with raw data + projection + dispatching missive parts, the parent manifest if any, and a summary of children. Backed byGET /api/manifests/:id/context. - Belongs to manifest — rendered on any arrow whose underlying event carries
data.triggerManifestId. The router auto-stamps this field on spec-levelchildEvents(CAIB-236) so the section appears for free on the manifest's spec-driven dispatches. Consumers stamp their own events with the same field to surface this pointer on arbitrary dispatch arrows (CAIB-275 convention).
Layer rendering
Each layer in the sequence diagram renders as a vertical column with an alternating background tint behind the lifelines, so layer boundaries are visible without reading the header label. The left-to-right layer order defaults to the order layers first emit; declare an explicit order with InspectorRegistry.setLayerOrder(['presentation', 'router', 'core', 'specialist', 'other']) (or the layerOrder option on defaultRegistry) and configured layers appear in declared order, with any unlisted layers tailing at the right end (CAIB-275).
Clicking a lifeline opens the side panel showing the most recent event that touched the component (mode (i)). Mode (ii) — reconstructed state at the click timestamp — wires up when CAIB-256's getStateAt ships.
Inspector registry
InspectorRegistry is the single wiring point for per-component information: display names, layers, prose descriptions, and (CAIB-256) state-reconstruction hooks. The reader requires one on construction.
For zero-config wiring, call defaultRegistry({ store }). It pre-registers the three Beach sentinel lifelines — __event-router (Event Router, layer router), __manifest-handler (Manifest Handler, layer manifest), and __unmatched (the drop lane, layer router) — and threads the missive store through so recordOutbound works. Anything else falls back to sensible defaults: the component id is the display label, and the first path-segment of the id (delimiter /) is the layer. Beach apps namespacing their participants (channels/email, handlers/sally) get layered grouping for free.
Consumers customise the vocabulary by registering specs:
const registry = defaultRegistry({ store })
.register({ id: 'channels/email', displayName: 'Email Channel', layer: 'channels' })
.register({ id: 'handlers/sally', displayName: 'Sally', layer: 'handlers',
description: 'Customer-support agent driving the conversation.' });
const reader = new InspectReader(store, { registry, listAll, manifests });register() throws on duplicate ids; use override() when replacing a default spec deliberately. The registry is mutable — call register() whenever new specs become available, and the reader picks them up on the next getSessionSequence call.
Recording side-channel deliveries
When a producer hands work to a system outside Beach's routing pipeline — a webhook, an external email send, a non-Beach dependency — surface the delivery on the sequence diagram by calling recordOutbound:
await registry.recordOutbound({
sessionId,
fromComponent: 'handlers/main',
toComponent: 'external/email-provider',
eventType: 'send',
triggerEventId: currentEventId, // ties the arrow to a turn
payload: { to, subject },
});The registry writes a synthetic missive (partType inspect:outbound) the reader picks up and renders as a recorded-outbound arrow. Requires the registry to have been constructed with a MissiveStore; calls on a store-less registry throw.
Customising payload rendering (CAIB-255)
Register per-partType renderers (or predicate rules) when calling createInspectHandler:
import { createInspectHandler, layouts } from '@cool-ai/beach-inspect';
const handler = createInspectHandler(reader, {
renderers: {
'a2ui-surface': (ctx) => `<iframe srcdoc="${encodeURIComponent(renderToStaticHTML(ctx.part.data))}"/>`,
'contract-document': renderContractTree,
},
renderRules: [
{ match: (part) => part.partType.startsWith('legal-'), render: renderLegal },
],
});Lookup order: renderRules (predicate, in order) → renderers (exact partType) → default JSON-tree fallback. Returned HTML is sanitised before insertion (<script> / <style> / on*= handlers / external stylesheet links stripped).
Arrow-section plugins (CAIB-273)
The arrow-click side panel always renders three built-in sections — Routing, Payload, Causal context. Beyond that, consumers register additional sections through the arrowSections option on createInspectHandler. Beach pre-registers two sections of its own:
manifest— surfaces the fullManifestContext(identity, trigger, dispatches, slots with raw / projection / missive drill-downs, parent, children) for any arrow with amanifestId(CAIB-275).belongs-to-manifest— surfaces the manifest that owns an event whose payload carriesdata.triggerManifestId. The router stamps this automatically on spec-levelchildEvents; consumers can stamp their own events with the same field to opt arbitrary dispatch arrows into the pointer (CAIB-275).
Sections may return either { heading, rows } for simple key-value content or { heading, html } for richer markup; HTML sections are server-sanitised before transport.
import { createInspectHandler, type ArrowSection } from '@cool-ai/beach-inspect';
const billingSection: ArrowSection = {
id: 'billing-attribution',
matches: (ctx) => ctx.dispatch === 'rule-matched-outbound',
async getContent(ctx) {
const record = await billingStore.lookup(ctx.eventId);
if (record === undefined) return null;
return {
heading: 'Billing attribution',
rows: [
['Cost centre', record.costCentre],
['Charged minutes', String(record.minutes)],
],
};
},
};
const handler = createInspectHandler(reader, { arrowSections: [billingSection] });Each section is invoked server-side via GET /api/arrow-sections?sectionId=…&eventId=…&dispatch=… whenever the user clicks an arrow. Return null (or { rows: [] }) to suppress rendering. Sections render in registration order, with Beach's built-ins listed first.
Falling back to the legacy four-tab view
The four-tab raw-data view (missives / envelope / manifests / orphans) is still available as an opt-in layout:
createInspectHandler(reader, { layout: layouts.rawDataTabs });No functionality regresses; the four-tab view is preserved for consumers who prefer raw data scanning over the graphical view.
wireInspect()
Zero-boilerplate helper that wires the observation callbacks in one call. Returns routerOptions to spread into the EventRouter constructor (router-side signals), channelRouterOptions to spread into the ChannelRouter constructor (boundary signals), plus the handler lifecycle observers onStarted / onSettled / onTimeout / onToolExecution to spread into createLLMHandler — the LLM handler, not the router, runs handlers and owns their lifecycle.
import { wireInspect } from '@cool-ai/beach-inspect';
import { EventRouter, ChannelRouter } from '@cool-ai/beach-core';
import { createLLMHandler } from '@cool-ai/beach-llm';
const { routerOptions, channelRouterOptions, onStarted, onSettled, onTimeout, onToolExecution } = wireInspect(store);
const router = new EventRouter({ ...routerOptions });
const handler = createLLMHandler({ ...config, onStarted, onSettled, onTimeout, onToolExecution });
router.register('handlers/main', handler);
const channelRouter = new ChannelRouter({ router, adaptors, ...channelRouterOptions });The channel-router crosses the boundary in two places the interior can't see: an inbound message arrives carrying a channel identity the routed session:request drops (its source is session), and an outbound reply is delivered with a direct adaptor.send, not a routed event. channelRouterOptions carries both callbacks — they surface the inbound arrival (channels/<id> → channel-router) and each outbound delivery (channel-router → channels/<id>) as arrows, so you can see which channel a message arrived on and left by. Both missive types land in the always-on routing detail group, so the boundary legs of the sequence can never be filtered off.
Manifest observation is wired separately on ManifestHandler construction (new ManifestHandler({ observer: manifestEventStore })) — wireInspect does not touch the manifest path.
All signals are enabled by default: handlers.started, handlers.settled, handlers.timeout, handlers.toolCalls, router.routingDecisions, router.cascadeFills, router.slotFillIntercepts, router.stateTransitions, router.handlerErrors, channelRouter.inboundArrivals, channelRouter.outboundDeliveries.
To disable individual signals, pass a WireInspectOptions object:
wireInspect(store, {
signals: { handlers: { toolCalls: false }, router: { routingDecisions: false } },
});A beach-inspect.config.example.json ships inside the package. Copy it from node_modules/@cool-ai/beach-inspect/beach-inspect.config.example.json, rename to beach-inspect.config.json, and pass the parsed contents to wireInspect(). Set any signal to false to disable it — no code changes needed.
Seeing inside queues (CAIB-292)
A consumer-owned queue is invisible to Inspect by default — the queue lives inside the handler closure, not on the router. One engine, wireInspectQueue, surfaces its admit / emit / errored lifecycle through QueueObserver (from @cool-ai/beach-core). The two things queue libraries actually differ on — their depth snapshot and how a task is submitted — are a small QueueBinding; built-in bindings ship for p-queue and bottleneck (both optional peer dependencies, so installs stay slim), and a few lines bind anything else.
import PQueue from 'p-queue';
import { wireInspect, wireInspectQueue, pQueueBinding } from '@cool-ai/beach-inspect';
const { routerOptions, queueObserver } = wireInspect(store);
const queue = new PQueue({ concurrency: 4 });
const inspected = wireInspectQueue(pQueueBinding(queue), { queueId: 'supplier-search', observer: queueObserver! });
const offers = await inspected.run(
() => searchDestination(dest),
{ triggerEventId: ctx.eventId, taskId: dest.code },
);Bottleneck is the same call with its binding — wireInspectQueue(bottleneckBinding(limiter), { queueId, observer }).run(fn, { triggerEventId }). For any other queue, implement QueueBinding (a depth() snapshot and a submit() call) and pass it to the same engine.
The engine wraps each submission to capture admittedAt, the current queuedDepth / runningDepth, and the consumer's taskId / triggerEventId in a closure — the libraries' native events don't carry per-task ids, so this is what pairs admit ↔ emit ↔ errored for concurrent work. onAdmitted fires immediately; onEmitted fires when the underlying library dequeues the task to run; onErrored fires with phase: 'running' if the task throws or phase: 'admission' if the queue rejected it before running. The underlying queue / limiter is unchanged — direct calls to its native API still work (those direct submissions just don't surface in Inspect — consumer's choice per call site).
wireInspect(store) exposes a queueObserver whenever signals.queue.enabled !== false (default on); it writes inspect:queue-admitted, inspect:queue-emitted, and inspect:queue-errored missives keyed by triggerEventId so the reader can join queue activity to the routing-decision arrow that scheduled the work.
Render-time surfacing of queue lifecycle in the side panel ships with CAIB-279 (render-time detail filter); this CR delivers the recording surface.
Beach does not ship a queue primitive — see the bounded-concurrency guide for the composition pattern and the "Surfacing queue activity in Inspect" subsection there.
LLM round-trip observer (CAIB-280)
wireInspect(store).llmObserver is a ready-to-pass LLMObserver (from @cool-ai/beach-llm) that consumers wire into AnthropicProvider / VercelAIProvider / MastraProvider's constructor. Each complete() call fires onRequest before and onResponse after, paired by a generated requestId. The observer writes inspect:llm-request / inspect:llm-response missives which land under the externalApi detail group in the side panel.
import { wireInspect } from '@cool-ai/beach-inspect';
import { AnthropicProvider } from '@cool-ai/beach-llm';
const { routerOptions, llmObserver } = wireInspect(store);
const provider = new AnthropicProvider(sdk, { observer: llmObserver! });Suppressible per wireInspect(store, { signals: { llm: { enabled: false } } }). Default on.
Render-time detail filter (CAIB-279)
wireInspect.signals is the recording dial — irreversible, sets which observation missives get written into the store. InspectHandlerOptions.detail is the render dial — reversible, names which detail groups the side panel shows. Two independent levers: recording is the cost dial (storage / redaction); rendering is the focus dial (what to look at right now without losing yesterday's detail).
import { createInspectHandler, type InspectDetailConfig } from '@cool-ai/beach-inspect';
createInspectHandler(reader, {
detail: {
routing: true, // spine — always treated as on regardless of input
handlerLifecycle: true,
toolCalls: false, // hide tool-execution missives in side panels
manifestInternals: true,
callbackHooks: true,
handlerErrors: true,
externalApi: false,
queueLifecycle: true,
consumerSynthetic: true,
setStateTransitions: true,
},
});Missing fields default to on; a missing config means everything on. routing is the spine — always rendered regardless of what's passed.
Hot-reload. Pass a function instead of a static config; the renderer re-reads on every Refresh:
createInspectHandler(reader, {
detail: () => JSON.parse(readFileSync('./beach-inspect-detail.json', 'utf-8')),
});Edit the JSON file at runtime; click Refresh in the UI; the new filter applies without restarting the process.
Continuity rule. A hidden missive folds into a +N hidden (group) annotation on the surrounding arrow rather than vanishing without trace — operators can re-enable the group without re-recording. The spine of routing arrows never breaks because routing is always on.
Group → partType mapping. Beach maps known inspect:* partTypes to detail groups via the exported PART_TYPE_TO_DETAIL_GROUP constant. PartTypes not in the map fall under consumerSynthetic by default — consumer-defined parts always render unless that group is explicitly disabled. Extending the mapping for custom inspect:* partTypes is not supported in this release; surface a follow-up CR if you need it.
Rationale
OpenTelemetry (BF-013) gives Beach consumers distributed tracing for production. For day-to-day development, having a local UI that reads the router's event log — without requiring an OTLP backend, Jaeger, or Honeycomb — dramatically improves the onboarding story. This is the role @cool-ai/beach-inspect plays: a minimal dev-time inspector, not a production observability platform.
Philosophically it is closest to Burr's built-in telemetry UI.
Process model — CLI (BCR-011)
@cool-ai/beach-inspect runs as a CLI that boots a transient local HTTP server. Developers run npx @cool-ai/beach-inspect (or pnpm dlx @cool-ai/beach-inspect); the CLI starts a server on a free local port (default: 9090), opens the default browser, and exits when the browser tab is closed or Ctrl+C is pressed.
This avoids:
- Middleware coupling — the tool is not a middleware mounted on the consumer's HTTP app, so it does not care whether the consumer uses Express, Hono, Fastify, or anything else.
- Long-running-process ops — no PM2/systemd config needed; the process lives for the debugging session only.
The CLI reads from the consumer's configured event store (Redis URL, SQLite path, etc.) via the same connection the agent itself uses. Configuration is discovered from the consumer's existing .env or passed as CLI flags.
Concern
@cool-ai/beach-inspect provides:
- A CLI entry point —
@cool-ai/beach-inspectcommand that launches the transient server. - Event log reader — consumes the router's event stream (same stream
@cool-ai/beach-coreemits). Read-only. - Session view — shows a session's mailbox, turn history, active handler, envelope stream.
- Envelope view — shows the parts that made up a specific envelope, per originator, per delivery class.
- Tool call view — shows a tool call's arguments, approval state (if applicable), result. For specialist tools, shows the
specialist_executionlog records. - Replay trigger — invoke
@cool-ai/beach-session's replay on a specific turn, with a modified input if desired, and render the result alongside the original.
Not in this package
- Production observability — use OpenTelemetry exporters via
@cool-ai/beach-core. - Eval result management — use
@cool-ai/beach-evals. - Dataset management, regression dashboards, or cross-consumer analytics.
Target scope for v1
The v1 ambition is deliberately small:
- One page for "list of recent sessions."
- One page for "session detail" (events, mailbox, envelopes).
- One page for "envelope detail" (parts per originator).
- Server-side rendered (no SPA build).
- Reads from the same event store the router writes to (no separate persistence layer).
Consumers wanting more (filtering, search, session timeline visualisations, replay-with-tweaks, comparison views) can either:
- Build extensions on top of the core event-log-reader primitives Beach exposes.
- Integrate a proper observability backend (Honeycomb, Langfuse, Braintrust, custom Grafana).
Consumers
Developers working on any Beach-based agent. Not a runtime dependency; only used in development.
Related
- https://cool-ai.org/docs/design-principles — principle 3.6 (observability is design-time).
- ../core/ — where the event log originates.
- ../evals/ — for regression testing;
@cool-ai/beach-inspectis complementary.
