Skip to content

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

bash
pnpm add @rulvar/cli

The 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:

text
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>
CommandPurpose
runStart a workflow from a file path or a registered name, drive it to a settled outcome, exit with a code reflecting it.
resumeRebind a journal to its workflow and continue; fully replayed prefixes cost zero live calls.
runs lsList run metadata (id, status, name, tags, last update) from the store.
inspectPrint one run's journal-derived state: entries, suspensions, spend.
planAsk the planner to write a workflow script for a goal, then run it in the worker sandbox.
kbMaintain the model knowledge claim store.

Flag semantics are uniform:

  • --store PATH selects the JsonlFileStore directory (default .rulvar). Every command that opens a journal store selects it the same way. An explicit stores entry in your config's engineOptions wins over the flag.
  • --args JSON supplies workflow arguments. It appears on resume too 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 N sets the run's dollar ceiling, immutable after start (see Budgets).
  • --profile NAME applies 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.

ts
// 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 list prints the claims with full provenance.
  • rulvar kb sweep runs the falsification matrix declared in the kbSweep section 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 inbox is 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.

ts
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

MethodPathPurpose
POST/runsStart 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/:idRun 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/eventsSSE event stream with Last-Event-ID reconnection.
POST/runs/:id/external/:keyResolve an awaitExternal suspension or an approval.
GET/runs/:id/costThe 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.

bash
curl -N -H 'Last-Event-ID: 42' http://localhost:8787/runs/$RUN_ID/events

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

bash
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 suspended and the resolution applied, the server resumes it in place, re-binding the registry workflow and the original arguments it retained; the response reports resumed: 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.

ts
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 suspended is 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.

ts
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

ShellProcess modelNotes
rulvar CLIA project-local tool, one process per invocationJournal 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 serverEmbedded in your existing serviceMount 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 workerOne process per concurrency slot, scaled horizontallyRun 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