Authoring Nika workflows
Nika turns repeatable AI work into files: one .nika.yaml, four verbs,
audited before it runs. You author the file; nika check supplies
the evidence for an authorized run.
The oracle is evidence, not permission
Use the installed engine's nika check --json report, not a second
checker assembled from this prose. Keep its engine/spec identity with the
result. Judge clean and native_strict_clean separately from paid_ready:
a file can be paid-ready and still fail a permits finding. For composition,
inspect judged and the children's findings; a report that did not resolve
children cannot certify the tree.
Check readiness never authorizes execution, spend, a wider permit, or a
publication. Carry out execution already authorized by the user without
asking again; preserve its scope and the engine and host gates. If a
business decision or human-gate answer is still missing, ask for that
answer rather than inferring it from permission to run.
--model mock/echo changes the envelope model, not per-task
model pins, subprocesses, network tools, file writes, or secret sources.
Before an authorized rehearsal, review those effects and use an isolated
workspace with the intended boundary. In an editor, workspace trust is a
separate host decision; an offline model does not bypass it.
Read the workspace and resolve unfamiliar shapes
Join the workspace before adding to it. A local workflow may already own the job, its conventions and its permits boundary:
nika list # workflows below this directory
nika explain <candidate> # waves · cost · touches · run line
nika inspect <candidate> # tasks · verbs · graph anatomy
nika check <candidate> # the oracle: clean or an exact repair
nika list lists candidates; it does not certify them. Reuse or extend a
matching local file only after nika check is clean. If nothing local fits,
continue to the embedded shelf. Read an example when its shape resolves an
uncertainty in the task; read a second only for a gap the first leaves.
A small edit to a known workflow does not require creating example files.
nika try # the shelf · the path, then the jobs
nika new <slug> # take the one matching your intent (table below) — read it
nika new <second-slug> # optional: a different shape still needed
Read for SHAPE, not for prose. Four things, in this order: which verb
each task carries · where the with: edges are · what the permits:
block ended up containing · how the last task lands the artifact. Those
four are the decisions that cost rounds when guessed instead of copied.
Which example answers which intent
| Your intent | Read this |
|---|---|
| one model call, nothing around it | 01-hello |
| independent steps, then a merge | 02-parallel-fanout |
| shell out to a real binary (git · docker) | 03-exec-pipeline |
| a model must return JSON fitting a shape | 04-schema-retry |
| fetch a URL and shape what comes back | 05-fetch-chain |
| open-ended work, step count unknown up front | 06-code-review |
| the same task for every item of a collection | 07-for-each-locales |
| extract facts, then score them without a second infer | 13-extract-then-law |
| publish or abstain from a Decision Bundle | 14-decide-publish |
| an agent drafts a file and checks it until valid | 15-compose-self-check |
| the run reads its own DAG / cost / records | 16-inspect-self |
| mock TTS that writes a real WAV | 17-tts-self |
| land a typed artifact on disk | meeting-actions |
| poll something, act only when a condition holds | price-watch |
| rows in, chart and report out, zero model calls | csv-chart-report |
| a batch where bad items must not kill the run | etl-quarantine |
| a folder of files, one job per file | localization-factory |
| a human signs before an irreversible step | release-train |
| a job too big for one file | §Composition below, then 01-hello for the child |
Second column pinned to the pack by the engine's own test (every slug
this table names resolves through nika_pack::example). Any slug works
with or without its showcase/ prefix and with or without the
.nika.yaml extension. nika new <slug> makes one yours;
nika new <name> does the same from the template side
(nika new '?' prints that set).
The loop (always)
- Reuse the relevant workflow or example. For a new structure,
inspect the shelf with
nika tryand read a matchingnika new <slug>. For a small repair, keep the existing file and change only what the task and diagnostics require. - Write the file. The envelope is
nika: <id>(kebab-case — the workflow id lives ON the tag since 2026-08-12; that one key carries BOTH the mark and the name, anddescription:died with theworkflow:object, which is no longer an envelope key at all) + atasks:MAP keyed by task id — the key IS the identity, never a- id:sequence. Pick models and builtins from the embedded catalogs —nika catalog(providers · models · capabilities · which env var each needs) andnika catalog --tools(thenika:*builtins aninvokereaches without MCP); before a run,nika inspect <file>shows the anatomy: tasks · waves · the cost floor. - Check it:
nika check <file>(exit 0 = clean · 2 = findings), thennika check --native-strict <file>— it fails on anynative-firsthint (anexec:a builtin covers). - Repair:
nika check <file> --fixapplies the machine-applicable repairs first (typo'd fields · tools · args ·after:targets ·${{ }}references — typed did-you-mean only, ambiguity is skipped with a note, never guessed) and re-audits; repair what remains from the diagnostics — they name the exact task, reference and fix. Unknown code?nika explain NIKA-XXXX. - Repeat 3–4 until clean, or report the concrete unresolved dependency
or decision with the checked file and its diagnostics. An incomplete
handoff is not a runnable result. For an execution-ready handoff,
require a clean
nika check --native-strict; for paid inference, also inspectpaid_readyand its blockers.--native-strictis the run-gate bar (anexec:a builtin covers)..paid_readyis the paid-infer bar (nika check --json <file> | jq .paid_ready). A green exit with leftoverinfer-as-law/digit-string-enum/glob-readme/jq-as-map/unproven-lawis legal, not the one-way. The MCPnika_checkoracle failsinfer-as-lawanddigit-string-enumby default. The exec ledger does NOT buy an exemption (measured: a.pywrapper fails with a complete ledger) — it documents intent for a reviewer. What passes is anexec:of a real tool (git,docker); what fails is anexec:of a.py/.mjs/.shwrapper, ledger or not. - When execution is authorized, run it:
nika run <file>. Preview offline with--model mock/echo; run locally with--model ollama/<model>— or fully in-binary:nika model pull <owner/repo-GGUF>thennika model serve --model <id>(qwen3-family GGUFs today; the serve banner prints the exact env +model:line workflows use). Inputs ride--var key=value(repeatable · the flag names aninputs:declaration · unknown keys refused); a run paused on anika:promptresumes withnika run <file> --resume <trace> --answer <task>=<value>(confirm gates take booleans:--answer approve=true). - Pin it for CI only when the mock run needs no network, subprocess,
or write effect:
nika test <file> --updatewrites<file>.golden.json;nika test <file>replays and compares — deterministic, zero keys. The simulated test plane refuses those effects deliberately. For an effecting workflow, rehearse withnika run <file> --model mock/echoin scratch, inspect the artifacts, and verify its trace; never promise a golden that cannot run. - Prove a run that mattered: execution journals are enabled by default
under
.nika/traces/; a refusal before execution, disabled recording or lost ownership can leave no complete trace.nika trace verify <trace>climbs a four-tier ladder and reports the highest tier honestly attained — chain OK · SEALED (the run signature verifies against a custody key) · ANCHORED (the detached transparency-log sidecar verifies fully offline) · REPLAYED (--replay <fresh-trace>compares a fresh run; verify never re-executes).nika trace show <trace>reads the card;nika trace evidence <trace>exports the pack an auditor reads without trusting your summary. Verification never creates or seals the journal; an internally consistent chain does not itself prove producer honesty. Cite the trace and the actual proof tier, never a memory of the run.
The envelope: three value authorities, one boundary
Every value a workflow depends on is DECLARED, and the family is closed:
| Authority | What it holds |
|---|---|
inputs: |
typed parameters a caller supplies (--var key=value), and typed configuration a deployment supplies — the latter carries required: false and a default: |
const: |
fixed values baked into the file |
secrets: |
governed store references (source: env + key:) |
vars: and env: are dead envelope fields (NIKA-VALUES-001 ·
NIKA-VALUES-002); any other namespace is NIKA-VALUES-003. config:
was a fourth authority and is not one now — it is not a field at all,
so it refuses NIKA-PARSE-005 rather than teaching a migration.
Classify by ROLE, never bulk-rename: a caller's parameter is an
inputs: entry, a baked value is a const:, a credential is a
secrets: entry, and a name a child process must SEE is
permits: { env: [NAME] }. inputs: resolves ONLY against the
declared block — the engine never falls back to the OS environment, so
every value the file depends on is visible in the file. nika check --fix migrates the vars: half mechanically; env: has no mechanical
repair, because that classification is yours.
permits: is the boundary, and ABSENT MEANS ZERO AUTHORITY: any
effect under no block refuses NIKA-AUTH-006 at check, before a token
is spent. A pure-compute body states the zero explicitly as
permits: {}. nika check --infer-permits <file> prints the tightest
block — paste it in, and from then on the boundary is default-deny: a
new host, path or tool must be added consciously, in a reviewable diff.
A permit bound is always a literal, never an interpolation
(NIKA-AUTH-007), and *.example.com is refused — a subdomain
wildcard hands the boundary to the zone operator; name exact hosts
(NIKA-AUTH-010).
Static coverage and runtime boundaries are different claims.
- Constant interpolation is not a blanket static blind spot. The
checker resolves statically known values: a fetch through
${{ const.api_url }}with no permits is refused before execution. Genuinely runtime-dependent values still require runtime re-gating. Readpermits.notesandpermits.partialin the report; a deferred path or host is not proof of zero effects. - A host permit does not cover the host it redirects to.
net: { http: ["www.rust-lang.org"] }is green at check and refused at run:NIKA-SEC-004 · rust-lang.org resolves outside the declared net.http boundary. The redirect target is not knowable statically. Declare both hosts, or point at the final one. - An unresolved path needs a reviewed finite boundary. Inferred permits and their review notes are a starting point, not permission to invent an unrestricted path. Declare only the directories/files the job needs, and test both an allowed value and an out-of-bound value in scratch. Do not widen the boundary merely to silence a refusal.
- The axes are conjunctive, and
fsbounds take globs while hosts do not.tools: ["nika:write"]with nofs.writeauthorizes nothing: the write refuses on thefsaxis at check and at run. You need the tool AND the path.fsbounds may be globs (./docs/**/*.mdis accepted); host bounds may not (*.example.comisNIKA-AUTH-010). That asymmetry is deliberate and it is not guessable, so do not reason from one to the other.
A spawned child inherits NOTHING from the engine: its environment is
composed from a cleared slate — the runner floor ∪ the names declared
in permits: { env: [NAME] } ∪ the task's own env: map. A variable
the child needs must be named.
The whole surface (nothing else exists)
Nine envelope keys, one verb per task, and a fixed set of modifiers.
nika spec --schema is the machine truth; this is the map.
Envelope · nika · model · inputs · const · secrets ·
permits · run · tasks · outputs. Nothing else parses: a key
outside this nine refuses NIKA-PARSE-005. workflow:, types:,
config:, policy: and assert: were envelope keys and are not one
now — workflow: survives only INSIDE invoke:, and a
deployment-supplied value is an inputs: entry with required: false
and a default:.
Task modifiers, beside the one verb:
| Field | What it does |
|---|---|
with: |
the DATA edge — bind another task's output, body reads ${{ with.alias }} |
after: |
the CONTROL edge — success · failure · skipped · terminal · unwind |
when: |
a CEL boolean gate · closed callables: size() · has() · .size() · .contains() · .startsWith() · .endsWith() |
for_each: |
fan out over a collection · a BLOCK, never a scalar — items: carries the collection and is required, max_parallel: caps concurrency (1 = sequential), fail_fast: aborts on the first error (default true), and all three live INSIDE the block · a bare for_each: <expr> refuses NIKA-PARSE-019, and max_parallel:/fail_fast: at task level are retired spellings · items: reads a prior task through with: like every other reference · the body reads the current element as ${{ item }} and its position as ${{ index }} (loop-scoped locals, NOT a fourth value authority · item.field reaches into an object element) · the task's .output is the ARRAY of per-iteration outputs, in input order |
retry: |
max_attempts · backoff_ms · backoff_strategy · backoff_max_ms · jitter · on_codes — transient failures only; a wrong prompt never heals by retry |
on_error: |
exactly ONE action — recover: · skip: (preserves the original error at tasks.X.error) — with an optional on_codes: filter · the default (no on_error:) IS failure, and there is no keyword for saying so (fail_workflow: is dead · a YAML comment says it) |
extract: |
named jq bindings → ${{ tasks.X.<name> }} |
returns: |
the task's output contract — exclusive with a verb-level schema: (NIKA-TYPE-003) |
timeout: |
a quoted Go duration |
lift: |
the ONE authored door, a list · each entry opens exactly one named law with a non-empty because: (check-visible · receipt-recorded) · {law: taint, from: <binding>, because: "…"} raises ONE binding through the permit-parameterization taint — never a permit bypass, the value is still matched against the declared boundary · {law: data-as-code, because: "…"} declares a nika:fetch payload code-bearing but never loaded — lifts that sink law ONLY, never the net boundary (from: is forbidden here) · a lift that would not have fired refuses NIKA-AUTH-011 · declassify: and inert: are dead spellings of the same door |
One shape a table cannot carry, because its whole defect is nesting:
nika: fan-out-shape
const:
targets: ["alpha", "beta"]
permits:
tools: [nika:log]
tasks:
each:
for_each:
items: ${{ const.targets }} # REQUIRED · the collection
max_parallel: 4 # inside the block · at task level it is refused
fail_fast: true # inside the block · this is the default
invoke:
tool: nika:log
args:
message: "${{ item }} at ${{ index }}"
outputs:
lines: .each
Checked and RUN against the shipped binary before it was written here ·
clean · compiled · paid_ready · zero hints · prints alpha at 0
then beta at 1. An example that only checks is half an example.
The one way (take the default, and the checker goes quiet)
Every authoring decision has a default. Take it unless the job forces otherwise, in this order:
- Shape before content. Resolve unfamiliar graph, binding and permit shapes from a relevant example before extending them. Preserve a working structure when the task only needs a small edit.
- One job, one task, one verb. If a task needs an "and then", it is two tasks. The verb IS the key.
- Pick the verb by execution model, not convenience.
invoke:when something callable already does it ·infer:when a model must produce judgement or language ·agent:when the number of steps cannot be known in advance and must be bounded ·exec:only when the first three genuinely cannot. - Classify every value before writing it. Caller-supplied →
inputs:· deployment-supplied → aninputs:entry withrequired: falseand adefault:· fixed here →const:· credential →secrets:. If you cannot name the class, you do not yet know what the value is. - Bind, never reach. A task needing another's output binds it in
with:. Reaching fortasks.*anywhere else isNIKA-VAR-021. - Order only when no data flows.
after:is pure sequencing; if data flows thewith:binding already IS the edge. Never both. - Bound the spend where it is spent. Every
infer:carriesmax_tokens; everyagent:carriesmax_turnsandmax_tokens_total. A ceiling the checker can compute beats a cap someone has to remember to pass. - Declare the boundary LAST, from the body. Write the tasks, then
nika check --infer-permitsand paste. A boundary derived from the body is tight; one written from intent is wishful. Expect aNIKA-AUTH-006the moment you save the permit-less draft (the write-time hook checks on your behalf): that finding is this step working, not a mistake to patch around. Read the review notes the inference prints and supply the paths it says it cannot compute. - Fail on purpose. Transient failure →
retry:· expected absence →on_error: on_codes + recover:· cleanup for a producer that started → an ordinary task you name, declaringafter: { producer: unwind }. This includes cancellation and timeout; a producer that never started unwinds nothing. Cleanup is best-effort, and process death can prevent it.terminalis a settled-state dependency, not a substitute for this cleanup lane. Swallowing an error is never the plan. - Prove it before handing it over.
nika checkclean, then--native-strict, then a golden pin if the workflow is hermetic. Report those checks and the run line, or execute it when authorized.
Paid infer (the order that is cheaper than tokens)
Measured on a 40+ task OpenAI extract → law run. Do not rediscover this with a paid seat.
nika check --json --native-strictuntilcleanandpaid_readyare both true (zero findings, zero paid-run hints).- Probe every new builtin in a one-task file on
mock/echobefore wiring it after a paidinfer:(nika:inspectis live — lesson16-inspect-selfassertsavailableat run start). - Freeze the extract schema type. Numeric facts are
type: integerwith a numericenum.enum: ["0","1","3"]is the shape models do not emit (JSON3— hintdigit-string-enum). - Pin the glob.
held/*.mdincludesREADME.md.exclude: "**/README.md"(hintglob-readme). - The model extracts facts.
nika:jqornika:decideis the law. A second infer to "pick the level" is the expensive mistake. The shape is13-extract-then-law. Prove the law on const fixtures (unproven-law) —14-decide-publishis the named bundle. - Then, and only then, swap
model:to a paid seat.
for_each + item.field is resume-eligible as a whole fan when
the collection and definition did not change. A mid-wave crash still
replays every item. After . as $c in jq, write ($c | map(...))
(hint jq-as-map). A red last nika:assert quarantines out/
(.nika/quarantine/<trace>/ — hint assert-quarantine).
After valid: is there a better one-way? (not optional)
nika check --native-strict green means the file is legal. It does
not mean it is the cheapest, most native, or most honest file.
.paid_ready reports the paid-infer blockers the checker knows about,
not execution consent or a complete safety proof. A runnable handoff
also needs a clean native-strict check and the intended authority. Each
question has a command or a file. Do not reason from memory.
Inspect the workflow, relevant examples and nika catalog --tools as
needed. Use invoke.workflow for child workflows and for_each for
collections. Test deterministic rules with known answers; a schema or
an anchor substring check does not establish that extracted facts are true.
- Is
.paid_readytrue?nika check --json <file> | jq .paid_ready.false→ repair.next(kind · task · advice) first, then the rest of.paid_blockers[]; also inspect.compiledand the findings. Do not swap offmock/while a paid blocker remains. - Is an unfamiliar shape still unresolved? Read the relevant
nika try/nika new <slug>example, then check the actual file. - Is every
exec:a real tool?nika check --native-strict. A.py/.shwrapper is not a tool. - Does any infer name the verdict? Hint
infer-as-law. Extract integer facts;nika:jqornika:decideis the law (13-extract-then-law). A second infer whose schema is a language enum (BCP-47 · sentiment) is language, not this hint. - Is every numeric enum
type: integer? Hintdigit-string-enum. - Does a markdown glob include README? Hint
glob-readme. - Did I probe every new builtin on
mock/echo? One-task file, then wire it.nika:inspectis live (16-inspect-self). - Would a closer template have given this graph?
nika new "?"andnika new "the job in plain words". If a skeleton is closer than what I wrote, start over from it. - Did
nika explain <file>stay honest? Waves · cost (FLOOR ≠ $0) · touches · the before a paid model panel. If a paid-run hint remains, the file is not done. - Is the law proven on known answers? Hint
unproven-law. A jq/decide that scores an infer needs a const-fixturenika:assert.
Before paid execution, resolve the paid blockers as well as the findings. If a dependency or decision is unavailable, hand over the checked artifact with its exact blocker and remaining action; do not call it ready or run it. Explain remaining non-paid hints without treating that explanation as an exemption from native-first admission (CONVENTIONS §10).
Cost honesty (never hide unknown spend)
nika checkprints the cost ceiling BEFORE any token:≤ $Xis a ceiling ·≥ $X FLOORmeans at least one task is unbounded — name the reason (a missingmax_tokens, an uncataloged model, an expression fan-out), never round it to $0.- The ceiling covers OUTPUT tokens only. The prompt is not in it.
max_tokensis the max OUTPUT tokens, and that is what the sum prices;input_per_millionhas no reader in the checker. Measured 2026-07-28: a workflow that fetches a 3.2 MB document and interpolates it into one prompt reports$0.0075and would bill about$2.46in input alone. When a prompt interpolates fetched or file content, say so at handoff and do not quote the ceiling as the bill — quote it as the output half, and name the unpriced input. (The same repro is 4x over that model's context window, which nothing reports either: check the window yourself when a prompt carries a document.) - A local model (
ollama/…) is unpriced compute, not « free » — say "unpriced", never "$0" or "free". - A
FLOORon a CLOUD model is not a cheap model, it is a missing price row, and the short catalog id is often the reason. Measured 2026-07-28:mistral/small(the idnika catalogprints) reports$0.0000 FLOOR · no catalog price (local/unknown model)whilemistral/mistral-small-latest, the same model, reports a$0.0024 worst-case output ceiling. The wording says "local/unknown" about a cataloged cloud model. Try the full model string before believing a cloud floor; if it still floors, hand the human the word "unpriced" and never a number. - A spend cap rides the run:
nika run <file> --max-cost-usd <n>refuses a known over-budget floor before execution. During execution, crossing the metered budget stops new admissions; already-started calls finish and count, so a concurrent wave can overshoot. Unpriced calls have no measured USD bound. Do not promise a hard invoice ceiling. nika explain <file>narrates all of this (waves · cost · touches · how to run) — use it before handing a workflow to a human.
The four verbs (exactly one per task)
infer:— an LLM call (prompt,schema?for typed output,max_tokens?)exec:— a subprocess ·command:is argv (["git", "status"]— one token per element, run via execve, so an interpolated value can never break out) · no implicit shell: pipes, redirects and globs go inshell:explicitly ·capture: stdout|stderr|combined|structured· last resort: run the native-first interrogation first (below)invoke:— a tagged union carrying EXACTLY ONE oftool:orworkflow:, plusargs:.tool:reaches a builtin or an MCP tool (HTTP fetch istool: "nika:fetch", a tool, not a verb);workflow:calls a whole other workflow (below). Both, or neither, is a parse error — two targets is two meaningsagent:— a bounded multi-turn loop (prompt,toolsallowlist,max_turns,max_tokens_total)
Composition (a workflow is callable)
A job too big for one file becomes a parent that calls children. The
child is a normal workflow; the parent reaches it through the verb it
already knows. The form is workflow: INSIDE invoke:, a sibling of
tool:, never a tool name. This is a complete parent. Check is green
only when the child sits at that relative path — the next law:
nika: site-audit-parent
inputs:
target:
type: string
permits: {}
tasks:
audit:
invoke:
workflow: "./audits/site-audit.nika.yaml" # sibling of tool:, not a tool
args:
url: "${{ inputs.target }}"
An agent drafting a workflow uses nika:compose through its tools:
whitelist and supplies workflow_yaml. This checks the draft without
executing it. The standalone builtin refuses the call; a parent calls
a child through invoke: { workflow: … }, as shown above.
Its valid field reports Core conformance, not execution admission.
The draft remains a proposal: check the actual file and its children with
nika check --json <file>. Any authorized run still passes through the
engine's normal admission; a compose result cannot grant or bypass it.
Four laws, all judged at check.
- STATIC target. A literal path or a pinned
registry:owner/name@version. A${{ }}-templated target refusesNIKA-COMP-001: a call graph you cannot draw before the run is a call graph you cannot bound. A relative path resolves from the PARENT FILE's directory, not the shell's cwd. - The child is READ at check time. A path that does not resolve is
NIKA-COMP-001too, so a parent cannot pass check without its children present on disk. - One target per
invoke:.tool:andworkflow:together is the same refusal class as two verbs on one task. - Containment, and the parent declares it (
NIKA-COMP-002). The child's boundary must be a SUBSET of the parent's, and the parent does not inherit anything by calling: apermits: {}parent calling a child that writes a file is refused at check AND at the run gate, once per effect:child fs write <path> is outside the parent boundaryandchild tool nika:write is outside the parent boundary(spec 14 laws 3 and 4). The parent'spermits:must be the union of what every child touches.nika check --infer-permitswill NOT compute that half for you: it prints a review note saying the child's boundary is resolved by the composition lane, never inferred. Read that message with suspicion:child tool X is outside the parent boundaryalso fires when the parent grants X and the CHILD's ownpermits:is the block missing it. Check the child alone before widening the parent.
What the parent reads back. ${{ tasks.<id>.output }} is the
child's whole outputs: map, and ${{ tasks.<id>.output.<name> }}
reaches one entry (measured: a child declaring greeting: yields
{"greeting": …} and the deep reference passes TYPES). Add returns:
to pin the shape, with one trap: the shape is written INLINE — there is
no envelope types: block to declare a name in (it refuses
NIKA-PARSE-005) — and the grammar is Nika's own, not JSON Schema, so
it is
{ object: { greeting: string } } and never { type: object, properties: … } (that spelling is NIKA-TYPE-001 too, on the
constructor). schema: on an infer: IS JSON Schema. Two type
languages in one file, and only one of them takes type:.
What crosses the boundary, and what the claim covers.
- The composed checker folds children's output-token estimates into
the parent's
cost.composedand totals, including calling-task multipliers. Verifyjudged.compositionandjudged.children, not only the parent's direct inference-task count. The reader-less Rust checker is not a substitute for the resolved CLI check. - The child receives the parent's remaining metered budget at call time. This is not an atomic reservation across concurrent children: already-admitted calls can overshoot, and static estimates still exclude input-token cost. Tighten concurrency and per-call limits; never call the parent's cap a hard invoice ceiling.
- An executed child has its own trace when recording is enabled and delivery succeeds. Follow the child's trace id when the parent reports one; disabled or failed recording does not guarantee a second file.
Reach for composition when a workflow has two audiences (a reusable audit any project can call) or when one file stops fitting in a reviewer's head. Do NOT reach for it to avoid writing a task.
Native-first (the law)
The order is invoke: nika:* → invoke: mcp:<server>/<tool> →
exec:. Before writing ANY exec:, answer in your head:
Which builtin replaces it? The embedded set spans SIX families. Assume one exists before assuming it does not — most
exec:lines written by agents are a builtin the author never looked for.Family Every builtin in it CORE nika:log·nika:emit·nika:assert·nika:prompt·nika:done·nika:waitFILE nika:read·nika:write·nika:edit·nika:glob·nika:grepDATA nika:jq·nika:json_diff·nika:json_merge_patch·nika:validate·nika:convert·nika:uuid·nika:date·nika:hash·nika:decideNETWORK nika:fetch·nika:notifyINTROSPECTION nika:compose(agent-loop only · calling a child isinvoke: { workflow: … }· §Composition) ·nika:inspectMEDIA nika:chart·nika:image_generate·nika:image_fx·nika:tts_generateThe NAMES above are canon — that is the whole set. The argument CONTRACTS are not: read them from
nika catalog --tools(--jsonfor the model-facing JSON Schemas) before calling one, and never guess an arg name.The reflexes worth memorising: HTTP (curl/wget/helper fetch) →
nika:fetch· file plumbing (cat/tee/cp/mkdir) →nika:read/nika:write(create_dirs: true) · JSON shaping (jq/sed) →nika:jqor anextract:binding · in-place edits →nika:edit· finding files (find/ls) →nika:glob· searching them (grep/rg) →nika:grep·date/uuidgen/shasum→nika:date/nika:uuid/nika:hash· format conversion →nika:convert· schema checks →nika:validate· image styling (ImageMagick / PIL / dither scripts) →nika:image_fx(deterministic — same input+args = same bytes, the artifact sha256 joins the trace chain).Which MCP tool replaces it? A product API deserves an MCP server, never a helper script.
Neither? Name the exact gap — then
exec:is legitimate (build tools · git · a product CLI with no MCP surface yet) and goes in the ledger.
Never write a helper script (node bin/helper.mjs …, python3 bin/thing.py …) that wraps HTTP/files/JSON — that is
native-first/005, the exact failure class this law exists for.
When the boundary pushes back (the reason glue gets written)
Two refusals send authors reaching for a scripting language. Neither one wants a script; both have a native recipe.
NIKA-SEC-004 — an untrusted value reached an effect argument. An
inputs:-supplied or fetched value that check cannot resolve DEFERS to
a mandatory run-time re-gate; escaping that re-gate is SEC-004. The
diagnostic talks about the capability boundary, so the reflex is to
widen permits: — that reflex is the trap, and it dead-ends.
The door is lift: — a task-level list, the ONLY sanctioned lift
(spec 10 §the authored doors). One construct, two laws: taint and
data-as-code; the law is a PARAMETER of the door, never a second
spelling (declassify: and inert: were those spellings, and are dead).
This is a complete nine-key file (checked on 0.109 · rc=0):
nika: load-reviewed-path
inputs:
p:
type: string
permits:
tools: ["nika:read"]
fs:
read: ["./reviewed"]
tasks:
load:
invoke: { tool: nika:read, args: { path: "${{ inputs.p }}" } }
lift:
- law: taint # the law this task opens
from: inputs.p # ONE binding
because: "deployment-controlled path, reviewed at release time"
law: and because: are required on every entry, from: on taint
only (forbidden on data-as-code); because: must be non-empty — it is
recorded in the receipt with the taint path and the value digest. It
lifts the TAINT law only: the value is still matched against the
declared boundary, so this is never a permit bypass. A lift that would
not have fired is refused (NIKA-AUTH-011), so dead lifts cannot
accumulate.
Why the staging recipe is the wrong first move. Landing the value
in a file with nika:write and passing the PATH as argv looks safe, and
it is — until the CLI has to READ that file back. That read adds
fs.read, which completes the lethal trifecta, which makes a dominating
human gate mandatory. Measured in a real session: the chain runs shim →
fs.read → trifecta → mandatory gate → a gate that cannot be answered
(see the run notes on nika:prompt). Reach for lift: first.
Staging remains correct where the value genuinely must not touch a
command line AND nothing reads the file back inside the same workflow.
NIKA-SEC-009 — the trifecta. Untrusted input, private data and an
egress in one task is refused as a shape, not as an accident. The move
is to keep the trifecta INCOMPLETE rather than to smuggle a leg through
a subprocess: take the fetched value as nika:fetch metadata or text
and do NOT add an fs.read of local content in the same flow.
If a genuine gap survives both recipes, exec: is legitimate — name the
exact missing capability in the ledger. A helper that exists to dodge a
refusal is the refusal winning.
Exec ledger (mandatory when any exec remains)
Every surviving exec: gets a row in the workflow's header comment:
# EXEC LEDGER ·
# | task | command | why no native path | unlock that removes it |
The ledger is for the REVIEWER, not for the checker — it never silences
a finding. --native-strict judges the SHAPE of the exec: a real tool
passes, a script wrapper fails, and a row in the ledger changes neither.
If the wrapper is genuinely unavoidable, the honest move is to say so to
the human at handoff, not to expect a green.
Discipline
- References:
${{ inputs.x }}·${{ const.x }}·${{ secrets.X }}·${{ tasks.<id>.output }}·${{ with.alias }}(never inline a credential) · and inside afor_each:body only, the loop-scoped${{ item }}·${{ index }}. - Quote any scalar that STARTS with
${{inside a FLOW mapping —with: { body: "${{ tasks.a.output }}" }— or YAML reads the{{as a nested map (NIKA-PARSE-001). In block style the quotes are optional. - In
outputs:bind${{ tasks.<id>.output }}— never the bare${{ tasks.<id> }}: that binds the ENVELOPE (status + timestamps), sonika testgoldens drift red on every run.nika checkteaches this as[envelope-output]; fix the binding, never re-baseline around it. - A task that reads another task's output binds it in
with:—with: { alias: "${{ tasks.<id>.output }}" }— and the body reads${{ with.alias }}(the binding IS the edge;tasks.*anywhere else is NIKA-VAR-021). Pure ordering isafter: { <id>: success }. Use the predicates in the task-modifier table above;nika spec --schemaandnika checkown the accepted spellings (NIKA-DAG-005otherwise). - Models are
provider/name(ollama/llama3.2:3blocal-first ·mock/echooffline preview). - Timeouts are quoted Go-durations (
timeout: "7m") — give local providers ≥300s: thinking models routinely think past 30s. - Determinism is declared, not hoped:
run:carriesentropy:(ambient— the default whenrun:is absent — ·none·{ seeded: <n> }) andclock:(system·virtual). A contradictory declared pair refuses at parse; anentropy: nonethat still consumes randomness refuses at check. - Structured output: give
infer:aschema:; addadditionalProperties: falsefor a deterministic shape. Numeric facts aretype: integer+ a numericenum. The model extracts facts;nika:jq/nika:decideis the law (13-extract-then-law). - Auth rides
headers: { x-api-key: "${{ secrets.KEY }}" }(masked · declared insecrets:with itsegress:sink) — neverexec: curlfor the sake of a header.
Writing JSON: build the VALUE, never the braces
Hand-writing JSON punctuation around an interpolation is the one way to get a green check, a green run, and an unreadable artifact. Both halves below were measured on 2026-07-28 with a value containing a quote and a newline. This is a complete nine-key file (checked on 0.109 · rc=0):
nika: write-json-value
inputs:
v:
type: string
permits:
tools: ["nika:jq", "nika:write"]
fs:
write: ["./out/**"]
tasks:
# ✗ green everywhere, and the artifact does not parse.
naive:
with: { v: "${{ inputs.v }}" }
invoke:
tool: "nika:write"
args: { path: "./out/naive.json", content: '{"value": "${{ with.v }}"}' }
# writes {"value": "he said "hi"
# and a newline"} ← malformed. Nothing warned.
# ✓ build the object as a VALUE, then write the value whole.
build:
with: { v: "${{ inputs.v }}" }
invoke:
tool: "nika:jq"
args: { expression: "{ value: .v }", input: { v: "${{ with.v }}" } }
safe:
with: { obj: "${{ tasks.build.output }}" }
invoke:
tool: "nika:write"
args: { path: "./out/safe.json", content: "${{ with.obj }}", create_dirs: true }
# writes {"value":"he said \"hi\"\nand a newline"} ← parses.
**The law: an interpolation m
…(truncated)