pleach
Build

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.

@pleach/core ships one binary, pleach, registered through the package's bin field. It has three subcommands today:

  • pleach dev — boot a self-contained local playground in your browser: the four-stage lattice visualizer, the event log, an inspector, a runtime console, and the audit ledger, all driven by a real graph turn. --demo runs keyless against a canned model.
  • pleach init — interactive wizard. Detects your stack and scaffolds a starter project (route handler, page, optional plugin stub, optional schema copy). This is the default; npx pleach with no args runs it.
  • pleach schema — copy the harness Postgres SQL bundle into your project so you can apply it against your database.

The binary name is pleach. The script lives at scripts/harness-init.mjs in the package — that internal path shows up in stack traces, but the user-facing name is the bin name.

pleach dev — the local playground

pleach dev boots a self-contained local playground in your browser — no host app, no build step, no database. It runs a real graph turn through the shipped createPleachRoute() and folds the resulting StreamEvent stream into five DX panels:

  • Lattice visualizer — the four stages (anchor-plan, tool-loop, synthesize, post-turn) light up live as nodes fire and channels are written.
  • Event log — the raw StreamEvent stream, the same shape the ledger and @pleach/react hooks consume.
  • Inspector — per-node / per-channel state at each superstep.
  • Runtime console — the bracketed runtime log breadcrumbs.
  • Audit ledger — one AuditableCall row per LLM call, rendered as it lands.
npx pleach dev                          # → opens the playground in your browser
npx pleach dev --demo                   # keyless preview — REAL graph, canned model text
npx pleach dev --headless --prompt "…"  # emit-stream capture, no browser

Flags

FlagDefaultEffect
--demooffKeyless mode. The real graph runs end-to-end; only the model text is scripted (canned, deterministic). Use it to see the lattice execute with no API key.
--sqloffDurable persistence. Back the route's storage + checkpoint + event-log seams with a local pglite (Postgres-in-WASM) store, so chats and checkpoints survive a restart — no Docker, no cloud account. Needs the optional peer @electric-sql/pglite (npm install @electric-sql/pglite); without it, falls back to the in-memory default with a hint.
--sql-dir <path>./.pleach-dev-sqlWhere the pglite store lives. Implies --sql. Point it somewhere stable so a restart resumes the same chats.
--headlessoffNo browser. Drive one turn and dump the captured emit stream to stdout — for CI, sharing, or piping into another tool.
--prompt <text> (alias --once)a built-in multi-tool chain promptThe user turn to run. Pairs with --headless; omitted, headless runs the default chain so the full lattice fires.
--markdown (alias --md)offWith --headless, render the emit capture as a Markdown session document instead of raw events.
--plugin <path>Load YOUR HarnessPlugin module into the runtime (repeatable). Test it in the browser, or headlessly with the flags below. The module exports its plugin as default, a named plugin, or a zero-arg factory.
--scenarios <path>A JSON battery of prompts (array of strings, or { name, prompt }, or { scenarios: [...] }). Drives each headlessly and prints a per-scenario report. Implies headless.
--report <json|md>jsonHarness report format when running a battery.
--assert <path>An expectations file. Exits non-zero on any failed expectation — gate it in CI or an agent loop.
--watchoffRe-run the battery and DIFF the report on every --plugin file edit (run → observe → edit).
--port <n> (alias -p)autoPort for the playground server.
--no-openoffStart the server but don't auto-open the browser.
--help, -hPrint the dev help banner and exit 0.

Key handling

pleach dev reads the active provider's <PROVIDER>_API_KEY from the environment first, then from the in-page key panel you can paste into without restarting. Precedence is process.env first, then the opt-in persistence store, then none.

Provider idEnv var
openrouterOPENROUTER_API_KEY
anthropicANTHROPIC_API_KEY
openaiOPENAI_API_KEY
googleGOOGLE_GENERATIVE_AI_API_KEY
deepseekDEEPSEEK_API_KEY
mistralMISTRAL_API_KEY
moonshotMOONSHOT_API_KEY

Persistence is opt-in. When you paste a key into the panel you can choose to save it to ~/.pleach/dev.env (written 0600, one <PROVIDER>_API_KEY=<value> line per provider); the key is never logged. Without saving, the key lives only for the running session. If no key is detected and you didn't pass --demo, the playground prints guidance and points you at --demo for a keyless run.

Keyless demo

--demo is the zero-config entry point: the real compiled graph executes a full turn — anchor-plan, tool-loop, synthesize, post-turn, channel writes, audit rows — and only the model's text output is canned. It's the honest preview: you watch the actual lattice run, not a mock of it. Headless demo (--headless --demo, no --prompt) runs the default multi-tool chain so the whole lattice fires for a CI smoke or a shareable capture.

Plugin-test harness — headless, continuous

Point the runtime at your own HarnessPlugin and drive a battery of prompts with no browser. Each scenario folds the same surfaces the playground tabs expose — conversation, runtime log, event log, channel snapshot, audit ledger, and the inspectRuntime report (capabilities wired n/total) — into one structured report, plus derived signals (reachedTerminal, stalled, notContributed, eventTypes, terminalSyntheses, auditFamilies, …):

# load your plugin into the live playground (test it in the browser)
npx pleach dev --plugin ./my-plugin.mjs

# headless battery → a JSON (or --report md) report per scenario
npx pleach dev --plugin ./my-plugin.mjs --scenarios ./scenarios.json --report md

# gate in CI / for an agent loop: non-zero exit on a failed expectation
npx pleach dev --plugin ./my-plugin.mjs --scenarios ./scenarios.json --assert ./expect.json

# iterate: re-run the battery + DIFF the report on every plugin edit
npx pleach dev --plugin ./my-plugin.mjs --scenarios ./scenarios.json --watch

scenarios.json is an array of prompts (or { name, prompt }); expect.json declares per-scenario or all expectations. Each predicate maps to a documented guarantee, so a green battery is a continuously-enforced value-prop contract:

  • LifecyclereachedTerminal (the turn reaches a terminal answer — assert this, not the rarer natural converged), notStalled, finalContentIncludes.
  • Capability wiringminCapabilitiesWired, contributes, eventTypes.
  • ChannelssnapshotChannels (the channels a feature promises are written, e.g. costEvents/costRollup).
  • Audit ledgerminAuditRows, everyAuditRowClassified (every LLM call carries a callClass), everyAuditRowTenantScoped (every row carries a tenantIdisolation on every row).
  • Call classesexactlyOneTerminalSynthesis (one final answer per turn — counts terminalSynthesis rows, so a thin-draft recovery retry doesn't trip it), atMostOneTerminalSynthesis (the robust "never two final answers"; passes a passthrough turn too), maxSynthesizeCalls (caps the retry budget), auditCallClassesIn (every call's class stays within the utility | reasoning | converse | synthesize taxonomy).
  • Stage latticeauditStagesIn (every call is stamped with a stage from the anchor-plan | tool-loop | synthesize | post-turn lattice).
  • Family locksingleFamily (the turn never silently widens across matrix families — at most one resolved family across its audit rows).

The report is exactly the feedback an agent needs to run a run → observe → edit loop on your plugin. To run the same loop inside your own runtime (no CLI), flip the harness chatinit flag.

pleach init — the wizard

The wizard detects what you already have and asks four questions:

  1. Template — chatbot (route + page), headless smoke script, or plugin-only.
  2. Provideranthropic, openai, openrouter, google. The default is whichever provider env var it finds first.
  3. Plugin stub? — optional pleach.plugin.ts (or .mjs) demonstrating three HarnessPlugin hooks.
  4. Schema scaffold? — optional. Runs the schema subcommand inline. Default is on when a supabase/ directory already exists.

Then it prints a write plan, asks for confirmation, and writes only files that don't already exist. Existing files are skipped, not overwritten.

npx pleach init [--template <name>] [--yes] [--cwd <dir>] [--help]

Flags

FlagDefaultEffect
--template <name>chatbot (with --yes)Preselect template. One of chatbot, headless, plugin-only. Also flat / bundles to override the plugin-stub shape (default: bundles).
--facets <list>prompts,safety,toolsComma-separated facet list for the plugin stub. Valid: prompts, safety, tools, stream, lifecycle.
--facets-file <mode>inlineSingle-file inline (default) or split to scaffold facets/<name>.ts files.
--license <SPDX>Apache-2.0License for the consumer's scaffolded package.json (only when --publishing is not none). Independent of the @pleach/* substrate's own FSL-1.1-Apache-2.0 posture.
--publishing <mode>none (under --yes)One of none, npm-private, npm-public. Controls package.json + README.md scaffold.
--author <name>null (under --yes)Author for scaffolded package.json. Interactive mode reads npm config get init-author-name as a default.
--repository <url>null (under --yes)Repository URL for scaffolded package.json. Interactive mode reads npm config get init-repository.
--yes, -yoffCI mode — accept all defaults without prompting.
--cwd <dir>process.cwd()Run in a different directory.
--help, -hPrint the init help banner and exit 0.

What it detects

All best-effort, all from the filesystem and env:

SignalValuesHow
Frameworknext-app, next-pages, astro, sveltekit, remix, vite, react-other, nodepackage.json deps + presence of app/
Package managerbun, pnpm, yarn, npmLockfile presence
@pleach/core installedyes / nopackage.json deps
TypeScript projectyes / notsconfig.json exists
Supabase migrations diryes / nosupabase/ exists
Provider env varswhich provider keys are reachableprocess.env first, then .env.local, .env.development, .env in that order

Detection runs most-specific to least-specific. Next.js wins over Vite over plain React, because plain React often hides under a Next or Vite shell. The fallback for "nothing matched" is plain Node.

The .env* walk is intentionally minimal — no interpolation, no multi-line values, first occurrence wins. Just enough to surface a provider key you set but never exported.

Provider env-var matrix

Provider idEnv var
anthropicANTHROPIC_API_KEY
openaiOPENAI_API_KEY
openrouterOPENROUTER_API_KEY
googleGOOGLE_GENERATIVE_AI_API_KEY

The wizard offers every provider whose env var it can find. The first reachable one is the default selection; if none is set, the default is anthropic.

The wizard's 4-provider list is narrower than the runtime's. The quickstart subpath's providerDetection covers 7 providers (adds deepseek, mistral, moonshot). If only a non-wizard provider env var is set, the wizard reports "none detected" and defaults to anthropic — but the runtime createPleachRoute your app boots will still detect and use the provider correctly. The wizard's list only drives the prompt question; the deployed handler reads the broader runtime list.

Templates

TemplateFrameworkFiles written
chatbotnext-appapp/api/chat/route.ts, app/page.tsx
chatbotnext-pagespages/api/chat.ts, pages/index.tsx
chatbotastrosrc/pages/api/chat.ts, src/pages/index.astro
chatbotsveltekitsrc/routes/api/chat/+server.ts, src/routes/+page.svelte
chatbotremixapp/routes/api.chat.ts, app/routes/_index.tsx
chatbotvite / react-othersrc/App.tsx (no server route — pair with a Node server)
headlessanyagent.mjs — smoke script using SessionRuntime
plugin-onlyanypleach.plugin.{ts,mjs} (bundled stub by default; --template flat for legacy single-facet)

The chatbot route templates import createPleachRoute from @pleach/core/quickstart; the page templates import ChatBox from the same subpath. The headless template uses SessionRuntime from @pleach/core directly. The plugin stub uses createPlugin, appendPrompt, and gatedPrompt — three of the plugin contract hooks. For the full quickstart subpath surface (createPleachRoute, useChat, <ChatBox />, defaultPlugin, createBenchmarkPlugin, providerDetection), see Getting started → The quickstart subpath.

Idempotence

Files that already exist are skipped, never overwritten. The wizard's done block reports written, skipped, and failed counts. For a clean re-run, delete the files the wizard wrote the first time.

CI mode

--yes runs without prompts. Defaults: template = chatbot, provider = whichever the env detection picked (or anthropic), withPlugin = true for chatbot or plugin-only, withSchema = true if supabase/ exists.

npx pleach init --yes --template plugin-only --cwd ./my-app

Non-TTY invocations (CI runners, piped stdin) take the --yes path automatically even without the flag.

Exit codes

CodeWhen
0Success, help, or --yes with zero write errors
1One or more files failed to write

Worked example — Next.js App Router

$ npx pleach init

pleach init — charm-style wizard for @pleach/core

┌─ environment
│ directory      › /Users/you/my-app
│ framework      › Next.js (App Router) (tsconfig.json)
│ package mgr    › pnpm
│ @pleach/core   › not yet installed
│ provider env   › anthropic (.env.local)
│ supabase/      › found
└─

? Template
  ● 1) Chatbot (route + page)   default — streaming UI
  ○ 2) Headless smoke script    node agent.mjs, no UI
  ○ 3) Plugin stub only         add to an existing app
› (1-3, default 1)

? Provider
  ● 1) anthropic                ANTHROPIC_API_KEY set
  ○ 2) openai                   OPENAI_API_KEY unset
  ○ 3) openrouter               OPENROUTER_API_KEY unset
  ○ 4) google                   GOOGLE_GENERATIVE_AI_API_KEY unset
› (1-4, default 1)

? Generate a sample plugin stub? (y/N) y
› plugin name (default: my-plugin) triage-plugin

? Scaffold Postgres schema into supabase/migrations? (Y/n)

┌─ write plan
│ • app/api/chat/route.ts
│ • app/page.tsx
│ • pleach.plugin.ts
│ • supabase/migrations/20260608120000_pleach_<bundle-file>.sql  (from schema bundle)
└─
? Write 4 file(s)? (Y/n)

done
  ✓ wrote      app/api/chat/route.ts
  ✓ wrote      app/page.tsx
  ✓ wrote      pleach.plugin.ts
  ✓ wrote      supabase/migrations/20260608120000_pleach_<bundle-file>.sql

next steps
  1. pnpm add @pleach/core
  2. ANTHROPIC_API_KEY loaded from .env.local (framework auto-loads at dev time)
  3. pnpm dev — chat lives at the root page

The "next steps" block adapts to the package manager the wizard detected. With bun it suggests bun add and bun run dev; with npm, the npm equivalents. Same with the provider-key line — if the key was found in process.env, the line is dropped from next-steps entirely.

Plugin stub shape

When template = plugin-only (or chatbot with --add-plugin), the wizard scaffolds a pleach.plugin.ts file. The default shape is now bundle-aware: each facet (prompts, safety, tools by default) gets its own definePleachPlugin const, composed into a single exported plugin array.

// pleach.plugin.ts (default — bundle-aware shape)
import { definePleachPlugin } from "@pleach/core";

const promptsFacet = definePleachPlugin("my-plugin", { prompts: [] });
const safetyFacet  = definePleachPlugin("my-plugin", { safetyPolicies: [] });
const toolsFacet   = definePleachPlugin("my-plugin", { tools: [] });

export const myPlugin = [promptsFacet, safetyFacet, toolsFacet];

Wire at the runtime registration site via array spread:

new SessionRuntime({ plugins: [...myPlugin] });

This shape teaches the multi-facet pattern from day 1 — when a facet grows past ~50 lines, move its const to facets/<facet>.ts per plugin authoring standards.

The single-facet shape (one definePleachPlugin call covering all hooks) is still available via --template flat:

npx pleach init --template flat

See plugin bundles for the rationale behind the multi-facet pattern and the planned composePlugin upgrade path.

pleach init plugin — monorepo subcommand

For an existing project that needs to add a plugin without re-running framework detection:

npx pleach init plugin --name corpus-safety --facets safety,prompts

Scaffolds plugins/corpus-safety/ with four files:

FileContent
src/pleach.plugin.tsBundle-aware stub with the requested facets
package.jsonLicense + peer-dep + scripts, standards-aware defaults
README.mdInstall + use + standards reference seed
tsconfig.jsonTypeScript config seed (ES2022, strict, declaration emit)

If a tsconfig.json exists at the top level, the wizard also inserts a path-map entry (compilerOptions.paths["corpus-safety"]) as a courtesy. Best-effort: skips and warns if the tsconfig can't be parsed.

Subcommand flags

FlagDefaultEffect
--name <kebab>requiredPlugin identity. Required under --yes.
--facets <list>prompts,safety,toolsSame vocabulary as pleach init.
--template flat|bundlesbundlesStub shape override.
--facets-file inline|splitinlineSingle-file vs split-file layout.
--at <path>plugins/<name>/Scaffold target.
--license <SPDX>Apache-2.0License id for the consumer's generated package.json. (Independent of the @pleach/* substrate's own FSL-1.1-Apache-2.0 posture.)
--publishing <mode>npm-privateOne of none, npm-private, npm-public.
--author <name>(none)Skipped under --yes unless supplied.
--repository <url>(none)Skipped under --yes unless supplied.
--yes, -yoffCI mode. --name becomes required.
--cwd <dir>process.cwd()Run in a different directory.
--help, -hPrint the subcommand help banner and exit 0.

The subcommand never modifies the top-level package.json or app code beyond the optional path-map insertion. It writes only inside the target directory.

pleach schema — copy the SQL bundle

Scaffolds the harness storage schema into a target directory. This is the surface that used to live under pleach init; pleach init --apply still routes here so existing scripts don't break.

npx pleach schema [--target <dir>] [--dry-run|--apply] [--timestamped] [--help]

Flags

FlagDefaultEffect
--target <dir>./supabase/migrations if it exists, else ./harness-migrationsDestination for the copied SQL files
--dry-runonPrint the file-copy plan; write nothing
--applyoffActually write the files
--timestampedoffPrefix each file with YYYYMMDDHHMMSS_ (UTC) so Supabase migration ordering is stable
--help, -hPrint the banner and exit 0

What it does

  1. Locates the schema bundle inside the installed package (node_modules/@pleach/core/dist/schema/postgres/). Falls back to the in-repo source tree when running against an unbuilt checkout.
  2. Optionally rewrites each filename with a UTC timestamp prefix. Multiple files in the same second get monotonically incremented suffixes — Supabase's lexicographic order stays stable.
  3. Copies the files into your target directory (creating it if needed).

What it doesn't do

  • Connect to your database.
  • Run the migrations.
  • Overwrite existing files. If a target file is already there, schema --apply refuses and exits 3 — read the manual-apply block below.

The full apply path is intentionally not in the CLI. Until a real consumer asks for it, the dry-run + manual-apply flow is the supported path and the surface stays small.

Exit codes

CodeWhen
0Success or dry-run completed
2Schema bundle missing (is @pleach/core installed?) or no SQL files in the bundle
3A target file already exists — refused to overwrite

Applying the migrations by hand

After pleach schema --apply:

for f in supabase/migrations/*pleach*.sql; do
  psql "$DATABASE_URL" -f "$f"
done

If you're using the Supabase CLI:

pleach schema --apply --timestamped
supabase db push

The schema bundle is idempotent at the DDL level — each file uses CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS, so re-applying is safe but won't migrate column shapes. Schema evolution lands as new files in the bundle; the package's CHANGELOG.md records the version each new migration shipped in.

Local development without a database

If you don't want to run any migrations during development, skip the CLI entirely and set:

HARNESS_MOCK_MODE=true

The runtime wires MemoryAdapter + MemorySaver automatically. See environment variables for the full list of runtime-read env vars.

Project layout — after the wizard

The baseline plus the files pleach init writes. This is what a Next.js App Router project looks like one command after npx pleach init on the chatbot template with plugin stub and schema scaffold:

my-app/
  app/
    api/
      chat/route.ts             # wizard-written — createPleachRoute({ provider, plugins })
    page.tsx                    # wizard-written — <ChatBox apiUrl="/api/chat" />
  pleach.plugin.ts              # wizard-written — createPlugin + appendPrompt + gatedPrompt
  supabase/
    migrations/
      20260608120000_pleach_*.sql   # wizard-copied from the schema bundle
  .env.local                    # ANTHROPIC_API_KEY (or your provider key)
  package.json
  tsconfig.json

What changes from the baseline:

  • The route handler is one line. createPleachRoute({...}) is what you'd normally write by hand; the wizard writes it for you. Wire your own HarnessPlugin set into the plugins: array as you grow.
  • pleach.plugin.ts is the starting point, not the final shape. The stub shows three hooks — prompts, runtimePrompts, and the plugin-construction call. The plugin contract covers the full set (contributeTools, contributeSafetyPolicies, etc.); replace the stub when you outgrow it.
  • supabase/migrations/ holds the schema bundle. Filenames are timestamped so Supabase's lexicographic migration order matches the bundle order. Without --timestamped, the files keep their bundle names — fine if you're applying them by hand but loses ordering when fed through supabase db push.

Sibling-SKU binaries

Sibling SKUs (@pleach/compliance@0.1.0, @pleach/eval@0.1.0, @pleach/gateway@0.1.0, @pleach/replay@0.1.0, @pleach/mcp@0.1.0, @pleach/coding-agent@0.1.0) are shipping today. The CLI convention is one bin per package under its own name in the @pleach/ scope — @pleach/compliance exposes pleach-compliance, @pleach/eval exposes pleach-eval, and so on. The pleach bin in core is reserved for substrate-level operations. Per-sibling bins land alongside each SKU's first useful command surface; check the package's npm page for the current bin inventory.

Where to go next

On this page