A workflow is a JavaScript script that fans work out to worker threads and
keeps its place on disk. It exists because fan_out starts threads but the
plan around them (review each file, then verify the findings, with a barrier
between) lives only in the orchestrating agent's context, and is lost to a
restart or a compaction. A workflow run holds that plan in a script and in a
folder instead (#3394).
An agent starts a run with run_workflow, reads it with workflow_status and
ends it with stop_workflow. The desktop app shows it as a card in the chat, a
row above the composer with Stop, and a list of its worker threads
(docs/desktop.md, Workflow runs), and tells the starting thread when it
ends.
The script is the body of an async function: it may await, and what it
returns (any JSON value) is the run's result.
phase("review");
const reviews = await parallel(
args.files.map((file) => () =>
agent(`Review ${file} for bugs. Be specific.`, {
schema: {
type: "object",
properties: { issues: { type: "array", items: { type: "string" } } },
required: ["issues"],
},
}),
),
);
phase("verify");
const verdict = await agent(
"Check these findings and drop the ones that are wrong:\n" + JSON.stringify(reviews),
{ harness: "codex" },
);
log("verified", reviews.length, "files");
return { reviews, verdict };| Global | What it does |
|---|---|
agent(prompt, options?) |
Starts a normal delegate thread with prompt and resolves to its reply. Options: schema (a JSON Schema: the worker is asked for JSON, and the reply is parsed and checked against it), harness, model, effort, title. Rejects when the worker fails or its reply breaks the schema, so a script can try/catch it. |
parallel(items) |
Promise.all over an array of functions (called in order) or promises. Results come back in the array's order. The run's concurrency limit, not parallel, decides how many workers run at once. |
phase(title) |
Names the step the next calls belong to. Phases and the workers in them are what the run reports. |
log(...parts) |
Adds a line to the run's log (at most 200 are kept). |
args |
The JSON the run was started with. |
Without a schema the resolved value is the worker's reply text. A worker's
reply reaches the script cut at 8 KiB, like a wait_session answer, so ask for
short structured results and keep long output in files.
schema is checked for type, enum, required, properties, items and
additionalProperties: false. Other keywords still go to the worker as part of
the schema it is asked to follow, and are not checked.
Anything else is a plain error: an unknown option, an empty prompt, or a call over the budget.
The script runs in QuickJS (the rquickjs crate) with nothing added that
reaches outside it:
- No filesystem or shell. There is no
require, noimportof anything, nostdoros, noprocess. Dynamicimport()fails: the runtime has no module loader. - No network. No
fetch,XMLHttpRequest,WebSocket. - No clock and no randomness. The context is built without
Dateandperformance, there are no timers (setTimeoutand friends), andMath.randomis deleted. This is on purpose, not only caution: a script that cannot read the time or draw a number does the same thing every time, which is what lets a restarted run replay its calls and arrive at the same next call. If a script needs a value that changes, the caller passes it inargs. - Bounded. The runtime has a 64 MiB memory cap and a 512 KiB stack, and
stops a script that is still computing when the run's timeout passes (a
while (true) {}ends with "the script ran past its time limit").
The only way out of the sandbox is agent, phase and log. A worker is a
thread like any other, with its own sandbox, approvals and access; the
workflow does not widen what the user allowed.
An embeddable engine was needed, and none was in the lockfile. QuickJS through
rquickjs was chosen over the pure-Rust boa_engine because it can be built
with exactly the intrinsics a script is allowed (leaving out Date and
Performance is a build choice, not a global to delete and hope), it has a
memory limit, a stack limit and an interrupt handler for a runaway loop, and
rquickjs supports async host functions natively, which is what agent()
is. It compiles from C like the bundled SQLite already does, and adds one
crate set (rquickjs, rquickjs-core, rquickjs-sys) with no modules and
no loader.
Both are tools of the rustcode MCP server, answered by the engine's hub, so
they reach any harness that has the orchestration tools (version 2 and up) and
need no app to be open. The built-in RustCode harness does not have them yet.
The workflow tools are three: run_workflow, workflow_status and
stop_workflow.
run_workflow takes script (inline, at most 64 KiB) or name (the file
.rustcode/workflows/<name>.js in the thread's project: letters, digits -,
_ and .), and optionally args, concurrency, call_budget and
timeout_minutes. It answers at once with { run_id, status: "running", limits }
and the run goes on in the background.
workflow_status takes a run_id and answers the run in full, or with no
run_id lists this thread's runs. wait_ms waits up to that long for the run
to end and answers sooner when it does (at most 25 minutes). The answer holds:
status:running,done,failed,timed_outorcancelled, witherrorwhen it did not finish;phases: each{ title, started_at, workers, running, done, failed };workers: each call's{ seq, phase, state, prompt, harness, model, session_id, link_id, started_at, finished_at, error }, so a worker's thread can be opened, read or interrupted like any delegate;calls(counts),limits,elapsed_ms,resumes, the last 20logs;result, the script's return value. One over 96 KiB is not inlined: the answer saysresult_truncatedand namesresult_file.
stop_workflow takes a run_id and ends the run: the script stops, no
further agent() call starts, and the workers still running are interrupted
(the same call interrupt_session makes). The run becomes cancelled and keeps
the calls it had finished. It answers with the run once it has wound down, or
with stopping: true when that takes more than 15 seconds. A run that already
ended is answered as it is. A run a restart left running that nothing drives is
ended on its record, so it is not resumed; its workers, if any are still alive,
are threads the user can stop like any other.
Workers are started with start_session as the thread that started the run, so
they nest under it, count against its delegate slots (delegates-per-thread,
delegates-total), and use the same approvals and access rules as a delegate
the agent starts by hand. When every slot is taken, the run waits and tries
again rather than failing.
Everything is under the starting thread's folder,
<config>/threads/<yyyy>/<mm>/<dd>/<thread id>/workflows/<run id>/:
| File | What it holds |
|---|---|
run.json |
The record: status, phases, log lines, limits, result, error. Replaced atomically on every change. |
script.js |
The script as it was started, so a restart runs that one even if the named file changed. |
calls.jsonl |
One line per state change of each agent() call. The last line of a key wins; a torn last line is skipped. |
result.json |
The final result, written when the run is done. |
A call's key is a hash of its prompt and options plus the number of earlier calls with the same hash. Two identical calls are two keys, and a call is found again whatever order other calls settled in. When a run is picked up again:
- a call whose key finished is answered from the file with no thread started;
- a call whose worker was started but not seen to finish waits on that worker
again (the start carries a stable
client_request_id, so asking again finds the same thread rather than starting a second one); - a call that failed, or has no record, runs as new. A failed call is not cached: the script may have caught the error, and a retry is more useful than replaying the failure;
- a call whose prompt or options changed has a different key and is a miss, so editing a script's tail and resuming keeps the work before it.
The script is deterministic, so the replayed calls are made in the order they were the first time and the run reaches its first unfinished call. If a script branches on which of several calls settled first, a resumed run can order later calls differently; keying by hash and occurrence (not by position) keeps those calls matched to their answers anyway.
When a run resumes. The engine resumes the runs a thread left running
when that thread registers with the engine's orchestrator hub (the thread is
opened or started on a harness with the orchestration tools). Until then
workflow_status reports the run as running with a note that it is not being
driven. Replay needs no worker to be alive: the finished calls are on disk.
Three settings, read when a run starts (server settings, settings.json, with
the usual defaults when unset):
| Setting | Default | Most | What it limits |
|---|---|---|---|
workflow-concurrency |
3 | 8 | Workers running at once. More calls wait for a permit. |
workflow-call-budget |
40 | 500 | Distinct agent() calls in a run's whole life. A replayed call is not charged again. The call that goes over rejects in the script. |
workflow-timeout-minutes |
60 | 1440 | How long a run may take, counted from each (re)start. When it passes, the run is timed_out, its waits stop and its unfinished workers are interrupted. |
run_workflow's concurrency, call_budget and timeout_minutes can lower a
run's limits, never raise them past the settings.
The desktop app reads a run from the same record through four ACP methods
(acp/workflows.rs; the app side is state/workflows.ts):
| Method | What it does |
|---|---|
_rustcode/workflows/list |
{sessionId}: that thread's newest 20 runs. Asking, with or without a thread, subscribes the connection to changes. Each run is workflow_status's answer without result (it can be large), with thread, has_result, woken and driven (whether this engine is driving it). |
_rustcode/workflows/changed |
A notification, {sessionId, run}, each time a run's record moves (a phase, a worker starting or ending, a log line, the end). The engine gathers changes for 300 ms and sends one summary per run. |
_rustcode/workflows/stop |
{sessionId, runId}: stop_workflow for the Stop button. |
_rustcode/workflows/wake |
{sessionId, runId}: claim the wake of a run that ended. Answers {claimed}, true once. |
Waking the starting thread. When a run ends the app tells the thread that
started it, once, with a short note marked as the app's own (Workflow review: done · 5 workers · 4m 12s, then how to read the result). It goes after the
turn the thread is in, like a delegate's finish. The claim is kept in the run
record (woken), so a second window or a reload does not repeat it. A thread
whose agent already read the end (workflow_status on a settled run, or its
own stop_workflow) is not woken for it. The app announces only a run that
ended within the hour before it first saw it, so opening an old thread does not
wake it for a run from last week; the agent can always read the run with
workflow_status. With no app open nothing wakes the thread: the agent reads
the run with workflow_status and wait_ms.
Limits in Settings. A Workflows group under Orchestration in Settings has the three settings in Limits, per machine like the delegate limits. A run reads them when it starts.
- The built-in RustCode harness and outside agents (
mcp-connect) as callers. - A button to resume a run a restart left undriven without opening its thread.
- Retention of old runs.