Control Flow
Pipelines aren't always a straight line. Sometimes you need to run two things at once, choose between paths, or repeat a step for each item in a list. That's what control flow is for.
The good news: you don't need to configure anything for the basic case. Steps just run in order, one after another. Control flow is only needed when you want something different from that default.
Sequential execution (the default)
ImplementedSteps run in declaration order. The second step starts when the first finishes. No configuration needed.
steps:
- name: fetch_profile
action: ai
prompt: "Describe the career history of {{ input.person_name }}"
- name: write_bio
action: ai
prompt: "Write a LinkedIn bio based on this profile: {{ fetch_profile.text }}"write_bio waits for fetch_profile to complete, then uses its output. That's it.
Parallel execution
ImplementedYou don't need a parallel: block to run steps concurrently — JigSpec's scheduler already does it automatically. Any step whose inputs don't depend on another step in flight is launched as soon as it's ready. If two steps reference nothing from each other, they run at the same time.
steps:
- name: search_web
action: ai
prompt: "Find recent news about {{ input.topic }}"
- name: search_papers
action: ai
prompt: "Find academic papers about {{ input.topic }}"
- name: synthesize
action: ai
prompt: |
Synthesize these sources:
News: {{ search_web.text }}
Papers: {{ search_papers.text }}search_web and search_papers both have no dependencies on each other, so the scheduler launches them together. synthesize references both, so it waits until both have completed. The fan-out is unbounded — if you declare ten independent first-step searches, all ten start simultaneously.
How to make steps run in parallel
The rule is "no shared dependencies." Two steps run in parallel when neither references the other through {{ step_name.field }} templates, nor through explicit input: entries. The scheduler does the topological sort for you.
Conditional routing
ImplementedTwo mechanisms let you branch pipeline execution:
- Per-step
when:gate — skip a step if a predicate fails. Use when you have one step that should only run under a condition. - The
routeaction — actively choose one branch from a list of named options by asking an AI or running code. Use when you need to select among several mutually exclusive paths.
Per-step when: gate
Every step accepts an optional when: field that points to a file and a value. Before the step runs, JigSpec reads the file, trims it, and compares it against value. If they match, the step runs. If they don't, the step is skipped and downstream steps that depend on it also skip.
steps:
- name: classify
action: ai
prompt: "Classify this support message: {{ input.message }}"
categories: [bug_report, feature_request, question]
- name: handle_bug
action: ai
prompt: "Write a bug report template for: {{ input.message }}"
when:
file: "{{ classify['category.txt'] }}"
value: "bug_report"
- name: handle_feature
action: ai
prompt: "Write a feature request for: {{ input.message }}"
when:
file: "{{ classify['category.txt'] }}"
value: "feature_request"The gate is deliberately boring: one file, one value, exact string match. The value is whatever was written to the file (case-sensitive), trimmed of leading/trailing whitespace. This keeps gates readable and deterministic.
The route action
When you want to pick exactly one of several options — and then have downstream steps gate on the choice — the route action is cleaner than writing a fan of when: branches. It wraps a single call (AI prompt or code block) whose only job is to produce a choice.txt file containing one of the declared options.
steps:
- name: pick_strategy
action: route
via: ai
prompt: "Given the user's request, pick the best strategy: {{ input.request }}"
options: [summarize, translate, extract_data]
- name: do_summarize
action: ai
prompt: "Summarize: {{ input.request }}"
when:
file: "{{ pick_strategy['choice.txt'] }}"
value: "summarize"
- name: do_translate
action: ai
prompt: "Translate to French: {{ input.request }}"
when:
file: "{{ pick_strategy['choice.txt'] }}"
value: "translate"
- name: do_extract
action: ai
prompt: "Extract structured data from: {{ input.request }}"
when:
file: "{{ pick_strategy['choice.txt'] }}"
value: "extract_data"Two forms are supported:
via: ai— the runtime augments your prompt with instructions to write the chosen option tochoice.txtusing theWritetool. The output is verified to be one ofoptions.via: code— yourrun:source code writeschoice.txtitself. Useful when the decision is deterministic (e.g., based on numeric thresholds).
- name: classify_by_size
action: route
via: code
runtime: node
input:
word_count: "{{ count_words.text }}"
run: |
const n = parseInt(input.word_count)
const bucket = n < 100 ? 'short' : n < 1000 ? 'medium' : 'long'
await fs.writeFile('choice.txt', bucket)
options: [short, medium, long]Either way, after the step runs you get a choice.txt in the step's workspace. Downstream steps gate on it with when: { file, value }.
Loops
ImplementedTwo loop shapes are supported, chosen by the fields you set:
- For-each — iterate over a list (
over+as+step). - Dynamic-exit — repeat a body until a file says it's done (
body+until).
Both shapes accept max_loops, a hard safety cap that fails the step with a clear error if the loop runs too long.
For-each
Apply the same step to every item in a list.
steps:
- name: extract_items
action: ai
prompt: "Extract the line items from this invoice as a JSON list: {{ input.invoice_text }}"
output_schema:
items:
- description: string
amount: number
- name: categorize_each
action: loop
over: "{{ extract_items.data.items }}"
as: item
max_loops: 50
step:
name: categorize
action: ai
prompt: "Categorize '{{ item.description }}' as: office, travel, software, or equipment"For each item in over, the inner step runs with {{ item }} bound to that element. Each iteration runs in its own iter-N/ workspace subdirectory, so you can inspect the intermediate outputs after the run.
Fields:
over— a template that resolves to an array (typically{{ prior_step.data.field }}from an extract step)as— the name to bind each element to inside the loop bodystep— the step to run per iteration (can be any action)max_loops— optional; fails fast ifoverhas more items than the cap
For-each iterations run in parallel where they don't depend on each other — the same rule as the top-level scheduler.
Dynamic-exit (loop until a condition)
Run a body step repeatedly until some file it writes matches an expected value.
steps:
- name: iterate_draft
action: loop
max_loops: 10
body:
name: revise
action: ai
prompt: |
Revise the draft below. When you believe it's finished, write "done" to
status.txt; otherwise write "continue".
Draft: {{ iterate_draft['draft.md'] | default('initial draft here') }}
outputs: [draft.md, status.txt]
until:
equals:
file: status.txt
value: doneThe body runs; JigSpec reads status.txt; if it equals done, the loop exits and the body's last outputs become the loop's outputs. Otherwise it runs again (up to max_loops).
Start simple
Most pipelines only need sequential execution. Reach for parallel, conditional, and loops only when the simpler approach has a real cost — an extra second of latency, an untaken branch, a repeated prompt.