Skip to content

Workflow nodes

A workflow is one YAML file describing a DAG of nodes the engine runs deterministically. For why deterministic YAML sits next to an agent at all, read Workflows first. What follows is the schema those concepts compile down to: the top-level fields, the seven node kinds, the shared fields, the data-flow model, and the rules the loader enforces.

name: example
description: |
Use when: you need a one-line schema reminder
Does: nothing, it is a docs example
nodes:
- id: hello
bash: echo "hi"

A workflow’s name: field, not its filename, is its catalog key, and the two must match. The catalog hot-reloads: a saved file is listable and runnable immediately, with no server restart.

FieldRequiredValue
nameyeskebab-case identifier (pr-review, not PR Review). runs is reserved.
descriptionyesThe structured block below.
nodesyesA non-empty list of DAG nodes.
providernoclaude, copilot, codex, pi, or stub; omitted uses the runner default.
modelnoDefault for agent nodes: fast, balanced, deep, auto, or a literal model id.
modelReasoningEffortnominimal | low | medium | high | xhigh.
webSearchModenodisabled | cached | live.
interactivenotrue is required when any loop node sets loop.interactive: true.
tagsnoA list of strings for filtering.
worktreeno{ enabled, branch } pins git-worktree isolation per run; branch accepts {workflow} and {run_id_short}. When enabled, setup is required and failure stops the run before its first node.
requiresProjectnotrue marks a repo-scoped workflow; workflow_run refuses to start it unless the resolved working directory is a git repo.
mutates_checkoutnofalse marks a read-only workflow; a non-isolated run against a project’s live checkout then skips the per-project mutation lock (it can’t clash with a mutating run). Defaults to mutating.
lockingnoexclusive (default) or shared. Shared runs can coexist on one project but remain mutually exclusive with a mutating run. This field takes precedence over mutates_checkout; use mutates_checkout: false to take no lock.
convergeno{ gate, max_rounds, on_exhaust } re-runs a subgraph until a gate node passes. See converge.

The run-start API’s isolation: "worktree" and the CLI’s --worktree force isolation on. isolation: "none" and --no-worktree explicitly override even a YAML declaration and run in the supplied directory. Keelson does not otherwise fall back to that directory when required setup fails. See Projects and worktrees for status, resume, and caller-owned lease behavior.

The loader accepts but ignores these workflow-level fields, dropping them with a warning so a cross-runtime file still loads: sandbox, betas, fallbackModel, additionalDirectories.

The Workflows UI cards and the workflow_list tool parse the structured block-scalar labels, so use them:

description: |
Use when: a PR needs a structured review before merge
Triggers: "review PR 42", "look at this pull request"
Does: fetches the diff, reviews it, posts findings as a comment
NOT for: writing new code or fixing the issues it finds

All four labels are optional, but Use when: and Does: should always be present. Keep each to one line.

Every node has an id plus exactly one of these seven mode fields. The three families are the determinism dial: deterministic nodes establish facts, agent nodes do judgment work, control nodes shape the run. Each kind is detailed below under its family heading; the shared fields apply on top of any kind.

FamilyKindWhat it runs
AgentpromptOne inline agent turn. The default choice for agent work.
AgentcommandA named markdown prompt from <home>/commands/; the file must already exist on disk.
AgentloopAn agent prompt repeated until a completion signal, bounded by max_iterations.
DeterministicbashA shell script; stdout becomes the node output. Optional timeout (ms).
DeterministicscriptInline TypeScript/JavaScript (runtime: bun) or Python (runtime: uv); runtime required, optional deps.
ControlapprovalPauses the run for a human decision.
ControlcancelTerminates a branch with a recorded reason.

Agent nodes run a model turn. prompt and command take the per-node agent fields from common fields. A loop also takes node-level model and provider; both are forwarded to every iteration. Other AI-specific fields are ignored with a warning.

One agent turn, and the default for judgment work. The body is the instruction: $ARGUMENTS, $ARTIFACTS_DIR, and $<id>.output are substituted into the text before the turn runs.

- id: summarize
prompt: |
Summarize these failures, most likely cause first:
$run-tests.output
depends_on: [run-tests]
context: fresh # a new session, isolated from other nodes

Reach for output_format (or output_schema) only when a later node needs to dot-access fields of the reply ($summarize.output.<field>); a plain text turn needs neither.

Runs a named markdown prompt from <home>/commands/<name>.md as one agent turn, with the same agent fields as prompt.

- id: triage
command: triage-issue # resolves <home>/commands/triage-issue.md
depends_on: [fetch]

The file must already exist on disk, so a command node cannot be authored from chat. Use an inline prompt node there instead.

Repeats one agent prompt until a completion signal, hard-bounded by max_iterations. It carries its own fields under loop:.

- id: fix-until-green
loop:
prompt: "Run the tests, fix one failure, reply DONE when all pass."
until: DONE # completion text to detect in the output
max_iterations: 5
fresh_context: true # a new session each iteration (default false)
# until_bash: "bun test" # optional probe; exit 0 also ends the loop
depends_on: [setup]
loop. fieldRequiredValue
promptyesThe turn run each iteration.
untilyesCompletion-signal text to detect in the output.
max_iterationsyesHard ceiling; exceeding it fails the node.
fresh_contextnoA new session each iteration (default false).
until_bashnoA probe run after each iteration; exit 0 ends the loop.
interactivenoPause for input each iteration; needs workflow-level interactive: true and a gate_message.

until is required even when you set until_bash, and retry is not allowed on a loop, which manages its own iteration.

Node-level model and provider are forwarded to every iteration and are subject to applicable run-start model-catalog preflight checks. Other AI-specific node fields, such as model_by_provider, model_by, effort, and tool filters, are ignored with a warning.

Deterministic nodes run code with no model. Upstream output reaches them on the environment channel, never spliced into the source, so the per-node agent fields above do not apply (they warn and are dropped).

A shell script. Its stdout becomes the node output.

- id: run-tests
bash: |
set -euo pipefail
bun test 2>&1 | tail -80
timeout: 300000 # ms, optional

The body runs raw: an upstream node’s output arrives as KEELSON_NODE_<id>_OUTPUT (the id’s hyphens become underscores), not by substitution. $<id>.output does not expand inside a bash body. That split is the security boundary, not a style rule. The one exception is $converge.round, which carries no upstream text and does expand in a shell body (see converge).

Inline TypeScript/JavaScript (runtime: bun) or Python (runtime: uv); stdout becomes the output. Prefer it over bash for a typed transform a shell would mangle (JSON parsing, structured data).

- id: count-failures
script: |
import { readFileSync } from "node:fs"
const r = JSON.parse(readFileSync(process.env.KEELSON_NODE_run_tests_OUTPUT_FILE, "utf8"))
console.log(r.failures.length)
runtime: bun # 'bun' (.ts/.js) or 'uv' (.py); required
deps: [zod] # optional packages, importable for this run
depends_on: [run-tests]

runtime is required. Like bash, a script reads upstream output from the KEELSON_NODE_* env channel, not $<id>.output. Structured output comes from KEELSON_NODE_<id>_OUTPUT_FILE rather than the variable, which is capped at 16 KiB. uv installs deps per run; bun auto-installs an imported package.

Control nodes shape the run rather than produce work.

Pauses the run for a human decision. The pause needs a running server (keelson start).

- id: gate
approval:
message: "Apply this plan?"
capture_response: true # store the reply as $gate.output
depends_on: [plan]

capture_response: true exposes the reviewer’s reply as the node’s output, so a downstream node can branch on approval versus requested changes. on_reject ({ prompt, max_attempts }) re-prompts on rejection instead of failing the run.

An approval node may declare a reviewer: one agent turn that answers the gate for the operator when it approves with high confidence. Anything else falls through to the human gate exactly as before, with the reviewer’s verdict shown in the approval callout so the operator sees why it escalated.

- id: gate
approval:
message: "Approve this plan? $ARTIFACTS_DIR/plan.md"
capture_response: true
reviewer:
when: "$gate-mode.output == 'true'"
model: deep
allowed_tools: [Read, Glob, Grep]
min_confidence: 85
prompt: |
Approve only when every acceptance criterion maps to a plan step and
nothing in the plan contradicts the code; spot-check with Read and Grep.
depends_on: [plan, gate-mode]
FieldRequiredMeaning
promptyesThe rubric. Takes the same substitutions as a prompt node ($nodeId.output, $inputs.<key>, $ARGUMENTS, $ARTIFACTS_DIR). The turn sees the gate’s message first, then this.
model, model_by_provider, effortnoModel selection, as on a prompt node. Unset falls back to the workflow default.
allowed_toolsnoTool allowlist for the turn. Keep reviewers read-only. On a provider that cannot enforce it, the reviewer’s approval does not count and the gate goes to the operator.
min_confidencenoInteger 0 to 100, default 85. An approve below it still pauses for the operator.
whennoSame condition syntax as a node’s when:. False skips the reviewer and the gate pauses as usual. The evaluator reads node outputs, not inputs, so a workflow that keys the reviewer on a run input echoes it from a small bash node first.

The reviewer’s reply is pinned to a fixed verdict shape and validated fail-closed:

{ "decision": "approve | changes | escalate", "confidence": 0, "reason": "...", "changes": "..." }

decision: approve with confidence at or above min_confidence resolves the gate: the node’s output is the reason, and the run’s node record carries answeredBy: "reviewer" with the verdict. A changes or escalate decision, a low confidence, a failed turn, or a reply that is not this shape (an extra key, changes on a non-changes decision) pauses for the human with the verdict (or the parse error) attached; an empty or malformed reply never reads as an approval. The operator reads the verdict and decides what to send back; the reviewer’s requested changes reach downstream nodes only through that reply. The verdict is also written to $ARTIFACTS_DIR/approvals/<nodeId>.reviewer.json. A reviewer counts as provider work for catalog resolution and preflight, and its when: and prompt get the same reference checks at load as a node’s own.

KEELSON_APPROVAL_REVIEWER=off is the operator floor: it disables every reviewer, so all gates go to the human regardless of what a workflow declares. A resumed run does not re-run the reviewer for a gate that was already answered.

Terminates the run with a recorded reason. Pair it with when: as a guard that stops a branch when there is nothing to do.

- id: bail
cancel: "nothing to review"
when: "$classify.output == 'NONE'"
depends_on: [classify]

These apply across kinds (some only to agent nodes, as noted).

FieldApplies toMeaning
depends_onall[id, ...] DAG edges. Omitted means a root node.
whenallA condition that must hold or the node skips. See conditions.
trigger_ruleallJoin semantics across dependencies. See the table below.
modelprompt, command, loopPer-node override: fast, balanced, deep, auto, or a literal model id.
providerprompt, command, loopPer-node provider override.
model_by_providerprompt, commandMap of provider ids to concrete model ids. The effective provider’s entry takes precedence over model.
model_byprompt, commandPick the model and/or effort from run data: see model_by.
different_vendor_frompromptAncestor prompt id that should run on a different model vendor. Emits a run warning when effective provider/model attribution proves both turns used the same vendor.
contextprompt, commandfresh forces a new agent session for the node; shared reuses one.
allowed_tools, denied_toolsprompt, commandTool-name filters. Rib tools are default-off; opt in with allowed_tools.
output_schemaallA JSON-Schema subset the output must satisfy, or the node fails: type, required, properties, items, and enum on a string. Any other keyword is rejected at load.
output_formatprompt, commandProvider structured-output request (Claude).
retryall but loop{ max_attempts: 1-5, delay_ms: 1000-60000, on_error }. delay_ms doubles each attempt; on_error is transient (default) or all.
always_runalltrue re-executes the node on a resumed run even if it previously succeeded (default false). Its transitive descendants also re-run, including prompt nodes that make provider calls and incur their normal token cost. Use it for a gate or validation that must re-check rather than replay a prior pass: see resume and side effects.
fail_on_tool_errorprompt, commandtrue fails the node if any invoked tool errored.
require_tool_callprompt, command[tool-name, ...] fails the node if a listed tool was available but the turn ended without a successful call to it, so a node whose deliverable is a tool call cannot report success having produced nothing. An error followed by a successful retry satisfies it. Only registry/MCP tools are checked: a provider SDK’s own built-ins (Read, Bash, …) are never in the catalog, so naming one has no effect, and providers that take no keelson tools (codex, gateways, stub) skip the check rather than fail it.
idle_timeoutprompt, command, loopMilliseconds of stream silence before the node fails.
effortprompt, commandReasoning tier, forwarded as reasoningEffort: none / low / medium / high / xhigh (max is a legacy alias for xhigh). Overrides the workflow-level effort; a loop node’s own value is dropped, so loop iterations take the workflow value. Copilot and Codex consume it on reasoning models; Claude ignores it. Omit it to take the model’s own default. A model with no reasoning tier, such as Copilot’s auto, errors on any value.
systemPrompt, thinkingprompt, commandClaude-only per-node controls. thinking is adaptive / enabled / disabled.
memory, notebookallRecall/writeback blocks wired to the memory store and the project notebook.
hooksprompt, commandFully honored only by Claude; Copilot covers PreToolUse / PostToolUse.

Provider selection follows this order:

  1. The run’s --provider override.
  2. The node’s provider.
  3. The workflow’s provider.
  4. KEELSON_WORKFLOW_PROVIDER, when set as the runner default.
  5. The runner’s configured default.

--provider wins over the environment default. Node and workflow pins also override that default.

For prompt and command, a matched model_by case is applied first, replacing only the fields it declares. Model selection then uses the node’s model, then the workflow’s model, with a model_by_provider entry for the effective provider taking precedence over both. A case that sets model but no model_by_provider therefore leaves a static per-provider entry in place, and that entry still wins. A loop uses its node-level model, then the workflow model.

A model tier resolves through the operator’s configured class override, the provider’s modelClasses, then the provider default. auto also uses the provider default. Static resolution and model-catalog preflight include every node that opens a provider session: prompt, command, and loop. Model diversity diagnostics include prompt and command, the kinds that support model_by_provider. Deterministic and control nodes are excluded.

Every new run checks those provider-bound nodes for pinned literal model ids and effort against the effective provider’s live catalog before the first node executes. Preflight also checks a tier when the operator’s config.json modelClasses override explicitly pins it to a concrete id. Tiers that use provider modelClasses or the provider default remain unchecked because they are not explicitly operator-pinned. This includes CLI, SPA, API, scheduled controller, producer refresh, and in-memory rib starts. A resumed run keeps the result its first launch passed rather than re-checking. Run the same check without executing with keelson workflow validate <name> --live.

An unavailable catalog is reported as not checked and does not block the run. For named runs, the notice is stored with the run and remains visible through keelson workflow status <run-id> after completion or a server restart. A model that reports no effort list is not judged on effort.

Use --no-preflight for one run, set "workflowPreflight": false in config.json, or set KEELSON_WORKFLOW_PREFLIGHT=0 to disable the run-start check.

Ignored with a warning on any node: agents, sandbox, betas, fallbackModel, maxBudgetUsd, mcp, skills. AI-specific fields are also ignored, with a warning, on bash, script, approval, and cancel. A loop accepts node-level model and provider; its other AI-specific fields are ignored with a warning.

model, model_by_provider and effort are fixed when the workflow is written. model_by picks among a closed set of them using a value the run produces, so one node can spend a strong model on the inputs that need it without being duplicated and joined back with trigger_rule: one_success.

- id: intake
prompt: "Classify this assignment as deep or std. Return JSON: {\"tier\": ...}"
output_format: { type: json_object }
- id: investigate
depends_on: [intake]
prompt: "Investigate: $ARGUMENTS"
model_by:
from: $intake.output.tier
cases:
deep: { model_by_provider: { copilot: gpt-6-astra }, effort: high }
std: { model_by_provider: { copilot: gpt-6-luna }, effort: high }
default: std # optional

from is either $inputs.<key> or $<nodeId>.output[.<field>], and the node it names must be an ancestor, the same rule prompt and shell bodies follow. It resolves when the node starts, and its value is matched against cases exactly.

Each case sets any of model, model_by_provider and effort; whatever it sets replaces the node’s static value, and whatever it omits leaves that value alone, so an effort-only case keeps the node’s model.

Because a case replaces only what it declares, a case that sets model on a node that also pins model_by_provider does not displace that map, and the map still wins for its provider. Set model_by_provider in the case when the branch needs to override a per-provider pin.

An unmatched value fails the node. Declare default, naming one of the cases, when a fallback is wanted. A node silently running on a model nobody chose is the failure this exists to prevent, so the fallback is opt-in and the error names both the value that missed and the cases that exist.

The set of reachable models stays statically known, which is the point of a map rather than free substitution: keelson workflow validate checks the shape of every branch, and --live checks every branch’s model against the provider’s catalog, not just the one a given run would take. A typo in a cold branch is caught before a run reaches it.

Use different_vendor_from when the later prompt is an independent verification seat:

- id: verify
depends_on: [investigate]
different_vendor_from: investigate
prompt: "Re-check the claims in $investigate.output"

The referenced node must be an ancestor prompt. Comparison uses the effective provider and model recorded after fallback and provider-reported model changes. When both models map to the same known vendor, the run emits a warning. An unknown model vendor is not treated as proof that the turns are diverse.

ValueThe node runs when
all_success (default)Every dependency succeeded.
one_successAt least one dependency succeeded, so a branch can rescue a failing sibling.
none_failed_min_one_successNothing failed and at least one succeeded.
all_doneEvery dependency reached a terminal state, for collector nodes.

when: is a condition over upstream output. A false condition skips the node.

  • Comparison: ==, !=, <, >, <=, >=.
  • Compound: && and ||, where || has lower precedence than &&. No parentheses.
  • Operands are an upstream reference and a literal: $classify.output == 'BUG', $check.output.count != '0'.

An unknown reference, an empty output, or a failed JSON access resolves to the empty string, so write conditions that fail closed.

converge: is a workflow-level field that re-runs part of the graph until a gate node passes. It is the shape for work that has to be checked and redone until it is right: fix, validate, check, repeat.

converge:
gate: converge-check
max_rounds: 8
on_exhaust: approval
FieldRequiredValue
gateyesThe id of the node that decides convergence. Passing ends the rounds.
max_roundsyesInteger 1 to 10. The hard ceiling on rounds.
on_exhaustnofail (default) ends the run failed; approval pauses for a human decision instead.

The subgraph is the gate node plus every node it transitively depends on. Everything else in the workflow is held back until the gate passes.

  1. The subgraph runs, in dependency order, as round 1.
  2. If the gate node succeeds, the run converged. The rest of the workflow runs once and the rounds stop.
  3. If the gate node fails and rounds remain, the subgraph’s node outputs are discarded and the whole subgraph runs again as the next round. A failing gate is the signal to redo the work, not an error.
  4. When max_rounds is spent without the gate passing, on_exhaust decides: fail fails the run, approval pauses so a human can accept the state or let it fail. Nodes outside the subgraph are skipped either way.

Any node in the subgraph can read the current round number as $converge.round, including a bash or script body, the one marker that does expand there. Outside a converge run it resolves to the empty string.

Two constraints the loader enforces on the gate:

  • The gate cannot be a loop node, which manages its own iteration.
  • The gate cannot declare retry:. A failing gate triggers another round, which is the retry, and stacking one on the other would re-run the gate against unchanged work.

The bundled resolve-pr workflow is the worked example: its gate re-checks CI and review threads each round, so the fix-and-push subgraph repeats until the PR is genuinely clean, escalating to an approval at the round cap.

Upstream output reaches a downstream node through one of two channels, and the split is a security boundary, not a style choice.

The workflow layer substitutes text in prompts, when: conditions, and cancel reasons:

ReferenceResolves to
$ARGUMENTSThe free-form text the run was started with.
$inputs.<key>The workflow input named <key>. Available in prompt, command, loop, and cancel bodies; not substituted in bash or script bodies (use KEELSON_INPUTS_<key> there).
$<id>.outputThe full text output of an upstream node. The node must be an ancestor via depends_on.
$<id>.output.<field>A field of the upstream output after JSON parsing (empty string when the output is not JSON).
$ARTIFACTS_DIRThe per-run scratch directory (in prompt text).
$converge.roundThe current converge round number, starting at 1. The only marker that also expands in a bash or script body, since it carries no upstream text. Empty string outside a converge run.
$memory.recall.itemsJSON array (stringified) of recalled memory rows. Requires a node-level memory.recall: block; resolves to [] when recall did not run.
$memory.recall.traceTrace ID string recorded for the recall query. Empty string when unavailable or when recall did not run.
$DIRECTIVES.<name>One of the harness-owned directives, expanded verbatim.

\$ escapes a literal $ in any substitution context.

Substitution is a single atomic pass: if an upstream output itself contains $something.output, that marker stays literal instead of expanding again.

Keelson owns a small set of named working rules, the same text the chat system prompt carries. A prompt node pulls one in with $DIRECTIVES.<name>, so a workflow states the rule once instead of paraphrasing it per node.

NameAsks the agent to
verifyRun a real check that exercises a code change (tests, type-checker, build, or the changed command) before reporting it done.
continueKeep going when a step needs no operator input, and stop only when blocked or before a destructive action.
confirmMark anything it could not confirm, and say where it looked.
reviewList only merge-blocking problems, each with file and line, why it is wrong, and how to show it fails.

An unknown name is a validation error, not a warning, so a typo cannot reach the model as literal text. \$DIRECTIVES.<name> keeps the marker literal.

Shell nodes read the environment instead. A bash or script node runs its body as written ($converge.round aside), with upstream output delivered as environment variables, never spliced into the source:

VariableCarries
KEELSON_RUN_IDThe current workflow run identifier.
KEELSON_NODE_<id>_OUTPUTThe upstream node’s output (non-alphanumeric characters in the id are normalized to _). Capped, see below.
KEELSON_NODE_<id>_STATEThe upstream node’s NodeOutput state: pending, running, completed, failed, or skipped.
KEELSON_NODE_<id>_OUTPUT_FILEPath to a file holding that output in full, set for every upstream node whatever its size. Absent only when the run has no artifacts directory, which every server and CLI run creates.
KEELSON_NODE_<id>_OUTPUT_TRUNCATED1 when the _OUTPUT value above was capped; unset otherwise.
KEELSON_NODE_<id>_ERRORThe upstream node’s error text when its state is failed; unset otherwise.
KEELSON_NODE_<id>_PROVIDEREffective provider recorded for an upstream agent node, including provider fallback. Unset for deterministic nodes.
KEELSON_NODE_<id>_MODELEffective model recorded for an upstream agent node, including a provider-reported model change. Unset for deterministic nodes.
KEELSON_ARTIFACTS_DIRThe per-run scratch directory.
KEELSON_INPUTS_<key>One variable per workflow input; non-alphanumeric characters in the key are normalized to _.
KEELSON_ARGUMENTSThe full free-form text the run was started with (equivalent to $ARGUMENTS in prompt substitution).
ARTIFACTS_DIRSame path as KEELSON_ARTIFACTS_DIR; provided so workflows ported from Archon can use the shorter name without modification.

An env value over 16 KiB is truncated to its first 8 KiB and last 8 KiB, joined by an inline [keelson: ...] marker, because the OS caps the size of a single environment entry. That marker lands in the middle of the value, so a JSON payload that outgrows the cap stops parsing.

Every node output is therefore written in full to <artifacts dir>/node-outputs/<id>.txt and that path published as KEELSON_NODE_<id>_OUTPUT_FILE, whether or not the value was capped. Read structured output from the file, not the variable:

Terminal window
ISSUE_JSON=$(cat "$KEELSON_NODE_fetch_issue_OUTPUT_FILE")
URL=$(printf '%s' "$ISSUE_JSON" | jq -r '.url')

Whether the file is there is a property of the run, not of the data: it is written for every upstream node regardless of size, and is missing only when the run has no artifacts directory at all, which every server and CLI run creates and which fails loudly on its own.

The variable stays the right channel for prose, for a grep, and for a value you know is small. KEELSON_NODE_<id>_OUTPUT_TRUNCATED is set to 1 when the value was capped, so a consumer that must use the variable can fail with a clear message instead of on a parse error.

keelson workflow validate warns when a shell body hands a capped _OUTPUT variable straight to jq, JSON.parse or json.loads and points at the _FILE companion.

Reserved node ids, because they collide with substitution namespaces: inputs, ARGUMENTS, ARTIFACTS_DIR, memory, converge, DIRECTIVES.

keelson workflow resume seeds reusable succeeded nodes as complete. Failed, awaiting, interrupted, and absent nodes re-execute. A node with always_run: true also re-executes even when its previous result succeeded.

When a node re-executes, all of its transitive descendants re-execute too. This keeps every derived output consistent with the new ancestor output. It also means a descendant prompt node makes another provider call and incurs its normal token cost. Independent branches remain seeded.

A resumed run reuses the same artifacts directory. Files from seeded nodes remain available, and files are not deleted when their owners are invalidated. A re-executed node may overwrite its own files. Writing a file is not by itself a reason to set always_run.

A trigger_rule: all_done collector can succeed while an upstream node fails. When that upstream node re-executes, the collector and everything derived from it re-execute automatically. The collector does not need always_run solely to refresh its output.

A fully seeded converge subgraph remains seeded. If invalidation reaches its gate, the full converge ancestor subgraph re-executes from round 1, and affected descendants outside the subgraph re-execute with it.

keelson workflow validate (and the workflow_save tool) run the real loader. The loader is strict on structure and lenient on vocabulary: anything that breaks the graph is a hard error; a recognized-but-unhonored field only warns.

These errors block a save or run:

  • YAML syntax errors, or a missing name, description, or nodes.
  • A node with zero or more than one mode field.
  • Per-node schema violations.
  • Duplicate node ids, unknown depends_on targets, dependency cycles.
  • Reserved workflow names (runs) or node ids (inputs, ARGUMENTS, ARTIFACTS_DIR, memory, converge, DIRECTIVES).
  • A $DIRECTIVES.<name> reference to a name the harness does not define.
  • A converge.gate that names no node in the workflow, or a gate that is a loop node or declares retry:.
  • A $<id>.output reference to a node that is not an ancestor (a forward or sibling reference), which would otherwise silently resolve to the empty string at run time.

Not checked until run time: whether a provider or model id is valid for the runner, and whether a command: or named script: file exists on disk.

Workflows live in three scopes, lowest precedence first, and the catalog merges them:

ScopeLocationVisibility
bundledshipped with keelson (read-only)The starter set, always present.
global<home>/workflows/Every project and conversation.
project<root>/.keelson/workflows/Only conversations and runs inside that project.

A later scope shadows a same-named earlier one: project over global over bundled. The bundled starters are also seeded into the home on first run, so they list as global once present. Ribs also contribute workflows into the catalog at activation, optionally bound to a snapshot key. The Workflows catalog documents the bundled set.

A complete three-node workflow: a deterministic check, an agent summary, a human gate.

name: test-triage
description: |
Use when: the test suite is failing and you want a triaged summary
Triggers: "triage the tests", "why are tests failing"
Does: runs the suite, summarizes failures, waits for approval to file notes
NOT for: fixing the failures themselves
nodes:
- id: run-tests
bash: bun test 2>&1 | tail -80
timeout: 300000
- id: summarize
prompt: |
Summarize these test failures by root cause, most likely culprit first:
$run-tests.output
depends_on: [run-tests]
- id: confirm
approval:
message: "File these findings to the project notebook?"
depends_on: [summarize]