Flow authoring#

A flow is the pipeline a task runs through, expressed as data: a validated graph of typed nodes (agent, evaluator, checks, tool, hitl, publish) connected by outcome-labelled edges. A task's task_type selects its flow. The orchestrator ships eight built-in flows — implementation (the default), deep_research, security_audit, content_chapter, content_translate, blog_article, blog_article_revise, and the never-task-dispatched merge (selected by git.merge_flow) — and an operator can add or override flows without touching Python. This guide shows how to author, register, validate, and debug a custom flow from scratch.

If you only want to change what a step says (not the graph), you do not need a new flow — edit the node's role_file prompt in the delivered copy (see Customize a node's prompt). Author a new flow only when you need a different set of steps, a different output kind, or a different route.

Where flows live#

Flows resolve from one place: the operator's <repo>/.worc/flows/<task_type>.yaml. Drop a YAML file here to add a new task_type or to replace a built-in of the same name. Each flow owns its prompts in a sibling folder named after the task_type: .worc/flows/<task_type>/*.md. So role_file paths in the YAML are written relative to .worc/flows/ and point into that folder (e.g. role_file: my_flow/implement.md).

The built-ins ship inside the package under packaged/flows/, but that tree is delivery-only: install copies it into .worc/flows/ as editable, active copies (and the tool executables they reference into .worc/tools/), and the orchestrator never reads the packaged tree at run time. So every seeded built-in is already your operator flow to edit — and a task_type with no file in .worc/flows/ is a hard "flow not found" (run install to deliver the built-ins), not a silent fall-back to a bundled copy. What you see in .worc/flows/ is exactly what runs.

The dispatch file stays flat (.worc/flows/<task_type>.yaml) so the registry finds it by name; only the prompts live in the per-flow subfolder. The shared supervisor prompt is the one exception — it stays at .worc/flows/roles/supervisor.md because the supervisor is a constant layer above every flow, not a node of any one flow.

.worc/flows/
├── my_flow.yaml            # dispatch file (task_type: my_flow)
├── my_flow/                # this flow owns its prompts
│   ├── implement.md
│   └── fixing.md
└── roles/
    └── supervisor.md       # shared supervisor layer (all flows)

.worc/ is gitignored as a whole by default, so .worc/flows/ has no git history out of the box. To track your flows in git (share them, review changes via PR, keep a diff), see How-To → Track your operator flows in git.

A minimal custom flow#

This is a complete, valid coding flow: implement → test → publish, with a bounded test-fix loop. Save it as .worc/flows/my_flow.yaml and put its two prompts under .worc/flows/my_flow/.

flow:
  name: my_flow
  task_type: my_flow # must match the file stem; a task selects it with `task_type: my_flow`
  permission_ceiling: workspace-write # the most any node here may do on disk
  output_policy: code_change # what the flow is allowed to produce
  publishing: pull_request # how the result is published

  nodes:
    - id: implement
      kind: agent
      role_file: my_flow/implement.md # relative to .worc/flows/, into this flow's own folder
      session_scope: editing_lineage
      permission_profile: workspace-write
    - id: testing
      kind: checks
      checker: command_profile # runs the repo's configured check command sets
    - id: fixing
      kind: agent
      role_file: my_flow/fixing.md
      session_scope: editing_lineage
      lineage_affinity: implement # resume the implement agent's session
      permission_profile: workspace-write
    - id: publish
      kind: publish
      policy: pull_request

  edges:
    - { from: implement, to: testing }
    - { from: testing, to: publish, outcome: pass }
    - { from: testing, to: fixing, outcome: fail, loop: test_fix }
    - { from: fixing, to: testing }

  budgets:
    test_fix: 5 # the fail loop is bounded; without a budget the flow fails validation

The two role files are ordinary Markdown prompts. implement.md might be as small as:

Implement the task at {task_path} in the repository {repo_path}. Follow the plan at {plan_path} if present. Keep the change focused.

Flow header fields#

Field Required Values / meaning
name yes Flow name (match the task_type).
task_type yes The dispatch key. Must equal the file stem (<task_type>.yaml); a task selects the flow by setting this value.
permission_ceiling yes read-only or workspace-write — the hard cap; no node may exceed it, and it can never be widened by a task.
output_policy yes What the flow may produce: code_change, repository_document, or private_control_workspace_report. A closed set — see Output policy for what each produces and when to use it.
publishing yes Where the result lands: pull_request, documentation_pull_request, local_artifact, private_control_workspace_report, or none. The last three touch no Git — see Node kinds → publish for what each does.
report_dir no A repo-relative POSIX directory that replaces the report policy's default home (docs/research / .worc/security-reports); the engine still appends /<task_id>. Validated at load: no absolute path, drive letter, .., or backslash; every segment portable and not a Windows device name; never overlapping .worc/, .worc-io/, .git/, or the configured paths.tasks_dir tree. Refused on a code_change flow, which resolves no report directory — see report_dir — moving the deliverable's home.
network_policy no advisories / research grants the flow network flow-wide; omit and every node is offline by default (nodes may still opt in with network_access: true).
budgets if loops Bounds each named loop / rework edge (e.g. test_fix: 5). A budget of N is N fix rounds — test_fix: 5 lets five fail→fix rounds run and the sixth fail parks the task — exactly like an inline budget: 5 on an edge. Every fail/rework edge must be bounded or validation fails. The config caps are the unlosable backstop: the engine clamps every loop to min(flow_budget, agents.max_fix_cycles / agents.max_total_fix_iterations) at run time, so lowering a cap below a flow's declared budget is how an operator tightens it — never a validation error.
defaults no Field defaults applied to nodes that omit them; only defaults.evaluator exists — see Flow-level defaults.
decomposition no proposed_by (the agent node whose output carries the decompose/subtasks proposal), sub_flow (the node ids the subtasks fan out over), optional shared_budget (a name in budgets). All three must resolve, an edge from proposed_by must enter the region, and the region must have a forward exit edge — else the flow fails validation.
supervisor no Flow-local supervisor wording + cadence — see The flow's supervisor: block.

Every mapping in the document is a closed allowlist: an unknown key anywhere — flow header, node, edge, when, hitl, defaults, supervisor — is a fatal load error, never silently ignored (snapshot.py).

Output policy#

output_policy is a closed set of three — you pick which shape of deliverable the flow produces, and the engine resolves that name to a write area and required files (output_policy.py). You cannot specify anything else. The one thing you may move is where a report lands: report_dir: <base> gives either report policy another home, with the engine still appending /<task_id> itself (see report_dir below). The name is a contract, not a description: repository_document does not mean "any document in the repo", it means one specific research-report bundle. Choose by what the flow actually writes, never by which word reads best — picking the wrong one is silent until the first successful write, then hard-stops the task.

output_policy Writing nodes may write to Must produce Enters git? Pair with publishing Use when
code_change anywhere in the repo — the diff is the deliverable — yes pull_request / documentation_pull_request The result is edits to the working tree: code, or a brand-new prose/Markdown file committed to the repo (a blog post, a chapter, a translation).
repository_document <report_dir>/<task_id>/ only — docs/research by default report.md + sources.json yes documentation_pull_request The result is a research/analysis report bundle with a sources manifest (the deep_research shape; a citation checks node validates sources.json).
private_control_workspace_report <report_dir>/<task_id>/ only — .worc/security-reports by default report.md no — never committed none (the publish node itself takes policy: private_control_workspace_report) The result is an advisory report that must stay out of git (the security_audit shape).

How it is enforced. For the two report policies an after-stage guard fail-closes the task to manual_action_required the moment a writing node touches anything outside its report directory (agent.py → _apply_output_containment_guard); private_control_workspace_report additionally refuses to publish if its report is git-trackable (a leak), and its resolved report directory is excluded from the code commit's staging set for the whole run, so a terminal that never reaches publish cannot commit it either. code_change has no report directory, so that containment guard is off and the ordinary dangerous-diff guard applies instead.

The common trap. A brand-new document committed under blog/, docs/, or anywhere that is not a report-plus-sources bundle is a code_change, not a repository_document — exactly like the packaged content_chapter / content_translate flows, whose .md deliverables all declare output_policy: code_change. Reaching for repository_document because the phrase reads like "it's a document" confines every write to one directory (docs/research/<task_id>/, or your report_dir), so the real file lands outside it and the task hard-stops instead of finishing.

report_dir — moving the deliverable's home#

Both report policies write to <report_dir>/<task_id>/, and report_dir names only the base: the engine still appends /<task_id> itself, so per-task collision-freedom and the self-identifying report directory are not yours to lose. What the flow must produce there does not move with it — report.md + sources.json for repository_document, report.md for private_control_workspace_report — and neither does privacy. The value is part of the flow fingerprint (the SHA-256 over the raw flow: mapping), like every other header field.

flow:
  output_policy: repository_document
  report_dir: docs/adr # deliverables land in docs/adr/<task_id>/

The value is validated at load and the refusal names the key and the rule it broke (validator.py → _check_report_dir). It must be a repo-relative POSIX directory: no absolute path, no drive letter (C:/x), no .., no backslash (refused, never converted), no empty segment (so no trailing /), every segment portable on every OS and not a Windows device name (the same per-segment rules that guard a task id and a node id) — and it must not overlap the orchestrator's own roots (.worc/, .worc-io/), .git/, or the task lifecycle tree you configured as paths.tasks_dir, in either direction. A leading-dot directory outside those is ordinary: .worc-connect/triage is a fine base. On a code_change flow the key is a load error rather than a silent no-op, because that policy resolves no report directory at all.

A private report under a base outside .worc/ is yours to gitignore. The validator cannot see your ignore rules and does not pretend to, so "a private report never enters git" is carried at run time, in two places. At publish: a private report whose files are git-trackable ends the task at manual_action_required rather than reaching staging, a commit, or a PR. And under every other terminal too: a task that stops before publish still commits and pushes its branch, so the resolved private directory is excluded from that commit's staging set — declared at each commit from the task's frozen flow, so a fresh-process worc resume and the operator merge path are covered, and excluded base-wide on the merge sweep, whose staging unit is the whole tree. The report stays on disk, untracked, and terminal cleanup names it as an unaccounted change instead of publishing it. So if you point private_control_workspace_report at .worc-connect/triage, add that directory to .gitignore first.

The prompts of the nodes that write the deliverable should name {report_dir} rather than a literal path (see Role files): a prompt that hardcodes docs/research/<task_id>/ keeps writing there after an operator moves report_dir, and the after-stage guard hard-stops the task on the first write. The packaged deep_research/synthesis.md is the worked example.

Node kinds#

  • agent — launches a coding-agent CLI (Codex or Claude Code) with the node's role_file as its prompt. Fields: role_file (required), session_scope (fresh_disposable | editing_lineage | resume_own_lineage), permission_profile (≤ the flow ceiling), optional resume_role_file, skills, allow_skills, output_artifact, output_file, output_schema, hitl, when, lineage_affinity, timeout_seconds, extra_args, best_effort (an infrastructure failure on this node does not fail the task — the engine continues; used by the summary stage), and the per-node overrides below.
  • evaluator — a read-only judge that returns accept / rework. Fields: role (e.g. review), role_file, optional resume_role_file (with session_scope: resume_own_lineage), blocking (a failing verdict blocks vs is advisory), max_rework_per_stage, gate_severity (minimum finding severity that gates — one of blocking / critical / high / medium / low, default high; lower it to make a critic block on any finding). Evaluators are forced read-only, and session_scope: editing_lineage on one is a fatal ceiling violation (a judge must never inherit the author's context) — fresh_disposable or resume_own_lineage only. An evaluator returns a structured findings verdict and is fail-closed — see What a node returns. The two gating modes differ in where they end: a blocking evaluator reworks every time it finds a gating issue until the flow's named-loop budget is spent and then parks the task at manual_action_required (max_rework_per_stage is a non-blocking-only knob and is not consulted). A non-blocking evaluator (blocking: false) never parks the task: once its max_rework_per_stage budget — or the budget of the loop its rework edge charges, or the global max_total_fix_iterations backstop, whichever runs out first — is spent with a finding still open, it accepts and the flow continues, and the orchestrator emits a console warning naming the limit reached first + a ⚠️ Telegram trace (accept (rework budget exhausted)) so an operator knows the stage moved on and may need follow-up. Because it never parks, the loop guards (repeated_findings, no_file_change) do not apply to its rework edge, and worc validate-flow warns when that loop allows fewer rounds than max_rework_per_stage (one of the two numbers is then dead — make them agree). Its rework count is read from its recorded verdicts, so it survives rerun --continue; --reset-fix-budget starts it afresh along with the loop counters. Any findings an evaluator accepts with (sub-gate_severity, non-gating) are not discarded: at task close each evaluator node's final verdict is mirrored into the task's summary.json / summary.md follow-ups (and thus the PR body), deduped against the supervisor's own list, so a legitimately non-blocking finding still reaches the operator instead of vanishing. This runs regardless of supervisor.emit_follow_ups.
  • checks — runs deterministic repository commands, no agent. checker: command_profile runs the configured check command sets; other checkers exist (citation, which validates a sources manifest — the filename comes from the node's manifest: field, default sources.json, one portable filename inside the flow's report dir — and records the manifest_path it checked in its citation.json report so a downstream verifier opens the same file, and dependency_scan). Outcomes: pass / fail. The command_profile checker publishes its verdict to the next node as {checks_path} on both outcomes, and the two payloads differ because the readers do: on a failure it is the first failing command's log, which is the text a fixing node acts on; on a pass it is checks.json, one entry per command (command, exit_code, passed, skipped, timed_out). Read a skipped entry as "did not run", never as a pass. The pass payload exists for the evaluator asked to judge an acceptance criterion like "lint and build pass": with nothing published it had no evidence of the gate and, under a profile that gives it a shell, would re-run the commands inside the agent sandbox — which is not the environment the gate runs in, so a failure there says something about the sandbox and nothing about the diff. A checks node is fail-closed on an incomplete run, so a required toolchain that cannot launch — or every selected set skipped — parks the task at manual_action_required rather than publishing. Note skip_if_unavailable: true only half helps: it turns the launch failure into a loud skip, but a set that was the only one selected and is then skipped leaves the gate with nothing run, which parks on the same path. The clean escape is per task: nodes.<checks-node-id>.enabled: false. Two more properties worth designing around: command_profile sets are diff-selected, so an empty diff (or no configured command_sets) is a vacuous pass that costs nothing; and a green-but-dirtying check — one that passes but rewrote commit-candidate files across the run — also parks the task, so name a checking command (format:check), never a rewriting one.
  • tool — runs your own executable from .worc/tools/ out-of-process (any language), under the same launch ceiling as an agent. Fields: tool (the registered executable name), optional flat-scalar args, optional timeout_seconds, when. Outcomes: pass / fail / route:* (by exit code or an optional JSON object on stdout). Use it for deterministic logic that is neither an LLM step (agent) nor a built-in gate (checks). See Custom tool nodes.
  • hitl — a bare human gate: no agent, no prompt, no role_file. It pauses the flow for one durable human round-trip through the notifier (the same primitive the embedded refinement HITL and the dangerous-diff approval use), keyed by the node id. Fields: signal (question | approval, required), optional timeout_s (else telegram.ask_timeout_s), when. signal: approval emits route:approve / route:deny — declare both edges; signal: question proceeds unconditionally once any non-empty answer arrives. Fail-closed: no notifier configured, a timeout, a transport error, or an empty/undecided answer parks the task at manual_action_required. A restarted process resumes the persisted waiting interaction against its original deadline. (Distinct from the hitl: field on an agent node, which lets that agent ask mid-stage.)
  • publish — the terminal. policy: pull_request / documentation_pull_request commits the code + the audit trail, pushes, and opens a PR. policy: private_control_workspace_report finalizes a private report in the flow's report directory (.worc/security-reports/<task_id>/ unless report_dir moved it) and touches git not at all — it fails closed to manual_action_required if any file under the report dir is git-trackable (a leak); this is what the shipped security_audit uses. policy: local_artifact and policy: none are graph terminals that perform no Git action. The orchestrator still owns any real commit/push/PR.

Nodes never pick the next node or commit anything — the engine routes on edge outcomes, and only the orchestrator does Git.

Every node id must be a portable single-segment token: ^[a-z0-9][a-z0-9_-]{0,63}$ and not a Windows device name (con, nul, com1–com9, lpt1–lpt9). The id becomes an artifact directory name and, for agent/tool nodes, the {<node-id>_path} prompt variable — so a dot, an uppercase letter, a path separator, or a device name is rejected at flow load, host-independently (never sanitized). Agent/tool ids additionally may not shadow a reserved core variable (plan, diff, change_overview, review, conflicts, …, or a subtask-prefixed name).

Edges, outcomes, and loops#

Each edge is { from, to, outcome?, loop?, budget? }. A checks or tool node emits pass/fail (a tool may also emit route:*); an evaluator emits accept/rework; a hitl node with signal: approval emits route:approve/route:deny; a plain agent edge needs no outcome. An explicit route:<label> edge is accepted on any node kind. Any fail/rework edge that loops back must carry a loop: name (bounded in budgets:) or an inline budget: N. Both read the same way, and so does the global agents.max_total_fix_iterations backstop: a budget of N is N fix rounds, checked before a round is charged, so budgets: {sources_fix: 2} and budget: 2 both allow two rounds and refuse the third. (A named loop and the backstop used to allow one round fewer than their number; a flow tuned around that now gets one more round per loop.) Exactly one entry node (no incoming edges) is allowed, every node must be reachable from it, and every node must be able to reach a terminal — the validator enforces all three.

A named loop can also end before its budget, on evidence that it is not progressing, and the failure report's limit_exhausted says which evidence: no_file_change when the working tree stays unchanged across consecutive rework charges (the agent answers without editing), and repeated_findings when the same node returns an identical, non-empty finding set three times in a row (the fixer works, the verdict never moves). The second is read back from the recorded verdicts of the current attempt, so a restart does not reset it — write a critic lens whose findings name something the fixer can actually change, or it will trip this guard rather than converge. Neither guard applies to a non-blocking evaluator's rework edge: that node accepts instead of parking. A task that still reaches done afterwards — continued by an operator, or past an evaluator that does not gate publication — keeps that report as recovered-failure_report.json.

Skipping a node conditionally (when:)#

Any node may carry when: { fact: <namespace>.<name>, equals: true|false } (equals defaults to true); the node is skipped when the fact does not equal that value. The fact must be prefixed derived. or config. — a bare or typo'd namespace is a fatal load error — but the fact vocabulary itself is tiny and fail-open-to-off: only derived.needs_refinement (true unless the task file classified as complete) and config.external_research (true iff the flow declares a network_policy) resolve, and every other name resolves to false, which silently skips the node on every run. Treat when: as a statement of a node's structural dependency, not a switch; the operator-facing way to turn a node off is per task (nodes.<id>.enabled: false).

Either way the graph must route the skip: a skipped non-terminal node yields its skip-outcome and the engine takes the matching forward edge, so an outcome with no declared edge would fail the task. The per-task disable set is validated against the resolved flow before any side effect for exactly this reason — an unknown node id, or one whose skip-outcome matches no forward edge, ends the task at terminal failed rather than mid-run. A skipped terminal node needs no edge; it simply ends the flow.

Role files (prompts)#

A node's prompt is the content of its role_file — plus, on a turn that continues a session the node has already spoken on, the optional resume_role_file beside it. Both render the same variable set on the same node. Role files render only an allowlisted set of path/metadata variables — {task_path}, {repo_path} (alias {repo}), {plan_path}, {diff_path}, {change_overview_path} and {diff_base} (the change overview beside the diff and the full SHA the change is measured from — see What a node is handed about the change), {checks_path}, {review_path}, {memory_path}, {subtask_order}/{subtask_count}/{subtask_spec_path}, {predecessor_context} (the intra-task subtask handoff brief), {conflicts_path} (the merge flow's conflict inventory — empty outside a conflicted merge-task), {report_dir} (the resolved repo-relative per-task directory the flow's writing nodes are confined to — docs/research/<task_id>, or whatever base the flow's report_dir names), the two metadata scalars {task_id} and {stage}, plus every agent/tool node's {<node_id>_path} — never task bodies, diffs, env, or secrets. A variable that is empty for a given node renders as the empty string; wrap optional references in a conditional block {?name}…{/name} so they drop cleanly when empty. {report_dir} is the one exception to that rule: a role prompt naming it (or {?report_dir}) on a code_change flow is a fatal flow-load error, not an empty render, because that policy resolves no report directory and {report_dir}/report.md would silently become /report.md. For the full variable contract and which runner populates each, see Configuration → Prompt templates.

role_file paths are contained to the flow directory: a path with .. or an absolute path is rejected at load. Keep prompts inside your <task_type>/ folder.

Prompts and tools are frozen per task (WRI-010). When a task starts, the orchestrator snapshots the flow YAML, every role/supervisor prompt it references, and each tool executable into a private per-task bundle and runs the whole task against that frozen copy. So editing a prompt or tool in .worc/ while a task is running does not affect that task — the change lands on the next fresh task (or a rerun). An operator rerun --continue adopts live prompt/tool/flow edits: it re-freezes from the current on-disk copy and resumes from the checkpoint, so a between-run fix takes effect from there on (only automatic crash-recovery keeps the task's original frozen copy). This is transparent to authoring — you still edit .worc/flows/ normally.

What a node is handed about the change#

From the first workspace-write edit on, the context footer of every agent and evaluator node carries three things about the task's change, and a prompt does not have to name any of them: the change overview (.worc-io/<task-id>/change-overview.txt — one line per changed path with how it changed, A / M / D / R old → new, and its line counts, then the totals and the size of the full diff; at most 200 paths per section, so it stays a page however large the change is; a path security.denied_read_paths covers is listed by name only, marked [denied]), the full redacted diff, and Change base: <sha>. Once the orchestrator has committed inside the task — each subtask of a decomposed run — the overview gains a second section, "Change since the orchestrator's last commit".

The footer closes with one where-to-start sentence the orchestrator writes, because a prompt cannot branch on capability (change_guidance.py). It depends on whether the node can run commands on every provider its route may land on, anything the orchestrator cannot establish counting as no. A node with a shell is told to start from the overview, read added files directly, inspect any other file with git diff <base> -- <path>, read the full diff only if it needs all of it, and leave [denied] paths alone; any other node is told to read the full diff, with the overview as its map. Inside a decomposed run the sentence also names the second section as the subtask's own change. A fallback attempt handed only the partial diff an interrupted attempt left gets none of the three, since they describe a different change.

What that means for a prompt you write:

  • A role that judges what changed (a code review) needs to say nothing about where to start, and should not tell the node to read the whole diff first — that undoes the point for a node that can open only what it needs. The packaged review prompt starts from the overview and accounts for every path it lists.
  • A role that must read the whole change regardless — a critic judging a revision as a whole — says so explicitly and names {diff_path}, otherwise the sentence steers a node with a shell to the overview. The packaged content critics (tone_style, story_critic, en_critic) do this.
  • A role that fixes findings starts from the findings and the check log, using the overview only to locate the files they name; the packaged fixing prompts are written that way, and documentation picks the paths it covers from the overview.
  • To say something specific about the overview or the base, name {change_overview_path} / {diff_base} inside {?change_overview_path}…{/change_overview_path}, so the block drops before the first edit.

Continuation prompts (resume_role_file)#

A node in a loop runs more than once on the same conversation, and by default every one of those turns is handed the whole role file again — the same rules and remit into a session that already holds them. With budgets.test_fix: 15 that is fifteen restatements riding a history the provider re-sends each turn, and a start-of-work text ("Implement the assigned task…") replayed at an agent that merely ran out of turns reads as "start over".

Name a second, short file and the orchestrator picks which one each turn gets:

- id: fixing
  kind: agent
  role_file: my_flow/fixing.md
  resume_role_file: my_flow/fixing.continue.md # only on a turn that continues this node's session
  session_scope: editing_lineage
  lineage_affinity: implement

You never write the choice into the prompt. The continuation file is used only when a session is live and this node has already spoken on it — deliberately not "is a session being resumed". An editing lineage is keyed by its affinity target, so fixing joins the session implement opened: on fixing's first round the conversation has history but nobody has stated fixing's rules yet, so it gets the full text; from round two on it gets the short one. An evaluator needs no such check — its session is its own and is written only by its own successful pass, so round 1 always gets the full text and every later round the short one.

If the session is lost — the provider dropped it, or the run failed over to the other provider — that attempt is handed the full text automatically, so a continuation turn can never land in an empty conversation. That is decided at the seam that also decides the resume argv, so it needs no flow change.

Allowed only where a session actually survives: session_scope: editing_lineage on an agent node, resume_own_lineage on an evaluator. Anywhere else it is a validation error rather than a field that quietly never fires. It is also not available in a flow's defaults: block — a prompt file belongs to one node.

What to write into it: what changed since the last turn (the artifact paths, which are the only thing that really differs round to round), what is expected of this turn, and the one line of the output contract whose wording carries meaning the schema cannot. What to leave out: the rules, the remit, and anything already stated — a continuation file that restates the main one has bought nothing.

Both files render the same variables, and the variable gates that decide whether to publish an optional block ({?memory_path}, {?predecessor_context}) read both templates — otherwise a continuation prompt naming either would render an empty block and say nothing.

The shipped flows use it: implementation/implementation.continue.md, implementation/fixing.continue.md, merge/fixing.continue.md, deep_research/critic.continue.md, and the four content flows' fixing.continue.md.

Per-node overrides#

Every agent/evaluator node may pin its own provider (codex | claude), model, and reasoning; omit any and the node inherits the config.yaml provider defaults (provider ⇒ the global primary). A node may also set network_access: true|false to override the flow-wide network default for that node alone, and git_evidence: true to ask for the read-only git verbs. Spend more reasoning where rework is decided (review), less on mechanical steps. See Configuration → Per-node overrides in flows.

Read-only git evidence (git_evidence)#

An audit node that treats delivery history as prime evidence needs to read it. git_evidence: true on an agent or evaluator node asks for that; the operator's security.allow_git_evidence decides whether the request is honored. Both halves are required — a flow can express the need but cannot grant itself the capability, and the switch hands the verbs only to the nodes that asked, never to every read-only node in the run. With the switch off a declaring flow still loads and validates; it simply runs as it would without the declaration. Note the shipped posture: install writes security.allow_git_evidence: true, so on a fresh workspace the request is honored — and under security.strict_isolation: false (also what install writes) the key is inert, because the advanced mode already hands every node an unscoped shell and there is no capability left for the grant to add.

It only belongs on a read-only node. git_evidence: true on a node that resolves to workspace-write is a fatal ceiling violation, not an ignored field: such a node already has an unscoped shell, so the declaration would buy nothing there — and narrowing that shell to the git verbs is the opposite of what the field means. Drop the field, or set permission_profile: read-only on the node.

What you get is an observable contract, not a flag list: history is readable, the repository cannot be changed, nothing is published. The two providers reach it differently, and the guarantees have different shapes:

  • Claude has no shell at all under read-only, so the grant adds one and scopes it to the read-only verbs (log, show, diff, blame, status, rev-list, rev-parse, ls-files, shortlog, describe, cat-file, for-each-ref). A command matching none of them is refused by the CLI before it runs. Underneath that allowlist the OS sandbox write-denies the whole clone — so the allowlist is the convenience and the sandbox is the guarantee. On a host where a shell cannot be sandboxed the adapter refuses the attempt rather than running it unsandboxed (on Linux/WSL2 missing bubblewrap+socat that is a hard preflight-class failure; on native Windows the shell is simply dropped and the node runs as an ordinary read-only node) — both of those are the security.strict_isolation: true default; under strict_isolation: false the operator has accepted the risk and the shell stays, unsandboxed.
  • Codex needs nothing added: its read-only sandbox already permits commands, so git log works there today. What forbids mutation is not a verb list but the sandbox — the workspace is mounted read and the network is off, so git commit has nothing to write to and git push nowhere to go. That is the stronger of the two guarantees, and no prompt, task, or flow can argue with it.

So do not expect a symmetric verb allowlist across providers; expect the same observable contract. security.denied_commands stays the floor beneath both (a deny always beats an allow), and publishing remains the orchestrator's alone.

Re-verify the Claude deny direction when you pin a new claude major. That the CLI denies a Bash call matching no --allowedTools pattern — rather than approving it — is behavior no offline test can assert and no --help grep can answer; preflight checks that the flag exists, not what it means. It was verified by hand against claude 2.1.217, under the argv this adapter builds, with two probes: (1) --allowedTools "…,Bash(git log:*)" plus a request to run echo x > f.txt must come back an error tagged "non_execution_kind": "permission-rule" with no file created; (2) git log --oneline -1 must run clean. If a future CLI approves the first probe instead, the verb allowlist is decorative and only the sandbox confines the shell — history stays readable and the repository still cannot be changed, but the node is no longer held to reading git. The documented fallback in that case is Codex's shape: drop the allowlist and rely on the sandbox alone.

If a write from such a node ever does land, the run does not park: a console warning plus a ⚠️ Telegram trace (done (node wrote to the workspace unexpectedly)), and the run continues — the change is neither published nor handed to any downstream node. The label lost its read-only wording because the event is no longer specific to that profile: every node class that gets a shell without write access is bracketed the same way, and git-control drift (done (node changed git control state)) no longer parks a task either. The reasoning matches a non-blocking evaluator's exhausted budget: the capability is real and worth keeping, so a stray file is reported rather than traded for it.

The same never-park rule covers a change to Git control state (a hook, .git/config, the index): a warning plus a ⚠️ done (read-only node changed git control state) trace naming the drifted aspect. Treat that one as a stop-the-run signal, not a note. The two events differ in cost: a stray file is inert (nothing stages it, nothing downstream reads it), while a planted .git/hooks/post-commit is executed by the next git command in that clone — and the next one is the orchestrator's own commit or push. Kill the run, discard the clone, and look at what was planted. A workspace-write node doing the same still parks the task in manual_action_required.

Node-declared skills#

An agent node can name the target repository's own Claude Code skills, so a deterministic flow drives instructions your team already wrote and tested instead of a re-authored copy that drifts:

- id: implement
  kind: agent
  role_file: my_flow/implement.md
  skills: [acme-tdd, acme-migrations] # advanced mode only

The graph, the gates, the isolation and the publication stay the orchestrator's; what the node does inside its turn becomes your own instruction set. The orchestrator appends one deterministic block to the node's prompt naming each skill — you never write it, so it cannot drift from the YAML — and that block states the precedence out loud: where a skill conflicts with the security preamble or the role prompt, those win, and no skill grants any right to commit, push, or open a pull request. Do not put skill names in the role file instead: a /name typed into a prompt is text the model may or may not act on, while the key is a validated field the run refuses to start without.

Six things to know before you rely on it.

  • Skills are off unless a node asks. A node declaring neither key runs with the provider's own per-attempt off-switch (Claude --disable-slash-commands, Codex --disable skill_search), and every built-in flow's node is in that state. A skill the flow never asked for could otherwise fire on its own description, and a graph whose steps are chosen by a description in the target repository is not the deterministic graph you adopted this orchestrator for. Naming a skill is itself the request; allow_skills: true turns them on without requiring any particular one.
  • Requiring is advanced mode only. Under security.strict_isolation: true the Skill tool does not exist for the session at all, so skills: and an explicit allow_skills: true are refused at validation rather than accepted and left inert — an inert skills would let a run report success having silently skipped your tested step. Omitting the key is never an error, and allow_skills: false is legal at every value of that switch: a flow may always narrow.
  • The skill must live in the target repository. <repo>/.claude/skills/<name>/SKILL.md, resolved fail-closed before anything runs — a name that does not resolve refuses the task and names the path it looked at. One portable directory name each (no separators, no ..). Your user-level ~/.claude/skills is deliberately not loaded: reaching it would import your user-global hooks, MCP servers and plugins into every run.
  • Skills are not frozen for the task. Unlike the flow YAML and the role prompts, which are frozen at task start, a skill is an ordinary repository file the CLI discovers and reads by itself — so a writing node earlier in the run can change one and the next node reads the changed text. Keep skills out of the paths your nodes edit if that matters.
  • The off-switch stops invocation, not reading. With skills off the session genuinely reports no skill tool and an empty skill list — a real CLI gate, not prompt text. It does not make the files invisible: a node with a shell can still find a SKILL.md, read it and follow it as ordinary text. Read it as "this node will not run a skill as a step", never as "this node cannot see them".
  • Agent nodes only. skills and allow_skills on an evaluator are a fatal unknown-field error at load. An evaluator is forced read-only and returns a typed findings contract a skill could disturb, so every evaluator — and every supervisor turn — runs with skills off.

An empty skills: [] is rejected (omitting the key already means "off"), and skills cannot be combined with allow_skills: false. The declared names and the resolved switch are persisted on the node run and in the prompt audit.

Flow-level defaults#

defaults.evaluator is applied to any evaluator node that omits the field: session_scope (default fresh_disposable), permission_profile (default read-only), max_rework_per_stage (default 1), gate_severity (built-in default high). Use it to avoid repeating the same settings across many evaluators — deep_research sets gate_severity: medium here, covering both of its evaluators at once, because its evaluators judge whether a research deliverable is good enough rather than whether code is correct and so have no natural way to emit high/critical.

The flow's supervisor: block#

The supervisor is a layer above the flow, not a node — but a flow may reshape its wording and narrow its cadence. Only wording lives in files; the structured schemas (follow_ups, memory_delta) stay in code, so a flow author can never break the machine contract the orchestrator parses.

supervisor:
  role_file: my_flow/supervisor.md # observe lens; else config.supervisor.role_file, else built-in
  finalize_role_file: my_flow/summary.md # the final-summary lens; else the built-in
  handoff_role_file: my_flow/handoff.md # the subtask-brief lens; else the built-in
  emit_follow_ups: true # opt the finalize turn into the evidence-gated follow_ups array
  observe:
    mode: events # may only NARROW config.supervisor.observe.mode
  • All paths are contained to the flow directory, like any role_file.
  • emit_follow_ups is a code-oriented capability (technical-debt / refactor signals); research and prose flows leave it off. It costs no extra call — the same finalize turn emits the array. It governs only the supervisor-authored half: your evaluators' accepted findings reach the follow-ups section regardless.
  • observe.mode may only narrow. A flow declaring a broader mode than the config fails validation naming both modes — a flow is authored content and must not be able to spend more than the operator allowed. The corollary bites in the other direction too: because a flow that states events is asserting it needs deviation notes, an operator setting the global mode to none gets a rejection for that flow rather than a silent degrade. Narrow the flow's own copy if that is what you want. The rule does not apply at supervisor.enabled: false — there is no cadence to widen, so such a flow runs unchanged.
  • A finalize lens is worth writing when the built-in one is wrong for your deliverable. The built-in is written for a code change ("grounded in the actual committed change"); applied to a research deliverable that is two new Markdown files it produced a summary with two false claims and re-presented the critic's central finding as a strength. deep_research therefore ships its own, which additionally forbids first-person verification claims — the finalize turn describes what the pipeline did, never what it personally re-opened.
  • State the length expectation in your finalize lens. Prose that comes back below a short collapse floor is discarded: no summary.md is written, the run is flagged degraded, and the deterministic report becomes the PR body (the discarded text rides a WARNING). Every packaged lens and the built-in fallback state this, so the contract sits where the model reads it.

Editing sessions and lineages#

session_scope decides how a node reuses the provider's LLM session (its accumulated conversation context):

  • fresh_disposable — a cold session every visit; nothing is carried forward. The default, and the right choice for a read-only evaluator unless it genuinely needs its own memory across rounds (an evaluator must never inherit an author's context, so editing_lineage on one is a fatal ceiling violation — resume_own_lineage is the escape).
  • editing_lineage — a durable session shared across a group of editing nodes so they keep continuous context. This is what an edit → fix loop uses.
  • resume_own_lineage — a node's private durable session across its own rework rounds (e.g. a critic that must remember what it already flagged); not shared with any other node.

Token cost: resuming a session (editing_lineage, resume_own_lineage, and joining via lineage_affinity) carries the shared session's full accumulated history into the next stage. That preserves context, but every following model turn re-sends the whole transcript, so input-token usage grows with each resumed turn. Share a session deliberately — it earns its cost when stages genuinely build on each other (an edit → fix loop), and wastes it when a later stage only needs a small, self-contained input (a one-line polish). Prefer fresh_disposable between semantically different stages, and pass what the stage actually needs as an artifact instead.

A flow can carry more than one durable editing session per execution unit — one per lineage. The lineage key is derived from the graph, lineage_affinity or <node id>:

  • An editing_lineage node with no lineage_affinity owns a lineage named after itself.
  • A node with lineage_affinity: X joins the lineage owned by X, resuming and updating that same session.

So in the example above, fixing (lineage_affinity: implement) continues the session implement established. To run two isolated tracks in one flow — say a code track and a separate spec track — give each track its own affinity-less editing_lineage owner node and point that track's fix node at it; the two sessions never leak context into each other. (Both tracks still edit the same working tree and join the same committed diff — a lineage is about session context, never filesystem isolation.)

Two rules the validator enforces: a session cannot resume across providers (an editing_lineage node and its affinity target must not pin conflicting providers), and affinity chains are forbidden — a lineage_affinity target must itself be a lineage owner (a node with no affinity of its own), so affinity is one hop only.

Note: two editing_lineage nodes that both omit lineage_affinity are now two separate lineages (each keyed by its own id), not one shared session — read operator flows with the lineage_affinity or <node id> rule in mind.

What a node returns (output contracts, schemas, and slots)#

Every agent and evaluator node returns a typed structured result, not free text — but you almost never write the shape yourself. The node's kind and role select a built-in output contract automatically:

Node Built-in contract The agent is required to return
agent, plain author none its final message (no schema enforced)
agent with hitl: declared human_input content + an optional question/approval object
agent named by decomposition.proposed_by planning content + optional human_input + decompose + subtasks
evaluator findings { findings: [ { severity, path, what, fix } ] }

Two properties of these contracts change how you write the role prompts:

  • The evaluator contract is fail-closed. An empty findings array is a real clean pass, but a missing or malformed one means the agent ignored the schema — the orchestrator refuses to guess and sends the task to manual_action_required instead of a silent accept. So an evaluator role prompt must actually produce the findings result; a prose-only "looks good to me" review will hard-stop the task. The contract cannot express "I could not review", and an empty array is not the way to say it — that is the clean verdict, and it would publish an unreviewed diff. Tell the evaluator instead that a gating finding always names a real path and that an inability to review belongs in the finding's own text. When every gating finding is pathless the orchestrator announces the wasted round rather than preventing it: a console warning naming the first finding plus a ⚠️ rework (no gating finding names a path) trace label. Routing is untouched — a blocker that gates for a real reason but was written without a path must not be discarded — so the loop keeps its named budget.
  • The core re-validates every typed result itself, independently of the provider. A malformed planning / human_input / findings result fails the node rather than being trusted — you cannot loosen this from a flow.

Where a node's output goes#

A node's output is written to a file and passed to later nodes as a path variable — never inlined as text (the same rule as all prompt variables). Two mechanisms:

  • Named slots — set output_artifact: <slot> on an agent node to land its content in a well-known file that downstream nodes read by variable:
output_artifact File Downstream variable Typical filler
enriched_spec task.enriched.md — (audit only) a refinement node
plan plan.md {plan_path} a planning node
summary summary.md {summary_body_path} (the publish node's PR body — not a prompt variable) usually the supervisor layer, not a flow node
report report.md in the flow's private report dir — (audit only) the report node of a private_control_workspace_report flow

The slot vocabulary is fixed to these four; a flow only chooses which node fills each, and one node fills at most one slot. The node returns the content as its structured output — the orchestrator writes the file — so a slot node needs no writable path: the packaged security_audit report node is permission_profile: read-only and still produces report.md. A node that fills a slot writes no {<node_id>_path}: the slot is its channel.

  • Generic channel — every other agent node's output is written to <node_id>.out.md, and every tool node's redacted stdout to stdout.txt, both under that pass's stages/<node-id>/run-<node-run-id>/ directory, each exposed automatically as {<node_id>_path} (resolving the node's latest run). So a later node can consume an earlier node's output by naming that variable in its prompt. Both agent and evaluator prompts resolve these names, so an evaluator can grade an upstream node's own work and not only the file some later node wrote from it — that is how a coverage gate grades the analysis passes behind it.

  • output_file: — when the file is the output. For a node whose real product is a file it writes, declare output_file: <filename> and {<node_id>_path} resolves to a redacted copy of that file instead of the node's closing message. A node that writes a document and then describes it in one paragraph otherwise hands the next node the paragraph, which is the smaller half — the packaged deep_research synthesis node sets output_file: report.md for exactly this reason, so its evaluators grade the report rather than a summary of it. Three rules: it is one portable filename, not a path (no separators, no .., no reserved name — fatal at load), resolved inside the flow's output_policy report directory or, for a policy without one, the repository root; it is mutually exclusive with output_artifact (declaring both is a fatal contradiction — a slot node's channel is its slot); and the name must match what the role prompt tells the node to write, because a file that never appears falls back to the closing message with a WARNING rather than failing. Either way it stays a path, and downstream prompts are unchanged ({synthesis_path} is {synthesis_path}).

Overriding a node's output_schema (the one real foot-gun)#

An agent node may set an inline output_schema: (a JSON Schema) to override the built-in contract when a custom node must return data of your own shape. One rule dominates:

Every object in the schema — the top level and every nested object — must set additionalProperties: false.

Codex enforces --output-schema through OpenAI Structured Outputs, which rejects any non-strict schema with a hard 400, failing the node on every run (this exact mistake once broke the built-in review node). Claude tolerates a loose schema, but write it strict so the same flow runs on both providers. Also:

  • Keep the schema flat and fully typed. Deeply nested or loosely typed schemas make structured output fragile and slower to produce.
  • If your schema omits the content key, the named slots have nothing to persist — keep a string content field when the node also fills plan / summary / enriched_spec / report.
  • No extra flow or config is needed for Codex to hand the JSON back — the adapter reads it from the run's last-message file for you. Your only job is a strict schema.

The supervisor summary/follow-ups and the memory delta are produced by the constant orchestrator layer above the flow, not by nodes you author — you never define their schemas (you only toggle them via supervisor.emit_follow_ups and the memory config block). The follow-ups list also absorbs any findings your evaluator nodes accepted with (see Node kinds → evaluator), merged in at task close independent of emit_follow_ups.

Custom tool nodes#

A tool node runs your own program instead of an LLM — any language, by contract, not by interpreter. Use it for deterministic logic that is neither "smart" work (agent) nor a built-in gate (checks): a bespoke .md linter, a data producer for the next node, a router.

Where a tool lives. Put the executable at .worc/tools/<name> and reference it from a flow by that one name, never a path. Resolution is cross-platform from the single name: on POSIX the bare <name> must be chmod +x; on Windows the resolver also tries launcher suffixes, so the same flow name finds <name>.cmd/.exe (and a .cmd/.bat is launched through the command interpreter, since Windows cannot start a batch file directly). The registry resolves the name to a contained, executable file and rejects anything else (a missing tool, a traversal, a symlink out of .worc/tools/) fatally at flow validation — the config-aware layer the engine runs at dispatch and worc validate-flow runs on demand — so a bad tool name fails that task before any node runs rather than mid-flow. (preflight does not check it: it validates no flows at all — see Validation catches flow errors before any task runs.)

Built-in tools ship with the orchestrator. worc install delivers packaged tools into .worc/tools/ (per machine, so the launcher always matches the OS), exactly as it delivers the built-in flows — a plain re-run fills in missing files, --reconfigure snapshots the existing dir first. On POSIX every delivered file also gets the +x bit, since a wheel does not carry it and the registry requires it there. The content flows' check_chapter prose gate and the blog flows' check_length size floor are two such tools (see check_chapter and check_length below). Because a packaged tool node is resolved in every repo that dispatches the flow, its executable must be delivered everywhere — which the installer guarantees.

The contract (like a Claude Code hook). The orchestrator runs the tool through the same launch ceiling as an agent — an argv list (never a shell string), a mandatory timeout, and exactly the allowlisted security.allowed_environment (the parent environment is never inherited). It feeds a small JSON context on stdin — only allowlisted paths + your args, never secrets, the full environment, or a session id:

{
  "task_id": "…",
  "node_id": "…",
  "subtask_order": null,
  "paths": {
    "repo": "…",
    "task_path": "…",
    "plan_path": "…",
    "diff_path": "…",
    "change_overview_path": "…",
    "diff_base": "…",
    "checks_path": "…",
    "review_path": "…",
    "conflicts_path": "…",
    "report_dir": "…"
  },
  "args": { "…": "…" }
}

paths.change_overview_path and paths.diff_base arrive with paths.diff_path — null until the first workspace-write edit — and diff_base is the one entry that is a commit SHA rather than a path. paths.report_dir is the flow's resolved repo-relative report directory for this task (the same value a prompt sees as {report_dir}); it is null on a code_change flow. The tool reports back through its exit code and an optional JSON object on stdout:

  • Linter style — just exit 0 (→ pass) or non-zero (→ fail); stdout is ignored as the outcome but saved as an artifact. Minimum effort.
  • Rich style — print {"outcome": "pass" | "fail" | "route:<label>", "findings": [...], "data": {...}}. A JSON outcome is authoritative (an invalid value fails closed to manual_action_required); route:* drives an explicit edge. findings and data are recorded (shown to the human / supervisor) but never auto-applied — the core never turns a returned value into a Git or state write.

Two infrastructure shapes are lifted out of that contract before the outcome is read, and both park the task at manual_action_required without charging a fix iteration. A crashed tool — a non-zero exit with empty stdout and non-empty stderr — cannot be an actionable quality verdict, so a redacted stderr head goes into the stop reason instead of routing a fixer at nothing. And a repeated failure, bounded by which channel the tool reported through — "no findings" is three situations, not one. With a top-level findings array the detector is off and the loop's budget is the only limit. With data alone the failure is still actionable, so the limit is the fix budget the flow declares for this node's rework/fail edge (named or inline, the larger when several; never below two): only that many byte-identical results in a row end the loop. With neither, nothing downstream can act on the repeat, and the second identical result parks the task before another fix edge is charged. The park message says no top-level findings array was returned and quotes a bounded head of the (redacted) repeated output; a changed report restarts the count. Because whether a budget of six means "six fix rounds" or "six identical failures" depends on your program, not on the flow, worc validate-flow prints a WARN for every tool node whose loop declares a budget. The practical consequence for a tool you write: when you return fail, return findings with it.

Write stdout as UTF-8 explicitly — this is a contract, not a nicety. The runner decodes a tool's stdout as UTF-8 on every OS, so a tool that falls back to the host locale encoding is broken on Windows only, and silently until the first non-ASCII byte. A piped child on a Windows host gets cp1252, which cannot encode a ≤ or a → — so a violation message carrying one kills the script inside its own print, and the node then reports a crashed checker instead of the fail it had already computed. Both shipped tools pin stdin/stdout to UTF-8 for exactly this reason (in Python: sys.stdout.reconfigure(encoding="utf-8"), or write bytes). Both once got this wrong, which is why it is stated here.

Composition. A tool's redacted stdout is exposed downstream as {<node_id>_path}, exactly like an agent's output. That is how a tool-as-check hands its report to a fixer agent — the fixer's role prompt reads {md-check_path} — and how a tool-as-producer feeds the next node. No magic: findings reach the agent as a path variable, not through the engine.

Args are a flat scalar mapping (str/int/float/bool) declared in the flow — allowlisted static config, not secrets. Timeout resolves node.timeout_seconds → config.tools.default_timeout_seconds → the built-in 3600s; a timeout is an infrastructure failure (→ manual_action_required, not a quality fail, and it never spends a fix iteration), as is a launch error.

The honest v1 boundary (do not over-trust). A tool is not OS-sandboxed the way codex/claude sandbox themselves — its real ceiling is file trust (you own .worc/tools/, exactly as you own your flows and config.yaml) plus the env-allowlist (no secrets reach it), artifact redaction, the mandatory timeout, and the absence of any core path that applies its output to Git/state. Consequently network_policy is not forced on a tool in v1 (an arbitrary binary can open a socket); if you need hard network/filesystem isolation, that is an OS/container concern (a deferred follow-up). Treat a tool with the same trust you treat a role prompt you author.

Example — a tool check that routes back to a fixer#

nodes:
  - id: md-check
    kind: tool
    tool: md-check # → .worc/tools/md-check
    args: { min_chars: 500, max_chars: 4000 }
  - id: fix
    kind: agent
    role_file: roles/fix.md # its prompt references {md-check_path}
edges:
  - { from: md-check, to: publish, outcome: pass }
  - { from: md-check, to: fix, outcome: fail }
  - { from: fix, to: md-check } # unconditional — back for a re-check

roles/fix.md shows the agent exactly the findings, via one path line: Report: {md-check_path}.

The check_chapter prose gate#

check_chapter is the built-in tool shipped for the content-authoring flows (content_chapter, content_translate). It is a self-contained script (no third-party dependencies), delivered on install as an extensionless +x file plus a check_chapter.cmd Windows launcher.

It reports through the standard tool contract above (JSON {"outcome", "data"} on stdout, exit 0/non-zero). Scope is only the changed chapters: it reads the changed .md paths from the run's diff_path, falls back to .md files named in task_path, and if neither yields a chapter it is a vacuous pass — so the gate never fails on pre-existing issues in untouched chapters.

The structural rules always run: ≤1 title per page; ## → ### → #### hierarchy (no skipped level); pages open with a ## Part; Purpose + Emotional point present; the not …, but … AI-antithesis pattern (+ a small English cliché list); no service-label headings (What this is, Philosophy, How it works, Usage, Author's note). The per-page length rules are opt-in — they run only when the flow passes the matching args, exactly like check_length:

args key Enforces
min_chars A page's prose must be at least this many characters. Omit or set 0 to disable.
max_chars A page's prose must be at most this many characters (a hard cap). Omit or set 0 to disable.
max_paragraphs A page may have at most this many paragraphs. Omit or set 0 to disable.

So content_chapter runs the gate with args: {} (structure only) and content_translate adds args: { min_chars: 1000, max_chars: 1400, max_paragraphs: 8 } (structure + per-page length). Its rule set is deliberately opinionated for one long-form chapter format, not a generic prose linter — the constants under "domain constants (tunable)" in the delivered script are a starting point to fork and tune under your own .worc/tools/check_chapter for a different project's structure, language, or house style.

The check_length size-floor gate#

check_length is the built-in tool shipped for the blog flows (blog_article / blog_article_revise) — a generic minimum-size floor, usable by any flow's tool node, not tied to blogging specifically. It is a self-contained script (no third-party dependencies), delivered on install as an extensionless +x file plus a check_length.cmd Windows launcher.

It reports through the standard tool contract above (JSON {"outcome", "data"} on stdout, exit 0/non-zero). Unlike check_chapter, scope resolution has no task-text fallback: it reads the changed .md paths from the run's diff_path alone, and an absent/empty diff is always a vacuous pass. A prose task body routinely names reference docs (a tone-of-voice guide, an idea doc) that are not the deliverable; falling back to "any .md named in the task text" once made the gate measure those instead of the actual output on a run where nothing had been written yet (the 2026-07-14 codex-Windows-sandbox post-mortem, finding F3). For each resolved file it strips markdown heading lines, groups what remains into paragraphs on blank-line boundaries, and fails if the prose falls short of either floor in args:

args key Enforces
min_chars The heading-stripped prose must be at least this many characters. Omit or set 0 to disable.
min_paragraphs The heading-stripped prose must have at least this many paragraphs. Omit or set 0 to disable.

A longer/more-paragraphed document always passes — this is a floor, never a ceiling.

Registering and running the flow#

Registration is implicit: the file is the registration. Put my_flow.yaml under .worc/flows/, and a task selects it with front matter:

---
id: task-123
title: "Do the thing my_flow does"
task_type: my_flow
---

An unknown task_type (no matching flow file) fails the task at flow resolution before any branch is created. A task only names the flow — it can never edit the graph (the one exception is disabling a node per task with nodes.<id>.enabled: false).

Validation catches flow errors before any task runs#

A flow is loaded and validated at dispatch, when a task resolves it — so a broken or unsafe flow fails that task rather than a global gate. preflight does not validate flows. Check them on demand with worc validate-flow, which runs exactly the validator the engine runs at dispatch, read-only, over your .worc/flows/, without claiming anything:

worc validate-flow my_flow    # one flow (bare stem, or my_flow.yaml)
worc validate-flow --all      # every *.yaml in .worc/flows/

Exit 0 = every checked flow is valid, 1 = at least one is not, 2 = the named flow was not found, a usage error (a bare worc validate-flow with neither a name nor --all, or both at once), or the config would not load — so worc validate-flow my_flow && worc watch gates correctly. Note --all checks every file in .worc/flows/, so one unrelated broken flow there fails the gate; name the flow you are about to run when that is a problem. WARN lines never change the exit code; there are three: an unknown {token} in a role prompt, a tool node whose loop declares a fix budget (see Custom tool nodes), and a non-blocking evaluator whose rework loop allows fewer fix rounds than its max_rework_per_stage (the loop's flow budget, clamped to agents.max_fix_cycles, or its inline budget) — it would accept with ⚠️ after the smaller number, so raise the loop budget or lower max_rework_per_stage until they agree. The scope is your operator flows only — a packaged built-in you have not edited is covered by the orchestrator's own suite, not by validating it against this repo's config.

Every violation in a layer is collected and reported together, and validate-flow prints each one on its own indented line under the FAIL — flow validation failed (N violation(s)): header, so one pass shows you all of them. Three layers run:

  • Graph integrity — every node id is a portable single-segment token (not a dot/separator/uppercase/Windows device name), edges resolve, outcomes are valid per node kind, every fail/rework edge is bounded and every named loop: is declared in budgets, exactly one entry node, every node is reachable from it, every node reaches a terminal, each lineage_affinity target is an editing_lineage owner with no affinity of its own (no chains) and no conflicting pinned provider, and every decomposition reference resolves into a connected region with a forward exit.
  • Security ceiling — no node's permission_profile exceeds the flow permission_ceiling; evaluators are forced read-only and may not use editing_lineage; git_evidence is not declared on a workspace-write node; role_file and the supervisor.* prompt paths contain no traversal; unknown fields fail closed; a report_dir is a repo-relative POSIX directory with portable segments that overlaps neither .worc/, .worc-io/ nor .git/, is absent on a code_change flow, and no code_change role prompt names {report_dir}. A node's extra_args is checked against a config-independent ceiling — one that does not consult config.yaml at all — so the absolutely-forbidden flags (--dangerously*, --yolo, --ignore-rules) and both provider full-access selectors (Codex --sandbox danger-full-access, Claude --permission-mode bypassPermissions, in either argv spelling) are refused here whatever the config says. A --permission-mode value weaker than the profile the node asked for — auto over a stricter profile, say — is refused too, checked over the provider config and the node's extra_args together.
  • Config consistency — a pinned provider is in agents.allowed, its reasoning is supported by that provider, Codex is not routed to a workspace-write node resolving network_access: true, at least one allowed provider can operate at the flow's permission_ceiling, a tool: node names a tool actually delivered under .worc/tools/, the flow's supervisor.observe.mode does not widen the config's cadence, a report_dir does not overlap the configured paths.tasks_dir tree (the one reserved root only the config can name), and (under security.strict_isolation) no extra_args selects a full-access sandbox mode.

Two things this layer deliberately does not reject, because the orchestrator already degrades them safely: a flow budget above the agents.max_* cap (the engine clamps it at run time — that is how an operator tightens a bound), and a PR-publishing flow under git.create_pull_request: false (a supported local-commit configuration).

Two failure modes are worth calling out because they cost you a queued task rather than a command:

  • A cadence widening is fatal but cheap in machine terms — it happens during flow resolution, before branch prep, so no provider runs and nothing is committed — yet the task has already been claimed by then and ends in terminal failed, which you re-queue by hand. Validate after editing observe.mode on either side.
  • On resume, the live flow is re-validated against the live config, so a config change can only ever narrow what an in-flight task may do.

Inspecting rendered prompts and artifacts#

  • Set prompt_audit: true (config-wide or per task) to record who received what prompt per node run under logs/<task-id>/prompt-audit/. Compare the rendered prompt against your role file when an edit "did nothing" — you may be editing a different copy than the one the node resolves.
  • Per-run artifacts live under .worc/logs/<task-id>/, one directory per node run: stages/<node-id>/run-<node-run-id>/ holds that pass's rendered-prompt.md, findings.json, <node-id>.out.md, checks reports, and tool streams, so a rework loop keeps every pass instead of clobbering the last. stages/<node-id>/history.jsonl is the chronological index. Task-level slots (plan.md, current.diff with its change-overview.txt, summary.md/summary.json) sit at the task root. Node execution and status live in state.db (inspect with status).

Best practices and foot-guns#

  • Keep the ceiling as low as the flow needs. Set permission_ceiling: read-only for an advisory flow; grant workspace-write only to the nodes that must edit. A task can never widen the ceiling — get it right in the flow.
  • role_file discipline. Put every prompt inside the flow's own <task_type>/ folder; use relative paths only. Do not point at roles/ (that folder is the shared supervisor layer, not your flow's prompts).
  • Bound every loop. Any fail/rework edge that loops needs a budget; an unbounded loop fails validation. A budget of N is N fix rounds; for a non-blocking evaluator, give its loop at least max_rework_per_stage rounds, or validate-flow warns that the smaller number wins.
  • Network is off by default. Declare network_policy for a flow-wide grant, or network_access: true on the single node that needs it. Codex workspace-write + network is rejected — split external fetches into a read-only node. All of that describes the shipped default only: under security.strict_isolation: false (the advanced mode, and what install writes) every node is online whatever the flow granted, so network_policy / network_access are inert there — as are permission_profile and git_evidence, since every node already has an unscoped shell and writes across the workspace volume. Keep strict isolation on when a flow's offline guarantee is load-bearing; the mode removes it.
  • One entry, all reachable. Design the graph so exactly one node has no incoming edge and every node can reach a terminal (a node with no outgoing edges — normally your publish node).
  • Do not gate a node with when:. The fact vocabulary is two names long and anything else resolves to false, so a when: you invented skips the node on every run, silently. Turn a node off per task with nodes.<id>.enabled: false (Skipping a node conditionally).
  • Prompt variables are paths only. Never expect task bodies, diffs, or secrets in a prompt — only the allowlisted path/metadata variables are substituted.
  • Name the report directory as {report_dir}, never as a literal path. A report flow's writing prompt that hardcodes docs/research/<task_id>/ keeps sending the agent there after an operator sets report_dir, and the after-stage guard hard-stops the task on the first write (report_dir).
  • Strict output_schema or none at all. If you override a node's output_schema, put additionalProperties: false on every object in it — Codex rejects a non-strict schema with a 400 and the node fails every run. Prefer the built-in contract unless you genuinely need a custom shape (What a node returns).
  • Evaluators must emit findings, not prose. A review/verifier/critic role prompt has to return the structured findings result; a prose-only verdict is treated as "schema not honored" and fail-closes the task to manual review.
  • Validate before you rely on it. Run worc validate-flow <name> after every flow edit — not preflight, which no longer checks flows. It fails closed, printing the header and every violation beneath it, and a config edit can invalidate a flow too, so run it after those as well.

See also#