Workflows
While sub-agents let the model fan out ad hoc, the workflow tool runs
a deterministic orchestration script against a fleet of sub-agents — the fan-out/fan-in
structure is fixed in code, not re-decided call by call.
Workflow scripts are Ruby (running on an embedded mruby interpreter, compiled to WASM — no system Ruby needed). There is no JavaScript option.
Script primitives
Section titled “Script primitives”| Call | Returns | Purpose |
|---|---|---|
agent(prompt, opts = {}) |
String | Run one sub-agent to completion. opts: model:, tools: (restrict to these), read_only: true, schema: (a JSON Schema string — the reply comes back as a JSON string matching it), isolation: "worktree" |
skill(name, params = {}, opts = {}) |
Hash / Array / scalar | Run a named skill or browser recording. Prefix with "browser:" or "md:" if a recording and a SKILL.md share a name. Unlike agent, results with a schema come back already parsed |
parallel(items) { |it| ... } |
Array | Run the block over every item concurrently; results keep input order |
pipeline(items, *stages) |
Array | Each item flows through every stage independently — no barrier between stages, so item A can be three stages ahead of item B |
log(msg) |
— | A progress line in the run’s log |
phase(title) |
— | A cosmetic named marker in the progress stream (no effect on scheduling) |
args |
Hash / Array / scalar / nil | The args this run was started with (see named workflows below) |
budget_remaining |
Integer | Remaining output-token budget for this run |
JSON.parse / JSON.generate |
— | Available for round-tripping schema: results and args |
# @description Review a diff across three dimensions, in parallelfindings = parallel(["correctness", "security", "perf"]) do |dimension| agent("Review the current diff for #{dimension} issues", read_only: true)endfindings.each { |f| log(f) }Concurrency is cooperative under the hood (mruby Fibers plus a Go-side event loop), but real work
happens on goroutines — parallel/pipeline genuinely overlap sub-agent calls, capped at 8
concurrent agent/skill calls in flight per run regardless of how many items you hand to
parallel.
What the script can and can’t do
Section titled “What the script can and can’t do”The interpreter is IO-free: there is no File, Dir, Time, Process, or shell backticks —
touching any of them raises before the script produces a result. Anything that reaches the outside
world (files, a git/gh command, the current date) goes through an agent(...) call, which delegates
to a real sub-agent with real tools; to write a report or state file, tell an agent to write it.
Pure in-process Ruby is available: Array/Hash/String/Integer, JSON.parse/JSON.generate,
and Regexp — literal /.../ patterns, =~, and String#match/scan/gsub/sub/split.
The regex engine is RE2 (linear-time, so a model-written pattern can’t hang the run), which brings a
few differences from Ruby’s default engine:
- No backreferences or lookaround inside the pattern (an unsupported construct raises rather than
matching wrongly). Replacement backreferences —
\1,\&,\k<name>— ingsub/subdo work. - Named groups use
(?P<name>). - To make
.cross newlines use the/msuffix, not an inline(?m)(inline(?m)means multiline here, not dot-all); the/xextended mode is unavailable.
To pull structured data out of a sub-agent, prefer agent(prompt, schema: ...) and JSON.parse
over scraping its prose reply with a regex — the schema makes the reply valid JSON by construction,
which is both simpler and far more reliable than pattern-matching free text.
Running in the background
Section titled “Running in the background”In interactive transports (TUI, Web, IM) every workflow(...) call is asynchronous — it starts
immediately in the background and returns a run id (wf_1, wf_2, …) without waiting for the
script to finish; the agent is notified automatically with the result when the run completes.
In the headless one-shot (octo "prompt") the call instead blocks and returns the final result
directly: the process exits when the turn ends, so a detached run could never deliver its result.
Progress lines stream to stderr while it runs.
| Tool | Purpose |
|---|---|
workflow_status |
no id: list every run in the session with status/elapsed/last-activity. With an id: full result or error, plus up to 500 lines of captured log |
workflow_kill |
cancel a run; propagates to any in-flight sub-agents |
Up to 4 background workflow runs can be active at once (per CLI process, or per session for
Web/IM); a fifth workflow(...) call errors immediately rather than queuing.
Worktree isolation
Section titled “Worktree isolation”Pass isolation: "worktree" to agent(...) and that call runs inside a fresh
git worktree add -b octo-wf/<label>-<random> off the current HEAD — an isolated checkout so
concurrent branches touching files never collide. It’s opt-in per call, not automatic for
parallel/pipeline.
When that agent finishes:
- No changes → the worktree and its branch are removed automatically.
- Changes present → they’re committed onto the branch, the worktree is left on disk, and the branch name, path, and a diffstat are appended to the agent’s reply text.
Nothing merges the branch back for you — reviewing and merging is on you (or a later agent() call
you write to do it).
Named and saved workflows
Section titled “Named and saved workflows”Save a script once, run it by name afterward:
workflow_save(name: "my-check", script: "...") # writes .octo/workflows/my-check.rbworkflow(name: "my-check", args: { target: "src/" })namemust match^[a-z0-9][a-z0-9-]*$.scope: "project"(default) writes to.octo/workflows/— this needs a git repo, or the save fails;scope: "user"writes to~/.octo/workflows/instead.- The registry merges built-in presets < user workflows < project workflows by name, rescanned
fresh on every call — no caching, no restart needed after editing a saved script by hand.
Project workflows are resolved from the current working directory of the turn (the directory
you ran
octofrom, thedirectoryof a scheduled cron task, or a web/IM session’s working directory when it’s been changed from the server default), not necessarily the server’s process CWD. A running workflow script’s ownagent()/skill()calls keep resolving against that same directory too, even in background mode. - A workflow’s description comes from a leading
# @description ...comment line. args(any JSON value) is threaded through to the script’sargscall; omit it andargsreturnsnil.
Three presets ship in the binary and are always available, even with nothing saved yet:
| Name | What it does |
|---|---|
parallel-understand |
Maps a codebase in parallel — one reader per subsystem, synthesized into one architecture map |
batch-migrate |
Applies one mechanical change across many files, each in its own git worktree so parallel edits never collide |
daily-triage |
Runs a daily triage loop — discovers open issues and CI failures, drafts safe fixes in isolated worktrees, verifies them, and writes a state report |
Resuming a run
Section titled “Resuming a run”A completed agent/skill call is journaled to
~/.octo/workflow-journals/<run-id>.jsonl as it happens, so a crash loses at most the in-flight
call. The result or error text names this journal id — a format like wf-20260703-143000-a1b2c3d4.
workflow(script: "...", resume_from: "wf-20260703-143000-a1b2c3d4") replays completed calls from
the journal by position, skipping straight to where the run left off — but only if the script and
args hash to exactly the same value as the original run; changing either is treated as starting
over, not a resume (and returns an error telling you to omit resume_from if that’s actually what
you want).
Everyday nudges
Section titled “Everyday nudges”If you manually invoke two or more distinct skills or browser recordings in one turn, octo nudges
(a model-visible, UI-hidden note) toward turning that into a saved workflow — pointing at either the
workflow-creator skill or workflow_save directly. It won’t nudge again in a turn where you’ve
already called workflow or workflow_save.
Next: browser recordings compose directly into workflow scripts via skill("browser:<name>") — see
Automate with browser control.