Skip to content
pleach
Build

CLI

The pleach binary ships four subcommands — tui opens the terminal UI (and is what a bare pleach runs); 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 four subcommands today:

  • pleach tui — the terminal UI. This is what a bare npx pleach runs. It talks to the same runtime the web route does, through a Node sidecar.
  • 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. Requires a provider key — there is no keyless mode.
  • pleach init — interactive wizard. Detects your stack and scaffolds a starter project (route handler, page, optional plugin stub, optional schema copy). Reached by name; it is no longer what a bare npx pleach runs.
  • pleach schema — copy the harness Postgres SQL bundle into your project so you can apply it against your database.

Bare npx pleach changed. It used to run the init wizard; it now opens the TUI. Scripts that relied on the old behavior should call npx pleach init explicitly.

Two routing rules are worth knowing. A leading --flag (rather than a subcommand name) routes to schema, preserving the older npx harness init --apply form. An unrecognized bare word prints the help banner rather than erroring.

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 --headless --prompt "…"  # emit-stream capture, no browser

Flags

FlagDefaultEffect
--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).
--model <id>the provider's defaultPin the model the playground turn runs on.
--base-url <url>the provider's defaultPoint the provider stream at a different base URL (self-hosted gateway, proxy).
--port <n> (alias -p)autoPort for the playground server.
--open / --no-openopenAuto-open the browser, or start the server without doing so.
--help, -h–Print the dev help banner and exit 0.

Every value-taking flag also accepts the --flag=value form.

Note that --plugin on its own keeps the interactive server — load your plugin and exercise it in the browser. Headless battery mode is not requested directly; it engages when any of --scenarios, --assert, or --watch is present. --headless is a separate one-shot path.

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, the playground prints guidance naming the provider env vars to set. There is no keyless mode — a canned turn proves no value prop, so the playground requires a real key.

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:

  • Lifecycle — reachedTerminal (the turn reaches a terminal answer — assert this, not the rarer natural converged), notStalled, finalContentIncludes.
  • Capability wiring — minCapabilitiesWired, contributes, eventTypes.
  • Channels — snapshotChannels (the channels a feature promises are written, e.g. costEvents/costRollup).
  • Audit ledger — minAuditRows, everyAuditRowClassified (every LLM call carries a callClass), everyAuditRowTenantScoped (every row carries a tenantId — isolation on every row).
  • Call classes — exactlyOneTerminalSynthesis (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 lattice — auditStagesIn (every call is stamped with a stage from the anchor-plan | tool-loop | synthesize | post-turn lattice).
  • Family lock — singleFamily (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. Provider — anthropic, 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, -h–Print 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 also covers deepseek, mistral, and 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, -h–Print 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, -h–Print 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.

pleach tui — the terminal UI

npx pleach             # bare invocation opens the TUI
npx pleach tui         # the same thing, named
npx pleach tui --help  # args pass straight through to the TUI binary

Everything after tui is forwarded verbatim to the binary, so the TUI owns its own flag surface.

The TUI is a compiled Go binary. It does not reimplement the runtime — it starts a Node sidecar and drives it, so a terminal session and an HTTP route execute the same code:

pleach tui  →  the Go binary  →  the Node sidecar  →  your route handler

That is why TUI behavior and server-route behavior stay in step: the sidecar resolves configuration (provider key, filesystem root, runtime toggles) and then delegates unmodified. There is one execute path, not two implementations that have to agree.

Flags

Everything after tui is forwarded verbatim, so this is the binary's own surface, not a wrapper's.

FlagWhat it does
--url <server>Connect to a remote @pleach/core server instead of starting the local sidecar.
--continue, -cResume the last session. Local sessions persist in the durable SQLite store.
--resume <id>Resume one specific prior session id.
--print <msg>Headless. Send one message, stream just the answer to stdout, exit — no TUI. This is the scriptable smoke/verify path.
--eventsWith --print (or a positional/stdin message): stream every graph event — nodes, stages, channels, tools, deltas — instead of only the answer. Requests the server's verbose diagnostics tier.
--tunePrint what this machine's ledger would derive for the agent's tuning knobs, beside the values in force, then exit. Read-only — never writes a knob.
--heartwood-url <url>Absolute URL of a persisted-identity endpoint for /heartwood. Falls back to the local ~/.pleach/heartwood.md.

--print with --events is the pairing to reach for when you want to see the four-stage lattice rather than read about it: one command, every node firing and every stage transition on stdout.

npx pleach --print "What is 47 x 89?"            # just the answer
npx pleach --print "What is 47 x 89?" --events   # the full event lattice

Every one of these runs a real, billed model turn. There is no keyless mode on any pleach CLI — headless output is a different projection of a live turn, never a canned one.

A deprecated --demo alias for --print --events still parses and prints a deprecation notice. Do not reach for it: the name reads keyless and the turn is billed. Use --print --events.

The binary is built at install time

The Go binary is materialized by the package's postinstall script. Installs that skip lifecycle scripts — npm ci --ignore-scripts, common in regulated and SBOM-generating environments — will not have it.

A missing TUI binary is an expected state, not a failure. Bare npx pleach falls back to the help banner rather than erroring, so it never dead-ends. Rebuild it with npm rebuild @pleach/core. Every other subcommand is pure Node and keeps working regardless.

Local development without a database

If you don't want to run any migrations during development, skip the CLI entirely and omit storage and checkpointer when you construct the runtime. With both absent it falls back to MemoryAdapter + MemorySaver, so nothing touches a database. 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, and the terminal UI is one of them: it ships inside @pleach/core rather than as a sibling SKU, so pleach tui needs no additional install. 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