pleach
Build

React

Two React surfaces — @pleach/react primitive hooks (0.1.0, FSL-1.1-Apache-2.0) and the @pleach/core/react facade. The facade retires at a future major via a back-compat shim.

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.

PackageRoleStatus
@pleach/reactLower-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)
@pleach/core/reactHigher-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 status table.

30-second start

Install the package alongside its peers:

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

Acquire a runtime, then drive a turn:

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 and Facade sections below; the @pleach/react README is the canonical surface reference.

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 status table tracks current ranges per release.

Standalone @pleach/core 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:

# 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

// 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";
Subpath@pleach/reactSourcesrc/

Primitives: @pleach/react

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

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.

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.
FieldTypeNotes
runtimeSessionRuntime | nullnull until first commit; stable identity after
sessionIdstring | nullResult of initSession; null permanently when omitted
extrasTExtras | nullSide artifacts when buildRuntime returns a wrapped result
readyPromise<void>Resolves after construct + bind; rejects on either failure

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.

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) 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:

{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

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.

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

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.

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

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

UtilSignatureUse case
createCorrectionDedup(config) => (ts, lastTs, lastReason, newReason) => booleanFactory 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

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

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

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.

Subpath@pleach/core/reactSourcesrc/react/

HarnessProvider

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

// 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

HookReturnsUse when
useHarnessContext()Raw context valueBuilding custom hooks
useRuntime()The bare SessionRuntimeImperative calls that don't have a hook
useIsEnterpriseEnabled()booleanGating extension-only UI

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

useHarness

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

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>
  );
}
FieldType
messagesMessage[] — live transcript
sessionSessionState | null
sendMessage(content: string, opts?) => Promise<void>
createSession(config?) => Promise<SessionState>
abort() => void
isStreamingboolean
syncStatusSyncStatus — coarse status pill

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

Session-list hooks

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

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>
  );
}
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

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

HookReturnsUse case
useTools(opts?)Full tool list with optional filterCommand palette
useToolsByCategory()Tools grouped by categorySettings panel sections
useTool(name)Single tool definitionPer-tool detail view
useToolValidation(name)(args) => ValidationResultValidate form args against the tool's JSON schema before dispatch
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

Coarse and fine-grained views over the sync coordinator.

HookReturnsUse case
useSyncStatus(opts?){ stats: SyncStats; errors: SyncError[] }Diagnostics panel
useIsSynced()boolean"All changes saved" pill
usePendingChanges()numberUnsaved-changes guard
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

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

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

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.

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

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.

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

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

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:

__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

'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>

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.

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). Mount LatticePanel / ChannelActivityStrip standalone next to your own chat UI when you don't want the full shell.

Where to go next

On this page