Budget-capped, tracked, killable multi-agent jobs. A workflow run wraps one
dedicated agent session — Claude driving the harness's built-in Workflow
tool, or Codex driving Ultracode — in a hard dollar budget enforced from
Nerve's own usage metering, with run-scoped termination and a durable journal
directory.
Use a run whenever an agent should burn real money unattended: large fan-out research, dataset audits, adversarial review loops. The run is the unit you budget, watch, and kill.
Starting a run creates:
- A
workflow_runsrow (idwfr-<8 hex>), statuspending. - A journal directory
<workflows.runs_dir>/<run-id>/(mode0700). - When a concurrency slot frees, a dedicated session
workflow:<run-id>with source"workflow"on the backend the engine kind selects. The run's whole life is typically one long agentic turn in that session.
The spec prompt is sent wrapped in run framing: the budget and metering
cadence, the orchestration hint (Workflow tool / Ultracode), a "you run
autonomously — interactive questions are auto-denied" note, and an instruction
to end with a concise result summary (recorded as the run's result).
Spec fields:
| Field | Required | Description |
|---|---|---|
prompt |
yes | Task instructions, written for an autonomous orchestrating agent |
budget_usd |
yes* | Hard dollar cap (finite, > 0). *Pass 0 to explicitly request an unbudgeted run — honored only with workflows.allow_unbudgeted: true. NaN/Infinity are rejected. A budgeted codex-ultracode run additionally requires its model to have a codex.pricing entry (an unpriceable model would meter $0 and never enforce) |
title |
no | Short human-readable label |
model |
no | Model override (default: the backend's configured model — agent.model / codex.model) |
effort |
no | Reasoning effort override (low/medium/high/xhigh/max) |
cwd |
no | Working directory for the run's session; must exist (default: workspace) |
Budget containment: a run's own session cannot call workflow_run_start
or workflow_run_kill — nested runs would spend outside the parent's cap
(and kills don't cascade). Use the in-run orchestration primitives
(Workflow tool / Ultracode) for parallelism instead.
| Engine | Backend | Orchestrates via |
|---|---|---|
claude-workflow |
claude | The harness's built-in Workflow tool — parallel sub-agents inside the run's own Claude session |
codex-ultracode |
codex | Ultracode multi-agent runs — the managed plugin in the isolated Codex home |
Pick claude-workflow by default: no extra setup beyond a working Claude
backend, Anthropic models, and the most accurate mid-turn spend signal (live
Workflow snapshot tokens).
Pick codex-ultracode for GPT-family models or when you specifically want
Ultracode's worker orchestration. It needs a working Codex backend
(nerve codex doctor green) with codex.ultracode.enabled: true — the run's
session is a normal Codex session whose prompt instructs Ultracode
orchestration, so without the plugin the agent just works single-handed.
Ultracode's own caps (codex.ultracode.max_concurrency, token budget,
max_agents) still bound the inner workers. With ChatGPT subscription auth,
spend is metered from API-equivalent estimates rather than billed dollars
(see below).
pending ──▶ running ──▶ done
│ ├──────▶ failed
│ ├──────▶ killed
│ └──────▶ budget_exhausted
└──────────────────▶ killed (killed before dispatch)
- At most
workflows.max_concurrent_runsexecute at once; excess runs queue inpending(costing nothing) and dispatch oldest-first as slots free. donestores the final message (last 4 KB in the DB row, full text inresult.md) and notifies with spend vs budget.failedstores the error and notifies at high priority.- Stopping the run's session directly (web Stop button,
stop_session) lands the run inkilledwith reason "session stopped". Killing via the run API is idempotent on already-terminal runs. - Every status or spend change broadcasts a
workflow_run_updateevent on the global WebSocket channel (see api.md).
Restarts. Runs do not survive a daemon restart. On startup a recovery
pass marks any orphaned pending/running run failed ("interrupted by
nerve restart"), journals the interruption, and sends a high-priority
notification. Nothing resumes on its own — restart runs explicitly if still
needed. This is deliberate: a paid job can never burn unmetered.
Budgets are metered dollars, not advisory token counts. Every
workflows.poll_interval_seconds the monitor recomputes each running run's
spend as:
spent = recorded + live
- recorded — the run session's per-turn costs from usage accounting:
billed
cost_usdwhen present, falling back to the API-equivalentestimated_cost_usdfor subscription-auth Codex turns (ChatGPT auth bills no dollars; the estimate column carries the real spend). - live — best-effort estimate for the in-flight turn:
- claude-workflow: sub-agent output tokens summed from the session's live
Workflowsnapshots, priced as output tokens at the run's model. - codex-ultracode: Ultracode run journals under the Codex home created
since the run started, priced per worker. Attribution is heuristic
(start time +
cwdmatch) — concurrent Codex runs sharing onecwdmay cross-attribute mid-turn; the turn-end fold into recorded cost is exact.
- claude-workflow: sub-agent output tokens summed from the session's live
Meter accuracy. Recorded cost lands only at turn end, and a run's whole life can be one long turn — the live estimates bridge that gap but don't see everything (e.g. the orchestrator's own tokens on the Claude side). The practical overshoot bound is roughly one turn's cost beyond what the live estimates catch. Treat a budget as a cap with real teeth, not an accounting guarantee to the cent.
Enforcement:
- At
warn_fraction(default 80%) — a one-time high-priority notification plus abudget_warningjournal event;warned_atis stamped so it never repeats. - At 100% — the run flips to
budget_exhaustedand is terminated: gracefulstop_sessioninterrupt, up tokill_grace_secondsfor the turn to wind down, then a force client discard that kills the session's own CLI subprocess (Claude) or app-server process group (Codex). Termination is scoped strictly to the run's own session/process group — never a pattern-matchedpkill. Other runs and sessions are untouched.
The prompt framing tells the run its budget and the metering cadence, so a well-built spec paces fan-out and checkpoints partial results before the axe falls (see the template below).
<workflows.runs_dir>/<run-id>/
run.json # current run row, atomically rewritten on every change
events.ndjson # append-only lifecycle log: created, started, budget_warning,
# done/failed/killed/budget_exhausted, interrupted, enforced_stop
result.md # full final message (written on 'done' only)
The DB row references its session with ON DELETE SET NULL, so run history
survives session deletion — the journal directory keeps the details.
| Tool | Arguments |
|---|---|
workflow_run_start |
engine, prompt, budget_usd (required); title, model, effort, cwd |
workflow_run_status |
run_id |
workflow_run_kill |
run_id, reason |
workflow_run_list |
status (active, exact status, or empty), limit |
workflow_run_start returns the run id immediately; execution happens in the
background. Typical agent usage:
workflow_run_start(engine="claude-workflow", title="Sample batch audit",
budget_usd=12, prompt="Audit samples/batch-07/ for ...")
→ Workflow run wfr-3fa2b81c created (pending). Engine: claude-workflow,
budget: $12.00. Journal: ~/.nerve/workflow-runs/wfr-3fa2b81c. ...
# later — poll instead of waiting; the run notifies on finish/warn/kill
workflow_run_status(run_id="wfr-3fa2b81c")
Start/kill are engine-owned operations and are rejected for external MCP
client sessions (Codex CLI, Claude Code, etc. connected via the MCP
endpoint) — external clients can inspect via workflow_run_list/status
only.
| Endpoint | Description |
|---|---|
GET /api/workflow-runs?status=&limit=&offset= |
List runs, newest first (status: active, exact, or empty) |
POST /api/workflow-runs |
Start a run: {engine, prompt, budget_usd, title?, model?, effort?, cwd?} |
GET /api/workflow-runs/{id} |
Run detail (spec.prompt trimmed to 500 chars on the wire) |
POST /api/workflow-runs/{id}/kill |
Terminate: {reason?}; idempotent on terminal runs |
GET /api/workflow-runs/{id}/journal |
Journal contents: run snapshot, events, result |
See api.md for request/response shapes.
nerve workflow list # runs with status + spend vs budget
nerve workflow status <run-id> # one run in detail
nerve workflow start ... # start a run (engine, prompt, budget)
nerve workflow kill <run-id> # terminate a runSee nerve workflow --help for flags.
A cron job may declare a workflow block instead of prompt — each
trigger starts a workflow run:
# <workspace>/config/cron/jobs.yaml
jobs:
- id: nightly-sample-audit
schedule: "0 3 * * *"
workflow:
engine: claude-workflow
prompt: "Audit yesterday's ingest batch under samples/ for ..."
budget_usd: 10
title: Nightly sample audit # optional — model/effort/cwd tooThe block mirrors the REST/MCP spec: engine, prompt, budget_usd
(required, positive, finite), title (defaults to the job id), model,
effort, cwd (optional). workflow takes precedence over
prompt/prompt_file. The job is fire-and-forget: the cron log records
only the launch — no cron session is created, and the run sends its own
budget/completion/failure notifications. General job fields (schedule,
gates, enabled) are unchanged — see cron.md.
Two fields deserve care on workflow jobs:
lock: trueis recommended. For workflow jobs it extends beyond the launch: a trigger is skipped (logged asskipped: … still active) while a previous run from the same job is still pending/running. Without it, a schedule shorter than the run duration stacks concurrent full-budget runs.catchup: falseis recommended. The default (true) fires an overdue job once at daemon startup — for a workflow job that launches a paid multi-agent run right after every restart that crossed a scheduled slot. Note this is the one exception to "nothing resumes on its own after a restart": the old run is still marked failed; catchup starts a new one.
A robust pattern for quality-critical jobs: the inner workflow alternates generate and adversarial verify cycles and stops only when a verify pass produces no new findings and no disproofs. Budget is sliced per cycle so a budget stop degrades to a smaller-but-verified report instead of nothing.
workflow_run_start(
engine="claude-workflow",
title="Sample batch quality audit",
budget_usd=12,
cwd="/data/audits",
prompt="""
Audit the dataset sample under samples/batch-07/ for quality issues:
mislabeled records, duplicates, schema drift, impossible values.
Run generate/verify cycles until the findings converge.
Cycle N:
1. GENERATE — fan out one worker per file group. Each worker writes
candidate findings to findings/cycle-N/<group>.md as
(id, issue, severity, evidence: file + record ids).
2. VERIFY — fan out fresh adversarial reviewers, one per file group,
assigned so no reviewer checks findings they generated. Their brief:
try to DISPROVE every candidate finding against the raw data, and
hunt for issues the generators missed. Verdict per finding:
confirmed / disproved (with reason) / new.
3. RECONCILE — merge verdicts into findings/confirmed.md. If this
verify pass produced zero new findings and zero disproofs, STOP:
agreement reached. Otherwise start cycle N+1 from the corrected
findings.
BUDGET: hard cap $12, metered by Nerve. Plan roughly $3 per full cycle;
before starting a cycle, if less than one cycle's cost remains, skip
straight to the final report with what is already confirmed. Checkpoint
the findings/ files at the end of every cycle so a budget stop loses at
most one cycle of work.
Final message: confirmed findings as a severity-sorted table with
evidence pointers, plus how many cycles convergence took.
""")
Why this shape works:
- Fresh verifiers per cycle stop the generator from grading its own work.
- "No new findings and no disproofs" is a concrete convergence test, not vibes — the loop terminates itself.
- Per-cycle budget slices + end-of-cycle checkpoints mean the 100% enforcement
(which does not wait for in-flight work beyond the grace window) still
leaves a usable, verified partial result in the journal's
cwdartifacts.
- Each running workflow occupies one
agent.max_concurrentslot for the entire duration of its (typically very long) turn. Keepworkflows.max_concurrent_runs(default 2) well belowagent.max_concurrent, or interactive chats will queue behind unattended batch jobs. - Inside a run, engine limits still apply — Ultracode workers are capped by
codex.ultracode.max_concurrency/max_agents. - Excess runs queue in
pendingand are promoted oldest-first; queued runs cost nothing.
workflows:
enabled: true # master switch (service + tools + API)
runs_dir: ~/.nerve/workflow-runs # journal root
poll_interval_seconds: 60 # budget monitor cadence
warn_fraction: 0.8 # one-time warning threshold
kill_grace_seconds: 30 # graceful stop → grace → force kill
max_concurrent_runs: 2 # excess runs queue as 'pending'
allow_unbudgeted: false # require budget_usd on every runSee config.md for the full reference.