Flow format reference
For people: give this whole file to any AI assistant (ChatGPT, Claude, Gemini, a local model…), then describe the flow you want. Print it with
scf flow-guide, or copy it fromdocs/FLOW_AUTHORING.md. Save the answer as<repo>/.claude-factory/flows/<name>.yaml(or~/.spaghetti-code-foundry/flows/for all repositories) and check it withscf validate <name>.yaml. The Draft flow with Claude button in the UI uses this same file.For the AI assistant: everything below is the complete, exact format. Follow it strictly.
What you are writing#
Spaghetti Code Foundry runs flows: YAML files describing a pipeline of steps that is executed headlessly on the user's machine, against a git repository. Steps are:
- agent steps (
type: claude) — run a coding agent (Claude Code, or OpenAI's Codex CLI) with a prompt, in the run's workspace. The agent can read and edit files. - shell steps (
type: shell) — run a shell command (sh -c) in the workspace. Exit code 0 is success. - approval steps — pause until a human approves or rejects.
- parallel steps — run a few agent/shell steps at the same time.
- flow steps — run another flow inline.
Steps run top to bottom. Jumps (on_success, on_failure, routes) make loops and branches.
Your answer#
Reply with only the complete flow as YAML in one ```yaml code fence — no other text,
unless the user asks for an explanation. Use only the fields listed here; unknown fields
make the flow invalid.
Top-level fields#
name: my-flow # required; short, kebab-case; usually the file name
description: one line shown in the UI
workspace: worktree # worktree (default) | inplace | empty — see below
one_per_repo: true # optional: only one run of this flow (and other such flows) per repo at a time;
# set it for flows that change code, leave it out for read-only flows
defaults: # optional, apply to every agent step unless the step overrides them
model: claude-sonnet-5-5
agent: claude # claude | codex
provider: anthropic
effort: high # low | medium | high | xhigh | max
permission_mode: acceptEdits
allowed_tools: [Read, Edit, Write, Glob, Grep, "Bash(npm *)"]
max_visits: 5
timeout_sec: 1800
max_budget_usd: 5 # per agent step
limits:
max_cost_usd: 20 # optional: the whole run stops when it has cost this much
sandbox: # optional
claude: false # true = agents' shell commands may only write inside the workspace
docker_image: node:22 # image for shell steps with `sandbox: true`
skills: # optional: how the run gets its skills; see "Skills" below
mode: planned # planned (default) | explicit | off (recorded is reserved)
ids: [rest-openapi] # only with mode: explicit
vars: # optional: variables with defaults; the user can override them per run
test_cmd: npm test
var_info: {...} # optional: describes the variables for forms (label, help, kind, range); runs are not checked against it
publish: {...} # optional: publish the flow to users — leave it out unless asked; see below
steps: [...] # required, at least one
workspace
| Value | The run works in… | Use for |
|---|---|---|
worktree | a fresh git worktree + branch of the local repository | almost everything (the default) |
inplace | the repository itself | rarely; changes land directly in the user's checkout |
empty | an empty directory | flows that clone something themselves (e.g. gh repo clone) |
Describing variables#
vars gives each variable its default. var_info describes the same variables so that a form can show a proper field for each one. It is optional, and a flow without it loads and runs as before.
name: test-only
description: Runs the project's tests
vars:
test_cmd: npm test
test_slots: 2
reuse_test_results: "yes"
review_twice_above_risk: "off"
var_info:
test_cmd:
label: Test command
help: Use auto to find the command from the project files, or type the command that tests the project.
kind: text
test_slots:
label: Parallel test runs
help: How many test runs may run at the same time on this machine.
kind: number
min: 0
reuse_test_results: {label: Reuse test results, help: "Skip a test run when the same code was already tested.", kind: yes_no}
review_twice_above_risk: {label: Review twice above risk, help: "Use off, or a risk score from 0 to 100. A plan above that score gets a second review.", kind: text}
steps:
- id: test
type: shell
run: "{{vars.test_cmd}}"
Each entry is keyed by a variable name in vars:
| Key | Meaning |
|---|---|
label | The name of the field, up to 80 characters |
help | One sentence under the field, up to 300 characters |
kind | number, yes_no, text (the default), list or choice |
min, max | Range of a number (both optional) |
choices | The allowed values of a choice (1 to 50, no duplicates) |
separator | For a list: how its items are written in the text: comma (the default), space or line |
set_by_run | true for a variable the run sets itself (such as github_repo or issue): no form shows it |
| Kind | Default must be | Notes |
|---|---|---|
number | a number, within min and max | |
yes_no | yes or no | |
text | any text | Use it for a value that takes two forms, such as off or a number. Say both in help. |
list | the items, written with the separator | The list is still stored as text. |
choice | one of choices | choices is required |
The flow is refused when it is saved if:
- a
var_infoentry names a variable that is not invars(the error names it); minormaxis used on a kind other thannumber,choiceson a kind other thanchoice, orseparatoron a kind other thanlist;minis abovemax, or a choice is listed twice;- the default in
varsdoes not fit its own entry.
A run is not checked against var_info. Values from --var, from .claude-factory/config.yaml and from existing watchers are accepted as before, even if they do not fit. GET /api/flows/<name> returns var_info with the flow ({} when it has none).
Step fields (every type)#
| Field | Meaning |
|---|---|
id | Required, unique. Starts with a letter; letters, digits, _, -. Not next, end, fail, stop. |
type | Required: claude | shell | approval | parallel | flow |
description | Shown in the UI |
on_success | Where to go when the step succeeds: a step id, next (default), end, fail or stop |
on_failure | Where to go when it fails: a step id, next, end, fail (default) or stop |
routes | List of {if: <regex>, goto: <target>}. On success, the first regex that matches the output decides where to go (checked before on_success). Regexes are multi-line: ^ and $ match at line starts/ends. |
pass_if | Regex the output must match, or the step counts as failed |
fail_if | Regex; if the output matches, the step counts as failed |
jump_only | true = skipped when running top to bottom; only reached by a jump (handlers, fix steps) |
max_visits | How often this step may run in one run (loop guard; default 5). Exceeding it fails the run. |
timeout_sec | Kill the step after this many seconds |
resume_from | For a step that stops the run: when the run is resumed, restart at this step instead |
Targets: next = the following step (skipping jump_only steps) · end = the run succeeds ·
fail = the run fails · stop = the run stops as stopped (waits for a human; they can resume it).
Step types#
claude — an agent step#
- id: implement
type: claude
prompt: | # required
Implement this: {{task}}
Add tests. Do not commit.
model: claude-sonnet-5-5 # optional; see "Models" below
agent: codex # optional: claude (default) or codex
provider: ollama # optional: a provider name (anthropic, openai, ollama, lmstudio, or the user's own)
effort: high # optional: low | medium | high | xhigh | max
system_prompt: You are a careful senior engineer. # optional, appended to the agent's system prompt
permission_mode: acceptEdits # optional: acceptEdits | auto | bypassPermissions | default | dontAsk | plan
allowed_tools: [Read, Edit, Write, Glob, Grep, "Bash(npm test*)"] # optional
resume: plan # optional: continue the session of an earlier claude step (by id)
max_budget_usd: 3 # optional: stop this step at this cost
sandbox: true # optional: agent's shell commands may only write inside the workspace
skill_role: reviewer # optional: give this read-only step compact review checks from the run's skills (coder is the default)
skills: selected # optional: catalog | selected (default) | off; see "Skills" below
- The step output is the agent's final answer. It succeeds unless the agent errors (or
pass_if/fail_ifsay otherwise). - Read-only steps (plan, review):
permission_mode: dontAskwithallowed_tools: [Read, Glob, Grep]— the agent can then only read. - Editing steps:
permission_mode: acceptEditsand the tools it needs, e.g.Edit,Write,Bash(npm test*).Bash(git push*)is never allowed — pushing is a shell step. resume: <id>continues that step's conversation: the agent already knows the code, so fix and follow-up steps are cheaper and better. Only resume steps of the same agent.- To make routing reliable, tell the agent to end with a fixed line, e.g.
End with exactly one line: VERDICT: APPROVE or VERDICT: CHANGES, and route on it:routes: [{if: "^VERDICT: APPROVE\\s*$", goto: docs}].
Models
| Spec | Means |
|---|---|
claude-opus-5-5 / opus | Claude Opus — best for planning, architecture, hard reviews |
claude-sonnet-5-5 / sonnet | Claude Sonnet — the default choice for coding |
haiku | Claude Haiku — cheap, for simple summaries/classification |
codex | The Codex CLI with its default model (often used as an independent second reviewer) |
codex:gpt-5 | Codex with a specific model |
ollama:qwen3-coder | Claude Code on a local Ollama model |
codex:ollama:gpt-oss:20b | Codex on a local Ollama model |
Leave model out to use the flow default / the user's routing rules.
shell — a command#
- id: test
type: shell
run: "{{vars.test_cmd}}" # required; runs with sh -c in the workspace; exit 0 = success
sandbox: true # optional: run inside Docker (flow sandbox.docker_image)
The output is stdout + stderr (long output is trimmed to the end).
- id: push
type: shell
repo_access: true # this step talks to the repository's host (gh, git clone/fetch/pull/push/ls-remote)
run: git push -q -u origin HEAD
- Set
repo_access: trueon every shell step that callsgh, uses the remote with git (clone,fetch,pull,push,ls-remote), or calls a helper script that does. - Only shell steps have it.
- Never set it on a step that runs the project's tests or build. Split a step that does both.
- A step with it cannot have
sandbox: trueand cannot be listed in aparallelstep. - In a run with an owner, a marked step signs in with the stored sign-in of the repository named by
github_repo: a token (GH_TOKENforgh, a credential helper for git), a deploy key (git over ssh only, noGH_TOKEN; a step that callsghby name fails) or the GitHub App (a new token for this step, which must finish within one hour).$FACTORY_REPO_URLis the stored address. Steps without the flag and agent steps never get it. Callghby name, not by its full path. - A failed sign-in ends the run:
on_failureis not followed. A refusal on stderr fails the step even if the script goes on, so a call you tolerate on purpose must send its error output to/dev/null.
approval — wait for a human#
- id: approve
type: approval
message: "Push the changes for {{vars.github_repo}}?" # required
on_failure: stop # a rejection goes to on_failure (default: fail)
Approved → on_success. Rejected → on_failure. The output is "approved by X: note" /
"rejected by X: note". Not allowed inside sub-flows.
parallel — run steps at the same time#
- id: checks
type: parallel
steps: [lint, unit_tests] # ids of claude or shell steps (at least 2), usually jump_only
- id: lint
type: shell
jump_only: true
run: npm run lint
- id: unit_tests
type: shell
jump_only: true
run: npm test
Succeeds when all of them succeed.
flow — run another flow inline#
- id: release_checks
type: flow
flow: release-check # a flow name
vars: {test_cmd: "{{vars.test_cmd}}"}
Runs in the same workspace. May not contain approval steps.
Skills#
Skills are optional. Leave the fields out and the flow works as before: a plan gate asks for skills and the run locks them.
Flow skills.mode
| Mode | What happens |
|---|---|
planned | The default. The skills come from the request of the plan gate. |
explicit | The skills are the ids you list. They are checked when the run starts, under the same policy as a planned request, and locked at the first agent step. No plan gate is needed; a SKILL_REQUEST line of a plan gate is ignored. |
off | No check, no lock and no skill block for the run. This also skips the administrator's skills.selection.include. The log says so once. |
recorded | Reserved. It is accepted and runs like planned for now. |
idsis required forexplicitand not allowed for the other modes. Ids are lower-case slugs, not repeated, at most 20.- Only the top flow decides; the
skillsfield of a sub-flow is ignored.
Step skills (claude steps only)
| Value | What happens |
|---|---|
selected | The step gets the locked skills. This is the default. |
catalog | A planner step: it gets the skill catalogue before its prompt and no skill block. The step must be read-only. The lock is still verified. |
off | This session gets no skill block. The lock is still verified. A step with skills: off always starts a new session: it does not resume another step's session, because that one may hold a skill block. |
Step skills cannot be set together with skill_role: reviewer.
name: rest-change
steps:
- id: implement
type: claude
prompt: "Implement this: {{task}}"
skills:
mode: explicit
ids: [rest-openapi]
- id: summary
type: claude
prompt: Summarise the change in three lines.
skills: off
Skills in your own flow#
- No skill fields: the flow works as before.
mode: explicitwithids: the normal choice for your own flow. The ids are checked when the run starts and locked at the first agent step.mode: off: for a flow that must never get skills.mode: planned: only does something when the flow has the plan gate of the shipped flows: a top-level shell step with the idrisk_gateorpost_planthat runs theskill-requestcheck, whose plan ends with aSKILL_REQUESTline. Copy the check command of a shipped flow and print its checked request in the output of the gate. A matching id and a request line are not enough. Copy a shipped flow for this.
name: typed-change
steps:
- id: implement
type: claude
prompt: "Implement this: {{task}}"
- id: review
type: claude
skill_role: reviewer
permission_mode: dontAsk
allowed_tools:
- Read
- Glob
- Grep
prompt: Review the change in this repository. Do not change files.
- id: summary
type: claude
prompt: Summarise the change in three lines.
skills: off
skills:
mode: explicit
ids: [typescript]
With the default policy, ids that are not installed or not pinned stop the run at its start with skills not resolved: …; skills.unresolved can allow a warning for low-risk skills (see "Missing and conflicting skills" in the user guide). A skill never grants tools. See "Automatic skills" in docs/USER_GUIDE.md.
Publishing a flow to users#
Only when the person asks for it. A publish: section lets people with the role user start the
flow and fill in some of its variables:
name: ask-a-question
description: Answers a question about the code
workspace: worktree
vars:
topic: ""
test_cmd: npm test
publish:
enabled: true
name: Ask about the code # optional: what users see; falls back to name
description: Answers a question about this repository
vars: # each key must be a variable above; leave out the ones to hide
topic:
mode: input # the user fills it in
label: Topic
help: What should the answer be about?
required: true
default: ""
test_cmd:
mode: fixed # shown to the user, cannot be changed
steps:
- id: answer
type: shell
run: echo "Topic is $FACTORY_VAR_TOPIC"
- Modes:
hidden(same as leaving the variable out),fixed(label,help) andinput(label,help,default,required). - Never write
version; the server sets it when the flow is saved. - A published flow (
enabled: true) cannot havetype: flowsteps. - Names of inputs use letters, digits,
_and-, and must not give the same environment name as another variable (foo-barandfoo_bardo). - In a shell step read an input as
$FACTORY_VAR_NAME, never as{{vars.name}}. - The
messageof an approval step is shown to users. In a published flow it may only use{{task}},{{vars.<name>}}of afixedorinputvariable (orgithub_repoorissue) and{{steps.<id>.output}}.{{vars}},{{workdir}},{{run.*}},{{learnings}}and other step fields are refused.
Security rules for published flows#
A user steers a published flow with text, so treat every input as untrusted. The schema refuses the first two problems when you save; the rest is up to you.
- Never paste user text into a shell command. A flow with a user input is refused when a shell
runhas{{vars.<input>}}or a bare{{vars}}. Read"$FACTORY_VAR_NAME"and"$FACTORY_TASK"instead, always quoted. agent_envas an input (mode: input) is refused: a user could setNODE_OPTIONS,DYLD_*orBASH_ENVfor agent steps. Also do not publishtest_cmdas an input, and neverevalorsh -ca value users fill in.- For users' repositories use
workspace: emptyand clone in arepo_accessstep.worktreeis a branch of the server's own folder, so it is refused for users, likeinplace: the API answers 403, the flow is not listed, and a user's run of it fails before any step. Only admins (and runs without an owner) can useworktreeandinplace. - Give agent steps only the tools they need. The task and the issue text are untrusted instructions.
- Keep secrets out of
vars. Hidden variables are not secret from the run itself.
See docs/THREAT_MODEL.md for what is protected and what is still open.
Templates and environment variables#
In prompt, message and vars values, {{…}} placeholders are replaced:
| Placeholder | Value |
|---|---|
{{task}} | The task text the user typed when starting the run, followed (after a resume) by the answers given on the run page, oldest first, under the heading ## Answers to the questions of this run (oldest first) |
{{vars.NAME}} | A flow variable |
{{workdir}} | The workspace path |
{{run.id}}, {{run.dir}}, {{run.branch}} | Run id, the run's own folder (for logs/files), the run's branch |
{{steps.ID.output}} | Output of a step that already ran (empty if it hasn't run yet) |
{{steps.ID.ok}}, {{steps.ID.exit_code}} | Whether it succeeded; a shell step's exit code |
{{learnings}} | Lessons saved by earlier runs in this repository (may be empty) |
A flow variable named agent_env is special: its KEY=value pairs (separated by ; or new
lines) are added to the environment of every agent step — e.g. agent_env: JAVA_HOME=/opt/jdk21
so the agent can run ./gradlew. (PATH, tokens, FACTORY_* and SCF_* can't be set this way; in a
run that never uses the machine's login, GH_*, GITHUB_*, GIT_*, SSH_*, XDG_* and LC_ALL are ignored too.)
Shell run may only use {{vars.*}}, {{workdir}} and {{run.*}} — never {{task}} or
{{steps.*}} (that text is untrusted and would be a shell-injection risk). In shell steps, use
these environment variables instead (always quote them: "$FACTORY_TASK"):
| Variable | Value |
|---|---|
$FACTORY_TASK | The task text, followed by the answers given on the run page under the heading ## Answers to the questions of this run (oldest first) (also $SCF_TASK) |
$FACTORY_OUT_<STEP_ID> | Output of a step; id upper-cased, - → _ (step run-tests → $FACTORY_OUT_RUN_TESTS) |
$FACTORY_VAR_<NAME> | A flow variable (github_repo → $FACTORY_VAR_GITHUB_REPO) |
$FACTORY_RUN_ID, $FACTORY_WORKDIR, $FACTORY_BRANCH | Run id, workspace, branch |
$FACTORY_BASE_SHA | The commit the run started from (git diff $FACTORY_BASE_SHA = everything the run changed) |
$FACTORY_REPO_URL | The stored address of the repository; set only in a marked step that uses a stored token, a deploy key or the GitHub App |
$FACTORY_TOOLS | Folder with helper scripts (below) |
$FACTORY_NEXT_<REASON> | The closing "what to do next" sentence for a comment on the issue. <REASON> is QUESTIONS, PLANNER_QUESTIONS, APPROVE_PLAN, APPROVE_SPLIT or APPROVAL; e.g. $FACTORY_NEXT_APPROVAL is "It waits for your approval — reply /approve or /reject." The sentence is fixed per reason. Write "_${FACTORY_NEXT_APPROVAL}_" with braces when _ follows |
$FACTORY_FIRST_<REASON>, $FACTORY_FIRST_NOTHING | The bold first line of a comment on the issue. <REASON> is the same five as above; e.g. $FACTORY_FIRST_APPROVE_PLAN is **What you need to do:** Reply /approve or /reject. $FACTORY_FIRST_NOTHING is for a comment that needs no answer: **Nothing needed from you** — it is being worked on. Print it first, then an empty line (echo "$FACTORY_FIRST_APPROVAL"; echo), and always quote it because it contains * |
$FACTORY_FIRST_INFO, _MERGE_PR, _OPEN_PR, _START_PARTS, _START_CODING, _SHIPS, _LOOK, _MERGE_RELEASE, _DRAFT, _FIXED, _MERGE_BACK | First lines for comments that report something (plan, result, split, daily report). Also as $SCF_FIRST_…. INFO is **Nothing needed from you**; MERGE_PR is **What you need to do:** Review and merge the pull request.; OPEN_PR is **What you need to do:** Open a pull request from the branch.; START_PARTS is **What you need to do:** Start the new issues when you want them built.; START_CODING is **What you need to do:** Add the code label to start coding.; SHIPS is **Nothing needed from you** — it goes to main with the release pull request.; LOOK is **What you need to do:** Look at the changes.; MERGE_RELEASE is **What you need to do:** Merge the release pull request when you like.; DRAFT is **Nothing needed from you** — it stays a draft until the checks pass. Print one first, then an empty line. To choose, set first="$FACTORY_FIRST_MERGE_PR" and print "$first" |
$FACTORY_FIRST_FIXED, $FACTORY_FIRST_MERGE_BACK | The hotfix report of issue-gitflow: **Nothing needed from you** — the fix is on main and in develop. and **What you need to do:** Merge main into develop, because the fix is not there yet. |
$FACTORY_HOTFIX | on, off or other: whether this run may take the hotfix path. off: the Hotfixes setting is off. other: the flow is not the unchanged built-in issue-gitflow. Set by the engine, for every step |
$FACTORY_SELF_SHA, $FACTORY_SELF_REPO | The commit the running Foundry's own checkout was at when it started, and its GitHub repository (owner/name, lower case). Empty when the Foundry does not run from a git checkout with a GitHub origin |
$FACTORY_LEARNINGS_FILE | File where lessons for this repository are kept |
Every FACTORY_… variable is also set as SCF_… (e.g. $SCF_TASK); the built-in flows use FACTORY_….
Helper scripts in $FACTORY_TOOLS:
| Script | Does |
|---|---|
"$FACTORY_TOOLS/detect-commands" test | Prints the repository's test command (also build, lint) — use when test_cmd is auto |
"$FACTORY_TOOLS/test-summary" <marker-file> | Summarises test reports newer than the marker file |
"$FACTORY_TOOLS/secret-scan" [range] | Checks commits for secrets (pushes are always checked anyway) |
The Foundry always blocks pushes to protected branches (main, master, …) and pushes that
contain secrets — a flow cannot turn that off. Push to a new branch and open a pull request.
There is one exception. When an admin switches on Hotfixes in Settings, the step push_main of
the unchanged built-in issue-gitflow may push main (never delete it), and only that step.
The engine gives it a one-time token for the push; the hook ignores anything else, such as a
FACTORY_PUSH_ALLOW set by a step. A flow you write, or a copy of issue-gitflow with any change
(a step, a variable default, a limit), never gets this; there $FACTORY_HOTFIX is other. The
exception is a guard against mistakes, not a wall: a command that skips git hooks still gets
through, so also use branch protection on GitHub for a hard block.
hotfix (an issue with a hotfix label, default `bug`):
main ──► hotfix/42-… ──► merge ──► tests ──► push main ──► merge main into develop ──► push develop
│ (tests on main (main, no fast-forward) (the issue is closed) (tests first)
│ before the change)
feature (everything else): develop ──► feature/42-… ──► develop (main: with the daily release)
The monitor finds the commit of a fix in the output of the run. A succeeded step push_develop
prints a line COMMIT: <40 characters>; on the hotfix path the succeeded step hotfix_done prints
MAIN: <40 characters>. Keep these lines if you copy the flow. Without one, the monitor does not
know the commit and waits for the next server start or fix_wait_days.
Patterns#
Test → fix loop (the fix continues the coding session; at most 3 fixes):
- id: test
type: shell
run: "{{vars.test_cmd}}"
on_failure: fix
- id: fix
type: claude
jump_only: true
resume: implement
max_visits: 3
prompt: |
The tests fail. Fix the code (never weaken or delete tests).
{{steps.test.output}}
on_success: test
Review gate (a second agent reviews; changes are fixed, then reviewed again):
- id: review
type: claude
agent: codex
permission_mode: dontAsk
allowed_tools: [Read, Glob, Grep]
max_visits: 3
prompt: |
Review the uncommitted changes (git diff HEAD) for bugs, edge cases and missing tests.
Report only real problems. End with exactly one line: VERDICT: APPROVE or VERDICT: CHANGES
routes: [{if: "^VERDICT: APPROVE\\s*$", goto: commit}]
- id: address_review
type: claude
resume: implement
prompt: |
Fix these review points:
{{steps.review.output}}
on_success: review
Ask a human and wait (stop the run; the user answers and resumes it):
- id: plan
type: claude
permission_mode: dontAsk
allowed_tools: [Read, Glob, Grep]
prompt: |
Plan: {{task}}
If something important is unclear, list your questions and end with the line: NEEDS_INFO
Otherwise write the plan and end with the line: READY
routes: [{if: "^NEEDS_INFO\\s*$", goto: ask}]
- id: ask
type: shell
jump_only: true
resume_from: plan # after the human answered, plan again
run: printf '%s\n' "$FACTORY_OUT_PLAN"
on_success: stop
Commit and push to the run's branch (never to main):
- id: commit
type: shell
run: |
git add -A
git commit -q -m "$FACTORY_TASK" && git log --oneline -1
- id: push
type: shell
repo_access: true
run: git push -q -u origin HEAD && echo "pushed $(git branch --show-current)"
Open a GitHub pull request (needs the gh CLI):
- id: open_pr
type: shell
repo_access: true
run: |
printf '%s\n\n%s\n' "$FACTORY_TASK" "$FACTORY_OUT_IMPLEMENT" > "{{run.dir}}/pr.md"
gh pr create --fill --body-file "{{run.dir}}/pr.md"
Work on a GitHub issue (workspace empty; clone, read the issue, report back):
workspace: empty
vars: {github_repo: owner/repo, issue: ""}
steps:
- id: clone
type: shell
repo_access: true
run: gh repo clone "$FACTORY_VAR_GITHUB_REPO" . -- -q && git checkout -q -b "factory/issue-$FACTORY_VAR_ISSUE"
- id: issue
type: shell
repo_access: true
run: gh issue view "$FACTORY_VAR_ISSUE" --repo "$FACTORY_VAR_GITHUB_REPO" --comments
- id: implement
type: claude
prompt: |
Implement this GitHub issue:
{{steps.issue.output}}
# … tests, review, commit, push, then:
- id: report
type: shell
repo_access: true
run: gh issue comment "$FACTORY_VAR_ISSUE" --repo "$FACTORY_VAR_GITHUB_REPO" --body "Done on branch $(git branch --show-current)"
Checklist before you answer#
- Every jump target (
on_success,on_failure,routes[].goto,resume_from,resume,parallel.steps) is an existing step id ornext/end/fail/stop. - Fix/handler steps are
jump_only: true, and every loop has amax_visits. - The step before a
jump_onlyblock ends with an expliciton_success(usuallyendor a later step), or the run falls through into the next non-jump step as intended. - Shell
runuses only{{vars.*}},{{workdir}},{{run.*}}; everything else via$FACTORY_…env vars, quoted. - Agent prompts are specific: what to do, what not to do (e.g. "do not commit"), and — when the flow routes on the answer — the exact last line to write.
- Read-only agent steps use
permission_mode: dontAskwith read-only tools. - Nothing pushes to
main; there is nogit push --force. - Regexes in YAML strings escape backslashes:
"^VERDICT: APPROVE\\s*$". - Flows that change code have
one_per_repo: true. - Every shell step that calls
ghor the remote hasrepo_access: true; steps that run tests or the build do not; no such step hassandbox: trueor is in aparallelstep.
Complete example#
name: feature-with-review
description: Sonnet implements the task with tests; Codex reviews; fix loops; commit
workspace: worktree
one_per_repo: true
defaults:
model: claude-sonnet-5-5
permission_mode: acceptEdits
allowed_tools: [Read, Edit, Write, Glob, Grep, "Bash(npm *)", "Bash(git diff*)", "Bash(git status*)"]
max_budget_usd: 5
limits:
max_cost_usd: 25
vars:
test_cmd: npm test
steps:
- id: plan
type: claude
model: claude-opus-5-5
effort: high
permission_mode: dontAsk
allowed_tools: [Read, Glob, Grep]
prompt: |
Plan how to implement this in the repository, with the files to change and the tests to add.
Do not modify files.
Task: {{task}}
- id: implement
type: claude
prompt: |
Implement the task following the plan. Add tests. Do not commit.
Task: {{task}}
Plan:
{{steps.plan.output}}
- id: test
type: shell
run: "{{vars.test_cmd}}"
on_failure: fix
- id: review
type: claude
agent: codex
permission_mode: dontAsk
allowed_tools: [Read, Glob, Grep]
max_visits: 3
prompt: |
Review the uncommitted changes (git diff HEAD) for this task: {{task}}
Report only real problems (bugs, edge cases, missing tests), each with file and fix.
End with exactly one line: VERDICT: APPROVE or VERDICT: CHANGES
routes: [{if: "^VERDICT: APPROVE\\s*$", goto: commit}]
on_success: address_review
- id: address_review
type: claude
jump_only: true
resume: implement
max_visits: 2
prompt: |
Fix the review points you agree with; explain in one line each why you skip the others.
{{steps.review.output}}
on_success: test
- id: fix
type: claude
jump_only: true
resume: implement
max_visits: 3
prompt: |
The tests fail. Fix the code (never weaken or delete tests).
{{steps.test.output}}
on_success: test
- id: commit
type: shell
jump_only: true
run: |
git add -A
git commit -q -m "$FACTORY_TASK" && git log --oneline -1
on_success: end