@openprose/reactor-cli
v0.2.2
Published
Deterministic command-line driver for the @openprose/reactor SDK.
Readme
@openprose/reactor-cli
The deterministic command-line driver for the @openprose/reactor
SDK. It configures the SDK — it never re-implements the reconciler and never
parses .prose itself. Compile freezes intelligence (model sessions) into
deterministic, content-addressed artifacts; run/serve execute those frozen
artifacts with a dumb reconciler.
The command is reactor, shipped from this reactor-cli package. Reactor is
React.memo applied to expensive LLM work: you declare standing
Responsibilities, the harness maintains a world-model and re-renders only
what materially moved (the reconciler is dumb — no judge step), and every
decision leaves a content-addressed receipt. Cost scales with surprise, not
the clock.
Versions (live on npm):
reactor-cli0.2.0 ·@openprose/reactor0.3.0 ·reactor-devtools0.2.0.reactor --versionprints the CLI version (0.2.0), not the SDK version (0.3.0) — expected, not a mismatch.
Install
All three packages are live on npm. Prefer a project-local install (no root,
no global binary collisions) and call the binaries through npx:
npm install --save-dev @openprose/reactor-cli @openprose/reactor @openai/agents zod
# then: `npx reactor …` / `npx reactor-devtools …`To touch the keyless replay with no install at all:
npx -p @openprose/reactor-devtools reactor-devtools --example masked-relay --describeA global install is an alternative — but -g can collide with other tools'
binaries and is EACCES-prone on Linux/WSL (use a user prefix/nvm or sudo):
npm i -g @openprose/reactor-cli @openprose/reactor-devtools
# The live render also needs two peers:
npm i -g @openprose/reactor @openai/agents zodZero runtime deps in the SDK core. The live render needs two peers (
@openai/agents,zod);doctor,init, the whole observability suite, and thereactor-devtoolsreplay need neither.Requires Node >=20 (matches the SDK's
enginesfloor).
Quickstart
The keyless reactor-devtools replay is the first thing to touch — it's the
payoff that works with no key and no model call. Then scaffold and check your
own responsibility offline; only the live compile/run needs a model key.
# 1. See the thesis — keyless, no model call, no setup.
# Replay a real saved run's receipt ledger: dispositions, cost-by-surprise, chain-verify.
# Use the bundled fixture by name — no path to compute, works from any cwd:
reactor-devtools --example masked-relay --describe # headless summary (the text an agent reads)
reactor-devtools --example masked-relay # browser viewer at http://localhost:4555
# (or, against the bundled path explicitly:)
# reactor-devtools "$(npm root -g)/@openprose/reactor-devtools/fixtures/masked-relay" --describe
# 2. Scaffold and check your own responsibility — offline.
reactor init my-project # scaffold a gateway + responsibility + reactor.yml
cd my-project
reactor doctor # check node, SDK, key/deps, sandbox, state-dir, IR
reactor compile --check # honest STALE + contract fingerprint; zero cost
# 3. Run it live (needs a model key).
export OPENROUTER_API_KEY=... # doctor confirms it's present, never echoes it
reactor compile # run the compile sessions -> content-addressed IR cache
reactor topology # the DAG Forme wired from your contracts
reactor serve --http 8080 # drive the scaffold's static gateway to a real receipt
# (binds 127.0.0.1 by default; no auth in v1 — see "Deploying reactor serve")
reactor-devtools .reactor --describe # replay YOUR run's receiptsThe scaffold seeds a static gateway, so drive it with
reactor serve(which ingests the seeded items), notreactor run(which is for graphs whose connectors emit on their own). We're fixingrunto either ingest the static case or say so explicitly rather than no-op.
compile/run/serve reach the model surface and need a live key
(OPENROUTER_API_KEY) plus the optional peer deps (@openai/agents, zod).
Every other command — doctor, init, and all of the observability commands —
runs fully offline, with no key and with the model deps absent.
If you're an agent onboarding on behalf of a user: the binary is
reactor. Runreactor init → doctor → compile → run, then openreactor-devtools <state-dir>to inspect the receipt ledger. OpenProse contracts run on any Prose-Complete harness; the Reactor CLI is the deterministic host layer that compiles, runs, and inspects them.
Benchmarks are openly pending — the proof is the receipts and the keyless replay above, not a number in our marketing.
The reference client: compile → run → serve
The CLI is the reference client for the SDK's three-phase lifecycle:
compileruns the intelligent compile sessions (Forme topology, per-node canonicalizer, postconditions) and freezes them into a content-addressed IR cache under<state-dir>/compile/. The cache KEY is(contract-set fingerprint, SDK version, model id)— cost is never part of cache identity. An unchanged contract set re-compiles at zero session cost (a cache hit);--checkexits non-zero when the cache is stale (wire it into CI). The IR persists a serializable spec, so a fresh process re-lowers each node's canonicalizer with the keylesscompileNode(spec)— no model, no network — to mount it.runensures the IR is fresh (compiles if stale), boots the reactor, drains to quiescence, prints per-node dispositions + cost, and exits. One-shot.serveboots the durable host (filesystem receipts + world-models), runs the continuity driver loop, and exposes an HTTP surface. It stays up untilSIGINT/SIGTERM, then drains in-flight work and exits.
Cost scales with surprise
Every receipt carries a surprise_cause. A node that re-wakes but whose inputs
did not move memo-skips at zero render cost; a node renders (and spends
tokens) only when its (contract_fp, input_fps) memo key actually moves. So the
standing cost of a quiet system trends to a bounded audit floor, not zero, and a cost spike is always a real
change propagating — reactor receipts cost and reactor status roll cost up by
surprise_cause so you can see exactly what surprised the system.
Deploying reactor serve
reactor serve --http <port> boots the durable host and exposes a small HTTP
surface for liveness, observability, and manual ingress. The routes (namespaced
under /<reactor-name>/... on a multi-reactor host; the prefix is omitted for a
single reactor):
| Method + path | What it returns |
| --- | --- |
| GET /health | Liveness — 200 with { "status": "ok" } once the host is up. |
| GET /status | Standing compile cost beside live run cost + per-node dispositions (the status command's JSON). |
| GET /cost | The cost rollup by surprise_cause (the receipts cost JSON). |
| POST /trigger/<node> | Wake <node> with an optional JSON body as an external arrival; returns the resulting disposition. |
It drains in-flight work on SIGINT/SIGTERM before exiting.
⚠ Bind address and auth — read before exposing this. As of this release
serve --httpbinds127.0.0.1by default — it is not reachable off the host unless you pass an explicit--host 0.0.0.0. And v1 ships no auth.POST /trigger/<node>is unauthenticated, so anything that can reach the port can wake a node and cause model spend. Exposing it externally (--host 0.0.0.0) is only safe behind a reverse proxy or network policy that adds authentication and rate-limiting. Treat the bare HTTP surface as a single-operator, trusted-network interface.
Kubernetes / container deployment
Probe liveness on the HTTP server and readiness with reactor doctor --json
(keyless, offline — it reports node/SDK/key/deps/state-dir/IR health):
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
exec:
command: ["reactor", "doctor", "--json"]
initialDelaySeconds: 5
periodSeconds: 15A reference Dockerfile sketch — pin Node 22, install the three packages globally
(SDK first), and run serve --http bound to all interfaces inside the
container (keep the bind at 127.0.0.1 on the host and front it with an
ingress/proxy that adds auth):
FROM node:22-slim
RUN npm i -g @openprose/reactor @openprose/reactor-cli @openprose/reactor-devtools \
&& npm i -g @openai/agents zod
WORKDIR /app
COPY . .
EXPOSE 8080
CMD ["reactor", "serve", "--http", "8080", "--host", "0.0.0.0"]Configuration — reactor.yml
reactor init writes a fully-commented reactor.yml. The schema:
state:
dir: ./.reactor # durable state (receipts, world-models, IR cache)
model:
provider: openrouter
render_model: google/gemini-3.5-flash
compile_model: google/gemini-3.5-flash
temperature: 0
max_turns: 200
sandbox:
mode: none # none (default) | docker
shell_timeout_ms: 300000
gateways: # external-driven entry points
- node: inbox
source_id: inbox
connector:
type: static # static | http | file (or a connectors.{cjs,js} plugin)
id_field: id
items: [{ id: item-1, body: "the first item" }]
reactors: [] # optional: a multi-reactor host (see below)Global flags --state-dir, --project, --json, --offline override the file
on every command.
Sandbox
The sandbox block is the render threat-model knob:
mode: none(the locked default) — renders run in the SDK's cwd-scoped, time/output-bounded shell (shell_timeout_mstunes the bound; default 300 s). The trusted posture.mode: docker— each render command runs inside a throwaway, network- disabled container (docker run --rm --network=none -v <ws>:<ws> -w <ws> <image> ...), bind-mounting only the workspace. If Docker is absent, the run degrades to the bounded shell with a surfaced note (it never crashes).reactor doctorreports Docker availability whenmode: docker.
Connectors + gateways
A gateway is an external-driven entry point. A connector is three pieces:
fetch (source I/O) + extract (payload → arrivals keyed by id_field) +
stage (write the arrival into the gateway's truth before the wake). Built-ins
by type:
static— a fixeditemslist (great forinit/examples/tests).http—GET <url>(substitutes{cursor}), JSON array → arrivals.file— watch adirof.jsonfiles.
A project may also ship a connectors.cjs/connectors.js plugin exporting
{ connectors: { [source_id]: { fetch, extract? } } }. Idempotency is durable: a
per-source cursor dedups arrivals, so a restart never re-ingests the backlog.
Multi-reactor host + --concurrency
A reactors: list in reactor.yml hosts N isolated reactors (each its own
state-dir, substrate, schedule, cursors). The HTTP surface namespaces each under
/<name>/... (the prefix is omitted for a single-reactor host).
--concurrency N is an across-reactor worker-pool bound: independent reactors
render in parallel up to N. Within a single reactor, drains stay strictly
serial — at most one drain in flight per reactor, behind a per-reactor
serialization queue (the SDK's single-flight atomicity requires this).
Within-reactor parallelism is a future enhancement. The current SDK has no
maxConcurrencyoption, so--concurrencyparallelizes reactors, not nodes within a reactor. See the Change-B deferral note in the implementation plan.
Command reference
Run reactor <command> --help for the full options of any command.
| Command | Live? | What it does |
| --- | --- | --- |
| reactor init [dir] | offline | Scaffold a minimal project (gateway + responsibility + reactor.yml + .gitignore). Refuses a non-empty target dir without --force. |
| reactor doctor [--live] | offline (--live probes) | Report node/SDK/key/deps/offline/sandbox/state-dir/IR health. --live runs one smoke render. |
| reactor compile [--force] [--check] | live (cache hit/--check offline) | Run compile sessions → IR cache. --check exits non-zero when stale. |
| reactor run | live | Ensure IR fresh, boot, drain to quiescence, report + exit. |
| reactor serve [--http <port>] [--concurrency <n>] [--poll-interval <ms>] | live | Boot the durable host + continuity loop + HTTP surface. |
| reactor trigger <node> [--data <json>|@file] | live | Trigger a node with an external wake (one-shot mount, or POST to a daemon). With --data, the payload is STAGED into the node's ingress so it actually reaches the render (not just the report). |
| reactor status | offline | Standing compile cost beside live run cost + dispositions. |
| reactor topology | offline | Print the compiled DAG: nodes (+ wake source) and resolved edges. |
| reactor inspect <node> [--strict] | offline | A node's topology position, fingerprints, last receipt, chain. |
| reactor logs [--node <node>] | offline | The receipt stream (optionally filtered to one node). |
| reactor trace [<node>] | offline | Each node's receipt chain: wake → disposition. |
| reactor receipts [list\|verify\|cost] [--node <node>] [--rate <rate>] | offline | Audit the receipt trail (verify is non-zero on a broken chain). An unknown subcommand is a usage error (exit 2). cost --rate $3/Mtok (or 500000tpd) prices the token rollup into a dollar column. |
What
receipts verifyproves (and doesn't). It proves receipt-chain consistency: each receipt'scontent_hashmatches its payload, and each receipt links to itsprev. In v1 it does not bind the world-model artifacts — editing aworld-models/*/published.jsonwhile leavingreceipts.jsonintact is not caught. That is the null-signer / meaning-layer boundary: verify covers the chain, not the maintained truth on disk.
Documented exit codes
The CLI uses stable, documented exit codes so it composes in CI/scripts:
| Code | Meaning |
| --- | --- |
| 0 | Success / healthy. |
| 1 | A reported failure with an actionable message: stale cache (compile --check), a broken receipt chain (receipts verify, inspect --strict), no contracts found, a bad config, an unhealthy environment (doctor), a missing live key/dep (--live), or a connector/render error. |
| 2 | A usage error: an unknown command/flag (emitted by the arg parser), an unknown receipts subcommand (e.g. receipts verifyy), or a --state-dir that points at a file instead of a directory. |
Failure modes carry actionable messages, e.g. a missing live key → "set
OPENROUTER_API_KEY"; mode: docker with no daemon → "install/start Docker, or
renders fall back to the bounded shell"; a stale cache → "run reactor compile".
Reading the exit code in CI? Check
$?directly — do not pipe if you need the status:reactor compile --check; echo "exit=$?" # 1 when STALE, 0 when freshA pipe (
reactor compile --check | tee log) reports the last command's exit, not reactor's — so a STALE failure silently looks like a pass. Read$?from the bare command, or capture it before piping.
Examples
The examples/ directory runs end-to-end from a fresh checkout:
examples/quickstart— the scaffoldreactor initproduces: a gateway + a responsibility, compiled → run.examples/gateway-connector— a gateway wired to astaticconnector, showing the fetch → extract → stage → wake ingress.
Each example's README.md lists the exact commands.
Offline boundary
The default import surface and every model-free command are keyless: requiring
the CLI entrypoint loads neither @openai/agents nor zod. compile, run,
serve, trigger, and the connector/render paths reach the model surface only via
dynamic import() inside the handler — so doctor, init, and the whole
observability suite work with the model deps absent.
Contributing — the offline commit gate
The commit gate is the per-package offline test, which runs with no model key and no network. Install with a frozen lockfile, then run the gate:
pnpm install --frozen-lockfile
pnpm -C packages/reactor test:offline
pnpm -C packages/reactor-cli test:offline
pnpm -C packages/reactor-devtools test:offlineIf the repo root defines a test:offline script, pnpm test:offline from the
root is the one-shot gate that chains all three. (Root pnpm test chains the
live + skill suites and will go red on a keyless box — use the offline gate to
verify a commit.)
