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.
| 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) |
@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 status table.
30-second start
Install the package alongside its peers:
npm install @pleach/react @pleach/core react react-domAcquire 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-domSurface 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";@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.| 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
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 unmountPass 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
| 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
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.
@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
| 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
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>
);
}| 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
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.
| 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 |
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.
| Hook | Returns | Use case |
|---|---|---|
useSyncStatus(opts?) | { stats: SyncStats; errors: SyncError[] } | Diagnostics panel |
useIsSynced() | boolean | "All changes saved" pill |
usePendingChanges() | number | Unsaved-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 nowHarnessDevToolsAPI 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.firedand 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) andchannel.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
HarnessServer
Framework-agnostic HTTP handlers — what the hooks call into when the runtime lives on a server.
API routes
The HTTP + SSE wire contract these hooks consume.
Query
Server-side read API over persisted harness data — the RLS-bypassing counterpart to these RLS-bound hooks.
DevTools
window.__HARNESS_DEVTOOLS__ — the browser-console surface useHarnessDevTools wires.
@pleach/core
The runtime substrate — sessions, graph, channels, audit ledger, checkpointing, sync, storage adapters.
Building chat UI
From <ChatBox /> for the MVP to the @pleach/react primitives for the middle ground to assistant-ui or CopilotKit for design-system-grade chat. Three layers, one substrate.