Skip to content

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)

Implemented

Steps run in declaration order. The second step starts when the first finishes. No configuration needed.

yaml
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

Implemented

You 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.

yaml
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

Implemented

Two mechanisms let you branch pipeline execution:

  1. Per-step when: gate — skip a step if a predicate fails. Use when you have one step that should only run under a condition.
  2. The route action — 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.

yaml
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.

yaml
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 to choice.txt using the Write tool. The output is verified to be one of options.
  • via: code — your run: source code writes choice.txt itself. Useful when the decision is deterministic (e.g., based on numeric thresholds).
yaml
- 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

Implemented

Two loop shapes are supported, chosen by the fields you set:

  1. For-each — iterate over a list (over + as + step).
  2. 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.

yaml
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 body
  • step — the step to run per iteration (can be any action)
  • max_loops — optional; fails fast if over has 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.

yaml
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: done

The 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.