Authoring workflow templates
A template is a reusable workflow spec: a tree of typed nodes plus declared
inputs and metadata. This guide is for writing one that works, reads well, and
survives the next person who edits it. The engine itself is documented in
docs/architecture/workflows.md.
The shape of a template
Section titled “The shape of a template”{ "name": "produce-and-audit", "description": "Research a subject, produce an artifact, then audit it against a read-only QC gate.", "inputs": { "subject": {"type": "string", "required": true, "help": "What to produce."}, "acceptance": {"type": "string", "default": "", "help": "What 'good' means here."} }, "metadata": { "risk": "low", "steering_examples": [ {"event": "kickoff", "description": "Produce a one-page migration plan for …"}, {"event": "mutation", "description": "The audit found three Major findings — re-run …"} ] }, "root": { "kind": "sequence", "id": "…", "children": [ … ] }}Four things are worth getting right before the graph:
The description is the picker. It is the only line a user sees when choosing, so it has to distinguish this template from its neighbours. Under ~40 characters and the lint says so.
Every input needs help. The run dialog builds its fields from these; an
input with no help shows a bare snake_case field name and the user has to read
the spec to learn what it wants.
A required input has no default. They contradict each other — a default means it can be omitted. The lint treats this as an error.
Say where the job goes. Workflows › Start from template asks what you want to do,
picks a template for it, and opens that template’s run form with your sentence already in the
input marked "loop_field": "task" — or, when no input carries the marker, in the first required
text input. Mark the input a plain-language request belongs in, so it lands there.
Steering examples are not decoration. The widget surfaces them and
workflow_plan uses them as few-shot. Two kinds matter: a kickoff example
(what driving this looks like) and a mutation example (what editing it
mid-flight looks like) — the second is what teaches a model that editing a
running workflow is a normal thing to do.
Choose the cheapest node that does the job
Section titled “Choose the cheapest node that does the job”| Need | Use | Not |
|---|---|---|
| a classification, a score, a rewrite | infer | stage — you would pay for a session and a lane slot |
| real work with tools, files, spawning | stage | |
| reshaping data you already have | transform | infer — it is zero tokens |
| running a command, writing an artifact | action | stage — a subagent to paste text is pure waste |
| a decision between subgraphs | branch | a stage that “decides” and then does the work inline |
A five-judge panel built from infer is five bounded calls; the same panel built
from stage is five concurrent subagent sessions. That difference is the reason
the two kinds exist.
Action arguments go under config.with
Section titled “Action arguments go under config.with”{"kind": "action", "id": "baseline", "config": {"provider": "bash", "with": {"command": "make test"}}}Not flat beside provider. A flat argument reaches the provider as an empty
config; it then reports its own required field missing for a value that is
visibly present in the spec, every downstream binding fails, and the run dies
reporting “deadlocked”. Validation refuses the shape now so that cannot happen.
The keys the engine reads on any step (on_error, retry, success_when and the
like) are not arguments, and stay beside provider.
A step’s payload carries its own inputs, never whose work it is. Each key
reaches the provider as written (a bash step reads them as its environment), and
the engine adds the step’s identity from the run that executes it: run_id,
node_id, instance_path, project_id, workspace and idempotency_key.
Providers attribute and confine their work by those (artifact_inspect reads
only its own run’s artifacts), so a template cannot set them: saving one that
writes any of them is refused with WF_PAYLOAD_RUN_IDENTITY, naming the key, and
so is starting a run of one that reached you another way.
A step’s arguments don’t name whose work it is either. An automation’s action can
name the project its run belongs to, the session its agent works in, or the folders
and session of a second opinion; a workflow step’s with can’t, because for a step
each is its own run’s. A run-workflow step starts its run in the step’s own
project, a run-prompt step’s agent answers to no chat, and a second-opinion
step works and writes its brief in its run’s folder. So run-workflow’s
project_id, run-prompt’s session and second-opinion’s session_key,
workspace and brief_dir are refused in a step with WF_ARGUMENT_RUN_IDENTITY,
naming the key, when you save, check or start the template, and at the step when
another step’s output hands them in.
A step that runs PersonalClaw itself says personalclaw <command>, never
python3 -m personalclaw…. In a bash step that name is this install’s own
program, wherever it is installed, while the python3 on PATH has no PersonalClaw
in a uv tool install or the desktop app, so a step written that way fails there on
its first run.
A command that fails says why in its answer. Print one JSON object whose error
is the reason, and exit non-zero: that reason is the step’s failure on the run page
and in the run’s ending. A command that says nothing is named by its exit status.
A step whose failure must stop the run says so. Steps after a failed step run by
default (on_error: null_continue). A step whose failure means nothing after it may
run, such as a preflight that refuses its inputs, declares "on_error": "fail_run":
the run then ends there, failed, with the step’s reason (““Check the inputs” failed:
…, so nothing after it ran”). A step a safety control refused before it ran (the
action denylist, an app’s limits on a run that is its work) stops the run the same
way, whatever it declares. null_continue and fail_run are the only two values, and
validation refuses any other.
Reading what a container produced
Section titled “Reading what a container produced”{{nodes.<id>.output}} reads what a step recorded, and a container records only
what its kind gives it:
| Kind | Its output |
|---|---|
loop | its last cycle’s output, as {{last.output}} reads a cycle, once it ends done; nothing when it is handed to you instead, and a step reading it is then skipped |
branch | {"case": "<the case it took>", "produced": <what that case produced>}, with produced once that case has ended in success, read as {{last.output}} reads a cycle; a step reading it is skipped when the case failed |
foreach | the list of what each item that gave a result produced, in item order, once the fan-out ends in success; an item that failed or was skipped is left out, and the fan-out’s own row on the run page says how many were left out and why (“2 of 4 items gave a result: 1 failed, 1 skipped”). It is a list, so validation refuses a field read of it ({{nodes.<foreach>.output.title}}) and names | map('title') instead |
sequence, parallel | nothing: validation refuses the read when you save (WF_UNSATISFIABLE_OUTPUT_REF), so read the step inside whose output you need |
A loop’s output is its last cycle’s, so a loop that should hand on a running account
has each cycle return one, built on {{last.output.…}}.
Read a branch’s result as {{nodes.<branch>.output.produced}}, or a field of it
({{nodes.<branch>.output.produced.angles}}), never by the case’s own step id: a step
that reads a case that was not taken is skipped. Validation refuses a field a branch never
has, such as {{nodes.<branch>.output.angles}}, and names the read that works. Under
produced it refuses a field that a case the branch can take does not produce, wherever
that case’s output is known when you save (a transform’s object, a stage with no schema,
which answers {"result": …}), because the read fails every time that case runs.
Inside a foreach item, a read of a step in the same body is that item’s own step:
{{nodes.echo.output}} in item 3 is item 3’s echo, and a branch in each item routes on
its own item’s answer, however the items’ steps interleave and across a restart. The same
holds for everything the engine reads on a step’s behalf inside an item: {{nodes.<id>.artifact}},
a metric gate’s metric_from, the inputs an approval gate records, a loop’s progress field,
its judge’s ruling and the handoff its cycle passes on, and whether an item still counts
against max_concurrency. A read from after the fan-out names no item, so it reads the item
whose step finished last.
Macros: the patterns, as one-liners
Section titled “Macros: the patterns, as one-liners”Four ship, and they expand into core nodes at definition time — so what is stored, validated and run is one tree, and you can expand a macro then hand-edit the result when you outgrow the pattern.
| Macro | Expands to | Use when |
|---|---|---|
judge_panel | parallel[infer × lenses] → transform | a subject needs independent scoring |
verify_panel | foreach(pipeline)[transform(seed) → infer(refute)] → transform | a finding list needs adversarial checking |
route | infer(classify) → branch | the work depends on what kind of thing this is |
research_sweep | parallel[stage × modes] → transform → foreach[infer] | one search angle will not find everything |
{"macro": "judge_panel", "id": "review", "config": {"subject": "{{inputs.design}}", "lenses": [ {"name": "correctness", "prompt": "What cases does it not handle?"}, {"name": "feasibility", "prompt": "Which part will blow the estimate?"} ]}}Give each lens its own prompt. N identical prompts catch a flaky answer; N different lenses catch a failure mode the others are blind to — and the latter is what makes a panel worth its tokens.
verify_panel asks the model to refute, defaulting to refuted when
uncertain. A verifier asked “is this real?” agrees, because agreeing is the
locally plausible answer. Hand it the findings as a list (findings): it runs one
verifier per finding, and gives each one context, what the findings are about (the
target’s code or text), so the verifier can check rather than guess. Its _confirmed
step drops only a finding a verifier explicitly refuted: kept is every other finding,
each carrying refuted: false (it survived) or refuted: null (its verifier failed,
timed out or answered outside its schema, so nobody ruled on it); survived and
unverified are the two halves. To join several finders’ lists into one, tag each with
where it came from first: [{{nodes.a.output.findings | tag('lens','a')}}, …], then
| flatten.
route classifies at the fast tier by default: choosing between three paths is
a cheap judgment, and paying reasoning-tier prices to route into a
reasoning-tier branch doubles the cost of the decision for nothing.
Shared blocks: cite, do not restate
Section titled “Shared blocks: cite, do not restate”{"kind": "infer", "id": "audit", "config": {"prompt": "Audit this.\n\nReturn JSON: {\"findings\": [Finding]}.\n\n{{block:finding-record}}"}}Three blocks ship in bundled/shared/:
| Block | What it defines |
|---|---|
finding-record | the canonical Finding shape and severity ladder |
safety-tiers | the read-only → additive → reversible → destructive ladder |
gap-honesty | say what you could not establish, do not fill it |
Cite them rather than writing the text again. Six templates once defined the
Finding record three separate times, and copies do not stay identical — a gate
predicate like “no open Critical” stops meaning the same thing once one stage
grades on a different ladder. An unknown block name is an error, never a
passthrough: a literal {{block:…}} reaching a model is a convention silently
not applied.
The conventions that keep a library coherent
Section titled “The conventions that keep a library coherent”Triage first. Open with an infer classification whose output drives a
branch between entry subgraphs, so a small task skips the deep path:
{"kind": "infer", "id": "triage", "config": {"model_tier": "fast", "prompt": "Classify … Return JSON: {\"tier\": \"light|standard|deep\"}", "schema": {"tier": "string"}}},{"kind": "branch", "id": "gather", "config": {"on": "{{nodes.triage.output.tier}}", "enum": ["light", "standard", "deep"]}, "cases": { … }}Declare the enum. Validation then catches an uncovered case at save time
instead of raising a binding error mid-run, after the classifier already spent
its tokens. The step after the branch reads what the chosen entry found as
{{nodes.gather.output.produced}}, whichever case it was.
Capture a baseline before you mutate. A code-flavoured template runs its validation before the first mutating node, so a failure afterwards can be told apart from one that was already there — otherwise someone debugs the wrong commit.
Findings use the canonical record. {severity, location, problem, why, recommended_fix, status}, severities Critical|Major|Minor|Nit. location must
be specific enough to act on. why is the consequence, not a restatement.
An empty findings list is a valid answer. Say so in the prompt. Inventing a finding to look thorough makes gates fire on noise, makes an until-dry loop run forever, and teaches the reader to ignore the output.
Long-horizon templates: hand off, do not compact
Section titled “Long-horizon templates: hand off, do not compact”For a loop or foreach body that runs many iterations, have the node return a
handoff and let the next iteration start from it:
{ "handoff": { "verified_state": "auth.py:40-88 reviewed, no injection paths", "changes": "added a null check at line 52", "unverified": "the OAuth path was never reached", "next_action": "review handlers.py" }, "carryover": {"files_touched": [{"path": "auth.py", "lines": "40-88"}]}, "decision": {"choice": "reuse the existing store", "rejected_alternatives": ["a new sqlite file"]}}session: fresh (the default) prepends these to the next iteration’s prompt.
session: continuous injects nothing, because a continuous session already
holds the previous iteration in its transcript.
Return them only when you have something to say. A fabricated handoff is worse than none — the next iteration would trust it.
A document the steps keep: {{run.document}}
Section titled “A document the steps keep: {{run.document}}”When the steps of a run carry state in a file from one round to the next (a
report that grows, the sources already read), hand them {{run.document}}: the
absolute path of the run’s declared document (its kind’s, or the template’s own
top-level "document") in the run’s own documents folder. Never a bare file
name. A project-less run’s steps work in the shared workspace every session
defaults to, so a relative NOTES.md is one file that every run of the template
reads and rewrites, and a step told only a name searches the disk for it. The
judge, the judge’s artifact_exists check and the Document panel read the same
path, and the steps’ file tools reach that folder and no other run’s.
A run starts with no document unless it continues another. An input named
continue_from names the run whose document this one starts from a copy of;
declare it, and let a prompt read it so the step knows where it started. A fork
says it continues its parent there on its own, and a start that names a run it
cannot continue is refused, as is a subworkflow node that does. It is read once,
when the run starts, so an edit of a run that would change it is refused too.
deep-research is the worked example.
A search held to a budget: optimize-harness
Section titled “A search held to a budget: optimize-harness”The bundled optimize-harness template is the worked example of a loop that spends
money on purpose. It proposes an edit to one of your templates, measures it, keeps it
only if it is better, and files the best as a proposal in Proposals for you to
review. It installs nothing.
- Its budget is what its model calls cost.
budget_usdis required. Before every cycle, and before every scoring, the search reads what the run’s own model calls have cost so far (the usage rows booked to the run) and stops when the next cycle or scoring would pass the budget. It ends with a sentence saying so and what it kept: “The search stopped at its budget: it has spent $0.80 of its $1.00 budget, and a cycle of the search has cost up to $0.40, so another would pass it. It kept candidate 2, which scored 0.67.” A model with no price stops it too, because spend it cannot price is spend it cannot hold: give the model a price in Settings → Usage → Model prices, $0 for one that costs nothing. - A score is measured, never claimed. The scoring step runs the candidate against the target’s own runs that finished, the newest five: the candidate’s prompts are answered for each run’s recorded inputs and the eval judge decides each answer. A case whose answer or verdict could not be had is left out rather than scored 0, and a score rests on at least three judged runs. Nothing the proposer says about its own candidate is read as a score.
- A candidate is kept only when it beats the best so far. It must clear
suite_thresholdand score above the best so far, which rises with every candidate kept, so a later, worse candidate never replaces a better one. - It halts on what it was told.
max_iterations(12 unless you say, up to 50),no_improvement_halt(that many candidates without a better score) andhypothesis_abandon_after(the same fix tried that many times without being kept), besides the budget. - It refuses before it spends. The preflight refuses a run with no budget, a
max_iterationsabove 50, a sandbox inside the template it is improving, and a target with fewer than three finished runs to score against, saying which.
The scoring step is an action that runs in the gateway (optimize-score) rather
than a command in a bash step, and that is the general rule for any step whose
spend a template must hold to a budget: a model step’s calls, and those of an action
that runs in the gateway, are booked to the run and shown on its page, while a model
call made by a command that a bash step runs is not. The filing step is one too
(optimize-file), for the other general rule: a command a bash step runs is in the
sandbox, which may not lay out the top of PersonalClaw’s home (on Linux it can neither
add an entry there nor replace a file there), so a step that files a proposal, raises
an Inbox item or registers a study runs in the gateway.
A run’s caps: defaults.budget
Section titled “A run’s caps: defaults.budget”A template can cap what each of its runs spends:
"defaults": {"budget": {"max_tokens": 200000, "max_cost": 2.00}}max_tokens is the tokens its steps’ model calls use and max_cost the dollars
they cost, each a number of 0 or more, 0 for no cap. A run starts with them and
pauses once its steps have spent one, saying which: “Paused at its dollar budget:
$2.04 of $2.00 spent.” Nothing new starts once a cap is reached, and the steps
already running finish first, so a run can pass a cap by what they spent. On the
run page, Resume asks for the cap it reached and goes on with it raised, and only
you can raise it.
A step on a model with no price pauses a run that has a dollar cap, because the cap cannot count what it spent: give the model a price in Settings → Usage → Model prices, $0 for one that costs nothing, or press Resume, which goes on past that step and leaves it out of the run’s dollar figure. A run with no dollar cap is not stopped by one.
A run that another run starts, or an automation’s action, is held inside what that one has left, in tokens and in dollars: its own caps can narrow what it spends and never widen it. A cap that is not a number of 0 or more is refused when you save, and a key that names no cap is warned of, since it holds nothing.
Concurrency
Section titled “Concurrency”max_concurrency on a foreach caps how many items are in flight. Set it when
each item holds something scarce (a checkout, a lock, a rate-limited endpoint);
leave it unset for a handful of cheap items. It must be a whole number — 1.5
and true are rejected rather than coerced, because a coerced 1 would silently
serialize the fan-out and the run would still succeed.
pipeline: true is accepted and documented, but the engine already streams:
each item’s body is an independent subtree, so an item advances as soon as its
own previous stage finishes.
Nesting
Section titled “Nesting”subworkflow runs another workflow as a real child run — its own journal, state
and history, so it can be rewound and inspected on its own:
{"kind": "subworkflow", "id": "nested", "config": {"ref": "child-workflow", "inputs": {"payload": "{{nodes.prep.output}}"}}}Inputs are resolved against the parent before the child is created, because
the child cannot interpret {{nodes.…}} from a graph it is not part of. Depth is
capped at 3, and a workflow that references itself is refused before anything is
created. Bind to the result via {{nodes.nested.output.status}} and
{{nodes.nested.output.outputs}}. The child run is held inside what the parent’s
budget has left, and what it spends is booked on the step that waits for it.
Before you ship it
Section titled “Before you ship it”Validate without saving. Every save path attaches the lint’s findings, and
save: false is a real dry run — it validates and returns the issues without
writing, so you can iterate before committing anything:
# HTTPcurl -X POST localhost:10000/api/workflows \ -H 'content-type: application/json' \ -d '{"name": "my-template", "root": {…}, "save": false}'From chat, workflow_check does the same thing: it takes the spec
workflow_author saves and writes nothing, so it only reads and never asks for
approval. workflow_plan with template: "<name>" hands you an existing
template’s expanded tree to start from.
In the dashboard, open the definition (Workflows → Definitions) and choose
Edit, or Edit a copy for a shipped template, which is read-only and so is
saved as a copy under a name of yours. The editor’s Steps view lists every step
in the order it runs and edits the settings each one has, bindings included, and
the declared run inputs; its JSON tab holds the whole definition, for adding,
removing or moving a step. Check is the same save: false dry run, and each
issue it returns is shown at the step it names, in the engine’s own words. Every
save is a new version, and a version’s Restore opens the editor on it.
A read of a definition never shows a value held under a name that looks like a
credential — api_key, but also max_tokens or an authors field — and sends a
_has_<key>: true flag in its place. Send the flag back as it is: the save
restores the value from the definition the edit came from (based_on, or
based_on_version for a restore), and refuses the save with
WF_HIDDEN_VALUE_UNMATCHED, at that step, if there is nothing to restore it from.
| Code | Means |
|---|---|
WFL_INLINE_CONVENTION | you restated a shared block; cite it instead |
WFL_UNKNOWN_BLOCK | a {{block:…}} names something that does not exist |
WFL_REQUIRED_WITH_DEFAULT | an input is both required and defaulted |
WFL_THIN_DESCRIPTION | the picker cannot distinguish this template |
WFL_UNDOCUMENTED_INPUT | an input has no help |
WFL_NO_KICKOFF_EXAMPLE / WFL_NO_MUTATION_EXAMPLE | no steering example |
A user’s own workflow is only advised. The bundled library is held to zero findings including warnings, because a warning that ships propagates to every template copied from it.
Then validate strictly (strict: true rejects on warnings too) and drive it
once for real. A template that validates and has never run is a template with an
unmeasured prompt.