AGPL-3.0-or-later · forever.

the skeleton register

fanout

Your intent sounds like: do this for EVERY item One of 10 skeletons the pack ships, green as-is under nika check. Fill the # SLOT: lines, repair from the fix lines, re-check. Machines read the catalog; the binary lists them with nika new '?'.

  • 11SLOT pointsthe only lines you edit
  • 4patterns lockedarrive correct, stay correct
  • 3tools grantedthe minimum for the job
  • 96e4c9e0sha-pinnedre-hashed on every test run

the plan

derived from the file, never drawn by hand
discoverinvoke
readinvoke
processinfer
survivorsinvoke
mergeinfer

the file, whole

conformance-gated upstream on every spec push
fanout.nika.yaml
# SPDX-License-Identifier: Apache-2.0# yaml-language-server: $schema=https://nika.sh/spec/v1/workflow.schema.json## TEMPLATE · fanout · discover a collection → process every item in parallel → merge.##   [discover]──▶[process ∥×N]───▶[survivors]──▶[merge]#   nika:glob     for_each·infer    nika:jq        infer## The "do this for EVERY one of them" job, where you do not know how many there# are until the run starts. Discovery yields a collection, `for_each` fans it# out under a real leash (bounded parallelism, per-item timeout, retries), one# bad item cannot sink the batch, and the survivors merge into a single answer.## Demonstrates ·#   - `for_each:` over a RUNTIME collection · the fan width is discovered, never#     written down#   - the full leash · `max_parallel` + `timeout` + `retry` + `fail_fast: false`#   - `on_error: recover: null` + a null-aware fan-in · a failed item becomes a#     hole at its index, order is preserved, the batch survives## Needs · nothing. Scaffolds and runs green in an empty directory — an empty#   discovery skips the fan-out and the fan-in still returns a value.## Run · nika run <file> --model mock/echo    # offline rehearsalnika: v1workflow:  id: fanout-template           # SLOT: kebab-case workflow id  # SLOT: one honest sentence — `nika new` matches intents against these words  description: "do the same step for each item · discover the collection · process in parallel · merge"# SLOT: provider/model · local · zero key. Measured seat — `ollama/llama3.2:3b`.# `--model mock/echo` needs no seat and is the path this file guarantees.model: ollama/llama3.2:3brun:  # A `timeout:` is a deadline, and a deadline needs a clock. Declaring it says  # out loud which one: `system` is the ambient wall clock (the honest default);  # `virtual` drives a simulated clock for deterministic tests.  clock: systemconst:  collection_source: "./items"      # SLOT: where the collection comes frompermits:  tools: ["nika:glob", "nika:jq", "nika:read"]  fs:    # TWO entries, and both earn their place. `nika:glob` is gated on the    # DIRECTORY it walks, not on the files it returns — measured: with    # `./items/*.md` here (the pattern) the run dies `NIKA-SEC-004 ·    # ./items resolves outside the declared permits.fs.read boundary`,    # because `*` never crosses `/` and so never matches `./items` itself.    # And `nika:read` is gated on the FILES — a directory grant does not    # cover its children (measured: dir-only here made every per-item read    # die SEC-004, `recover: null` swallowed each, and the model was    # handed nulls — a green run inventing content). A deeper pattern    # (`./items/**/*.md`) walks deeper and needs `./items/**` for both.    read: ["./items", "./items/*"]tasks:  discover:    invoke:                         # SLOT: glob / fetch sitemap / exec + jq split      tool: "nika:glob"      args: { pattern: "${{ const.collection_source }}/*.md" }  # `discover` hands back PATH STRINGS — a prompt that interpolates the raw  # item shows the model a FILENAME, never the file (measured: a green run  # whose fluent « report » was invented from the names alone — check, mock  # rehearsal and run all green around it). Reading the item is its own fan.  # Delete this task — and fan `process` over `discover` — when your items  # already ARE the content (an inline list · fetched records). Need each  # content paired with its path? `resume-screener` shows the transpose.  read:    with:      discover: ${{ tasks.discover.output }}    for_each: ${{ with.discover }}    max_parallel: 8    fail_fast: false    on_error:      recover: null                 # an unreadable item yields null · the batch lives    invoke:      tool: "nika:read"      args: { path: "${{ item }}" }  process:    with:      read: ${{ tasks.read.output }}    # The quiet-day guard: an EMPTY discovery skips the `read` fan, and a    # skipped task hands NULL — which a bare for_each refuses (NIKA-VAR-006).    # The ternary keeps the no-items day green end to end.    for_each: "${{ with.read == null ? [] : with.read }}"    max_parallel: 4                 # SLOT: the polite ceiling    fail_fast: false    timeout: "60s"                  # SLOT: per-iteration bound    retry:      max_attempts: 3      backoff_strategy: exponential      jitter: true    on_error:      recover: null                 # a failed item yields null at its index · the batch lives    infer:                          # SLOT: the per-item job (any verb)      max_tokens: 500               # SLOT: the per-ITEM ceiling · multiply by the fan width      prompt: |        Process this item · ${{ item }}  survivors:    with:      process: ${{ tasks.process.output }}    invoke:                         # the null-aware fan-in · order preserved      tool: "nika:jq"      args:        input: ${{ with.process }}        # `. // []` · an EMPTY discovery skips the whole for_each (null        # upstream) — the fan-in must survive its own quiet day.        expression: "(. // []) | [ .[] | select(. != null) ]"  merge:    with:      survivors: ${{ tasks.survivors.output }}    infer:      max_tokens: 800               # SLOT: the fan-in ceiling      # SLOT: the fan-in · the survivors array (failed items filtered). Keep      # slot markers out of the block below — it is prompt TEXT, not YAML.      prompt: |        Merge these results into one report · ${{ with.survivors }}outputs:  report: ${{ tasks.merge.output }}

sha256 96e4c9e077f7c748. The copy above re-hashes to its pin on every test run (a copy is re-provable, never trusted). Source: fanout.nika.yaml in the spec pack · open it in the playground →

the patterns it locks

  • runtime collection
  • the full leash (max_parallel
  • fail_fast
  • retry)

Scaffold it locally: nika new fanout my-flow.nika.yaml. Try the shape in the playground, or walk the showcase for the same patterns on real work. Read the spec →