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 from docs/FLOW_AUTHORING.md. Save the answer as <repo>/.claude-factory/flows/<name>.yaml (or ~/.spaghetti-code-foundry/flows/ for all repositories) and check it with scf 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

ValueThe run works in…Use for
worktreea fresh git worktree + branch of the local repositoryalmost everything (the default)
inplacethe repository itselfrarely; changes land directly in the user's checkout
emptyan empty directoryflows 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:

KeyMeaning
labelThe name of the field, up to 80 characters
helpOne sentence under the field, up to 300 characters
kindnumber, yes_no, text (the default), list or choice
min, maxRange of a number (both optional)
choicesThe allowed values of a choice (1 to 50, no duplicates)
separatorFor a list: how its items are written in the text: comma (the default), space or line
set_by_runtrue for a variable the run sets itself (such as github_repo or issue): no form shows it
KindDefault must beNotes
numbera number, within min and max
yes_noyes or no
textany textUse it for a value that takes two forms, such as off or a number. Say both in help.
listthe items, written with the separatorThe list is still stored as text.
choiceone of choiceschoices is required

The flow is refused when it is saved if:

  • a var_info entry names a variable that is not in vars (the error names it);
  • min or max is used on a kind other than number, choices on a kind other than choice, or separator on a kind other than list;
  • min is above max, or a choice is listed twice;
  • the default in vars does 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)#

FieldMeaning
idRequired, unique. Starts with a letter; letters, digits, _, -. Not next, end, fail, stop.
typeRequired: claude | shell | approval | parallel | flow
descriptionShown in the UI
on_successWhere to go when the step succeeds: a step id, next (default), end, fail or stop
on_failureWhere to go when it fails: a step id, next, end, fail (default) or stop
routesList 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_ifRegex the output must match, or the step counts as failed
fail_ifRegex; if the output matches, the step counts as failed
jump_onlytrue = skipped when running top to bottom; only reached by a jump (handlers, fix steps)
max_visitsHow often this step may run in one run (loop guard; default 5). Exceeding it fails the run.
timeout_secKill the step after this many seconds
resume_fromFor 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_if say otherwise).
  • Read-only steps (plan, review): permission_mode: dontAsk with allowed_tools: [Read, Glob, Grep] — the agent can then only read.
  • Editing steps: permission_mode: acceptEdits and 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

SpecMeans
claude-opus-5-5 / opusClaude Opus — best for planning, architecture, hard reviews
claude-sonnet-5-5 / sonnetClaude Sonnet — the default choice for coding
haikuClaude Haiku — cheap, for simple summaries/classification
codexThe Codex CLI with its default model (often used as an independent second reviewer)
codex:gpt-5Codex with a specific model
ollama:qwen3-coderClaude Code on a local Ollama model
codex:ollama:gpt-oss:20bCodex 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: true on every shell step that calls gh, 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: true and cannot be listed in a parallel step.
  • 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_TOKEN for gh, a credential helper for git), a deploy key (git over ssh only, no GH_TOKEN; a step that calls gh by name fails) or the GitHub App (a new token for this step, which must finish within one hour). $FACTORY_REPO_URL is the stored address. Steps without the flag and agent steps never get it. Call gh by name, not by its full path.
  • A failed sign-in ends the run: on_failure is 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

ModeWhat happens
plannedThe default. The skills come from the request of the plan gate.
explicitThe 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.
offNo check, no lock and no skill block for the run. This also skips the administrator's skills.selection.include. The log says so once.
recordedReserved. It is accepted and runs like planned for now.
  • ids is required for explicit and not allowed for the other modes. Ids are lower-case slugs, not repeated, at most 20.
  • Only the top flow decides; the skills field of a sub-flow is ignored.

Step skills (claude steps only)

ValueWhat happens
selectedThe step gets the locked skills. This is the default.
catalogA 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.
offThis 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: explicit with ids: 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 id risk_gate or post_plan that runs the skill-request check, whose plan ends with a SKILL_REQUEST line. 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) and input (label, help, default, required).
  • Never write version; the server sets it when the flow is saved.
  • A published flow (enabled: true) cannot have type: flow steps.
  • Names of inputs use letters, digits, _ and -, and must not give the same environment name as another variable (foo-bar and foo_bar do).
  • In a shell step read an input as $FACTORY_VAR_NAME, never as {{vars.name}}.
  • The message of an approval step is shown to users. In a published flow it may only use {{task}}, {{vars.<name>}} of a fixed or input variable (or github_repo or issue) 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 run has {{vars.<input>}} or a bare {{vars}}. Read "$FACTORY_VAR_NAME" and "$FACTORY_TASK" instead, always quoted.
  • agent_env as an input (mode: input) is refused: a user could set NODE_OPTIONS, DYLD_* or BASH_ENV for agent steps. Also do not publish test_cmd as an input, and never eval or sh -c a value users fill in.
  • For users' repositories use workspace: empty and clone in a repo_access step. worktree is a branch of the server's own folder, so it is refused for users, like inplace: 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 use worktree and inplace.
  • 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:

PlaceholderValue
{{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"):

VariableValue
$FACTORY_TASKThe 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_BRANCHRun id, workspace, branch
$FACTORY_BASE_SHAThe commit the run started from (git diff $FACTORY_BASE_SHA = everything the run changed)
$FACTORY_REPO_URLThe stored address of the repository; set only in a marked step that uses a stored token, a deploy key or the GitHub App
$FACTORY_TOOLSFolder 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_NOTHINGThe 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_BACKFirst 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_BACKThe 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_HOTFIXon, 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_REPOThe 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_FILEFile 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:

ScriptDoes
"$FACTORY_TOOLS/detect-commands" testPrints 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#

  1. Every jump target (on_success, on_failure, routes[].goto, resume_from, resume, parallel.steps) is an existing step id or next/end/fail/stop.
  2. Fix/handler steps are jump_only: true, and every loop has a max_visits.
  3. The step before a jump_only block ends with an explicit on_success (usually end or a later step), or the run falls through into the next non-jump step as intended.
  4. Shell run uses only {{vars.*}}, {{workdir}}, {{run.*}}; everything else via $FACTORY_… env vars, quoted.
  5. 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.
  6. Read-only agent steps use permission_mode: dontAsk with read-only tools.
  7. Nothing pushes to main; there is no git push --force.
  8. Regexes in YAML strings escape backslashes: "^VERDICT: APPROVE\\s*$".
  9. Flows that change code have one_per_repo: true.
  10. Every shell step that calls gh or the remote has repo_access: true; steps that run tests or the build do not; no such step has sandbox: true or is in a parallel step.

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

Improve this page on GitHub