<SLOT: …> values where input is required. The path from intent to a correct file
starts with: route → copy → fill slots → nika check → repair →
re-check.
Choose a starting point
nika compile --list names the exact skeletons embedded in your installed
binary. Preview one with nika compile <slug> --json. Unknown intent stays
incomplete and does not pick a substitute. Only a Ready candidate plus an
explicit destination writes; remaining questions use repeatable
--answer KEY=JSON_LITERAL. The copyable sources below also run through
the released checker.
See bounded authoring examples for filled
workflows, expected outputs and rejection cases.
| Source template | Copyable skeleton |
|---|---|
agent-loop | agent-loop |
aggregate-by-key | aggregate-by-key |
api-upload-and-create | api-upload-and-create |
bounded-batch | bounded-batch |
chain | chain |
classify-and-route | classify-and-route |
corpus-qa | corpus-qa |
deduplicate-records | deduplicate-records |
docker-report | docker-report |
document-to-fields | document-to-fields |
etl-state | etl-state |
evaluate-and-optimize | evaluate-and-optimize |
fanout | fanout |
gate-and-act | gate-and-act |
human-gated-ship | human-gated-ship |
media-asset-pack | media-asset-pack |
parallel-review | parallel-review |
project-public-fields | project-public-fields |
recover-optional-file | recover-optional-file |
snapshot-diff | snapshot-diff |
validate-records | validate-records |
website-brief | website-brief |
The YAML below is projected from
nika-spec/templates/
— the source of truth, validated by the conformance runner — by
nika-spec/scripts/showcase-projector.py.These are source templates, not completed workflows. An unfilled
<SLOT: …> value is intentionally refused by nika check. Fill every slot,
then check your file. The documentation gate checks each skeleton too:
only the explicit slot findings are allowed; other errors still fail.chain
The default shape for « take real data, produce words, save them ».chain.nika skeleton
nika: chain-template # SLOT: kebab-case workflow id
# This skeleton keeps its authored offline model. Creating a workflow does
# not select a paid provider from ambient credentials. Choose a different
# model explicitly for a real run; `--model mock/echo` remains offline.
model: mock/echo
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"] # SLOT: same path as const.source
write: ["./output.md"] # SLOT: same path as const.destination
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
# The marker below is a VALUE, not a comment. The parser drops
# comments, so a `# SLOT:` line could never make `check` refuse —
# which is how an unfilled scaffold ran green and left a file that
# read like a result (#1066). Replace the line, keep the binding.
prompt: |
<SLOT: what should the model do with the gathered text?>
${{ 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 skeleton
nika: gate-and-act-template # SLOT: kebab-case workflow id
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: "."
extract:
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 skeleton
nika: fanout-template # SLOT: kebab-case workflow id
# This skeleton keeps its authored offline model. Creating a workflow does
# not select a paid provider from ambient credentials. Choose a different
# model explicitly for a real run; `--model mock/echo` remains offline.
model: mock/echo
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
# Known answers for the fan-in law — computed by hand, never by a model.
# `prove` feeds them through the SAME jq `survivors` uses; a drift between
# the two is a red assert, not a quietly wrong report.
law_cases: ["kept", null, "also kept"]
permits:
tools: ["nika:assert", "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:
items: ${{ 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:
items: "${{ 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: 2000 # SLOT: the per-ITEM ceiling · think + answer · multiply by the fan width
# The marker below is a VALUE, not a comment. The parser drops
# comments, so a `# SLOT:` line could never make `check` refuse —
# which is how an unfilled scaffold ran green and left a file that
# read like a result (#1066). Replace the line, keep the binding.
prompt: |
<SLOT: what to do with each item>
${{ item }}
# The fan-in below is a LAW: a jq over what a model produced. A law is
# proven on known answers first — `prove` runs the SAME expression over
# `const.law_cases`, `law_holds` refuses the run if it drifted. Measured on
# 0.118.7: without this pair `check` hints `[unproven-law]` on `survivors`.
prove:
invoke:
tool: "nika:jq"
args:
input: ${{ const.law_cases }}
expression: "((. // []) | [ .[] | select(. != null) ]) == [\"kept\", \"also kept\"]"
law_holds:
with:
ok: ${{ tasks.prove.output }}
invoke:
tool: "nika:assert"
args:
condition: "${{ with.ok }}"
message: "the fan-in law drifted from its hand-computed fixture"
survivors:
after:
law_holds: success # state, no data · the law is proven before it meets real data
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: 3000 # SLOT: the fan-in ceiling · think + answer
# 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 skeleton
nika: etl-state-template # SLOT: kebab-case workflow id
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 skeleton
nika: agent-loop-template # SLOT: kebab-case workflow id
# This skeleton keeps its authored offline model. Creating a workflow does
# not select a paid provider from ambient credentials. Choose a different
# model explicitly for a real run; `--model mock/echo` remains offline.
model: mock/echo
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: "<SLOT: what the agent must accomplish>"
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:
# BOTH bindings, on purpose. The plan is a summary of the goal, never a
# substitute for it: measured on five real seats, a prompt that carried
# only the plan produced three GREEN runs with an invented or an empty
# answer — the agent was asked to compute over data it never received,
# and the assert below only proves non-emptiness. The goal rides in.
goal: ${{ inputs.goal }}
steps: ${{ tasks.plan.output.steps }}
agent:
# Describe the job in the prompt; declare its result shape in schema:.
# The granted nika:done sentinel is required to finish this loop.
# Text-only plans receive feedback within the turn/token budgets.
# The engine validates the final result and can request a schema
# repair within its configured retry allowance and task budgets.
# A valid result shape does not prove the promised work was performed;
# retain an independent check of the facts or artifacts you require.
system: "You are a careful analyst. Work the plan step by step and report what you actually found." # SLOT
prompt: |
Goal · ${{ with.goal }}
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. Two halves, two
# clocks — `tools:` vs `permits.tools` is judged STATICALLY at check
# (since 0.115); adding `nika:read` to this list WITHOUT adding
# `permits.fs.read` stays the run's verdict and 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" # explicit completion · loop-owned
max_turns: 15 # SLOT: the loop bound
max_tokens_total: 80000 # SLOT: cumulative input + output tokens
schema: # SLOT: the typed final-message contract
# Closed on purpose: declare every allowed result field here.
# Keep extra fields out of the result; schema repair is bounded,
# and exhausting that allowance is a failure.
type: object
additionalProperties: false
required: [findings]
properties:
findings:
type: array
minItems: 1 # an empty list is a schema miss, never a green run
items: { type: string, minLength: 1 }
confirm:
with:
# The schema above already refuses an empty list and an empty string;
# this assert is the trust boundary downstream code can SEE, and the
# place for YOUR law once the goal fixes one (a count · a required id ·
# a value you computed by hand). `size` counts the collection in this
# expression here — a per-item rule belongs in the schema
# (as above) or in a `nika:jq` law. No static law catches an INVENTED
# answer: that gap closes upstream, by binding the goal into the agent.
has_findings: ${{ size(tasks.execute.output.findings) > 0 }} # the check crosses as ONE boundary expression · SLOT: your law
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 anafter: { act: terminal } record whatever happens.
human-gated-ship.nika skeleton
nika: human-gated-ship-template # SLOT: kebab-case workflow id
const:
evidence_dir: "./release" # SLOT: the DIRECTORY the file gate walks (grep walks a tree, never one file)
version_pattern: '1\.4\.2' # SLOT: the version you are shipping · a regex, so the dots are escaped
permits: # SLOT: the blast radius · default-deny once present
exec: ["echo"] # SLOT: ONLY the programs check_b + act run (argv form)
tools: ["nika:grep", "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
fs:
# TWO entries, both measured on 0.118.7. The grep walk is judged on its
# ROOT at check — `./release/*` alone is refused NIKA-SEC-004 — and reads
# its FILES at run: `./release` alone checks green and then returns NO
# MATCH for a pattern that is there (the files stay unreadable, the walk
# reports nothing) — a gate that reads RED with no error to tell you why.
read: ["./release", "./release/*"] # SLOT: keep in step with const.evidence_dir
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 · both gates run in parallel ──
check_a:
# FILE evidence, the native way. `nika:grep` returns `[{path, line, match}]`,
# already structured. An `exec: ["grep", …]` here draws the native-first/006
# hint (exit 2 under `--native-strict`) AND needs the same fs grant: an exec
# child is confined to `permits.fs.read` too (measured on 0.118.7 · without
# the grant `grep` dies NIKA-SEC-001 « seatbelt refused the confined
# process » and the message names no path). GREEN = the release notes under
# `evidence_dir` name the version being shipped.
invoke:
tool: "nika:grep"
args:
pattern: "${{ const.version_pattern }}"
path: "${{ const.evidence_dir }}"
on_error:
# Rehearsal · in an EMPTY directory the walk finds no `./release` and
# fails NIKA-BUILTIN-GREP-001 (measured); recovering to one match keeps
# the scaffold green the moment it is scaffolded. `on_codes:` keeps that
# narrow — a permission refusal still fails loudly. DELETE this block once
# the evidence exists: a ship gate must fail CLOSED on missing evidence.
on_codes: [NIKA-BUILTIN-GREP-001]
recover: [{ path: "REHEARSAL", line: 0, match: "REHEARSAL" }]
check_b:
# A COMMAND gate · a test suite, a CLI probe · argv form, never a shell.
exec:
command: ["echo", "ok"] # SLOT: gate 2 · the program must be in permits.exec
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: "" }
gates:
with:
all_green: ${{ size(tasks.check_a.output) > 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:
# NO `default:` — on purpose (THE TRADE above). At a terminal this ASKS;
# headless it PAUSES (exit 4 · durable, not a failure) and prints its own
# resume line. A `default: false` would answer NO unattended AND disarm
# the gate: measured on 0.118.7, this file is then refused NIKA-SEC-009
# at check. The unattended answer is the invocation's, `--answer`.
tool: "nika:prompt"
args:
message: "All gates GREEN. Proceed?" # SLOT: the decision, fully informed
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 skeleton
nika: website-brief-template # SLOT: kebab-case workflow id
# This skeleton keeps its authored offline model. Creating a workflow does
# not select a paid provider from ambient credentials. Choose a different
# model explicitly for a real run; `--model mock/echo` remains offline.
model: mock/echo
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 skeleton
nika: media-asset-pack-template # SLOT: kebab-case workflow id
# This skeleton keeps its authored offline model. Creating a workflow does
# not select a paid provider from ambient credentials. Choose a different
# model explicitly for a real run; `--model mock/echo` remains offline.
model: mock/echo
const:
subject: "<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 shape `manifest` reads
# (`.images` · `.prompt`) intact — a recover value interpolates `with.`
# (measured on 0.118.7), so the rehearsal manifest still records the
# brief. Delete once a real seat renders.
recover: { images: [], prompt: "${{ with.brief_image_prompt }}" }
manifest:
with:
render: ${{ tasks.render.output }}
invoke:
# ONE input, the generator's own record: `nika:image_generate` returns
# the `prompt` it received beside its `images` (measured on 0.118.7), so
# the manifest is a projection of a TOOL output — not a law over what a
# model produced. Feed it the model's words directly and it becomes one,
# and `check` asks for a const-fixture proof (`[unproven-law]` · the
# shape is `nika try 13-extract-then-law`). Here nothing is judged, only
# recorded — from the side that actually rendered.
tool: "nika:jq"
args:
input: ${{ with.render }}
expression: "{ brief: { image_prompt: .prompt }, images: .images }"
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 skeleton
nika: api-upload-and-create-template # SLOT: kebab-case workflow id
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, NARROWED to the one thing it may forgive: the key is
# not configured. A `secrets:` entry whose env var is unset fails
# NIKA-VAR-001 before any byte leaves (measured on 0.118.7 · the run
# settles « 1 recovered » on that code) — that absence IS the rehearsal
# switch. Everything else stays loud: a wired key the API rejects is
# NIKA-BUILTIN-FETCH-001 naming the 401 (measured), a dead endpoint too.
# A catch-all here turned a 401 into a GREEN run carrying the id below —
# a result that reads like a created item (measured against a fixture API
# answering 401). The literal is shaped EXACTLY like what the jq above
# projects, so `outputs.result` has the same shape on both paths. Delete
# the block once the key is wired, so an unset key is loud as well.
on_codes: [NIKA-VAR-001]
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. On the 0.120 compile door,
nika compile docker-report <dest>.nika is Ready (zero questions) and
writes that destination. Preview with nika compile docker-report --json.
The block below is the same conformance-validated source, if you prefer
to copy it.docker-report.nika skeleton
nika: docker-report-template # SLOT: kebab-case workflow id
# This skeleton keeps its authored offline model. Creating a workflow does
# not select a paid provider from ambient credentials. Choose a different
# model explicitly for a real run; `--model mock/echo` remains offline.
model: mock/echo
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: 2000 # SLOT: the spend ceiling for this call · think block + report
# 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: 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.
aggregate-by-key
Group validated integer amounts with exact totals.aggregate-by-key.nika skeleton
nika: aggregate-by-key
const:
currency: '<SLOT: the currency code>'
records:
- region: north
amount: 10
- region: south
amount: 20
- region: north
amount: 5
permits:
tools:
- nika:assert
- nika:jq
- nika:validate
tasks:
validate:
invoke:
tool: nika:validate
args:
data: ${{ const.records }}
format: json
schema:
type: array
maxItems: 8
items:
type: object
additionalProperties: false
required:
- region
- amount
properties:
region:
type: string
enum:
- north
- south
amount:
type: integer
minimum: 0
maximum: 1000000
admit:
with:
valid: ${{ tasks.validate.output.valid }}
invoke:
tool: nika:assert
args:
condition: ${{ with.valid }}
message: Input violates the declared shape or size bound
aggregate:
after:
admit: success
invoke:
tool: nika:jq
args:
input:
currency: ${{ const.currency }}
records: ${{ const.records }}
expression: '{currency, groups: (.records | group_by(.region) | map({region: .[0].region, count:
length, total: (map(.amount) | add)})), total: ([.records[].amount] | add // 0)}'
outputs:
report: ${{ tasks.aggregate.output }}
bounded-batch
Admit a finite batch before starting model calls.bounded-batch.nika skeleton
nika: bounded-batch
model: ollama/qwen3.5:4b
run:
clock: system
const:
brief: '<SLOT: the per-item instruction>'
items:
- first note
- second note
permits:
tools:
- nika:assert
- nika:validate
tasks:
validate:
invoke:
tool: nika:validate
args:
data: ${{ const.items }}
format: json
schema:
type: array
maxItems: 8
items:
type: string
minLength: 1
maxLength: 4000
admit:
with:
valid: ${{ tasks.validate.output.valid }}
invoke:
tool: nika:assert
args:
condition: ${{ with.valid }}
message: Input violates the declared shape or size bound
process:
after:
admit: success
for_each:
items: ${{ const.items }}
max_parallel: 2
fail_fast: true
timeout: 30s
infer:
prompt: '${{ const.brief }}
Item: ${{ item }}'
max_tokens: 2048
schema:
type: object
additionalProperties: false
required:
- summary
properties:
summary:
type: string
minLength: 1
maxLength: 4000
outputs:
results: '${{ tasks.process.output == null ? [] : tasks.process.output }}'
classify-and-route
Typed facts → evidence → decision → queue..classify-and-route.nika skeleton
nika: classify-and-route # SLOT: kebab-case workflow id
# This skeleton keeps its authored offline model. Creating a workflow does
# not select a paid provider from ambient credentials. Choose a different
# model explicitly for a real run; `--model mock/echo` remains offline.
model: mock/echo
const:
request: "<SLOT: the request to classify and route>"
bundle:
decision_bundle_format: 1
manifest:
id: incident-route
version: "1.0.0"
owner: example-org
license: Apache-2.0
valid_until: "2028-01-01T00:00:00Z"
evidence_schema:
urgency:
type: integer
required: true
sources: [classifier]
integrity: observed
transforms:
urgency_step:
kind: bucket
min: 0
max: 1
edges: [1]
values: [0, 10000]
rules:
dimensions:
incident_priority:
terms:
- { evidence: urgency, transform: urgency_step, weight_bp: 10000, monotonicity: increases }
thresholds:
- { dimension: incident_priority, recommend_gte_bp: 5000 }
governance:
never_automatic: [recommend]
override: { append_only: true, reason_required: true }
appeal: "ask the incident commander"
human_required_triggers: [conflict_on_required]
fixtures:
- { name: urgent, class: positive, evidence: { urgency: 1 } }
- { name: routine, class: negative, evidence: { urgency: 0 } }
- { name: edge, class: ambiguous, evidence: { urgency: 0 } }
- { name: conflict, class: contradictory, evidence: { urgency: 1 } }
- { name: probe, class: adversarial, evidence: { urgency: 1 } }
urgent_snapshot:
t: "2027-01-01T00:00:00Z"
evidence:
- key: urgency
value: 1
source: classifier
observed_at: "2027-01-01T00:00:00Z"
digest: fixture-urgency
confidentiality: internal
integrity: observed
quality: { freshness: fresh, completeness: complete, independence_group: classifier }
permits:
tools: ["nika:jq", "nika:decide", "nika:assert"]
tasks:
extract_facts:
infer:
max_tokens: 300 # SLOT: classification spend ceiling
prompt: |
Extract the urgency fact from this request. Use 1 only for an active
widespread outage, otherwise use 0.
${{ const.request }}
schema:
type: object
additionalProperties: false
required: [urgency]
properties:
urgency: { type: integer, enum: [0, 1] }
snapshot:
with:
facts: ${{ tasks.extract_facts.output }}
invoke:
tool: nika:jq
args:
input: ${{ with.facts }}
expression: '{t: "2027-01-01T00:00:00Z", evidence: [{key: "urgency", value: .urgency, source: "classifier", observed_at: "2027-01-01T00:00:00Z", digest: "runtime-urgency", confidentiality: "internal", integrity: "observed", quality: {freshness: "fresh", completeness: "complete", independence_group: "classifier"}}]}'
decide:
with:
evidence: ${{ tasks.snapshot.output }}
invoke:
tool: nika:decide
args:
bundle: ${{ const.bundle }}
evidence: ${{ with.evidence }}
queue:
with:
receipt: ${{ tasks.decide.output }}
invoke:
tool: nika:jq
args:
input: ${{ with.receipt }}
expression: 'if .outcome == "human_required" then "manual-review" elif .outcome == "recommend" then "priority" else "standard" end'
fixture_decide:
invoke:
tool: nika:decide
args:
bundle: ${{ const.bundle }}
evidence: ${{ const.urgent_snapshot }}
compiled:
with:
receipt: ${{ tasks.fixture_decide.output }}
invoke:
tool: nika:assert
args:
condition: ${{ with.receipt.outcome == "human_required" }}
message: "Urgent fixture must abstain to manual review"
outputs:
queue: ${{ tasks.queue.output }}
receipt: ${{ tasks.decide.output }}
corpus-qa
Canonicalize corpus → retrieve → answer or abstain..corpus-qa.nika skeleton
nika: corpus-qa # SLOT: kebab-case workflow id
# This skeleton keeps its authored offline model. Creating a workflow does
# not select a paid provider from ambient credentials. Choose a different
# model explicitly for a real run; `--model mock/echo` remains offline.
model: mock/echo
const:
query: "<SLOT: the question to answer from the corpus>"
documents: # SLOT: replace with retrieved source chunks
- { id: handbook, text: "Support hours are 09:00 to 17:00 UTC." }
- { id: security, text: "Security incidents are reported within one hour." }
- { id: handbook, text: "Support hours are 09:00 to 17:00 UTC." }
permits:
tools: ["nika:jq", "nika:assert"]
tasks:
conflicting_ids:
invoke:
tool: nika:jq
args:
input: ${{ const.documents }}
expression: 'sort_by(.id) | group_by(.id) | map(select((map(.text) | unique | length) > 1) | .[0].id)'
duplicates_consistent:
with:
conflicting_ids: ${{ tasks.conflicting_ids.output }}
invoke:
tool: nika:assert
args:
condition: ${{ size(with.conflicting_ids) == 0 }}
message: "One corpus id names conflicting source text"
index_once:
after:
duplicates_consistent: success
invoke:
tool: nika:jq
args:
input: ${{ const.documents }}
expression: "sort_by(.id) | unique_by(.id)"
index_twice:
with:
index: ${{ tasks.index_once.output }}
invoke:
tool: nika:jq
args:
input: ${{ with.index }}
expression: "sort_by(.id) | unique_by(.id)"
index_is_idempotent:
with:
first: ${{ tasks.index_once.output }}
second: ${{ tasks.index_twice.output }}
invoke:
tool: nika:jq
args:
input: { first: "${{ with.first }}", second: "${{ with.second }}" }
expression: ".first == .second"
compiled:
with:
idempotent: ${{ tasks.index_is_idempotent.output }}
invoke:
tool: nika:assert
args:
condition: ${{ with.idempotent }}
message: "Corpus index changed when canonicalized twice"
retrieve:
with:
index: ${{ tasks.index_once.output }}
invoke:
tool: nika:jq
args:
input: { documents: "${{ with.index }}", query: "${{ const.query }}" }
expression: '.documents as $docs | .query as $query | ($docs | map(select((.text | ascii_downcase) | contains($query | ascii_downcase))))'
answer:
with:
hits: ${{ tasks.retrieve.output }}
when: ${{ size(with.hits) > 0 }}
infer:
max_tokens: 500 # SLOT: answer spend ceiling
prompt: |
Answer from these passages only and cite their ids.
Question: ${{ const.query }}
Passages: ${{ with.hits }}
schema:
type: object
additionalProperties: false
required: [answer, citations]
properties:
answer: { type: string, minLength: 1 }
citations: { type: array, minItems: 1, items: { type: string } }
finalize:
with:
hits: ${{ tasks.retrieve.output }}
answer: ${{ tasks.answer.output }}
invoke:
tool: nika:jq
args:
input: { hits: "${{ with.hits }}", answer: "${{ with.answer }}" }
expression: 'if (.hits | length) == 0 then {known: false, answer: "I do not know from this corpus.", citations: []} else {known: true, answer: .answer.answer, citations: .answer.citations} end'
outputs:
result: ${{ tasks.finalize.output }}
deduplicate-records
Collapse identical duplicates and refuse conflicts.deduplicate-records.nika skeleton
nika: deduplicate-records
const:
dataset: '<SLOT: the dataset label>'
records:
- id: a
amount: 10
- id: a
amount: 10
- id: b
amount: 20
permits:
tools:
- nika:assert
- nika:jq
- nika:validate
tasks:
validate:
invoke:
tool: nika:validate
args:
data: ${{ const.records }}
format: json
schema:
type: array
maxItems: 8
items:
type: object
additionalProperties: false
required:
- id
- amount
properties:
id:
type: string
minLength: 1
maxLength: 64
amount:
type: integer
minimum: 0
maximum: 1000000
admit:
with:
valid: ${{ tasks.validate.output.valid }}
invoke:
tool: nika:assert
args:
condition: ${{ with.valid }}
message: Input violates the declared shape or size bound
consistent:
after:
admit: success
invoke:
tool: nika:jq
args:
input: ${{ const.records }}
expression: group_by(.id) | all(.[]; (map(.amount) | unique | length) == 1)
agree:
with:
consistent: ${{ tasks.consistent.output }}
invoke:
tool: nika:assert
args:
condition: ${{ with.consistent }}
message: Conflicting records share an id; do not silently keep the first
deduplicate:
after:
agree: success
invoke:
tool: nika:jq
args:
input:
dataset: ${{ const.dataset }}
records: ${{ const.records }}
expression: '{dataset, records: (.records | unique_by(.id))}'
outputs:
result: ${{ tasks.deduplicate.output }}
document-to-fields
Source text → anchored facts → nonempty proof..document-to-fields.nika skeleton
nika: document-to-fields # SLOT: kebab-case workflow id
# This skeleton keeps its authored offline model. Creating a workflow does
# not select a paid provider from ambient credentials. Choose a different
# model explicitly for a real run; `--model mock/echo` remains offline.
model: mock/echo
const:
# NOT a slot, deliberately. This literal is the REHEARSAL FIXTURE the
# skeleton's own `nika:assert` step reads — hole it and the template
# stops rehearsing green, without closing anything: this workflow
# persists nothing, so it leaves behind no file that reads like a
# result (#1066's actual failure mode). Replace it with your document,
# or bind this const to a `nika:read` / `nika:fetch` task's output.
document: |
Invoice INV-204
Supplier: Northwind Tools
Total: EUR 84.50
Rehearsal marker: mock
permits:
tools: ["nika:jq", "nika:assert"]
tasks:
normalize_document:
invoke:
tool: nika:jq
args:
input: ${{ const.document }}
expression: 'gsub("^\\s+|\\s+$"; "")'
document_nonempty:
with:
document: ${{ tasks.normalize_document.output }}
invoke:
tool: nika:assert
args:
condition: ${{ size(with.document) > 0 }}
message: "Document is empty after whitespace is trimmed"
extract:
with:
document: ${{ tasks.normalize_document.output }}
after:
document_nonempty: success
infer:
max_tokens: 600 # SLOT: extraction spend ceiling
prompt: |
Extract only facts explicitly present in this document. For every
value, copy the shortest exact source fragment that anchors it.
${{ with.document }}
schema:
type: object
additionalProperties: false
required: [fields]
properties:
fields:
type: array
minItems: 1
items:
type: object
additionalProperties: false
required: [name, value, anchor]
properties:
name: { type: string, minLength: 1 }
value: { type: string, minLength: 1 }
anchor: { type: string, minLength: 1 }
anchors_present:
with:
fields: ${{ tasks.extract.output.fields }}
document: ${{ tasks.normalize_document.output }}
invoke:
tool: nika:jq
args:
input: { fields: "${{ with.fields }}", document: "${{ with.document }}" }
expression: '. as $root | ($root.fields | length) > 0 and all($root.fields[]; . as $field | (.name | length) > 0 and (.value | length) > 0 and (.anchor | length) > 0 and ($root.document | contains($field.anchor)))'
compiled:
with:
anchors_present: ${{ tasks.anchors_present.output }}
invoke:
tool: nika:assert
args:
condition: ${{ with.anchors_present }}
message: "Document extraction returned no anchored fields"
outputs:
fields: ${{ tasks.extract.output.fields }}
evaluate-and-optimize
Draft → two fixed critique/revision rounds..evaluate-and-optimize.nika skeleton
nika: evaluate-and-optimize # SLOT: kebab-case workflow id
# This skeleton keeps its authored offline model. Creating a workflow does
# not select a paid provider from ambient credentials. Choose a different
# model explicitly for a real run; `--model mock/echo` remains offline.
model: mock/echo
const:
brief: "<SLOT: the deliverable to draft, critique and revise>"
permits: {}
tasks:
draft:
infer:
max_tokens: 300 # SLOT: the draft ceiling · sized for the SEAT (see CEILINGS)
prompt: "Draft this deliverable: ${{ const.brief }}"
schema:
type: object
additionalProperties: false
required: [text]
properties:
text: { type: string, minLength: 1 }
evaluate_1:
with:
candidate: ${{ tasks.draft.output.text }}
infer:
max_tokens: 400 # SLOT: the critique ceiling · a reasoning seat's floor is 256 here (measured)
prompt: |
Critique this candidate against the brief. Name one concrete repair.
Brief: ${{ const.brief }}
Candidate: ${{ with.candidate }}
schema:
type: object
additionalProperties: false
required: [score, repair]
properties:
score: { type: integer, minimum: 0, maximum: 100 }
repair: { type: string, minLength: 1 }
optimize_1:
with:
candidate: ${{ tasks.draft.output.text }}
repair: ${{ tasks.evaluate_1.output.repair }}
infer:
max_tokens: 300 # SLOT: the revision ceiling · sized for the SEAT
prompt: |
Revise the candidate using the repair, without changing the brief.
Candidate: ${{ with.candidate }}
Repair: ${{ with.repair }}
schema:
type: object
additionalProperties: false
required: [text]
properties:
text: { type: string, minLength: 1 }
evaluate_2:
with:
candidate: ${{ tasks.optimize_1.output.text }}
infer:
max_tokens: 400 # SLOT: idem · the same floor applies
prompt: |
Critique this revision against the same brief. Name one final repair.
Brief: ${{ const.brief }}
Candidate: ${{ with.candidate }}
schema:
type: object
additionalProperties: false
required: [score, repair]
properties:
score: { type: integer, minimum: 0, maximum: 100 }
repair: { type: string, minLength: 1 }
optimize_2:
with:
candidate: ${{ tasks.optimize_1.output.text }}
repair: ${{ tasks.evaluate_2.output.repair }}
infer:
max_tokens: 300 # SLOT: the final revision ceiling · sized for the SEAT
prompt: |
Produce the final revision using the last repair.
Candidate: ${{ with.candidate }}
Repair: ${{ with.repair }}
schema:
type: object
additionalProperties: false
required: [text]
properties:
text: { type: string, minLength: 1 }
final_evaluate:
with:
candidate: ${{ tasks.optimize_2.output.text }}
infer:
max_tokens: 400 # SLOT: the score-only ceiling · the same floor applies
prompt: |
Score the final candidate against the brief. Evaluate the candidate
exactly as written without proposing another revision.
Brief: ${{ const.brief }}
Candidate: ${{ with.candidate }}
schema:
type: object
additionalProperties: false
required: [score, reason]
properties:
score: { type: integer, minimum: 0, maximum: 100 }
reason: { type: string, minLength: 1 }
outputs:
result: ${{ tasks.optimize_2.output.text }}
final_score: ${{ tasks.final_evaluate.output.score }}
parallel-review
Collect independent reviews with a deterministic conjunction.parallel-review.nika skeleton
nika: parallel-review
model: ollama/qwen3.5:4b
run:
clock: system
const:
document: '<SLOT: the document to review>'
law: '{ready: (.clarity.ready and .accuracy.ready), reviews: [.clarity, .accuracy]}'
cases:
- clarity:
ready: false
reason: fixture
accuracy:
ready: false
reason: fixture
expected: false
- clarity:
ready: false
reason: fixture
accuracy:
ready: true
reason: fixture
expected: false
- clarity:
ready: true
reason: fixture
accuracy:
ready: false
reason: fixture
expected: false
- clarity:
ready: true
reason: fixture
accuracy:
ready: true
reason: fixture
expected: true
permits:
tools:
- nika:assert
- nika:jq
- nika:validate
tasks:
validate:
invoke:
tool: nika:validate
args:
data: ${{ const.document }}
format: json
schema:
type: string
minLength: 1
maxLength: 4000
admit:
with:
valid: ${{ tasks.validate.output.valid }}
invoke:
tool: nika:assert
args:
condition: ${{ with.valid }}
message: Input violates the declared shape or size bound
clarity:
after:
admit: success
law_holds: success
timeout: 30s
infer:
prompt: 'Review clarity and missing explanations. Document: ${{ const.document }}'
max_tokens: 2048
schema:
type: object
additionalProperties: false
required:
- ready
- reason
properties:
ready:
type: boolean
reason:
type: string
minLength: 1
maxLength: 4000
accuracy:
after:
admit: success
law_holds: success
timeout: 30s
infer:
prompt: 'Review unsupported claims and internal contradictions. Document: ${{ const.document }}'
max_tokens: 2048
schema:
type: object
additionalProperties: false
required:
- ready
- reason
properties:
ready:
type: boolean
reason:
type: string
minLength: 1
maxLength: 4000
verdict:
with:
clarity: ${{ tasks.clarity.output }}
accuracy: ${{ tasks.accuracy.output }}
invoke:
tool: nika:jq
args:
input:
clarity: ${{ with.clarity }}
accuracy: ${{ with.accuracy }}
expression: ${{ const.law }}
after:
law_holds: success
prove:
for_each:
items: ${{ const.cases }}
max_parallel: 1
fail_fast: true
invoke:
tool: nika:jq
args:
input: ${{ item }}
expression: . as $case | (${{ const.law }}) | .ready == $case.expected
law_holds:
with:
cases: ${{ tasks.prove.output }}
invoke:
tool: nika:assert
args:
condition: ${{ with.cases == [true, true, true, true] }}
message: All four conjunction truth-table cases must pass
outputs:
verdict: ${{ tasks.verdict.output }}
project-public-fields
Select allowed fields before sending data to a model.project-public-fields.nika skeleton
nika: project-public-fields
model: ollama/qwen3.5:4b
run:
clock: system
const:
instruction: '<SLOT: the public summary instruction>'
record:
topic: queue latency
severity: 2
email: private@example.invalid
internal_note: internal-only-fixture
permits:
tools:
- nika:assert
- nika:jq
- nika:validate
tasks:
validate:
invoke:
tool: nika:validate
args:
data: ${{ const.record }}
format: json
schema:
type: object
additionalProperties: false
required:
- topic
- severity
- email
- internal_note
properties:
topic:
type: string
minLength: 1
maxLength: 4000
severity:
type: integer
minimum: 0
maximum: 5
email:
type: string
minLength: 1
maxLength: 4000
internal_note:
type: string
minLength: 1
maxLength: 4000
admit:
with:
valid: ${{ tasks.validate.output.valid }}
invoke:
tool: nika:assert
args:
condition: ${{ with.valid }}
message: Input violates the declared shape or size bound
public:
after:
admit: success
invoke:
tool: nika:jq
args:
input: ${{ const.record }}
expression: '{topic, severity}'
summarize:
with:
public: ${{ tasks.public.output }}
timeout: 30s
infer:
prompt: '${{ const.instruction }}
Allowed fields only: ${{ with.public }}'
max_tokens: 2048
schema:
type: object
additionalProperties: false
required:
- summary
properties:
summary:
type: string
minLength: 1
maxLength: 4000
outputs:
public: ${{ tasks.public.output }}
summary: ${{ tasks.summarize.output.summary }}
recover-optional-file
Recover a missing optional file without hiding other failures.recover-optional-file.nika skeleton
nika: recover-optional-file
run:
clock: system
const:
fallback: '<SLOT: the explicit fallback content>'
permits:
tools:
- nika:assert
- nika:jq
- nika:read
- nika:validate
fs:
read:
- ./optional.txt
tasks:
read:
invoke:
tool: nika:read
args:
path: ./optional.txt
timeout: 5s
retry:
max_attempts: 2
backoff_strategy: exponential
jitter: false
on_error:
on_codes:
- NIKA-BUILTIN-READ-001
recover: ${{ const.fallback }}
validate:
with:
content: ${{ tasks.read.output }}
invoke:
tool: nika:validate
args:
data: ${{ with.content }}
format: json
schema:
type: string
minLength: 1
maxLength: 4000
admit:
with:
valid: ${{ tasks.validate.output.valid }}
invoke:
tool: nika:assert
args:
condition: ${{ with.valid }}
message: Content must be nonempty and at most 4000 characters
result:
after:
admit: success
with:
content: ${{ tasks.read.output }}
invoke:
tool: nika:jq
args:
input: ${{ with.content }}
expression: .
outputs:
content: ${{ tasks.result.output }}
snapshot-diff
Compare two snapshots without model judgment.snapshot-diff.nika skeleton
nika: snapshot-diff
const:
resource: '<SLOT: the resource being compared>'
before:
- alpha
- beta
after:
- beta
- gamma
permits:
tools:
- nika:assert
- nika:jq
- nika:validate
tasks:
validate:
invoke:
tool: nika:validate
args:
data:
before: ${{ const.before }}
after: ${{ const.after }}
format: json
schema:
type: object
additionalProperties: false
required:
- before
- after
properties:
before:
type: array
maxItems: 8
items:
type: string
minLength: 1
maxLength: 4000
after:
type: array
maxItems: 8
items:
type: string
minLength: 1
maxLength: 4000
admit:
with:
valid: ${{ tasks.validate.output.valid }}
invoke:
tool: nika:assert
args:
condition: ${{ with.valid }}
message: Input violates the declared shape or size bound
diff:
after:
admit: success
invoke:
tool: nika:jq
args:
input:
resource: ${{ const.resource }}
before: ${{ const.before }}
after: ${{ const.after }}
expression: '{resource, added: ((.after - .before) | unique), removed: ((.before - .after) | unique),
kept: ([.after[] as $id | select(.before | index($id)) | $id] | unique)}'
outputs:
diff: ${{ tasks.diff.output }}
validate-records
Reject malformed records before producing totals.validate-records.nika skeleton
nika: validate-records
const:
batch: '<SLOT: the batch label>'
records:
- id: a
amount: 10
- id: b
amount: 20
permits:
tools:
- nika:assert
- nika:jq
- nika:validate
tasks:
validate:
invoke:
tool: nika:validate
args:
data: ${{ const.records }}
format: json
schema:
type: array
maxItems: 8
items:
type: object
additionalProperties: false
required:
- id
- amount
properties:
id:
type: string
minLength: 1
maxLength: 64
amount:
type: integer
minimum: 0
maximum: 1000000
admit:
with:
valid: ${{ tasks.validate.output.valid }}
invoke:
tool: nika:assert
args:
condition: ${{ with.valid }}
message: Input violates the declared shape or size bound
report:
after:
admit: success
invoke:
tool: nika:jq
args:
input:
batch: ${{ const.batch }}
records: ${{ const.records }}
expression: '{batch, count: (.records | length), total: ([.records[].amount] | add // 0)}'
outputs:
report: ${{ tasks.report.output }}