Workflow orchestration for Claude Code skills. Define pipelines as YAML, chain skills with control flow (while, if, foreach, parallel), and run headlessly via the Claude Code SDK.
# Install globally from GitHub
npm install -g github:ethanfarah/skillflow
# Or clone and link locally
git clone https://github.com/ethanfarah/skillflow.git
cd skillflow
npm install
npm run build
npm linkAfter either method, the skillflow command is available globally.
# 1. Create a workflow file in your project (or anywhere)
cat > my-workflow.yaml << 'EOF'
name: quick-test
steps:
- shell: "echo Hello from SkillFlow"
id: greeting
- prompt: "What is 2 + 2? Reply with just the number."
id: math
max_turns: 1
EOF
# 2. Run it
skillflow run my-workflow.yaml
# 3. Dry-run to see what would execute (no SDK/shell calls)
skillflow run my-workflow.yaml --dry-runSkillFlow runs from any directory. Point --cwd at your target repo, or just cd there first:
# Run a workflow against a specific repo
skillflow run ~/workflows/add-feature.yaml \
-i "feature=Add dark mode support" \
--cwd ~/dev/my-project
# Or cd into the repo first
cd ~/dev/my-project
skillflow run ~/workflows/add-feature.yaml \
-i "feature=Add dark mode support"Workflow files can live anywhere — in the target repo, in a shared ~/workflows/ directory, or in SkillFlow's own workflows/ folder.
name: add-feature
description: "Implement a feature, run tests, submit PR"
inputs:
feature: { type: string, required: true }
branch: { type: string, default: "main" }
steps:
- prompt: "Implement this feature: {{ inputs.feature }}"
id: impl
max_turns: 50
- shell: "npm test"
id: tests
on_error: continue
- if:
condition: "{{ tests.exit_code }} == 0"
then:
- skill: submit-pr
id: pr
args: "{{ inputs.branch }}"
else:
- prompt: "Fix the failing tests, then run them again"
id: fixskillflow run add-feature.yaml \
-i feature="Add user avatar upload" \
--cwd ~/dev/my-app \
--verboseSkillFlow includes a visual workflow editor for building and editing workflows in your browser.
The GUI must be built before use. If you installed globally from GitHub, the GUI is pre-built. If you cloned the repo locally:
npm run gui:buildskillflow composeThis starts a local HTTP server (default port 3333) and opens the composer in your browser. You can then:
- Drag blocks from the palette onto the canvas
- Edit block properties in the right panel
- Reorder blocks by dragging
- Copy the generated YAML and save it as a workflow file
Options:
skillflow compose --port 8080 # use a different port
skillflow compose --no-open # don't auto-open the browserNote: If you're developing SkillFlow itself, use
npm run gui:devto run the Vite dev server with hot reload instead.
| Block | What it does | Needs Claude? |
|---|---|---|
shell |
Run a bash command | No |
prompt |
Ask Claude anything (freeform) | Yes |
skill |
Invoke a Claude Code skill (/submit-pr, /pr-review, etc.) |
Yes |
extract |
Parse structured data from Claude's NL output | Yes (Haiku) |
if |
Conditional branch | No |
while |
Loop with condition | No |
foreach |
Iterate over an array | No |
parallel |
Run steps concurrently | No |
workflow |
Call another .yaml workflow |
No |
worktree |
Git worktree create/cleanup lifecycle | No |
break |
Exit a loop early | No |
Each prompt and skill block gets a fresh, isolated Claude session with full autonomy. Data flows between steps via extract → {{ step_id.field }} templates.
skillflow run <workflow.yaml> [options]
Options:
-i, --input KEY=VALUE Set a workflow input (repeatable)
--cwd PATH Set working directory (default: current dir)
--verbose Stream full output live
--quiet Silent, log to file only
--log FILE Write structured JSON event log
--dry-run Show what would execute without running
-h, --help Show help
skillflow compose [options]
Opens the visual workflow composer in your browser (serves gui/dist/).
Build the GUI first: npm run gui:build
Options:
--port PORT Server port (default: 3333)
--no-open Don't auto-open browser
Workflows are YAML files with a name, optional inputs/outputs, and a list of steps.
name: hello-world
steps:
- shell: echo "Hello from SkillFlow"
id: greetingname: my-workflow
description: "What this workflow does"
inputs:
feature: { type: string, required: true }
branch: { type: string, default: "main" }
outputs:
result: "{{ final_step.output }}"
steps:
# ... blocks go hereRuns a Claude Code skill (like /implement-feature, /submit-pr, etc.) in a fresh SDK session.
- skill: implement-feature
id: impl
args: "{{ inputs.feature }}"
max_turns: 100 # optional, default 200
timeout: 600000 # optional, ms, abort if exceeded
cwd: /path/to/repo # optional, default workflow cwd
on_error: continue # optional: fail | continue | retrySends a prompt to Claude with full tool access.
- prompt: "Analyze the test coverage in this repo and suggest improvements"
id: analysis
system_prompt: "You are a test coverage expert" # optional
allowed_tools: ["Read", "Glob", "Grep"] # optional, restrict tools
max_turns: 50 # optional
timeout: 300000 # optional, msExecutes a bash command directly (no SDK).
- shell: "npm test"
id: tests
cwd: "{{ worktree_path }}" # optional
timeout: 300000 # optional, ms, default 120000Access results: {{ tests.stdout }}, {{ tests.stderr }}, {{ tests.exit_code }}
Security note: By default, {{ }} values in shell commands are interpolated as-is. If values come from untrusted sources, use safe_interpolate: true to automatically shell-escape all interpolated values:
- shell: "echo {{ inputs.user_input }}"
safe_interpolate: true # wraps resolved values in single quotesThe bridge between Claude's natural language outputs and your workflow's control flow. Uses a fast/cheap Claude call (Haiku) to extract structured JSON.
- extract:
id: pr_info
from: "{{ submit.output }}"
schema:
pr_number: { type: integer, description: "The PR number" }
pr_url: { type: string, description: "The full PR URL" }Access results: {{ pr_info.pr_number }}, {{ pr_info.pr_url }}
For while-loop seeding, use the "initial" sentinel with defaults:
- extract:
id: status
from: "initial"
schema:
should_continue: { type: boolean }
default: { should_continue: true }- if:
condition: "{{ test_results.exit_code }} == 0"
then:
- skill: submit-pr
args: main
else:
- prompt: "Fix the failing tests"- while:
id: retry_loop
condition: "{{ status.needs_retry }}"
max_iterations: 5 # safety limit, default 100
init: # runs once before first condition check
- extract:
id: status
from: "initial"
schema: { needs_retry: { type: boolean } }
default: { needs_retry: true }
steps:
- skill: fix-issues
- extract:
id: status
from: "{{ fix_result.output }}"
schema: { needs_retry: { type: boolean } }- foreach:
id: file_loop
items: "{{ file_list.data.files }}"
item_var: file # default: "item"
index_var: idx # default: "index"
steps:
- prompt: "Review {{ file }}"- parallel:
concurrency: 3 # optional, default: all at once
steps:
- shell: "npm test"
- shell: "npm run lint"
- shell: "npm run typecheck"Compose workflows with isolated scopes. Inputs go in, declared outputs come back. File paths are resolved relative to the parent workflow's directory.
- workflow:
id: review_result
file: "./review-cycle.yaml" # resolved relative to this workflow's dir
inputs:
pr_number: "{{ pr_info.pr_number }}"Creates an isolated git worktree, runs steps inside it, auto-cleans up.
- worktree:
id: work
branch: "feature-x"
base: "main" # optional, default HEAD
cleanup: true # optional, default true
steps:
- skill: implement-feature
args: "{{ inputs.feature }}"- while:
condition: "true"
max_iterations: 100
steps:
- extract:
id: check
from: "{{ result.output }}"
schema: { done: { type: boolean } }
- if:
condition: "{{ check.done }}"
then:
- break: trueUse {{ expression }} to reference data. Expressions are resolved in this order:
- Loop locals -
{{ item }},{{ index }}(inside foreach) - Inputs -
{{ inputs.feature_description }} - Environment -
{{ env.HOME }} - Step outputs -
{{ step_id.field }}
Dot access navigates nested objects: {{ pr_info.pr_number }}
For extract results, you can access fields directly without .data: {{ pr_info.pr_number }} (not {{ pr_info.data.pr_number }}).
Single-expression templates preserve types: {{ items }} returns the actual array, not a stringified version. This is how foreach.items gets an iterable array.
Per-step error policy:
- skill: merge-pr
on_error: continue # swallow error, mark step as failed, keep going
- shell: "flaky-command"
on_error: retry
retry:
max_attempts: 3
delay_ms: 1000
backoff: exponential # none | linear | exponential
- skill: critical-step
on_error: fail # default - stop the workflowIf on_error: retry is set without a retry block, defaults to 3 attempts with 1s exponential backoff.
Conditions support comparison and logical operators. All {{ }} values are resolved before evaluation. No JavaScript eval() is used.
# Comparisons
condition: "{{ count }} > 0"
condition: "{{ status }} == 'approved'"
condition: "{{ a }} != {{ b }}"
# Logical operators
condition: "{{ x }} > 0 && {{ y }} < 10"
condition: "{{ flag }} || {{ fallback }}"
condition: "!{{ should_skip }}"
# Truthiness (bare value)
condition: "{{ has_issues }}" # true/false, truthy/falsy check
# Parentheses
condition: "({{ a }} > 1) && ({{ b }} == 'yes' || {{ c }})"Falsy values: false, 0, "", null, undefined, and their string equivalents.
# Verbose: stream everything (all events logged to stderr)
skillflow run flow.yaml --verbose
# Normal (default): step start/end with durations
skillflow run flow.yaml
# Quiet: no stderr output, results only
skillflow run flow.yaml --quiet
# JSON event log (works with any verbosity)
skillflow run flow.yaml --log events.jsonWorkflows can be cancelled via AbortController:
const ac = new AbortController();
// Cancel after 5 minutes
setTimeout(() => ac.abort(), 5 * 60 * 1000);
const result = await runWorkflow(doc, {
inputs: { feature: "Add dark mode" },
abortSignal: ac.signal,
});The CLI handles SIGINT (Ctrl+C) and SIGTERM automatically, aborting the running workflow gracefully.
Workflow YAML files are validated at load time using Zod schemas. Typos and structural errors produce helpful messages:
Invalid workflow document:
- steps.0: Each step must be an object with exactly one block-type key: skill, prompt, shell, extract, workflow, worktree, if, while, foreach, parallel, or break
- steps.2.skilll: Unrecognized key(s) in object: 'skilll'
import { loadWorkflow, runWorkflow } from "skillflow";
const doc = loadWorkflow("./my-workflow.yaml");
const result = await runWorkflow(doc, {
inputs: { feature: "Add dark mode" },
cwd: "/path/to/repo",
workflowFile: "./my-workflow.yaml", // enables relative child workflow resolution
verbosity: "normal",
logFile: "events.json",
abortSignal: myAbortController.signal, // optional cancellation
});
console.log(result.succeeded); // boolean
console.log(result.outputs); // declared workflow outputs
console.log(result.duration_ms); // total runtimeThe included workflows/plan-to-finished-pr.yaml automates the full PR lifecycle:
- Implement the feature with
/implement-feature - Submit PR with
/submit-pr - Extract PR number and URL from the output
- Review loop: wait for review, check for issues, address them, repeat
- Merge the PR with
/merge-pr
skillflow run workflows/plan-to-finished-pr.yaml \
-i "feature_description=Add a search bar to the dashboard" \
--cwd ~/dev/my-project \
--log pr-log.jsonnpm testTests use Node's built-in test runner (node:test) and cover interpolation, conditions, context, executor, semaphore, retry, and YAML validation.
src/
schema/types.ts - TypeScript types for all blocks, outputs, events
schema/validate.ts - Zod schema validation for YAML workflows
runtime/
executor.ts - Recursive block interpreter (core engine)
context.ts - Variable store, {{ }} interpolation, scope forking
condition.ts - Safe expression evaluator (no eval)
runners/
skill-runner.ts - skill -> Claude Code SDK query()
prompt-runner.ts - prompt -> SDK query()
shell-runner.ts - shell -> child_process
extract-runner.ts - extract -> Haiku SDK call for NL-to-JSON
workflow-runner.ts - workflow -> recursive load + execute
worktree-runner.ts - worktree -> git worktree lifecycle
util/
interpolation.ts - {{ }} template parser
sdk-env.ts - SDK subprocess env (nested session fix)
semaphore.ts - Async concurrency limiter
retry.ts - Retry with backoff
logger.ts - Structured event logging
errors.ts - BreakSignal, WorkflowError, SkillError
cli.ts - CLI entry point
compose-server.ts - HTTP server for the visual composer (skillflow compose)
index.ts - Public API
gui/ - Visual workflow composer (React + Vite)
src/
App.tsx - Root component, useReducer state, 3-panel layout
lib/ - state.ts, yaml-gen.ts, block-meta.ts, block-defaults.ts
components/ - BlockCard, BlockEditor, BlockPalette, forms/, …