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 handdiscoverinvoke
readinvoke
processinfer
survivorsinvoke
mergeinfer
the file, whole
conformance-gated upstream on every spec pushfanout.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)
cross-references
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 →