# SLOT:
marker at every decision point. The path from intent to a correct file
is mechanical: route → copy → fill slots → nika check → repair →
re-check.
Route by intent
| Your intent sounds like… | Template | What it locks in |
|---|---|---|
| « take data, produce words, save them » | chain | deterministic gather · one model job · explicit persist |
| « watch X, act when Y » | gate-and-act | jq extraction · CEL skip-gate · often zero model calls |
| « do this for EVERY item » | fanout | runtime collection · the full leash (max_parallel · fail_fast · retry) |
| « only what changed since last run » | etl-state | state read→diff→write · on_error: recover: quarantine |
| « research / review / open-ended » | agent-loop | plan-then-execute · default-deny tools · budgets · typed final message |
| « anything irreversible (deploy · send · publish) » | human-gated-ship | parallel gates · assert · nika:prompt GO · on_finally record |
| « understand a site (domain · theme · assets) from a URL » | website-brief | fetch traverse: crawl · one typed infer · explicit persist · zero exec |
| « generate image/audio assets from a brief » | media-asset-pack | nika:image_generate · nika:jq manifest · local/mock provider first |
| « call a product API: upload a file, then create from it » | api-upload-and-create | fetch multipart: upload · masked secrets header · mode/jq extraction |
| « read a system’s state (docker · kubectl · gh), explain it, keep the report » | docker-report | argv-array exec (provable allowlist) · parallel reads · exec ledger · one artifact |
The YAML below is normally projected from
nika-spec/templates/
— the source of truth, validated by the conformance runner — by
nika-spec/scripts/showcase-projector.py.It is ahead of that projection right now: these copies were migrated
by hand to the 0.106 grammar, and four of them gained the permits:
block an effect-bearing workflow now requires. The templates upstream
need the same pass, after which the projector reclaims these blocks and
the two surfaces agree again.chain
The default shape for « take real data, produce words, save them ».chain.nika.yaml
nika: v1
workflow:
id: chain-template # SLOT: kebab-case workflow id
# SLOT: one honest sentence — `nika new` matches intents against these words
description: "gather → think → persist"
# SLOT: provider/model · local · zero key. Measured seat — `ollama/llama3.2:3b`
# answers this prompt in ~50s. `--model mock/echo` needs no seat at all and is
# the path this file guarantees.
model: ollama/llama3.2:3b
const:
source: "./README.md" # SLOT: your input · README.md exists in most repos
destination: "./output.md" # SLOT: where the result lands
permits: # the blast radius · default-deny once present
tools: ["nika:read", "nika:write"]
# Two literal paths, one each way. `check` can read these (they come from
# `const:`) and proves them against the boundary before anything runs — keep
# them in step with the two `const:` entries above.
fs:
read: ["./README.md"]
write: ["./output.md"]
tasks:
gather:
invoke: # SLOT: the fact source · nika:read / nika:fetch / exec
tool: "nika:read"
args: { path: "${{ const.source }}" }
on_error:
# Offline rehearsal. A freshly scaffolded directory has no README, and a
# skeleton that dies on its first run teaches nothing — so a not-found
# recovers into a literal standing in for the real document. `on_codes:`
# keeps that narrow: ONLY not-found is forgiven, a permission error
# still fails loudly. Delete this block once the source really exists.
# In an EMPTY directory check prints one [inputs] hint that this read
# would fail — the run does not: this recover carries it (measured ·
# rc=0 · « 1 recovered »). The hint retires once your source exists.
on_codes: [NIKA-BUILTIN-READ-001]
recover: "REHEARSAL · no source document here yet."
think:
with:
gather: ${{ tasks.gather.output }}
infer:
max_tokens: 800 # SLOT: the spend ceiling · sized for the SEAT, not the answer
# SLOT: the one model job. Keep slot markers OUT of the block below —
# everything indented under `prompt: |` is prompt TEXT, so a stray
# `# SLOT:` line is sent to the model verbatim (visible in the mock
# echo). Comments belong out here, where YAML eats them.
prompt: |
Summarize the following in a short paragraph.
${{ with.gather }}
persist:
with:
think: ${{ tasks.think.output }}
invoke:
tool: "nika:write"
args:
path: "${{ const.destination }}" # SLOT: destination · same path as permits.fs.write
content: "${{ with.think }}" # ALWAYS pass content · a write without it writes nothing
on_error:
# Golden rehearsal. Under `nika test` the mock plane simulates the
# MODEL, not effects — this write is refused (NIKA-452) so the pin
# lane needs this recover to walk its own scaffold. `nika run` never
# fires it on success (a recover only runs on failure). Delete this
# block once pointed at real data, so a genuine write failure is loud.
recover: "REHEARSAL · write refused under nika test — the real run persists to the destination"
outputs:
result: ${{ tasks.think.output }} # SLOT: the callable contract
gate-and-act
Watch something, act only when a condition holds (often zero model calls).gate-and-act.nika.yaml
nika: v1
workflow:
id: gate-and-act-template # SLOT: kebab-case workflow id
# SLOT: one honest sentence — `nika new` matches intents against these words
description: "watch a value · act only when the condition holds"
const:
source_url: "https://api.example.com/v1/value" # SLOT: what to watch
threshold: 100 # SLOT: the trigger condition value
permits: # the blast radius · default-deny once present
tools: ["nika:fetch", "nika:notify"]
net:
http:
- "api.example.com" # SLOT: the watched host from const.source_url
- "hooks.slack.com" # SLOT: the host ALERTS_WEBHOOK_URL points at.
# `host_from_self` below means the host is not
# knowable at check — it is judged at RUN against
# this list, so an unnamed host fails mid-flight.
secrets:
webhook:
source: env
key: ALERTS_WEBHOOK_URL # SLOT: where the act lands
egress:
- to: "nika:notify"
host_from_self: true # the secret value IS the destination URL
tasks:
check:
invoke:
tool: "nika:fetch"
args:
url: "${{ const.source_url }}"
mode: jq
jq: "."
output:
value: ".value" # SLOT: the jq path to the watched field
on_error:
# Offline rehearsal · a sample UNDER the threshold — the gate stays
# closed, the skeleton runs green before you wire the real source.
recover: { value: 42 }
act:
with:
value: ${{ tasks.check.value }} # the binding IS the edge · check → act
# when: is a SKIP gate — routing, not failure (a skipped task is not an error)
when: ${{ with.value > const.threshold }} # SLOT: the CEL condition
invoke:
tool: "nika:notify" # SLOT: the action · notify / write / exec
args:
channel: webhook
target: "${{ secrets.webhook }}"
message: "Threshold crossed · ${{ with.value }}" # SLOT
severity: warning
outputs:
value: ${{ tasks.check.value }}
fanout
The same work for every item of a runtime collection, fully leashed.fanout.nika.yaml
nika: v1
workflow:
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:3b
run:
# 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: system
const:
collection_source: "./items" # SLOT: where the collection comes from
permits:
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 }}
etl-state
Incremental runs: only what changed since last time, survive bad input.etl-state.nika.yaml
nika: v1
workflow:
id: etl-state-template # SLOT: kebab-case workflow id
# SLOT: one honest sentence — `nika new` matches intents against these words
description: "only what changed since last run · read state · fetch fresh · diff · process the delta · save state"
const:
source_url: "https://api.example.com/v1/records" # SLOT: the data source
state_path: "./state/etl-state.json" # SLOT: the cursor file
permits: # the blast radius · default-deny once present
tools: ["nika:fetch", "nika:jq", "nika:json_diff", "nika:prompt", "nika:read", "nika:write"]
net: { http: ["api.example.com"] } # SLOT: the source host from const.source_url
fs: # the cursor file, read then rewritten — one path, both ways
read: ["./state/etl-state.json"] # SLOT: keep in step with const.state_path
write: ["./state/etl-state.json"] # SLOT: idem — the job writes nothing else
tasks:
# NEP-0002 · the Rule of Two, as a check. This run holds all three legs at
# once: it reads a private file, ingests UNTRUSTED network content, and
# persists that content into the very file the NEXT run reads as trusted
# state. One human decision has to dominate EVERY path to that write — so the
# gate sits before the first network touch, not next to the write (a gate the
# fetch can route around dominates nothing). Blocking on purpose: a `default:`
# here would disarm it, and the checker knows — measured, adding `default:`
# flips TRIFECTA to `✖ NIKA-SEC-009 lethal trifecta complete`.
approve:
invoke:
tool: "nika:prompt"
args:
message: "Fetch ${{ const.source_url }} and persist it into ${{ const.state_path }}?"
previous_raw:
invoke:
tool: "nika:read"
args: { path: "${{ const.state_path }}" }
on_error:
on_codes: [NIKA-BUILTIN-READ-001] # not-found ONLY · a permission error still fails loudly
recover: "[]" # first run · the STRING "[]", so `previous` parses it
# exactly like a real file — one code path, not two
previous:
with:
raw: ${{ tasks.previous_raw.output }}
invoke:
# `nika:read` hands back TEXT, and `nika:json_diff` compares VALUES. Feed
# it the raw string and the diff degrades to a single
# `{"op":"replace","path":""}` carrying the entire new document — it looks
# like it works, and it reports "everything changed" forever. Measured.
# `fromjson` is what makes the next task an actual diff.
tool: "nika:jq"
args:
input: "${{ with.raw }}"
expression: "fromjson"
fresh:
with:
go: ${{ tasks.approve.output }} # the binding IS the edge · the gate dominates the fetch
when: ${{ with.go == true }} # declined → no network touch at all
invoke:
tool: "nika:fetch" # SLOT: fetch / read / exec · the fresh data
args:
url: "${{ const.source_url }}"
mode: jq
jq: ".records"
on_error:
recover: [] # offline rehearsal · an empty batch, the delta stays quiet
delta:
with: # the bindings ARE the edges · previous + fresh → delta
previous: ${{ tasks.previous.output }}
fresh: ${{ tasks.fresh.output }}
invoke:
tool: "nika:json_diff" # RFC 6902 · empty patch = nothing new
args:
before: "${{ with.previous }}"
after: "${{ with.fresh }}"
process:
with:
delta: ${{ tasks.delta.output }}
when: ${{ size(with.delta) > 0 }}
invoke:
tool: "nika:jq" # SLOT: the delta job (jq · infer · write…)
args:
input: "${{ with.delta }}"
expression: "length"
save_state:
with:
fresh: ${{ tasks.fresh.output }}
go: ${{ tasks.approve.output }} # the binding IS the edge · the gate dominates the write
when: ${{ with.go == true }} # a refusal is a VALUE · the write is skipped, never failed
invoke:
tool: "nika:write"
args:
path: "${{ const.state_path }}"
content: "${{ with.fresh }}" # a VALUE · the engine serializes it, and the next run's
# `fromjson` reads it straight back
create_dirs: true
overwrite: true
outputs:
changes:
value: ${{ tasks.delta.output }}
description: "RFC 6902 ops since last run · empty = no-op run"
agent-loop
Open-ended work: plan with a fast model, execute with a budgeted agent, validate the typed result. Never ship an unleashed agent.agent-loop.nika.yaml
nika: v1
workflow:
id: agent-loop-template # SLOT: kebab-case workflow id
# SLOT: one honest sentence — `nika new` matches intents against these words
description: "open-ended research / review / triage · plan → budgeted agent → typed result"
# SLOT: a TOOL-CALLING model · local by default. This seat is measured, not
# assumed: `ollama/qwen2.5:14b` completes the planning call in ~65s under the
# ceiling below. A reasoning seat (`ollama/qwen3.5:4b`) spends its whole
# budget thinking and never emits JSON here — the guaranteed offline path is
# `--model mock/echo`, which needs no seat at all.
model: ollama/qwen2.5:14b
inputs:
goal:
type: string
required: true
# A `required:` input still carries a `default:` here so the skeleton
# RUNS the moment it is scaffolded. Replace the default with your job;
# `--var goal=…` overrides it at any time.
default: "list the risks of running an agent without a turn budget" # SLOT
description: "What the agent must accomplish" # SLOT
permits: # the blast radius · default-deny once present
# Exactly what the body invokes: `nika:assert` (the confirm task) plus the
# two the agent may call. No `fs:` — see the note on `tools:` below.
tools: ["nika:assert", "nika:done", "nika:jq"]
tasks:
plan:
infer:
prompt: "Break '${{ inputs.goal }}' into at most 4 concrete steps." # SLOT
# SLOT: the spend ceiling for the planning call. Size it for the SEAT,
# not for the answer: a reasoning model spends tokens thinking before
# it emits the first brace, and a ceiling that cuts it off mid-thought
# fails NIKA-INFER-002 ("no JSON value found · the reply was cut off at
# the token limit"). Measured — 400 starves qwen3.5:4b on this prompt.
max_tokens: 2000
schema:
type: object
additionalProperties: false
required: [steps]
properties:
steps: { type: array, items: { type: string } }
execute:
with:
steps: ${{ tasks.plan.output.steps }}
agent:
# Say what the JOB is. Do NOT describe the output shape.
#
# The engine binds `schema:` to the FINAL answer: a free-text answer
# that does not conform is RE-ASKED with the schema wired, bounded by a
# retry budget. Measured on ollama/qwen3.5:4b — this task, with zero
# shape instruction, returns a clean typed object.
#
# Hand-instructing "reply with the object, then call nika:done" is
# actively harmful: a `nika:done` carrying `result:` is validated
# DIRECTLY and is NEVER re-asked (nika-verb-agent/src/lib.rs · "a miss
# is a verdict, never a re-ask"), so a model that hands its JSON back
# as a *string* dies NIKA-INFER-002 with the budget already spent.
# Measured twice on that exact instruction. Let the engine own shape.
system: "You are a careful analyst. Work the plan step by step and report what you actually found." # SLOT
prompt: "Plan · ${{ with.steps }}"
tools: # SLOT: the MINIMUM grant for the job
# Pure compute — this pair needs no filesystem, so the skeleton runs
# anywhere. Granting the agent a tool here is only HALF a grant: every
# call still crosses the workflow boundary above. Adding `nika:read`
# to this list WITHOUT adding `permits.fs.read` fails at run with
# `NIKA-SEC-004 · agent tool "nika:read" refused by the security
# boundary` — measured, mid-loop, after the turns are paid for.
- "nika:jq"
- "nika:done" # the early-exit sentinel · loop-owned
max_turns: 15 # SLOT: the loop bound
max_tokens_total: 80000 # SLOT: the spend bound
schema: # SLOT: the typed final-message contract
type: object
additionalProperties: false
required: [findings]
properties:
findings: { type: array, items: { type: string } }
confirm:
with:
has_findings: ${{ size(tasks.execute.output.findings) > 0 }} # the check crosses as ONE boundary expression
invoke:
tool: "nika:assert"
args:
condition: "${{ with.has_findings }}"
message: "Agent returned no findings, do not trust an empty run" # SLOT
outputs:
findings:
value: ${{ tasks.execute.output.findings }}
description: "The agent's typed findings" # SLOT
human-gated-ship
Anything irreversible: parallel gates, a hard assert, a human GO, and anon_finally record whatever happens.
human-gated-ship.nika.yaml
nika: v1
workflow:
id: human-gated-ship-template # SLOT: kebab-case workflow id
# SLOT: one honest sentence — `nika new` matches intents against these words
description: "verify in parallel · human GO · act · record"
permits: # SLOT: the blast radius · default-deny once present
exec: ["echo"] # SLOT: ONLY the programs the gates + act run (argv form)
tools: ["nika:assert", "nika:prompt", "nika:notify"]
# `host_from_self` below sanctions the FLOW (the secret may BE the URL) — it
# does not grant the capability to reach anyone. The host stays unknown at
# check, so it is judged at RUN against this list: name the webhook host here
# or the record is refused mid-run, after the ship already happened.
net: { http: ["hooks.slack.com"] } # SLOT: the webhook host · nothing else may leave
secrets:
webhook:
source: env
key: TEAM_WEBHOOK_URL # SLOT: where the record lands
egress:
- to: "nika:notify"
host_from_self: true # the secret value IS the destination URL
tasks:
# ── the verification wave · all checks run in parallel ──
check_a:
exec:
command: ["echo", "ok"] # SLOT: gate 1 (argv form · injection-safe)
capture: structured
on_error:
# Golden rehearsal · `nika test` refuses exec (NIKA-SEC-001 · the mock
# plane simulates the model, not effects). Catch-all ON PURPOSE — never
# `on_codes: [NIKA-SEC-001]`: that code is also a real blocklist stop,
# and a narrow forgiveness would swallow a security refusal. The shape
# matches `capture: structured` so the gates expression reads it.
# Delete once the gate runs a real check.
recover: { exit_code: 0, stdout: "REHEARSAL", stderr: "" }
check_b:
exec:
command: ["echo", "ok"] # SLOT: gate 2
capture: structured
on_error:
# Same rehearsal armor as check_a · delete once real.
recover: { exit_code: 0, stdout: "REHEARSAL", stderr: "" }
gates:
with:
all_green: ${{ tasks.check_a.output.exit_code == 0 && tasks.check_b.output.exit_code == 0 }} # two value edges · one boundary expression
invoke:
tool: "nika:assert"
args:
condition: "${{ with.all_green }}"
message: "A gate is RED: refusing to proceed" # SLOT
human:
after:
gates: success # state, no data · no question until the board is green
invoke:
# On a terminal this ASKS. Headless, it answers with `default:` below —
# the run completes, it does not pause (measured · act settles skipped).
# The durable pause belongs to a prompt with NO default: headless it
# PAUSES (exit 4 · not a failure) and prints its own resume line:
# nika run <file> --resume <trace> --answer human=true · or false
tool: "nika:prompt"
args:
message: "All gates GREEN. Proceed?" # SLOT: the decision, fully informed
# Fail CLOSED. The unattended answer to "should I do the irreversible
# thing" is no — `default: true` would hand CI a rubber stamp.
default: false
act:
with:
go: ${{ tasks.human.output }} # the answer crosses as a value edge
when: ${{ with.go == true }}
exec:
command: ["echo", "shipped"] # SLOT: the irreversible action (argv · program must be in permits.exec)
# default capture · a failing ship fails LOUDLY (NIKA-EXEC-001):
# never `capture: structured` on the irreversible step (exit codes
# would become data and a red ship would read as success)
record:
after:
act: terminal # the always-pattern · runs on success, failure, OR refusal
with:
acted: ${{ tasks.act.status }} # observe the outcome · same pass-set as the after edge
invoke:
tool: "nika:notify"
args:
channel: webhook
target: "${{ secrets.webhook }}"
message: "Run finished · act=${{ with.acted }}" # SLOT · success | failure | skipped
severity: info
on_error:
# Rehearsal only. With TEAM_WEBHOOK_URL unset the reference cannot resolve
# and this task fails NIKA-VAR-001 (measured) — which would make the
# skeleton red on a machine that has no webhook. Recovering keeps the
# scaffold runnable; DELETE this block for real use, because a ship whose
# audit trail silently vanished is exactly what this task exists to prevent.
recover: "REHEARSAL · no webhook configured · nothing was sent"
outputs:
acted: ${{ tasks.act.status }}
website-brief
Understand a site from a URL: boundedtraverse: crawl → one typed brief → persist. Zero exec.
website-brief.nika.yaml
nika: v1
workflow:
id: website-brief-template # SLOT: kebab-case workflow id
# SLOT: one honest sentence — `nika new` matches intents against these words
description: "crawl → brief → persist"
# SLOT: provider/model · local · zero key. The schema below has five required
# fields, so give it a seat that holds structure — `ollama/qwen2.5:14b` is
# measured on this shape. `--model mock/echo` needs no seat at all.
model: ollama/qwen2.5:14b
const:
site_url: "https://example.com" # SLOT: the site to understand
out_path: "./out/brief.json" # SLOT: where the brief lands
permits: # the blast radius · default-deny once present
tools: ["nika:fetch", "nika:write"]
net: { http: ["example.com"] } # SLOT: the host from const.site_url. ONE entry is
# enough — `traverse:` is a same-origin BFS (+ the
# robots probe), so the crawl never leaves this host.
fs: { write: ["./out/brief.json"] } # SLOT: the one file · keep in step with const.out_path
# (the infer step needs no grant — it is pure compute)
tasks:
crawl_site:
invoke:
tool: "nika:fetch"
args:
url: "${{ const.site_url }}"
traverse: { max_pages: 5 } # SLOT: crawl bound · 1..=25 (robots honored)
on_error:
# Offline rehearsal · a literal standing in for the crawl digest, so the
# brief step runs with no network at all. Delete once the site is real.
recover: "REHEARSAL DIGEST · Example Co · a small tool company · plain blue and white pages · buttons, a pricing table, one logo."
brief:
with:
crawl_site: ${{ tasks.crawl_site.output }}
infer:
max_tokens: 1200
# SLOT: the one model job · what should the brief capture? Keep slot
# markers out of the block below — it is prompt TEXT, not YAML, so a
# stray comment line is sent to the model verbatim.
prompt: |
From this site crawl, produce a creative brief: the domain of
activity, the dominant visual theme, the audience, the usable
colors and image assets.
Crawl digest · ${{ with.crawl_site }}
schema: # SLOT: the typed shape downstream tasks rely on
type: object
additionalProperties: false
properties:
domain: { type: string }
theme: { type: string }
audience: { type: string }
colors: { type: array, items: { type: string } }
assets: { type: array, items: { type: string } }
required: [domain, theme, audience, colors, assets]
persist:
with:
brief: ${{ tasks.brief.output }}
invoke:
tool: "nika:write"
args:
path: "${{ const.out_path }}"
create_dirs: true
# The path ends `.json`, so `content:` is ONE interpolation of a value
# the engine serializes. Typing `{ "domain": ${{ … }} }` by hand emits
# unquoted fields and the artifact stops being JSON.
content: "${{ with.brief }}"
on_error:
# Golden rehearsal · `nika test` refuses effects (NIKA-452 · the mock
# plane simulates the model, not effects) — this recover lets the pin
# lane walk the scaffold. Never fires on a successful real write.
# Delete once wired, so a genuine write failure is loud.
recover: "REHEARSAL · write refused under nika test — the real run persists the brief"
outputs:
brief: ${{ tasks.brief.output }} # SLOT: the callable contract
media-asset-pack
Generate assets from a brief: typed creative direction →nika:image_generate → jq manifest.
media-asset-pack.nika.yaml
nika: v1
workflow:
id: media-asset-pack-template # SLOT: kebab-case workflow id
# SLOT: one honest sentence — `nika new` matches intents against these words
description: "brief → generate image assets → manifest"
# SLOT: provider/model · local · zero key. Measured seat — `ollama/qwen2.5:14b`
# holds the small schema below. `--model mock/echo` needs no seat at all.
model: ollama/qwen2.5:14b
const:
subject: "a calm cosmic landing hero" # SLOT: what the asset is about
out_dir: "./out/assets" # SLOT: where assets land
permits: # the blast radius · default-deny once present
tools: ["nika:image_generate", "nika:jq", "nika:write"]
fs:
write:
# Two entries, and both earn their place: `check` judges the `output_dir:`
# ARGUMENT (`./out/assets`), while the RUN gates every FINAL file path under
# it — the asset, its provenance manifest, and manifest.json. Grant only the
# directory and the file sails through check, then dies at run on the first
# asset. `*` is one segment and never crosses `/`, which is all this needs:
# image_generate lands its files flat, so no subtree grant is warranted.
- "./out/assets" # SLOT: keep in step with const.out_dir
- "./out/assets/*" # SLOT: idem · the files that land inside it
tasks:
brief:
infer:
max_tokens: 600
# SLOT: the creative direction · style · constraints. Keep slot markers
# out of the block below — it is prompt TEXT, not YAML, so a stray
# comment line is sent to the model verbatim.
prompt: |
Write one vivid, concrete image prompt for: ${{ const.subject }}.
No text in the image · no watermark · a calm central zone.
schema:
type: object
additionalProperties: false
properties:
image_prompt: { type: string }
required: [image_prompt]
render:
with:
brief_image_prompt: ${{ tasks.brief.output.image_prompt }}
invoke:
tool: "nika:image_generate"
args:
provider: mock # SLOT: local | openai | gemini | xai (local/mock first)
prompt: "${{ with.brief_image_prompt }}"
output_dir: "${{ const.out_dir }}"
filename_prefix: "asset" # SLOT: filename stem
on_error:
# Golden rehearsal · the image tool is an EFFECT, refused under
# `nika test` even on `provider: mock` (the plane simulates the
# model, not effects). The literal keeps the manifest jq's shape
# (`.images`) intact. Delete once a real seat renders.
recover: { images: [] }
manifest:
with:
brief: ${{ tasks.brief.output }}
render: ${{ tasks.render.output }}
invoke:
tool: "nika:jq"
args:
expression: "{ brief: .[0], images: .[1].images }"
input:
- "${{ with.brief }}"
- "${{ with.render }}"
persist:
with:
manifest: ${{ tasks.manifest.output }}
invoke:
tool: "nika:write"
args:
path: "${{ const.out_dir }}/manifest.json"
create_dirs: true
# `.json` path → `content:` is ONE interpolation of a value the engine
# serializes. `nika:jq` above BUILT that value; hand-typing braces
# around an interpolation emits unquoted fields and breaks the artifact.
content: "${{ with.manifest }}"
on_error:
# Golden rehearsal · same NIKA-452 refusal as `render` above. Never
# fires on a successful real write. Delete once wired.
recover: "REHEARSAL · write refused under nika test — the real run lands the manifest"
outputs:
manifest: ${{ tasks.manifest.output }} # SLOT: the callable contract
api-upload-and-create
Call a product API natively:multipart: upload (masked secrets header) → JSON create → typed result.
api-upload-and-create.nika.yaml
nika: v1
workflow:
id: api-upload-and-create-template # SLOT: kebab-case workflow id
# SLOT: one honest sentence — `nika new` matches intents against these words
description: "upload + create in one authenticated call"
const:
api_base: "https://api.example.com" # SLOT: the product API base
asset_path: "./out/assets/asset-1.png" # SLOT: the file to upload
secrets:
API_KEY:
source: env
key: EXAMPLE_API_KEY # SLOT: the OS env var holding the key
egress:
- to: "nika:fetch" # the send · default-deny otherwise
- to: "outputs" # the return value derives from the authed response
permits: # the blast radius · default-deny once present
tools: ["nika:fetch"]
# `egress:` above sanctions the FLOW (this secret may ride a fetch); it does
# NOT grant the capability to reach anyone. The host is the separate, required
# half — an unlisted host is refused at RUN, mid-flight, with the bytes
# already on the wire.
net: { http: ["api.example.com"] } # SLOT: the host from const.api_base
fs:
# A `multipart:` file part names a path, and that read crosses the boundary
# like any other: measured, without this entry the call dies `NIKA-SEC-004 ·
# ./out/assets/asset-1.png resolves outside the declared permits.fs.read
# boundary`. One exact file, never the tree it sits in. The drift detector
# models a multipart part as a read (2026-07-29) — the former NIKA-DRIFT-001
# false hint is closed.
read: ["./out/assets/asset-1.png"] # SLOT: keep in step with const.asset_path
tasks:
create:
invoke:
tool: "nika:fetch"
args:
url: "${{ const.api_base }}/items" # SLOT: the create endpoint
method: POST
headers:
x-api-key: "${{ secrets.API_KEY }}" # SLOT: the auth header name
multipart:
# Exactly one of `path:` (file) or `value:` (text) per part — a part
# carrying both, or neither, is refused before anything is sent.
- { name: file, path: "${{ const.asset_path }}" }
- { name: title, value: "Rehearsal item" } # SLOT: the metadata fields
mode: jq
jq: "{ id: .id, url: .url }" # SLOT: the fields downstream needs
on_error:
# Offline rehearsal · a literal shaped EXACTLY like what the jq above
# projects, so `outputs.result` has the same shape on both paths. It also
# absorbs the unset key: a `secrets:` entry whose env var is missing fails
# NIKA-VAR-001, and a recover catches that too (measured).
recover: { id: "rehearsal-0001", url: "https://app.example.com/items/rehearsal-0001" }
outputs:
result: ${{ tasks.create.output }} # SLOT: the callable contract
docker-report
The audited-ops shape: read a system’s real state via a pinned CLI (argv-array exec — the only form apermits.exec allowlist can prove), explain it with one bounded model call, keep the report as a file. Swap docker for any product CLI. The live-proven walkthrough: A Docker AI workflow.
Shipped in the engine pack since 0.99.0:
nika new --from docker-report <dest>.nika.yaml scaffolds this exact skeleton (verified on the
released binary). The block below is the same conformance-validated
source, if you prefer to copy it.docker-report.nika.yaml
nika: v1
workflow:
id: docker-report-template # SLOT: kebab-case workflow id
# SLOT: one honest sentence — `nika new` matches intents against these words
description: "read the daemon's state · explain it · keep the report"
# SLOT: local-first · `--model mock/echo` for the offline rehearsal. Measured
# seat — `ollama/llama3.2:3b` answers this prose prompt in well under a minute.
model: ollama/llama3.2:3b
permits: # the blast radius · default-deny once present
exec:
- "docker" # SLOT: the ONE program the reads may launch
tools:
- "nika:write"
fs:
write:
- "./docker-health.md" # SLOT: where the report lands (must match `keep`)
tasks:
# The reads run IN PARALLEL (no edges between them) — the
# scheduler proves it from the DAG, nobody orders it.
ps:
exec:
# SLOT: argv ARRAY form — one program, exactly these arguments. The array
# is why there is no shell here: no word-splitting, no globbing, nothing
# to quote wrong. `permits.exec` above is the provable allowlist.
command: ["docker", "ps", "--all", "--format", "{{.Names}}\t{{.Status}}\t{{.Image}}"]
on_error:
# Offline rehearsal · a host with no daemon answers with a literal that
# LOOKS like the real thing, so `diagnose` reads the same shape either
# way. Delete this once the daemon is really there.
recover: "REHEARSAL\tno docker daemon on this host\tn/a"
df:
exec:
command: ["docker", "system", "df"] # SLOT: the second read (drop the task if one suffices)
on_error:
recover: "REHEARSAL · disk usage unavailable without a daemon"
diagnose:
with:
ps: ${{ tasks.ps.output }}
df: ${{ tasks.df.output }}
infer:
max_tokens: 600 # SLOT: the spend ceiling for this call
# SLOT: what should the model DO with the readings? Keep slot markers out
# of the block below — everything indented under `prompt: |` is prompt
# TEXT, and a stray comment line is sent to the model verbatim.
prompt: |
You are reading a Docker host's state. Containers (name·status·image):
${{ with.ps }}
Disk usage:
${{ with.df }}
Write a short health report: what is running, what exited, what
looks unhealthy (restart loops · old exits), and whether disk
usage needs attention. Plain prose, no preamble.
keep:
with:
diagnose: ${{ tasks.diagnose.output }}
invoke:
tool: "nika:write"
args:
path: "./docker-health.md" # SLOT: same path as permits.fs.write
content: "${{ with.diagnose }}"
on_error:
# Golden rehearsal · `nika test` refuses effects (NIKA-452) — the pin
# lane recovers into this marker (outputs.report pins it · honest and
# deterministic). Never fires on a successful real write. Delete once
# the report really lands, so a genuine write failure is loud.
recover: "REHEARSAL · write refused under nika test — the real run lands ./docker-health.md"
outputs:
report:
value: ${{ tasks.keep.output }}
description: "Where the report landed — nika:write hands back the path it wrote"
The instantiation protocol
1
Route
Pick the template whose intent row matches. Never free-form a
workflow when a template routes.
2
Copy + fill
Copy the skeleton, change ONLY the
# SLOT: lines. Everything else
is locked structure.3
Check
nika check workflow.nika.yaml: the validator names the exact rule
on every error.4
Repair from the error
Fix exactly what the named rule says, re-check until clean. Don’t
fix what the validator didn’t name.
See also
Writing Nika as an agent
The deterministic protocol these templates anchor.
Patterns
The twelve composition patterns the templates lock in.
Examples
full tiered workflows built from these shapes.
Templates source
The skeletons in the spec repo, conformance-gated.