Skip to content

Authoring workflows

A workflow is one YAML file you can read in a single screen. Here is the practical how-to: the loop you follow to write one, where workflows live, and the recipes for the shapes you reach for most. For the full schema, see Workflow nodes; for a narrated first run, see Run and author a workflow.

  1. Draft the YAML. Prefer inline prompt, bash, and script nodes. A command: node references a markdown file that must already exist on disk.
  2. Validate. keelson workflow validate <name> runs the real loader. Fix every error and re-validate until it parses cleanly; read the warnings, which are advisory.
  3. Run it. keelson workflow run <name> --watch streams the DAG executing in dependency order.

Once it runs, write a case set and grade it so the next prompt tweak is measured against held-out cases instead of eyeballed.

Terminal window
keelson workflow validate test-triage
keelson workflow run test-triage --watch
ScopeLocationVisible to
bundledshipped with keelson (read-only)The starter set, always present.
global<home>/workflows/Every project and conversation.
project<root>/.keelson/workflows/Only that project.
ribworkflows/ inside an installed rib packageEvery project and conversation.

A later file scope shadows a same-named earlier one: project over global over bundled. Rib entries fill in around the file set, so any same-named file in the three file scopes overrides a rib entry. The catalog hot-reloads the file scopes, so a saved file is runnable immediately, with no restart; rib entries load at server boot with the rest of rib activation. The workflow’s name: field is its catalog key and must match the filename.

Give every workflow the structured description block so the UI cards and workflow list render it scannably:

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

Independent nodes run in parallel; a join node waits for all of them. Reference each upstream output with $<id>.output.

nodes:
- id: lint
bash: bun run check
- id: types
bash: bun --filter '*' typecheck
- id: verdict
prompt: "Summarize: lint=$lint.output types=$types.output"
depends_on: [lint, types]

A node emits a label; downstream nodes gate on it with when:. A false condition skips the node.

- id: classify
prompt: "Reply with exactly BUG or FEATURE for: $ARGUMENTS"
- id: fix
prompt: "Investigate and fix: $ARGUMENTS"
depends_on: [classify]
when: "$classify.output == 'BUG'"

Plan, pause for a human, then act only on approval. capture_response: true exposes the reply as the node’s output so a later node can branch on it.

- id: plan
prompt: "Draft a plan for: $ARGUMENTS"
- id: gate
approval:
message: "Run this plan?"
capture_response: true
depends_on: [plan]
- id: execute
prompt: "Execute the plan: $plan.output"
depends_on: [gate]

Resume a paused run from the CLI with keelson workflow respond <runId> <nodeId> <text>, or from the Workflows surface in the browser. A simple approval gate like this one needs only those three arguments; an interactive-loop pause also takes --pause-id, the per-pause token from the run’s approval_awaiting frame, to disambiguate retries.

A loop repeats its prompt until a completion token appears, hard-bounded by max_iterations so it can never run away.

- id: fix-until-green
loop:
prompt: "Run the tests, fix one failure, reply DONE when all pass."
until: "DONE"
max_iterations: 5

End a branch deliberately, with a recorded reason, when there is nothing to do.

- id: classify
prompt: "Reply NONE if there is nothing actionable, else describe the work."
- id: bail
cancel: "nothing actionable"
when: "$classify.output == 'NONE'"
depends_on: [classify]

Agent nodes run inside declared limits. The file picks the provider and model per node, gates tools with allowed_tools and denied_tools (rib tools are default-off, so opt in by name), bounds stalls with idle_timeout, and pins output shape with output_schema. Above the file, the operator floor still applies: KEELSON_WORKFLOW_PROVIDER pins the provider every prompt node uses.

A prompt node’s output can flow into a bash or script node, but the two channels keep it safe: shell nodes read upstream output from KEELSON_NODE_<id>_OUTPUT environment variables (where hyphens and any other non-alphanumeric characters in the node id are replaced with underscores, e.g. node fix-it → KEELSON_NODE_fix_it_OUTPUT), never spliced into the source, so the model’s text is always data and never code.

- id: draft
prompt: "Write a one-line release note for: $ARGUMENTS"
- id: record
bash: 'printf "%s\n" "$KEELSON_NODE_draft_OUTPUT" >> NOTES.md'
depends_on: [draft]
- id: fix-it
prompt: "Fix the issue: $ARGUMENTS"
- id: apply-fix
bash: 'printf "%s\n" "$KEELSON_NODE_fix_it_OUTPUT"'
depends_on: [fix-it]

The variable is capped at 16 KiB and head+tail truncated past that, with a marker in the middle: fine for prose, fatal for JSON. Every output is also written in full to KEELSON_NODE_<id>_OUTPUT_FILE, which is always set, so structured output is read from the file:

- id: plan
prompt: "Return a JSON plan for: $ARGUMENTS"
output_format: { type: json_object }
- id: count-steps
bash: 'jq -r ".steps | length" "$KEELSON_NODE_plan_OUTPUT_FILE"'
depends_on: [plan]

keelson workflow validate warns when a shell body parses the capped variable directly. See the env channel for the full table.