# React (/docs/react)



The canopy — what your visitors see and touch. The React surface
ships as two layered packages.

> **Frontend integration is a thematic island.** Five wiring
> surfaces (`react`, `server`, `api-routes`, `query`, `devtools`)
> — not a three-concept cluster. See [What lives outside the
> cluster pattern](/docs/concept-clusters#what-lives-outside-the-cluster-pattern).

| Package              | Role                                                                                                                                                                | Status                                                                                                                       |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `@pleach/react`      | Lower-level primitive hooks — runtime acquisition, message stream, event log, interrupt UI. The building blocks the higher-level facades compose internally.        | 0.1.0 · FSL-1.1-Apache-2.0 ([npm](https://www.npmjs.com/package/@pleach/react))                                              |
| `@pleach/core/react` | Higher-level facade — `HarnessProvider`, `useHarness`, sessions, tools, sync, team, compliance, devtools. Composes the primitives into one-call-fits-most surfaces. | Back-compat shim; each `@pleach/core/react/*` module re-exports from `@pleach/react/legacy`. Shims retire at a future major. |

Use the facade when you're building a typical chat UI and want
batteries included. Drop down to the primitives when you need
to own the runtime lifecycle, the stream state machine, or the
interrupt-UI routing yourself — for example, when embedding
inside an existing app framework that already owns React tree
state.

`@pleach/react` is a real implementation, published to npm at
`0.1.0` under FSL-1.1-Apache-2.0 with a stable public surface. Pin
it exactly — `^0.1.0` will **not** pick up the non-caretable
`0.1.x → 1.0.0` jump (nor the `0.0.x → 0.1.0` one), matching the
pin-exact policy on the [Packages](/docs/packages) status table.

## 30-second start [#30-second-start]

Install the package alongside its peers:

```bash
npm install @pleach/react @pleach/core react react-dom
```

Acquire a runtime, then drive a turn:

```tsx
import { useState } from "react";
import { useSessionRuntime, useSessionMessageStream } from "@pleach/react";
import { createPleachRuntime } from "@pleach/core/runtime";

function Chat() {
  const { runtime, sessionId } = useSessionRuntime({
    buildRuntime: () => createPleachRuntime({ /* config */ }),
    initSession: async (rt) => rt.sessions.create(),
  });

  const { streamMessage, isStreaming } = useSessionMessageStream({
    runtime,
    sessionId,
    onEvent: (event, ctx) => {
      // optional intercept — return { handled: true } to short-circuit.
      // `ctx.accumulator` carries the post-append streaming buffer.
    },
  });

  const [input, setInput] = useState("");

  return (
    <form onSubmit={(e) => { e.preventDefault(); streamMessage({ content: input }); }}>
      <input value={input} onChange={(e) => setInput((e.target as unknown as { value: string }).value)} disabled={isStreaming} />
      {/* render the accumulator via your onEvent reducer; gate UI on isStreaming */}
    </form>
  );
}
```

`@pleach/react` does NOT ship a provider — `useSessionRuntime` owns the construct-once / cleanup-on-unmount lifecycle behind a ref, and `useSessionMessageStream` takes a single config object whose `runtime` + `sessionId` are nullable until `useSessionRuntime` resolves. Drop down to a context-based provider only when you need the legacy `@pleach/react/legacy` `HarnessProvider` facade (re-export shim for `@pleach/core/react/*`). See the [Primitives](#primitives-pleachreact) and [Facade](#facade-pleachcorereact) sections below; the [`@pleach/react` README](https://github.com/pleachhq/react) is the canonical surface reference.

## Peer dependency contract [#peer-dependency-contract]

`@pleach/react` declares `react` and `react-dom` as **peer** dependencies, not dependencies. Install them explicitly in the consumer app. Two copies of React in one bundle break hook calls at runtime — the peer-dep boundary is what keeps that from happening silently.

`@pleach/core` is also a peer. The hooks call into the core runtime directly, so the consumer app owns the core install and pins it to a version range the package supports. Mismatched cores produce type-level drift today and runtime drift once the wire shape evolves.

Peer-dep ranges tighten at minor bumps. Pin via `^` on the major version in consumer apps, then re-verify the declared peer ranges at each minor bump of `@pleach/react` — the [Packages](/docs/packages) status table tracks current ranges per release.

### Standalone `@pleach/core` install is safe [#standalone-pleachcore-install-is-safe]

The peer-dep relationship is one-directional. `@pleach/react`
declares `@pleach/core` as a peer — the hooks call into the
core runtime, so the consumer pins core. The reverse isn't
true: `@pleach/core` can be installed without `@pleach/react`.

Server-only consumers (background workers, evaluation harnesses,
CLI tools) routinely install just `@pleach/core` and never touch
the React surface. `import "@pleach/core"` loads cleanly with
no React peer present.

The React hooks remain available at the `@pleach/core/react`
subpath. Consumers who want hooks import from that subpath and
install the `@pleach/react` peer explicitly:

```bash
# Server-only — no React peer needed
npm install @pleach/core

# UI consumer — install the peer
npm install @pleach/core @pleach/react react react-dom
```

## Surface at a glance [#surface-at-a-glance]

```typescript
// Primitives — @pleach/react (1.0)
import { useSessionRuntime }       from "@pleach/react/hooks/useSessionRuntime";
import { useSessionMessageStream } from "@pleach/react/hooks/useSessionMessageStream";
import { useEventLog }             from "@pleach/react/hooks/useEventLog";
import { useInterruptUI }          from "@pleach/react/hooks/useInterruptUI";
import { createCorrectionDedup }   from "@pleach/react/utils/correctionDedup";
import { createStreamLock }        from "@pleach/react/utils/streamLock";

// Facade — @pleach/core/react (1.x with back-compat shim)
import {
  HarnessProvider,
  useHarness,
  useHarnessContext,
  useRuntime,
  useIsEnterpriseEnabled,
  useSessionList,
  useSessionCheckpoints,
  useTools,
  useToolsByCategory,
  useTool,
  useToolValidation,
  useSyncStatus,
  useIsSynced,
  usePendingChanges,
  useTeam,
  useCompliance,
  useHarnessDevTools,
  updateDevToolsSession,
  createCorrectionDedup,
  createDefaultCorrectionDedup,
} from "@pleach/core/react";
```

<SourceMeta subpath="@pleach/react" source="{ label: &#x22;src/&#x22;, href: &#x22;https://github.com/pleachhq/react/tree/main/src&#x22; }" />

## Primitives: `@pleach/react` [#primitives-pleachreact]

Four hooks plus two utils. Each one owns one substrate concern.

### `useSessionRuntime` [#usesessionruntime]

Constructs the runtime exactly once on mount, optionally binds a
session, and exposes a `ready` promise. The factory is **not**
re-invoked across renders — swap the runtime by unmounting and
remounting the owner.

```tsx
const { runtime, sessionId, ready } = useSessionRuntime({
  buildRuntime: () =>
    new SessionRuntime({
      storage: new IndexedDBAdapter({ databaseName: "pleach" }),
      tenantId,
    }),
  initSession: async (rt) => (await rt.createSession()).id,
  onError: (err) => reportToSentry(err),
});

// runtime + sessionId are null until the mount effect commits.
// `ready` resolves when both are live.
```

| Field       | Type                     | Notes                                                       |
| ----------- | ------------------------ | ----------------------------------------------------------- |
| `runtime`   | `SessionRuntime \| null` | `null` until first commit; stable identity after            |
| `sessionId` | `string \| null`         | Result of `initSession`; `null` permanently when omitted    |
| `extras`    | `TExtras \| null`        | Side artifacts when `buildRuntime` returns a wrapped result |
| `ready`     | `Promise<void>`          | Resolves after construct + bind; rejects on either failure  |

### `useSessionMessageStream` [#usesessionmessagestream]

Fires one turn at a time against the active runtime/session,
serializing concurrent calls through a per-component stream
lock. The hook drives the message-state machine; the consumer
owns the UI reduction via `onEvent`.

```tsx
const {
  streamMessage,
  abort,
  isStreaming,
  isReasoning,
  reasoningText,
} = useSessionMessageStream({
  runtime,
  sessionId,
  onEvent:        (event, ctx) => dispatch({ type: "STREAM_EVENT", event }),
  onTurnComplete: (ctx)        => track("turn.complete", { events: ctx.events.length }),
  onError:        (err)        => dispatch({ type: "STREAM_ERROR", err }),
});

await streamMessage({ content: "Hello" });
```

`streamMessage` resolves cleanly whether the turn returned or
aborted; errors route through `onError`, so a straight-line
`await` works without per-call `try/catch`. `abort()` cancels
the in-flight stream without firing `onError` — aborts are the
explicit cancellation path.

**Reasoning traces.** `isReasoning` is `true` while a reasoning
model streams its chain-of-thought (the `reasoning.delta`
[events](/docs/stream-events#reasoning-traces-reasoningdelta))
before the answer begins; it flips back to `false` on the first
answer delta. `reasoningText` accumulates that chain-of-thought
for the current turn — never answer content — and is retained
after the answer starts so you can render a collapsible
"thinking" disclosure. Both reset at the next turn:

```tsx
{isReasoning && <Spinner label="Thinking…" />}
{reasoningText && (
  <details>
    <summary>Thought process</summary>
    <pre>{reasoningText}</pre>
  </details>
)}
```

`content.reset` events are deduped inside a 200ms window by
default; tune via `dedupeWindowMs`. The stream-lock drain
timeout defaults to 120s; tune via `streamLockTimeoutMs`.

### `useEventLog` [#useeventlog]

A facade over a caller-owned `EventLogClient`. Returns a stable
`log(event)` callback you can pass through memo deps without
invalidating downstream callbacks every render.

```tsx
const { log, flush } = useEventLog({
  client: eventLogClient,         // or `clientRef: { current: ... }` for
                                  // refs constructed inside a `useEffect`
  onEvent: (e) => devtools.push(e),
});

log({ type: "ui.bubble.copied", messageId });
await flush();   // best-effort drain before unmount
```

Pass `client: null` while the client is being constructed —
`log` / `flush` no-op gracefully. The `clientRef` overload is
the path when construction happens inside `useEffect` and the
component doesn't re-render on the `null → populated`
transition.

### `useInterruptUI` [#useinterruptui]

Subscribes to the runtime's interrupt stream and routes each
`PendingInterrupt` to the matching plugin-contributed handler.
Plugins contribute handlers via
`pluginManager.collectInterruptUIHandlers()`; the first handler
whose `interruptType` matches wins.

```tsx
const { activeInterrupts, renderActive, resolveInterrupt, cancelInterrupt } =
  useInterruptUI({
    runtime,
    handlers: pluginManager.collectInterruptUIHandlers(),
    onInterrupt: (i)  => track("interrupt.shown", { type: i.toolCall?.name }),
    onResolve:   (id, decision) => eventLog.log({ type: "interrupt.resolved", id, decision }),
    onCancel:    (id) => eventLog.log({ type: "interrupt.cancelled", id }),
    onTimeout:   (id) => eventLog.log({ type: "interrupt.timeout", id }),
  });

return <>{renderActive()}</>;
```

The hook accepts `runtime: null` and no-ops until a runtime
arrives — pair it with `useSessionRuntime` without ordering
gymnastics.

### Internal-but-exposed helpers [#internal-but-exposed-helpers]

`applyRoutingScaffoldCase` and `isWrappedBuildResult` are exported from the top-level `@pleach/react` barrel and from `@pleach/react/hooks` (7 exports there total) because the H-7 ladder still has callers in existing host code that depend on the helpers during the multi-session cutover. They are NOT part of the supported consumer API: shape may change in any 1.x minor release, and the pre-merge `audit:streamSingleTurn-consumer-surface` keeps that invariant honest. Consumers building against the public surface should reach for `useSessionMessageStream` (which wraps both) rather than the raw helpers.

### Utils [#utils]

| Util                    | Signature                                                    | Use case                                                                                                                                                                                                                                                                                                                                                                |
| ----------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createCorrectionDedup` | `(config) => (ts, lastTs, lastReason, newReason) => boolean` | Factory returning a predicate over **correction reasons + timestamps** (NOT content payloads). Allows when (a) outside the `windowMs` window, OR (b) the new reason has strictly higher priority than the previous reason per `priorityMap`. `createDefaultCorrectionDedup(windowMs?)` is a convenience baseline that dedupes only same-reason emits inside the window. |
| `createStreamLock`      | `() => { acquire, waitForDrain, isHeld }`                    | Factory returning a single-slot async mutex. `acquire()` returns a `{ release }` handle; `waitForDrain(timeoutMs?)` returns `{ timedOut }`; `isHeld()` is a synchronous diagnostic. Use when composing a bespoke streaming surface that wants the same drain semantics `useSessionMessageStream` uses internally.                                                       |

### Composing the primitives [#composing-the-primitives]

The three primary hooks work together — `useSessionRuntime`
owns the lifecycle, `useSessionMessageStream` owns the turn,
`useInterruptUI` owns the human-in-the-loop:

```tsx
function Chat({ tenantId }: { tenantId: string }) {
  const { runtime, sessionId } = useSessionRuntime({
    buildRuntime: () =>
      new SessionRuntime({
        storage: new IndexedDBAdapter({ databaseName: "pleach" }),
        tenantId,
      }),
    initSession: async (rt) => (await rt.createSession()).id,
  });

  const [messages, setMessages] = useState<Message[]>([]);

  const { streamMessage, isStreaming } = useSessionMessageStream({
    runtime,
    sessionId,
    onEvent: (e) => setMessages((prev) => reduceMessage(prev, e)),
  });

  const { renderActive } = useInterruptUI({
    runtime,
    handlers: interruptHandlers,
  });

  return (
    <>
      <Transcript messages={messages} />
      {renderActive()}
      <Composer
        disabled={isStreaming}
        onSend={(text) => streamMessage({ content: text })}
      />
    </>
  );
}
```

No provider, no global state — each component owns its own
runtime. Pair with a parent component that holds the runtime in
context when multiple children need to share it.

## Facade: `@pleach/core/react` [#facade-pleachcorereact]

One provider mounted near the root, plus a set of focused hooks
that compose the primitives above into the typical chat-UI
surface. Hooks return stable references — safe to destructure
at the top of a component.

<SourceMeta subpath="@pleach/core/react" source="{ label: &#x22;src/react/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/react&#x22; }" />

### `HarnessProvider` [#harnessprovider]

Mounts the runtime into the React tree. Every hook below requires
it as an ancestor.

```tsx
// app/page.tsx
'use client';

import { SessionRuntime, AiSdkProvider } from "@pleach/core";
import { IndexedDBAdapter } from "@pleach/core/sessions";
import { HarnessProvider } from "@pleach/core/react";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";

const openrouter = createOpenRouter({ apiKey: process.env.NEXT_PUBLIC_OPENROUTER_API_KEY! });

const runtime = new SessionRuntime({
  storage:  new IndexedDBAdapter({ databaseName: "pleach" }),
  provider: new AiSdkProvider({
    model:    openrouter("anthropic/claude-sonnet-4-5"),
    maxSteps: 5,
  }),
  userId:   "user_123",
});

export function App() {
  return (
    <HarnessProvider runtime={runtime}>
      <Chat />
    </HarnessProvider>
  );
}
```

Construct the runtime once outside the React render tree (or
inside a `useMemo` with stable deps) so it isn't re-created per
render.

### Low-level escape hatches [#low-level-escape-hatches]

| Hook                       | Returns                   | Use when                                |
| -------------------------- | ------------------------- | --------------------------------------- |
| `useHarnessContext()`      | Raw context value         | Building custom hooks                   |
| `useRuntime()`             | The bare `SessionRuntime` | Imperative calls that don't have a hook |
| `useIsEnterpriseEnabled()` | `boolean`                 | Gating extension-only UI                |

Prefer the higher-level hooks below for typical UI work.

### `useHarness` [#useharness]

The primary chat hook. Returns messages, send / create / abort
helpers, and a `SyncStatus`.

```tsx
function Chat() {
  const {
    messages,
    sendMessage,
    createSession,
    abort,
    isStreaming,
    syncStatus,
    session,
  } = useHarness();

  return (
    <div>
      {messages.map((m) => (
        <Bubble key={m.id} message={m} />
      ))}
      <Composer
        onSend={(text) => sendMessage(text)}
        disabled={isStreaming}
      />
      {isStreaming && <button onClick={abort}>Stop</button>}
      <SyncPill status={syncStatus} />
    </div>
  );
}
```

| Field           | Type                                        |
| --------------- | ------------------------------------------- |
| `messages`      | `Message[]` — live transcript               |
| `session`       | `SessionState \| null`                      |
| `sendMessage`   | `(content: string, opts?) => Promise<void>` |
| `createSession` | `(config?) => Promise<SessionState>`        |
| `abort`         | `() => void`                                |
| `isStreaming`   | `boolean`                                   |
| `syncStatus`    | `SyncStatus` — coarse status pill           |

`sendMessage` triggers `executeMessage` under the hood; the hook
reduces stream events into `messages` for you.

### Session-list hooks [#session-list-hooks]

For sidebars, inboxes, multi-session UIs. Read-only and live —
subscribe to storage updates so you don't have to refetch.

```tsx
function Sidebar() {
  const { sessions, isLoading } = useSessionList({
    filter: { userId: currentUser.id },
    limit:  50,
  });

  if (isLoading) return <Skeleton />;

  return (
    <ul>
      {sessions.map((s) => (
        <li key={s.id}>
          <a href={`/c/${s.id}`}>{s.title ?? "Untitled"}</a>
        </li>
      ))}
    </ul>
  );
}
```

```tsx
function CheckpointPicker({ sessionId }: { sessionId: string }) {
  const checkpoints = useSessionCheckpoints(sessionId);
  return (
    <select>
      {checkpoints.map((cp) => (
        <option key={cp.id} value={cp.id}>
          {cp.stageId} — {new Date(cp.createdAt).toLocaleTimeString()}
        </option>
      ))}
    </select>
  );
}
```

### Tool hooks [#tool-hooks]

Read-only views over the registered tool registry. Do not invoke
tools — they're for command palettes, schema-driven forms, and
per-tool settings.

| Hook                      | Returns                             | Use case                                                          |
| ------------------------- | ----------------------------------- | ----------------------------------------------------------------- |
| `useTools(opts?)`         | Full tool list with optional filter | Command palette                                                   |
| `useToolsByCategory()`    | Tools grouped by category           | Settings panel sections                                           |
| `useTool(name)`           | Single tool definition              | Per-tool detail view                                              |
| `useToolValidation(name)` | `(args) => ValidationResult`        | Validate form args against the tool's JSON schema before dispatch |

```tsx
function ToolPalette() {
  const { tools } = useTools({ enabledOnly: true });
  return tools.map((t) => <ToolCard key={t.name} tool={t} />);
}

function ToolArgsForm({ toolName }: { toolName: string }) {
  const validate = useToolValidation(toolName);
  const [args, setArgs] = useState({});
  const result = validate(args);

  return (
    <form>
      <SchemaForm value={args} onChange={setArgs} />
      {!result.ok && <ValidationErrors errors={result.errors} />}
    </form>
  );
}
```

### Sync hooks [#sync-hooks]

Coarse and fine-grained views over the sync coordinator.

| Hook                   | Returns                                     | Use case                 |
| ---------------------- | ------------------------------------------- | ------------------------ |
| `useSyncStatus(opts?)` | `{ stats: SyncStats; errors: SyncError[] }` | Diagnostics panel        |
| `useIsSynced()`        | `boolean`                                   | "All changes saved" pill |
| `usePendingChanges()`  | `number`                                    | Unsaved-changes guard    |

```tsx
function SavedPill() {
  const synced = useIsSynced();
  return <span>{synced ? "Saved" : "Saving…"}</span>;
}

function NavGuard() {
  const pending = usePendingChanges();
  useBeforeUnload(pending > 0, "You have unsaved changes.");
  return null;
}
```

### `useTeam` [#useteam]

Multi-user presence + cursor info for collaborative session views.

```tsx
function PresenceBar({ sessionId }: { sessionId: string }) {
  const { members, cursors } = useTeam({ sessionId });
  return (
    <div>
      {members.map((m) => (
        <Avatar key={m.userId} user={m} active={m.lastSeenMs < 5000} />
      ))}
    </div>
  );
}
```

Pair with `useSyncStatus` for the full real-time picture.

### `useCompliance` [#usecompliance]

Read-only view over the runtime's compliance surface — current
PII redaction policy, GDPR soft-delete status, the tamper-evident
hash chain head. Use it to surface compliance state in admin
panels and audit views; pair with `@pleach/compliance` when the
sibling SKU lands for richer policy control.

```tsx
import { useCompliance } from "@pleach/core/react";

function ComplianceBadge() {
  const compliance = useCompliance({ sessionId, compliance: complianceExtension });
  return <span>audit records: {compliance.auditRecordCount}</span>;
}
```

`UseComplianceReturn` is the typed shape; treat the return as
opaque until the policy surface stabilizes for 1.0.

### Correction dedup [#correction-dedup]

`createCorrectionDedup({ priorityMap, defaultPriority, windowMs })`
returns a closure that decides whether an incoming
`content.correction` should be allowed past the dedup window. Lift
this into any consumer that fans correction events into a single
rendering surface — it's the same dedup the substrate uses
internally.

```typescript
import { createCorrectionDedup, createDefaultCorrectionDedup } from "@pleach/core/react";

const shouldAllow = createCorrectionDedup({
  priorityMap:    { "tool.input": 10, "tool.output": 5 },
  defaultPriority: 1,
  windowMs:       500,
});

// or the defaults:
const shouldAllowDefault = createDefaultCorrectionDedup();
```

`CorrectionPriorityMap`, `CorrectionDedupConfig`, and
`ShouldAllowContentCorrectionFn` are the typed surfaces.

### `useHarnessDevTools` [#useharnessdevtools]

Wires `window.__HARNESS_DEVTOOLS__` to the active runtime so you
can drive checkpoints, sync, and tool inspection from the browser
console.

```tsx
import { useHarnessDevTools } from "@pleach/core/react";

function App() {
  if (process.env.NODE_ENV !== "production") {
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useHarnessDevTools();
  }
  return <HarnessProvider runtime={runtime}>...</HarnessProvider>;
}
```

The exposed surface:

```javascript
__HARNESS_DEVTOOLS__.session;               // SessionState
__HARNESS_DEVTOOLS__.checkpoints();         // Checkpoint[]
__HARNESS_DEVTOOLS__.rollback("cp_...");    // restore to checkpoint
__HARNESS_DEVTOOLS__.tools();               // ToolDefinition[]
__HARNESS_DEVTOOLS__.syncStatus();          // sync coordinator status
__HARNESS_DEVTOOLS__.forceSync();           // push a sync now
```

`HarnessDevToolsAPI` is exported for typing
`window.__HARNESS_DEVTOOLS__` in a `.d.ts` shim.
`updateDevToolsSession(state)` is the manual-push API for
forcing a session-state refresh into the devtools surface
from outside the React render cycle.

## Minimal end-to-end example [#minimal-end-to-end-example]

```tsx
'use client';

import { useMemo } from "react";
import { SessionRuntime } from "@pleach/core";
import { IndexedDBAdapter } from "@pleach/core/sessions";
import { IndexedDBSaver } from "@pleach/core/checkpointing";
import {
  HarnessProvider,
  useHarness,
} from "@pleach/core/react";

function App({ userId }: { userId: string }) {
  const runtime = useMemo(
    () =>
      new SessionRuntime({
        storage:      new IndexedDBAdapter({ databaseName: "pleach" }),
        checkpointer: new IndexedDBSaver({ databaseName: "pleach" }),
        userId,
      }),
    [userId],
  );

  return (
    <HarnessProvider runtime={runtime}>
      <Chat />
    </HarnessProvider>
  );
}

function Chat() {
  const { messages, sendMessage, isLoading } = useHarness();
  return (
    <>
      {messages.map((m) => (
        <p key={m.id}>{m.content}</p>
      ))}
      <button
        disabled={isLoading}
        onClick={() => sendMessage("Hello")}
      >
        Send
      </button>
    </>
  );
}
```

## Local playground — `<PleachLab>` [#local-playground--pleachlab]

The `@pleach/react/lab` subpath ships a zero-config split-pane local
playground: a render-isolated chat on the left, a live execution
visualizer on the right. Drop it on a page, hand it a runtime factory,
and watch the graph run.

```tsx
import { PleachLab } from "@pleach/react/lab";

<PleachLab buildRuntime={() => new SessionRuntime(/* … */)} />
```

The right pane visualizes execution at three depths, each folded
purely from the agnostic `StreamEvent` stream via `useLatticeState` —
no privileged hooks.

* **Stages** — the 4-stage lattice (`anchor · plan → tool loop →
  synthesize → post-turn`). The dominant stage lights; minority stages
  glow dimmer; self-loops show a `↻`.
* **Nodes** — each graph-node chip glows on `node.fired` and fades over
  the next few firings, so you see *which* node ran, not just which
  stage is active.
* **Channels** — a writer/clearer lane tickers each `channel.write`
  (version bump) and `channel.cleared` (ephemeral step-boundary reset),
  with a per-turn "hot channels" leaderboard. That's the subscription
  substrate that drives the next superstep.

Because the visualizer is `StreamEvent`-driven, the same `node.fired` /
`channel.write` / `channel.cleared` events are subscribable from any
`useChat({ onEvent })` consumer — or asserted in a headless test (see
[Testing](/docs/testing#headless-turn-driver-pleachcoretesting)). Mount
`LatticePanel` / `ChannelActivityStrip` standalone next to your own
chat UI when you don't want the full shell.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="HarnessServer" href="/docs/server" description="Framework-agnostic HTTP handlers — what the hooks call into when the runtime lives on a server." />

  <Card title="API routes" href="/docs/api-routes" description="The HTTP + SSE wire contract these hooks consume." />

  <Card title="Query" href="/docs/query" description="Server-side read API over persisted harness data — the RLS-bypassing counterpart to these RLS-bound hooks." />

  <Card title="DevTools" href="/docs/devtools" description="window.__HARNESS_DEVTOOLS__ — the browser-console surface useHarnessDevTools wires." />
</Cards>
