pleach
Build

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";
Sourcesrc/dev/

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

PropertyReturnsUse
sessionSessionStateCurrent 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()PromptCatalogThe composed system prompt as labeled modules — id, title, summary, mode, byte size, order
describe()RuntimeDescriptionEvery customization lever (prompts / tools / safety / plugins / storage / checkpointer / provider / eventLog) + default/custom state + how to set/inspect each
syncStatus()SyncStatsVersion 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.versionVector

For 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 succeeded

Useful 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 turn

The 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 threads diagnostics: true into the run config, so the lattice, event-log, and inspector panes light up node-by-node.
  • Over HTTP, the same signal is ?diagnostics=1 on the route the playground posts to (the runConfig.diagnostics === true gate).

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_skipped payload, 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.

EventDiagnostics fieldsWhat it's for
node.firedsubscribes (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.writewriter (producing node), ordinalTies a channel bump back to the node that produced it, so a visualizer can draw the data-flow edge.
channel.write_skippedchannel, 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.skippednode, 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.superstepstep (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.decidedfrom, 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

On this page