DevTools
`window.__HARNESS_DEVTOOLS__` — the browser-console surface for inspecting sessions, walking checkpoints, forcing syncs, and listing tools during development.
DevTools is one surface in the frontend integration thematic island — siblings of react, server, api-routes, and query.
In development, the runtime exposes a debugging interface on
window.__HARNESS_DEVTOOLS__. The surface is small on purpose —
just enough to inspect what the runtime sees, walk back through
checkpoints, and force-sync the outbox without leaving the
browser console.
Gate the wiring behind NODE_ENV !== "production" so the surface
doesn't ship in production bundles.
import {
useHarnessDevTools,
updateDevToolsSession,
} from "@pleach/core/react";
import type { HarnessDevToolsAPI } from "@pleach/core/react";Wiring
Call the hook once near the provider:
function App() {
if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line react-hooks/rules-of-hooks
useHarnessDevTools();
}
return <HarnessProvider runtime={runtime}>...</HarnessProvider>;
}Once mounted, window.__HARNESS_DEVTOOLS__ is the active
runtime's debug surface. Refreshing the page rebuilds it.
For TypeScript shims:
// types/global.d.ts
import type { HarnessDevToolsAPI } from "@pleach/core/react";
declare global {
interface Window {
__HARNESS_DEVTOOLS__?: HarnessDevToolsAPI;
}
}The surface
| Property | Returns | Use |
|---|---|---|
session | SessionState | Current full state — messages, tools, channels |
checkpoints() | Checkpoint[] | List checkpoints for the current session |
rollback(cpId) | Promise<void> | Time-travel to a checkpoint |
tools() | ToolDefinition[] | Active tool registry |
prompts() | PromptCatalog | The composed system prompt as labeled modules — id, title, summary, mode, byte size, order |
describe() | RuntimeDescription | Every customization lever (prompts / tools / safety / plugins / storage / checkpointer / provider / eventLog) + default/custom state + how to set/inspect each |
syncStatus() | SyncStats | Version vectors, pending changes, last sync |
forceSync() | Promise<void> | Drain the outbox now |
events() | HarnessEvent[] | Recent event log entries |
interrupts() | PendingInterrupt[] | Outstanding HITL approvals |
ledger() | AuditableCall[] | In-memory audit rows (when using MemoryProviderDecisionLedger) |
session
A property, not a method. Always reflects the latest state.
__HARNESS_DEVTOOLS__.session.messages.length
__HARNESS_DEVTOOLS__.session.pendingToolCalls
__HARNESS_DEVTOOLS__.session.versionVectorFor React state debugging — when the UI looks stale, compare
session against the rendered transcript. If they disagree, the
hook's subscription got dropped.
checkpoints()
Returns the checkpoint list for the current session, in creation order (ULID-sorted).
const cps = __HARNESS_DEVTOOLS__.checkpoints();
cps.map((c) => `${c.id} — ${c.stageId} — ${new Date(c.createdAt)}`);Each checkpoint carries id, sessionId, stageId, createdAt,
and the channel snapshot map.
rollback(checkpointId)
Time-travel. Restores the session to the checkpoint and re-renders.
await __HARNESS_DEVTOOLS__.rollback("cp_018f...");The next executeMessage continues from the restored point.
Subsequent checkpoints are preserved by default — pass
{ prune: true } as a second argument to drop them:
await __HARNESS_DEVTOOLS__.rollback("cp_018f...", { prune: true });The pruning option is what you want when branching for an eval re-run.
tools()
The currently-registered tool definitions.
__HARNESS_DEVTOOLS__.tools().map((t) => t.name);
// → ["search_corpus", "calculator", "fetch_url"]Inspect schemas:
const tool = __HARNESS_DEVTOOLS__.tools().find((t) => t.name === "search_corpus");
console.log(tool.inputSchema, tool.description);Useful when the LLM is calling a tool name you don't recognize — verify it's actually in the registry.
prompts() — see what the system prompt is made of
The composed system prompt is normally opaque. prompts() renders
it as an ordered list of LABELED modules — each carries an id, a
human-readable title + summary, its origin (core vs plugin),
composition mode, and byte size:
__HARNESS_DEVTOOLS__.prompts().modules.forEach((m) =>
console.log(`${m.order}. ${m.title ?? m.id} — ${m.summary ?? ""} (${m.bytes ?? "dynamic"} bytes)`),
);Useful when a systemPromptPlugin or a definePleachPlugin({ prompts })
contribution isn't showing up, or you're debugging a replace override
on a core.* slot — you see exactly which modules compose the prompt.
describe() — every customization lever at a glance
describe() returns a one-shot manifest of every runtime lever
(prompts, tools, safety, plugins, storage, checkpointer, provider,
event-log), each with its default/custom state and how to set or
inspect it — the generalization of the adapter-wiring report:
__HARNESS_DEVTOOLS__.describe().levers.forEach((l) =>
console.log(`${l.lever}: ${l.state} — ${l.summary}`),
);syncStatus()
Coarse-grained sync state.
__HARNESS_DEVTOOLS__.syncStatus();
// → {
// local: { clientId, vector },
// remote: { vector },
// pending: 3,
// lastSyncedAt: 1717350000000,
// errors: []
// }For the rich shape, the React useSyncStatus hook returns the
full SyncStats + SyncError[]. DevTools is the quick-look
surface.
forceSync()
Drains the outbox immediately rather than waiting for the next
flushIntervalMs tick. Returns when the cycle completes.
await __HARNESS_DEVTOOLS__.forceSync();
__HARNESS_DEVTOOLS__.syncStatus().pending; // → 0 if cycle succeededUseful when you want to verify a write made it through before closing the tab.
events()
Recent event log entries. Returns the last N (default 100); pass a filter for typed slices:
__HARNESS_DEVTOOLS__.events();
__HARNESS_DEVTOOLS__.events({ types: ["tool.failed"], limit: 20 });
__HARNESS_DEVTOOLS__.events({ since: "01jc8..." });The shape is the same HarnessEvent shape the event log
documents.
interrupts()
Outstanding HITL approvals on the current session. Useful when the UI's approval modal isn't surfacing what the runtime is waiting on.
__HARNESS_DEVTOOLS__.interrupts();
// → [{ id, action_request, config, description, ... }]Resolve from the console:
const [pending] = __HARNESS_DEVTOOLS__.interrupts();
await runtime.resolveInterrupt(pending.id, { type: "accept", args: null });ledger()
The in-memory audit ledger contents. Only populated when the
runtime is configured with MemoryProviderDecisionLedger. Returns
empty when the production Supabase adapter is wired (use the
query API for that).
__HARNESS_DEVTOOLS__.ledger().filter((r) => r.callClass === "synthesize");
// → typically exactly one row per turnThe one-synthesize-per-turn invariant is the easiest property to
spot-check from DevTools — if you see two synthesize rows for a
single turnId, something has drifted.
updateDevToolsSession(state)
The manual push API. Normally the hook subscribes to runtime events and updates the DevTools surface automatically; this is the escape hatch for tests or imperative state writes.
import { updateDevToolsSession } from "@pleach/core/react";
updateDevToolsSession(synthesizedState);Use sparingly. The hook subscription is the supported path.
Diagnostics telemetry — the --demo verbose tier
window.__HARNESS_DEVTOOLS__ inspects state at rest. To watch the
graph move — which node fired, which channel bumped, which router
arm was taken — turn on the diagnostics telemetry tier. It's a
verbose set of engine-level stream events the
runtime emits only when a turn runs with diagnostics enabled:
- In the playground, run
npx pleach dev --demo— the demo/debug path threadsdiagnostics: trueinto the run config, so the lattice, event-log, and inspector panes light up node-by-node. - Over HTTP, the same signal is
?diagnostics=1on the route the playground posts to (therunConfig.diagnostics === truegate).
Three properties make this safe to leave wired in a debug build:
- Off by default, byte-identical off. A normal turn emits none of the diagnostics-gated fields below — the enrichments are strictly additive, so an off turn's stream is unchanged.
- Zero extra cost. The
channel.write_skippedpayload, for instance, is data the reducer already computed for its value-aware skip; diagnostics just surfaces it instead of dropping it. - Leak-free / agnostic. Every field is a node NAME, a channel NAME, a superstep number, or a router arm LABEL. Channel values, message content, and tool arguments never ride this surface.
The events
Two of these are brand-new event types; the rest are existing stream events that grow extra diagnostics-only fields when the tier is on.
| Event | Diagnostics fields | What it's for |
|---|---|---|
node.fired | subscribes (channels the node reads), ordinal (superstep index), retries (attempts consumed) | The other half of the data-flow the base event's writes only shows one side of — pair subscribes with writes to draw a node's in/out edges. |
channel.write | writer (producing node), ordinal | Ties a channel bump back to the node that produced it, so a visualizer can draw the data-flow edge. |
channel.write_skipped | channel, reason: "no-op-bump", writer?, ordinal? | The silent-stall signal. A node wrote a channel but the post-reducer value was unchanged, so the version did NOT bump and subscribers did NOT re-fire. The single most useful "why didn't the graph advance?" breadcrumb. |
node.skipped | node, reason: "arbiter-terminated", ordinal? | The arbiter-suppression blind spot. A node whose subscriptions were satisfied but which the synthesis-terminal arbiter latch suppressed. Distinguishes an intentional suppression from ordinary reactive quiescence (nothing subscribed) — previously invisible. |
stage.superstep | step (monotonic superstep ordinal) | The sequencing key. Correlate every node.fired / channel.write ordinal back to the superstep it belongs to. Always emitted (leak-free), diagnostics or not. |
route.decided | from, to, arm?, isEnd? | The branching / edge-taken signal. A conditional router chose an arm. On the default agent graph the two routers are shouldContinue (post-LLM: tools / enrich / synthesize) and afterHallucination (post-detector: llm / citation) — see the Edge catalog. This is what tells you which way the graph branched, where stage.transition only tells you it moved. |
A worked reading of a stall: you see stage.superstep ticking but no
stage.transition — the graph looks frozen. Filter for
channel.write_skipped and you find the node that keeps writing an
unchanged value (no-op-bump), so its subscribers never re-arm. Or
you filter for node.skipped and find the arbiter latched a node out.
Either way the blind spot the plain stream left is now named.
Because these are stream events, consume them
the same way as any other — off executeMessage() or
useChat({ onEvent }) — filtering on type:
for await (const ev of runtime.executeMessage(sessionId, prompt, { diagnostics: true })) {
if (ev.type === "route.decided") console.log(ev.from, "→", ev.to, `(${ev.arm})`);
if (ev.type === "channel.write_skipped") console.warn("stalled channel:", ev.channel);
}Production safety
useHarnessDevTools does not check NODE_ENV internally —
the caller is responsible. The hook body unconditionally writes
to window.__HARNESS_DEVTOOLS__; ship it in production and your
production bundle gains a debug surface and a tree-shake escape
for the underlying modules.
Three options to gate it:
// Option 1 — conditional hook (lint rule will complain; disable it):
if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line react-hooks/rules-of-hooks
useHarnessDevTools();
}
// Option 2 — separate dev-only component, code-split by env:
const DevTools = process.env.NODE_ENV !== "production"
? require("./DevTools").default
: null;
// Option 3 — always-on but pre-stripped at build time via dead-code elimination:
if (false /* @__PURE__ */) useHarnessDevTools();Option 1 is the simplest. Option 2 is the cleanest for bundle size. Option 3 is for build pipelines that don't tree-shake conditionals well.
Where to go next
React
`useHarnessDevTools` and the typed `HarnessDevToolsAPI`.
HarnessServer
Framework-agnostic handlers behind the data this surface inspects.
API routes
The HTTP + SSE wire contract feeding the session state this surface mirrors.
Query
Server-side reads for the same audit/event data — DevTools is the in-memory peek; query is the persisted view.
Stream events
The full StreamEvent catalog the diagnostics tier enriches — node.fired, channel.write, route.decided, and the rest.
Edge catalog
The two conditional routers `route.decided` reports on — shouldContinue and afterHallucination.
CLI
`pleach dev --demo` — the keyless playground that runs turns with the diagnostics tier on.
Query
Server-only read API over persisted harness data — usage, transcripts, events, jobs, assets, tools, analytics. Uses a service-role Supabase client and bypasses RLS; never import into browser bundles.
CLI
The pleach binary ships three subcommands — dev boots a self-contained local playground; init scaffolds a route, page, and plugin stub for your framework; schema copies the Postgres bundle.