CLI, server, and worker
@rulvar/cli is the optional ops layer. Same engine, three lifetimes: the rulvar command for a terminal, createServer for a network surface, and createWorker for background multi-process runs. All three are built strictly on the public engine API, so anything a shell does, your host application can do with the same calls; the shells exist so you do not have to write them.
Library mode is the default
Embed the engine directly for scripts, tests, and single-process apps. Reach for a shell when:
- you want terminal ops over a journal directory (
rulvar run,inspect,resume), OR - you expose runs over HTTP (start, watch, approve from a browser or another service), OR
- runs must be resumed by whichever process is available, safely, across machines.
Install
pnpm add @rulvar/cliThe package is ESM only and requires Node >= 22.12.0, like the rest of rulvar. Two commands load optional companions dynamically at command time: rulvar plan needs @rulvar/planner installed, and rulvar kb sweep needs @rulvar/evals. A missing companion is a clear error on that command, never a load failure of the others. The OTel exporter declares @opentelemetry/api (^1.9) as an optional peer.
The rulvar command
The canonical grammar, with no aliases:
rulvar run <file|name> [--args JSON] [--store PATH] [--budget-usd N] [--profile NAME]
rulvar resume <runId> [--args JSON] [--store PATH]
rulvar runs ls [--store PATH]
rulvar inspect <runId> [--store PATH]
rulvar plan "<goal>" [--dry-run]
rulvar kb <list | inbox | sweep>| Command | Purpose |
|---|---|
run | Start a workflow from a file path or a registered name, drive it to a settled outcome, exit with a code reflecting it. |
resume | Rebind a journal to its workflow and continue; fully replayed prefixes cost zero live calls. |
runs ls | List run metadata (id, status, name, tags, last update) from the store. |
inspect | Print one run's journal-derived state: entries, suspensions, spend. |
plan | Ask the planner to write a workflow script for a goal, then run it in the worker sandbox. |
kb | Maintain the model knowledge claim store. |
Flag semantics are uniform:
--store PATHselects theJsonlFileStoredirectory (default.rulvar). Every command that opens a journal store selects it the same way. An explicitstoresentry in your config'sengineOptionswins over the flag.--args JSONsupplies workflow arguments. It appears onresumetoo because original run arguments are not journaled in this version: the host re-supplies them, and a fully replayed prefix never notices their absence.--budget-usd Nsets the run's dollar ceiling, immutable after start (see Budgets).--profile NAMEapplies a shipped run profile (fast,standard,deep,ultra): pure data bundles of effort hints, concurrency, budget defaults, and a permission preset, merged under your own options so your config always wins.
The CLI renders progress from the run's event stream: live TUI rendering on a TTY, plain line output otherwise. When a run suspends, the CLI resolves interactively: approvals prompt for allow or deny, awaitExternal suspensions prompt for a value. If input runs dry (EOF), the run is left suspended in the store, ready for a later rulvar resume, the HTTP server, or a queue worker.
Configuration file discovery
Commands assemble their engine from rulvar.config.mjs (or rulvar.config.js) in the working directory. The default export has three optional fields: engineOptions (anything createEngine accepts), workflows (the registry for by-name runs), and kbSweep (the rulvar kb sweep matrix). An absent config is fine; a workflow module passed to rulvar run may also carry workflow, engineOptions, and workflows as named exports.
// rulvar.config.mjs
import { defineWorkflow } from '@rulvar/core';
import { anthropic } from '@rulvar/anthropic';
const triage = defineWorkflow({ name: 'triage' }, async (ctx) => {
return ctx.agent('Triage the open incidents and rank them by blast radius.');
});
export default {
engineOptions: {
adapters: [anthropic()],
},
workflows: { triage },
};With that file in place, rulvar run triage --budget-usd 2 starts the registered workflow against a JSONL journal in .rulvar.
The plan command
rulvar plan "<goal>" is the terminal entry to the planned mode: the planner model writes a workflow script against the ctx dialect and your profile cards, the script is linted and self-repaired from structured diagnostics, compiled, and executed deterministically in the worker sandbox. --dry-run prints the accepted script without running it. The command imports @rulvar/planner dynamically, so install it alongside @rulvar/cli to use planning.
Knowledge-base maintenance
The kb subcommands maintain the per-project model knowledge claim store (./rulvar.models.json):
rulvar kb listprints the claims with full provenance.rulvar kb sweepruns the falsification matrix declared in thekbSweepsection of your config: a fixed model pool (sweep volume is never authorized by proposal volume) unioned with every model carrying an active negative claim plus the re-measure queue. Optional canary probes run per pool member first and flip drifted claims stale. Requires@rulvar/evals.rulvar kb inboxis reserved for a future knowledge-base release and currently exits with an explanatory error.
The HTTP server
createServer turns an engine and a workflow registry into a WHATWG fetch handler. It opens no socket of its own: you mount server.fetch on whatever Request/Response HTTP layer your host already runs, and your middleware owns TLS, timeouts, and authentication.
import { createEngine, defineWorkflow, JsonlFileStore } from '@rulvar/core';
import { anthropic } from '@rulvar/anthropic';
import { createServer } from '@rulvar/cli';
const releaseNotes = defineWorkflow({ name: 'release-notes' }, async (ctx) => {
const draft = await ctx.agent('Draft release notes from CHANGELOG.md.');
const verdict = await ctx.awaitExternal<{ approved: boolean }>('editor-signoff', {
prompt: 'Approve the draft?',
});
return verdict.approved ? draft : null;
});
const engine = createEngine({
adapters: [anthropic()],
stores: { journal: new JsonlFileStore({ dir: '.rulvar' }) },
});
const server = createServer({ engine, workflows: { 'release-notes': releaseNotes } });
const response = await server.fetch(
new Request('http://localhost/runs', {
method: 'POST',
body: JSON.stringify({ workflow: 'release-notes', options: { budgetUsd: 5 } }),
}),
);
const { runId } = (await response.json()) as { runId: string };Authentication is deliberately out of scope
The server is host-embedded, and auth belongs to host middleware. Do not expose server.fetch to an untrusted network without your own authentication layer in front of it.
Routes
| Method | Path | Purpose |
|---|---|---|
POST | /runs | Start a run of a registered workflow. Body: { workflow, args?, options? } where options accepts runId, budgetUsd, name, tags, deadlineAt. Answers 201 with { runId, status, workflow } and a Location header. |
GET | /runs/:id | Run status. A run tracked in this process reports the live outcome, including the pending list of open suspensions; any other known run reports its stored metadata. |
GET | /runs/:id/events | SSE event stream with Last-Event-ID reconnection. |
POST | /runs/:id/external/:key | Resolve an awaitExternal suspension or an approval. |
GET | /runs/:id/cost | The run's CostReport. |
The server is a single-process shell: it tracks the runs it started (or resumed) in memory and serves everything else from the engine's stores (engine.stores), which is why it takes no store parameter of its own.
Server-sent events
Each SSE frame carries id: (the event's per-run telemetry seq), event: (the WorkflowEvent type), and data: (the full event JSON). Reconnect with the standard Last-Event-ID header and the server replays from its buffer strictly after that seq when it can find it, or the whole buffer otherwise: delivery is at-least-once, so deduplicate on the id. Events are process-local telemetry, never run truth; a run known to the store but not live in this process answers with an empty stream that closes immediately. See Observability for the event catalog.
curl -N -H 'Last-Event-ID: 42' http://localhost:8787/runs/$RUN_ID/eventsResolving approvals and external input
POST /runs/:id/external/:key is the HTTP form of RunHandle.resolveExternal. The key of an awaitExternal suspension is the key the workflow chose (editor-signoff above), and its value must validate against the schema pinned at suspension time, when one was set. An approval suspension synthesizes its key as approval:<seq> and resolves with { "decision": "allow" | "deny", "reason"?: string }. Both appear in the pending list of the run status.
curl -X POST http://localhost:8787/runs/$RUN_ID/external/editor-signoff \
-d '{ "approved": true }'Two paths serve the request:
- Live in this process: the resolution goes through the run handle. If the run had already settled
suspendedand the resolution applied, the server resumes it in place, re-binding the registry workflow and the original arguments it retained; the response reportsresumed: true. - Not live here: the server appends the resolution directly to the journal (under a lease when the store is leasable) and leaves the resume to a queue worker or a later
rulvar resume. Payload validation still runs before the append, so an invalid resolution fails the request instead of poisoning the journal.
A resolution against an already-closed suspension is never an error that damages anything: the first closing entry wins, and the response reports applied: false with the superseding entry.
Cost, errors, and retention
GET /runs/:id/cost returns the exact in-process CostReport for a run that settled here (per-phase and per-agent-type attribution exists only in process). For any other run it folds the journal and prices usage through the optional priceUsd callback of createServer; without one, those usages surface in the report's unpriced list, never as a silent zero.
Typed engine errors map onto status codes with a { error: WireError } body: configuration and invalid-resolution errors answer 400, a held lease or a journal outside the compatibility window answers 409, anything else 500.
Retention is opt-in: pass retention: (meta) => boolean and the server evaluates it when a tracked run settles terminally, applying engine.deleteRun (transcripts first, then the journal) on a true verdict. Absent the option, everything persists indefinitely.
The queue worker
createWorker runs suspended and interrupted runs in the background, safely across processes. It demands a store with the lease capability; handing it a plain JournalStore is a typed ConfigError at construction, never a silent split-brain. It also verifies the store is the same instance the engine writes (engine.stores.journal), because a fencing epoch protecting a store nobody appends to would protect nothing.
import { createEngine } from '@rulvar/core';
import { anthropic } from '@rulvar/anthropic';
import { SqliteStore } from '@rulvar/store-sqlite';
import { createWorker } from '@rulvar/cli';
import { workflows } from './workflows.js';
const store = new SqliteStore({ path: '/var/lib/acme/runs.db' });
const engine = createEngine({
adapters: [anthropic()],
stores: { journal: store },
defaults: { workflows }, // the worker resolves workflows through this registry
});
const worker = createWorker(engine, {
store,
concurrency: 1,
onError: (runId, error) => console.error(runId, error),
});
worker.start();
process.on('SIGTERM', () => {
void worker.stop();
});Leases and the fencing epoch
Every sweep lists run metadata and considers runs whose status is running (a crashed or currently owned run) or suspended. For each candidate the worker acquires a lease; a LeaseHeldError means another worker owns it, and at-least-once semantics make skipping safe. The resume itself passes the lease through ResumeOptions.lease, so every journal append of the resumed run carries the fencing epoch: a stale worker's writes are rejected by the store and never become visible, whether or not that worker noticed it lost the lease. Split-brain is excluded by construction, not by timing.
The lease is renewed at a third of its ttl (default ttl 60000 ms, exposed as DEFAULT_WORKER_TTL_MS; ttlMs must match the store's configured ttl). A failed renew cancels the run promptly instead of burning live calls whose appends would be rejected anyway.
Two more checks keep the loop honest:
- At acquire, the journal's hashVersion is checked against the engine's compatibility window, strictly before any append: an older library never writes into a newer journal. Runs failing this (or workflow binding) are poisoned for this worker and reported through
onError; they need the host, not a retry loop. See Journal compatibility. - A run that settles
suspendedis remembered with its journal length and is not re-leased until the journal grows, which is exactly what an offline resolution (from the server's external endpoint, for example) does.
Queue semantics are honestly at-least-once, with deduplication provided by the journal's two-phase entries: re-leasing a settled or unchanged run replays to the same outcome with zero live calls, which is the never-pay-twice invariant doing its job. Workflows resolve through the engine's defaults.workflows registry plus persisted compiled-workflow sources, never through a worker parameter; original run arguments are re-supplied per run through the optional argsFor(meta) callback.
The returned handle is small: start() begins sweeping on the poll cadence (default 1000 ms), sweep() performs one deterministic pass and returns the number of runs picked up (useful in tests and cron-shaped hosts), stop() cancels in-flight runs and releases held leases, and active() lists the runIds currently held. Retention mirrors the server: an opt-in retention predicate evaluated during sweeps over settled runs, applied under a briefly held lease.
No cross-process rate limiter
There is no distributed rate limiter in this version: per-provider concurrency caps live in each engine, so each worker process throttles independently. Scaling out concurrency defaults to 1 leased run per worker process, and hosts scale by adding processes, which the fencing epoch makes safe. Divide your provider quota across workers via concurrency.perProvider per process, or front the providers with an external gateway.
A typical multi-process deployment composes the shells over one leasable store:
The OTel exporter
toOtel projects one settled run's event stream onto an OpenTelemetry tracer. Events are consumed in seq order: span-opening events start spans, their matching closers end them, and payload-only events attach as span events on the innermost open span, following the run, phase, agent, tool, child span hierarchy. The function returns the number of spans created.
import { trace } from '@opentelemetry/api';
import { toOtel } from '@rulvar/cli';
const handle = engine.run(releaseNotes, undefined);
const spanCount = await toOtel(
{ runId: handle.runId, events: handle.events, result: handle.result },
trace.getTracer('rulvar'),
);Pass contextApi and setSpan (the context API and trace.setSpan from @opentelemetry/api) to get real parent-child span nesting; without them, spans come out flat but fully attributed. The exporter needs only the tiny structural TracerLike surface, so it works with any SDK setup and stays out of your dependency tree until you opt in.
Deployment notes
| Shell | Process model | Notes |
|---|---|---|
rulvar CLI | A project-local tool, one process per invocation | Journal and config travel with the project directory: .rulvar for the JSONL journal, rulvar.config.mjs for engine assembly. Anyone with the directory can inspect and resume. Best for development loops and operator resolution of suspended runs. |
| HTTP server | Embedded in your existing service | Mount server.fetch behind your auth middleware and reverse proxy. Use a durable store: the default InMemoryStore disables resume and is only fit for demos. Single process by design; pair it with workers so resolutions posted for non-live runs actually resume. |
| Queue worker | One process per concurrency slot, scaled horizontally | Run one worker per unit of provider quota under systemd or a container orchestrator; every worker builds its own engine over the same LeasableStore (for SqliteStore, the same database file or volume). Keep ttlMs equal to the store's lease ttl, and wire onError into your alerting: poisoned runs need a human. |
Next steps
- Durability: what resume replays, reruns, and skips.
- Stores: the lease contract workers rely on, and how to pick a store.
- Observability: the event stream the TUI, SSE, and OTel exporter all consume.
- Planner: the mode behind
rulvar plan. - API reference for @rulvar/cli: every exported symbol.