Skip to content

Machine-written scripts

@rulvar/planner ships the flagship hybrid mode: a planner model writes the whole workflow script once, before anything executes. The draft is linted, self-repaired from machine-readable diagnostics, compiled by compileScript, and executed in a worker_threads sandbox with seeded, journaled globals. You get model-authored control flow with none of the runtime improvisation: by the time a single dollar is spent on execution, the plan is frozen source you can read, diff, and re-run.

Two runs, one journal mechanism

Planning and execution are both ordinary journaled runs. The planning conversation replays for free when you replan the same goal, and the execution run is resumable by construction because the engine persists the compiled source itself. Nothing on this page introduces a second durability model.

Highlights

  • A frozen, reviewable plan. The planner writes against two compact cards, the API card (the sandbox dialect) and the profile card (your registered agent profiles). The output is source code, not hidden state.
  • Self-repair over structured diagnostics. Lint and compile findings are JSON, not prose. They ride a repair prompt back to the planner for up to repairRounds rounds (default 3).
  • A closed dialect, compiled. compileScript validates the script as an async function body over the curated sandbox globals, enforces the import allowlist (default: no imports), and rejects violations with a typed ScriptRejected carrying diagnostics.
  • Deterministic execution. WorkerSandboxRunner runs the compiled script in a worker with seeded, journaled shims for time and randomness, and JSON-only RPC to the host engine.
  • A type-level safety split. Workflow values are closures and run in process only; CompiledWorkflow values are pure data and are the only form the sandbox accepts. Feeding a closure to the sandbox is a compile-time error.
  • Journaled and resumable end to end. Every agent call, step, and random value the script produces is a journal entry; engine.resume(runId) rehydrates the persisted, hash-pinned source and replays completed work.

Quick start

bash
pnpm add @rulvar/core @rulvar/planner @rulvar/anthropic
ts
import { createEngine } from "@rulvar/core";
import { anthropic } from "@rulvar/anthropic";
import { WorkerSandboxRunner, plan, runPlanned } from "@rulvar/planner";

const engine = createEngine({
  adapters: [anthropic()],
  defaults: {
    routing: {
      plan: "anthropic:claude-opus-4-8",   // writes the script
      loop: "anthropic:claude-sonnet-5",   // runs the spawned agents
    },
    profiles: {
      researcher: { description: "Finds and cites primary sources." },
      writer: { description: "Turns research notes into prose." },
    },
  },
  runners: { sandbox: new WorkerSandboxRunner() },
});

const planned = await plan(engine, "Compare three storage engines and draft a recommendation");
console.log(planned.source); // the frozen script: read it before you pay for execution
console.log(planned.lint);   // advisories on the accepted draft; never errors

const handle = engine.run(planned.workflow, {}, { budgetUsd: 10 });
const outcome = await handle.result;

// Or compose plan-then-run in one call:
const direct = await runPlanned(engine, "Compare three storage engines and draft a recommendation");

Registering a sandbox runner is not optional decoration: running or resuming a CompiledWorkflow on an engine without runners.sandbox is a typed ConfigError before any journal entry is written.

The pipeline

plan() asks a planner model (invocation role plan; override with PlanOptions.model) to write a script against the rendered cards. The reply's first fenced code block is extracted deterministically (or the whole reply when there is no fence), then linted and compiled. Error diagnostics are serialized back into a repair prompt; the loop accepts the first draft with zero errors, and after repairRounds repair rounds it gives up with a typed ScriptRejected carrying the last diagnostics, so a planner that cannot produce a valid script terminates loudly instead of looping.

The planning conversation is itself an ordinary journaled run whose id derives deterministically from the goal: planRunIdOf(goal) returns plan- plus a hash prefix, so one goal maps to one planning journal on your store. Calling plan() again with the same goal resumes that journal: already-paid drafts and repair turns replay for free under the never-pay-twice invariant, and only genuinely new turns cost money.

What the planner sees

Two cards render into the planner prompt:

  • apiCard() teaches the sandbox dialect: the exact global set, the option shapes, and the dialect restrictions below. It is pure and byte-stable.
  • engine.profileCard(names?) renders your registered agent profiles (descriptions, declared tools, ladders, limits). PlanOptions.profiles filters which profiles are advertised. The same card feeds the dynamic orchestrator's spawn_agent tool, so both machine modes speak one agent vocabulary; see Agents.

A planner-written script is an async function body over bare globals. A representative accepted draft:

ts
const sources = await parallel([
  () => agent("Find primary sources on LSM tree compaction", { agentType: "researcher" }),
  () => agent("Find primary sources on B-tree write amplification", { agentType: "researcher" }),
]);

const draft = await agent(
  "Write a comparison from these notes:\n" + sources.join("\n"),
  { agentType: "writer", onError: "null" },
);

if (draft === null) {
  log("warn", "draft agent failed; returning raw notes");
  return { notes: sources };
}
return { report: draft };

The curated global set is exactly agent, parallel, pipeline, step, phase, log, budget, workflow, awaitExternal, now, random, and uuid (exported as SANDBOX_GLOBALS). These are the Ctx primitives bound as bare globals; the full semantics of each live in Workflows.

The dialect closes every hole that would smuggle nondeterminism or unjournalable values across the boundary:

SurfaceIn the sandbox dialect
schemaJSON Schema literal only; no schema-library values
toolsNames of registered tool profiles only
modelA string
onError'throw' or 'null' only
OptionsNo functions anywhere in options; policies as declarative rule tables; ladders as JSON
workflowRegistered-name string form only: workflow('name', args)
budgetAsync reads: await budget.spent(), await budget.remaining()
Time and randomnessnow(), random(key?), uuid(); the bare platform APIs are shimmed or absent

Machine scripts always run under the lenient error policy: onError defaults to 'null', a failed spawn yields null instead of unwinding the whole script, and every suppressed failure still surfaces as a DroppedItem in the run outcome's dropped list. Lenient mode suppresses the exception, never the evidence.

compileScript and the import allowlist

compileScript(source, options?) validates planner-generated source and returns a CompiledWorkflow. Validation covers syntax (the source must compile as an async function body over the sandbox globals; its return value is the workflow result) and module access:

  • allowImports defaults to []: no imports at all.
  • Static import syntax, import.meta, require(), export declarations, and dynamic imports with non-literal specifiers are always rejected.
  • A dynamic import('specifier') with a string literal passes only when the specifier is listed in allowImports.

Any violation throws a typed ScriptRejected; scriptDiagnosticsOf(error) returns the machine-readable findings:

ts
import { compileScript, scriptDiagnosticsOf } from "@rulvar/planner";
import { ScriptRejected } from "@rulvar/core";

try {
  const wf = compileScript(draftSource);
} catch (error) {
  if (error instanceof ScriptRejected) {
    for (const d of scriptDiagnosticsOf(error)) {
      console.error(`${d.ruleId} ${d.line ?? "?"}:${d.column ?? "?"} ${d.message}`);
    }
  }
}

Diagnostic rule ids are syntax, empty-source, no-import, no-require, no-export, and disallowed-import. The deeper dialect rules (schema literals only, no functions in options, tools by profile name) are enforced where they actually bind: at the sandbox boundary at runtime, where only journal-compatible JSON crosses, and advisorily by the lint pass in the repair loop.

The self-repair loop and eslint-plugin-rulvar

The repair loop's teeth come from eslint-plugin-rulvar, the determinism lint for workflow modules. lintScript(source) wraps the script body in an async function for parsing (top-level return and await are legal in the dialect), runs the workflows preset plus compileScript, and shifts reported lines back so they index into the body source. Its findings and the compile diagnostics share one PlanDiagnostic shape, so the repair prompt is uniform.

RuleSeverityCatches
rulvar/no-bare-dateerrorDate.now and new Date instead of the journaled now()
rulvar/no-bare-randomerrorMath.random instead of the journaled random()
rulvar/no-fetcherrorAmbient network I/O outside agent tools
rulvar/no-process-enverrorAmbient host state via process.env
rulvar/no-promise-all-over-ctxerrorPromise.all over ctx calls; parallel journals, schedules, and settles
rulvar/duplicate-identical-callwarningByte-identical agent/workflow calls that would forward-match one journal entry; a deliberate repeat needs a distinguishing key

The loop accepts a draft when no error-severity diagnostics remain; leftover warnings are returned on PlanResult.lint so you can inspect what the planner shipped with. The plugin is a normal ESLint plugin (the eslint-plugin- prefix is an npm requirement; the package is versioned in lockstep with the @rulvar/* set), so you can add eslint-plugin-rulvar as a dev dependency and run the same preset over your human-authored workflow modules:

ts
// eslint.config.js
import { workflowsConfig } from "eslint-plugin-rulvar";

export default [{ files: ["workflows/**/*.ts"], ...workflowsConfig }];

For CI pipelines that want the planner's view of raw ESLint output, toJsonDiagnostics(messages) converts LintMessage[] into the same JSON shape the repair loop consumes.

The worker sandbox

WorkerSandboxRunner executes the compiled script inside a worker_threads worker. Its contract:

  • Curated scope. The globals are exactly the sandbox set above. import, fetch, and process are absent; Date.now and Math.random are replaced by the seeded shims.
  • Seeded, journaled shims. now(), random(), uuid(), and the platform replacements are one seeded stream derived from the runId. now() is a seeded logical clock, not wall clock: two fresh runs with the same runId produce byte-identical journals. Every generated value is mirrored to the host and journaled as an ordinary rand entry, so a resumed worker regenerates identical values and the mirrors forward-match instead of duplicating.
  • JSON-only RPC. Every primitive call travels as JSON-RPC over a dedicated MessagePort to the host engine, validated as journal-compatible JSON at the boundary. Worker and host never exchange non-journalable values. parallel branches, pipeline stages, phase bodies, and step bodies execute inside the worker; only their JSON results cross to the host for journaling.
  • Resource ceilings. Breaching timeoutMs (default 300000) or memoryMb (default 512) terminates the worker; the run completes with outcome error carrying a typed error code.
  • Lifecycle fidelity. The worker reports busy-state transitions, so a sandboxed run suspends on awaitExternal and quiesces exactly like an in-process one: a computing worker keeps the host process alive, a suspended run lets it exit, and the port is closed at the terminal outcome.

The host half of the protocol is createSandboxBridge(ctx, { post }) from @rulvar/core: it serves every proxied primitive against the canonical run ctx, which is why the runner is built entirely from the public core API and why an alternative runner can implement the same ScriptRunner seam.

A determinism boundary, not a security boundary

The sandbox exists to guarantee deterministic replay and to bound the blast radius of a generated script: no ambient time, no ambient randomness, no network, no host process access, JSON-only traffic. It is not a defense against hostile code. Containment of untrusted code belongs to tool executors (subprocess, container) and worktree isolation; see Tools.

Workflow versus CompiledWorkflow

The two workflow forms exist so the type system, not a runtime check, keeps closures out of the sandbox:

Workflow<A, R>CompiledWorkflow
Produced bydefineWorkflowcompileScript
FormClosure value carrying a body functionPure data: name, source string, errorPolicy
Executes inYour process (InProcessRunner)The engine's registered runners.sandbox
Error policy'strict' by defaultAlways 'lenient'
ResumeRe-supply the definition, or register it under defaults.workflowsRehydrated from the persisted source, hash-pinned

WorkerSandboxRunner.execute accepts CompiledWorkflow only. There is no way to hand it a closure, and there is no way to serialize a closure into a CompiledWorkflow, so "accidentally shipped a function into the sandbox" is not a bug class you can write.

Journaled and resumable, end to end

At engine.run the engine persists the compiled source as a transcript blob and records its ref plus a content hash in the run metadata. From then on the run is self-describing:

ts
// Later, in a different process, no workflow argument needed:
const resumed = engine.resume(handle.runId);
const outcome = await resumed.result;

engine.resume(runId) reloads the stored source, verifies byte identity against the recorded hash, and re-executes the script in the sandbox. The dialect validation is not re-run at resume: the hash proves the source is exactly the one validated at run start, and the sandbox boundary enforces the hard rules at runtime regardless. Inside the re-execution, the seeded shims regenerate identical values and every completed agent call, step, and child workflow is served from the journal by scoped forward-matching, so completed work is never paid twice. Supplying a compiled workflow whose source hash differs from the recorded one is a typed ConfigError.

Cross-process resume needs durable stores on both sides: a durable journal store for the entries and a durable transcript store (for example FileTranscriptStore) for the persisted source. The default in-memory stores disable resume with a loud warning; see Durability and Stores.

When to prefer this over the dynamic orchestrator

Both machine modes let a model author control flow; they differ in when the model decides. Prefer machine-written scripts when the goal varies per run but the plan, once written, does not need to change mid-flight:

Machine-written scriptDynamic orchestrator
Control flow decidedOnce, before executionLive, turn by turn
Model spend on control flowOne planning conversation, journaled and replayed on replanningOrchestrator turns for the whole run, bounded by a dedicated cap
AuditabilityFrozen source: read, diff, review, and store itDecision entries and a transcript
AdaptationRe-plan between runs; the planning journal replays the unchanged prefixMid-run replanning, up to typed plan revisions with PlanRunner
Failure surfaceLint plus compile reject bad scripts before executionGuards, admission, and termination accounting bound bad decisions during execution

Concretely:

  • Reach for plan() and the sandbox when you want a model to write the plan and refuse to let it improvise at runtime: recurring jobs with varying inputs, pipelines an operator must be able to review before execution, and anything where a deterministic re-run of the exact same script matters.
  • Reach for the dynamic orchestrator when the next step depends on results that cannot wait for the script to finish: wide fan-out with mid-run replanning, escalation handling, and admission decisions. That machinery costs orchestrator turns and buys adaptability; see Adaptive orchestration.

If the plan never changes mid-run, a script is strictly better: cheaper, easier to audit, and byte-for-byte reproducible. When in doubt, start here and move to the orchestrator only once a real workload shows you plans that must change in flight.

Next steps