# @openprose/reactor-cli

The deterministic command-line driver for the [`@openprose/reactor`](https://www.npmjs.com/package/@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-cli` 0.2.2 ·
> `@openprose/reactor` 0.3.1 · `reactor-devtools` 0.2.0. `reactor --version`
> prints the CLI version (0.2.2), not the SDK version (0.3.1) — 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`:

```sh
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**:

```sh
npx -p @openprose/reactor-devtools reactor-devtools --example masked-relay --describe
```

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

```sh
npm i -g @openprose/reactor-cli @openprose/reactor-devtools
# The live render also needs two peers:
npm i -g @openprose/reactor @openai/agents zod
```

> Zero *runtime* deps in the SDK core. The live render needs two peers
> (`@openai/agents`, `zod`); `doctor`, `init`, the whole observability suite, and
> the `reactor-devtools` replay need neither.
>
> Requires **Node >=20** (matches the SDK's `engines` floor).

## 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.

```sh
# 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 receipts
```

> The scaffold seeds a **static** gateway, so drive it with `reactor serve` (which
> ingests the seeded items), not `reactor run` (which is for graphs whose
> connectors emit on their own). We're fixing `run` to 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`.
> Run `reactor init → doctor → compile → run`, then open
> `reactor-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:

1. **`compile`** runs 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-`compile`s at **zero session
   cost** (a cache hit); `--check` exits 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 keyless `compileNode(spec)` — **no model, no
   network** — to mount it.

2. **`run`** ensures the IR is fresh (compiles if stale), boots the reactor,
   drains to quiescence, prints per-node dispositions + cost, and exits. One-shot.

3. **`serve`** boots the *durable* host (filesystem receipts + world-models),
   runs the continuity driver loop, and exposes an HTTP surface. It stays up until
   `SIGINT`/`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 --http` binds **`127.0.0.1` by 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):

```yaml
livenessProbe:
  httpGet:
    path: /health
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10
readinessProbe:
  exec:
    command: ["reactor", "doctor", "--json"]
  initialDelaySeconds: 5
  periodSeconds: 15
```

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

```dockerfile
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:

```yaml
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               # optional — delete the line to send no temperature
  max_turns: 200
  # reasoning_effort: none     # reasoning models (gpt-5.x, o-series) reject an
                               # explicit temperature unless effort is none

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_ms` tunes 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 doctor` reports Docker availability when `mode: 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 fixed `items` list (great for `init`/examples/tests).
- **`http`** — `GET <url>` (substitutes `{cursor}`), JSON array → arrivals.
- **`file`** — watch a `dir` of `.json` files.

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
> `maxConcurrency` option, so `--concurrency` parallelizes *reactors*, not nodes
> within a reactor.

## 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 verify` proves (and doesn't).** It proves **receipt-chain
> consistency**: each receipt's `content_hash` matches its payload, and each
> receipt links to its `prev`. In v1 it does **not** bind the world-model
> artifacts — editing a `world-models/*/published.json` while leaving
> `receipts.json` intact 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:
>
> ```sh
> reactor compile --check; echo "exit=$?"   # 1 when STALE, 0 when fresh
> ```
>
> A 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/`](./examples) directory runs end-to-end from a fresh checkout:

- [`examples/quickstart`](./examples/quickstart) — the scaffold `reactor init`
  produces: a gateway + a responsibility, compiled → run.
- [`examples/gateway-connector`](./examples/gateway-connector) — a gateway wired
  to a `static` connector, 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:

```sh
pnpm install --frozen-lockfile
pnpm -C packages/reactor test:offline
pnpm -C packages/reactor-cli test:offline
pnpm -C packages/reactor-devtools test:offline
```

If 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.)
