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: exampledescription: | Use when: you need a one-line schema reminder Does: nothing, it is a docs examplenodes: - id: hello bash: echo "hi"Top-level fields
Section titled “Top-level fields”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.
| Field | Required | Value |
|---|---|---|
name | yes | kebab-case identifier (pr-review, not PR Review). runs is reserved. |
description | yes | The structured block below. |
nodes | yes | A non-empty list of DAG nodes. |
provider | no | claude, copilot, codex, pi, or stub; omitted uses the runner default. |
model | no | Default for agent nodes: fast, balanced, deep, auto, or a literal model id. |
modelReasoningEffort | no | minimal | low | medium | high | xhigh. |
webSearchMode | no | disabled | cached | live. |
interactive | no | true is required when any loop node sets loop.interactive: true. |
tags | no | A list of strings for filtering. |
worktree | no | { 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. |
requiresProject | no | true marks a repo-scoped workflow; workflow_run refuses to start it unless the resolved working directory is a git repo. |
mutates_checkout | no | false 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. |
locking | no | exclusive (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. |
converge | no | { 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.
Description format
Section titled “Description format”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 findsAll four labels are optional, but Use when: and Does: should always be
present. Keep each to one line.
The node kinds
Section titled “The node kinds”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.
| Family | Kind | What it runs |
|---|---|---|
| Agent | prompt | One inline agent turn. The default choice for agent work. |
| Agent | command | A named markdown prompt from <home>/commands/; the file must already exist on disk. |
| Agent | loop | An agent prompt repeated until a completion signal, bounded by max_iterations. |
| Deterministic | bash | A shell script; stdout becomes the node output. Optional timeout (ms). |
| Deterministic | script | Inline TypeScript/JavaScript (runtime: bun) or Python (runtime: uv); runtime required, optional deps. |
| Control | approval | Pauses the run for a human decision. |
| Control | cancel | Terminates a branch with a recorded reason. |
Agent nodes
Section titled “Agent nodes”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.
prompt
Section titled “prompt”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 nodesReach 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.
command
Section titled “command”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. field | Required | Value |
|---|---|---|
prompt | yes | The turn run each iteration. |
until | yes | Completion-signal text to detect in the output. |
max_iterations | yes | Hard ceiling; exceeding it fails the node. |
fresh_context | no | A new session each iteration (default false). |
until_bash | no | A probe run after each iteration; exit 0 ends the loop. |
interactive | no | Pause 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
Section titled “Deterministic nodes”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, optionalThe 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).
script
Section titled “script”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
Section titled “Control nodes”Control nodes shape the run rather than produce work.
approval
Section titled “approval”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.
reviewer
Section titled “reviewer”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]| Field | Required | Meaning |
|---|---|---|
prompt | yes | The 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, effort | no | Model selection, as on a prompt node. Unset falls back to the workflow default. |
allowed_tools | no | Tool 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_confidence | no | Integer 0 to 100, default 85. An approve below it still pauses for the operator. |
when | no | Same 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.
cancel
Section titled “cancel”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]Common node fields
Section titled “Common node fields”These apply across kinds (some only to agent nodes, as noted).
| Field | Applies to | Meaning |
|---|---|---|
depends_on | all | [id, ...] DAG edges. Omitted means a root node. |
when | all | A condition that must hold or the node skips. See conditions. |
trigger_rule | all | Join semantics across dependencies. See the table below. |
model | prompt, command, loop | Per-node override: fast, balanced, deep, auto, or a literal model id. |
provider | prompt, command, loop | Per-node provider override. |
model_by_provider | prompt, command | Map of provider ids to concrete model ids. The effective provider’s entry takes precedence over model. |
model_by | prompt, command | Pick the model and/or effort from run data: see model_by. |
different_vendor_from | prompt | Ancestor 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. |
context | prompt, command | fresh forces a new agent session for the node; shared reuses one. |
allowed_tools, denied_tools | prompt, command | Tool-name filters. Rib tools are default-off; opt in with allowed_tools. |
output_schema | all | A 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_format | prompt, command | Provider structured-output request (Claude). |
retry | all 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_run | all | true 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_error | prompt, command | true fails the node if any invoked tool errored. |
require_tool_call | prompt, 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_timeout | prompt, command, loop | Milliseconds of stream silence before the node fails. |
effort | prompt, command | Reasoning 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, thinking | prompt, command | Claude-only per-node controls. thinking is adaptive / enabled / disabled. |
memory, notebook | all | Recall/writeback blocks wired to the memory store and the project notebook. |
hooks | prompt, command | Fully honored only by Claude; Copilot covers PreToolUse / PostToolUse. |
Model resolution
Section titled “Model resolution”Provider selection follows this order:
- The run’s
--provideroverride. - The node’s
provider. - The workflow’s
provider. KEELSON_WORKFLOW_PROVIDER, when set as the runner default.- 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_by
Section titled “model_by”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 # optionalfrom 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.
trigger_rule
Section titled “trigger_rule”| Value | The node runs when |
|---|---|
all_success (default) | Every dependency succeeded. |
one_success | At least one dependency succeeded, so a branch can rescue a failing sibling. |
none_failed_min_one_success | Nothing failed and at least one succeeded. |
all_done | Every dependency reached a terminal state, for collector nodes. |
Conditions
Section titled “Conditions”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
Section titled “Converge”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| Field | Required | Value |
|---|---|---|
gate | yes | The id of the node that decides convergence. Passing ends the rounds. |
max_rounds | yes | Integer 1 to 10. The hard ceiling on rounds. |
on_exhaust | no | fail (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.
- The subgraph runs, in dependency order, as round 1.
- If the gate node succeeds, the run converged. The rest of the workflow runs once and the rounds stop.
- 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.
- When
max_roundsis spent without the gate passing,on_exhaustdecides:failfails the run,approvalpauses 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
loopnode, 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.
Data flow
Section titled “Data flow”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:
| Reference | Resolves to |
|---|---|
$ARGUMENTS | The 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>.output | The 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_DIR | The per-run scratch directory (in prompt text). |
$converge.round | The 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.items | JSON array (stringified) of recalled memory rows. Requires a node-level memory.recall: block; resolves to [] when recall did not run. |
$memory.recall.trace | Trace 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.
Directives
Section titled “Directives”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.
| Name | Asks the agent to |
|---|---|
verify | Run a real check that exercises a code change (tests, type-checker, build, or the changed command) before reporting it done. |
continue | Keep going when a step needs no operator input, and stop only when blocked or before a destructive action. |
confirm | Mark anything it could not confirm, and say where it looked. |
review | List 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:
| Variable | Carries |
|---|---|
KEELSON_RUN_ID | The current workflow run identifier. |
KEELSON_NODE_<id>_OUTPUT | The upstream node’s output (non-alphanumeric characters in the id are normalized to _). Capped, see below. |
KEELSON_NODE_<id>_STATE | The upstream node’s NodeOutput state: pending, running, completed, failed, or skipped. |
KEELSON_NODE_<id>_OUTPUT_FILE | Path 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_TRUNCATED | 1 when the _OUTPUT value above was capped; unset otherwise. |
KEELSON_NODE_<id>_ERROR | The upstream node’s error text when its state is failed; unset otherwise. |
KEELSON_NODE_<id>_PROVIDER | Effective provider recorded for an upstream agent node, including provider fallback. Unset for deterministic nodes. |
KEELSON_NODE_<id>_MODEL | Effective model recorded for an upstream agent node, including a provider-reported model change. Unset for deterministic nodes. |
KEELSON_ARTIFACTS_DIR | The per-run scratch directory. |
KEELSON_INPUTS_<key> | One variable per workflow input; non-alphanumeric characters in the key are normalized to _. |
KEELSON_ARGUMENTS | The full free-form text the run was started with (equivalent to $ARGUMENTS in prompt substitution). |
ARTIFACTS_DIR | Same 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:
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.
Resume and side effects
Section titled “Resume and side effects”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.
Validation rules
Section titled “Validation rules”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, ornodes. - A node with zero or more than one mode field.
- Per-node schema violations.
- Duplicate node ids, unknown
depends_ontargets, 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.gatethat names no node in the workflow, or a gate that is aloopnode or declaresretry:. - A
$<id>.outputreference 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.
Scopes
Section titled “Scopes”Workflows live in three scopes, lowest precedence first, and the catalog merges them:
| Scope | Location | Visibility |
|---|---|---|
| bundled | shipped 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.
Example
Section titled “Example”A complete three-node workflow: a deterministic check, an agent summary, a human gate.
name: test-triagedescription: | 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 themselvesnodes: - 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]Related
Section titled “Related”- Workflows: the execution model, the two data channels, and the run lifecycle.
- Run and author a workflow: authoring a DAG and watching it execute.
- Authoring workflows: the recipes for the common control-flow shapes.
- Memory and state: the
memoryandnotebookblocks a node can declare.