# Nika Authoring

> Author, check and repair Nika workflows (.nika.yaml files — the workflow language for AI). Use when writing or editing a *.nika.yaml file, converting a repeated AI task or prompt chain into a workflow, or when nika check reports NIKA-XXXX findings to fix.

- Skill: `supernovae-st/nika-authoring` (Agent Skill)
- Install (CLI): `npx skillmds@latest add supernovae-st/nika-authoring`
- Raw SKILL.md: https://api.skillmd.com/api/skills/supernovae-st/nika-authoring/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: supernovae-st (https://skillmd.com/u/supernovae-st)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/supernovae-st/nika-authoring

---


# 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)

1. **Reuse the relevant workflow or example.** For a new structure,
   inspect the shelf with `nika try` and read a matching `nika new <slug>`.
   For a small repair, keep the existing file and change only what the
   task and diagnostics require.
2. **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, and `description:` died with the
   `workflow:` object, which is no longer an envelope key at all) + a
   `tasks:` 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) and
   `nika catalog --tools` (the `nika:*` builtins an `invoke` reaches
   without MCP); before a run, `nika inspect <file>` shows the anatomy:
   tasks · waves · the cost floor.
3. **Check it**: `nika check <file>` (exit 0 = clean · 2 = findings),
   then `nika check --native-strict <file>` — it fails on any
   `native-first` hint (an `exec:` a builtin covers).
4. **Repair**: `nika check <file> --fix` applies 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`.
5. 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 inspect `paid_ready` and its blockers.
   `--native-strict` is the run-gate bar (an `exec:` a builtin covers).
   `.paid_ready` is the paid-infer bar
   (`nika check --json <file> | jq .paid_ready`).
   A green exit with leftover `infer-as-law` / `digit-string-enum` /
   `glob-readme` / `jq-as-map` / `unproven-law` is
   legal, not the one-way. The MCP `nika_check` oracle fails
   `infer-as-law` and `digit-string-enum` by default.
   The exec ledger does NOT buy an exemption (measured: a `.py` wrapper
   fails with a complete ledger) — it documents intent for a reviewer.
   What passes is an `exec:` of a real tool (`git`, `docker`); what
   fails is an `exec:` of a `.py`/`.mjs`/`.sh` wrapper, ledger or not.
6. 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>` then
   `nika 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 an
   `inputs:` declaration · unknown keys refused); a run paused on a
   `nika:prompt` resumes with
   `nika run <file> --resume <trace> --answer <task>=<value>`
   (confirm gates take booleans: `--answer approve=true`).
7. Pin it for CI **only when the mock run needs no network, subprocess,
   or write effect**: `nika test <file> --update` writes
   `<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 with
   `nika run <file> --model mock/echo` in scratch, inspect the artifacts, and
   verify its trace; never promise a golden that cannot run.
8. **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.
  Read `permits.notes` and `permits.partial` in 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 `fs` bounds take globs while hosts do
  not.** `tools: ["nika:write"]` with no `fs.write` authorizes nothing:
  the write refuses on the `fs` axis at check and at run. You need the
  tool AND the path. `fs` bounds may be globs (`./docs/**/*.md` is
  accepted); host bounds may not (`*.example.com` is `NIKA-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:

```yaml
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:

1. **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.
2. **One job, one task, one verb.** If a task needs an "and then", it is
   two tasks. The verb IS the key.
3. **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.
4. **Classify every value before writing it.** Caller-supplied →
   `inputs:` · deployment-supplied → an `inputs:` entry with
   `required: false` and a `default:` · fixed here → `const:` ·
   credential → `secrets:`. If you cannot name the class, you do not yet
   know what the value is.
5. **Bind, never reach.** A task needing another's output binds it in
   `with:`. Reaching for `tasks.*` anywhere else is `NIKA-VAR-021`.
6. **Order only when no data flows.** `after:` is pure sequencing; if
   data flows the `with:` binding already IS the edge. Never both.
7. **Bound the spend where it is spent.** Every `infer:` carries
   `max_tokens`; every `agent:` carries `max_turns` and
   `max_tokens_total`. A ceiling the checker can compute beats a cap
   someone has to remember to pass.
8. **Declare the boundary LAST, from the body.** Write the tasks, then
   `nika check --infer-permits` and paste. A boundary derived from the
   body is tight; one written from intent is wishful. Expect a
   `NIKA-AUTH-006` the 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.
9. **Fail on purpose.** Transient failure → `retry:` · expected absence
   → `on_error: on_codes + recover:` · cleanup for a producer that started →
   an ordinary task you name, declaring `after: { producer: unwind }`.
   This includes cancellation and timeout; a producer that never started
   unwinds nothing. Cleanup is best-effort, and process death can prevent it.
   `terminal` is a settled-state dependency, not a substitute for this
   cleanup lane. Swallowing an error is never the plan.
10. **Prove it before handing it over.** `nika check` clean, 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.

1. `nika check --json --native-strict` until `clean` and `paid_ready`
   are both true (zero findings, zero paid-run hints).
2. Probe every new builtin in a one-task file on `mock/echo` *before*
   wiring it after a paid `infer:` (`nika:inspect` is live — lesson
   `16-inspect-self` asserts `available` at run start).
3. Freeze the extract schema type. Numeric facts are `type: integer`
   with a numeric `enum`. `enum: ["0","1","3"]` is the shape models do
   not emit (JSON `3` — hint `digit-string-enum`).
4. Pin the glob. `held/*.md` includes `README.md`. `exclude:
   "**/README.md"` (hint `glob-readme`).
5. **The model extracts facts. `nika:jq` or `nika:decide` is the law.**
   A second infer to "pick the level" is the expensive mistake.
   The shape is `13-extract-then-law`. Prove the law on const fixtures
   (`unproven-law`) — `14-decide-publish` is the named bundle.
6. 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.

0. **Is `.paid_ready` true?** `nika check --json <file> | jq .paid_ready`.
   `false` → repair `.next` (kind · task · advice) first, then the rest
   of `.paid_blockers[]`; also inspect `.compiled` and the findings.
   Do not swap off `mock/` while a paid blocker remains.
1. **Is an unfamiliar shape still unresolved?** Read the relevant
   `nika try` / `nika new <slug>` example, then check the actual file.
2. **Is every `exec:` a real tool?** `nika check --native-strict`. A
   `.py`/`.sh` wrapper is not a tool.
3. **Does any infer name the verdict?** Hint `infer-as-law`. Extract
   integer facts; `nika:jq` or `nika:decide` is the law
   (`13-extract-then-law`). A second infer whose schema is a language
   enum (BCP-47 · sentiment) is language, not this hint.
4. **Is every numeric enum `type: integer`?** Hint `digit-string-enum`.
5. **Does a markdown glob include README?** Hint `glob-readme`.
6. **Did I probe every new builtin on `mock/echo`?** One-task file,
   then wire it. `nika:inspect` is live (`16-inspect-self`).
7. **Would a closer template have given this graph?** `nika new "?"`
   and `nika new "the job in plain words"`. If a skeleton is closer
   than what I wrote, start over from it.
8. **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.
9. **Is the law proven on known answers?** Hint `unproven-law`. A
   jq/decide that scores an infer needs a const-fixture `nika: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 check` prints the cost ceiling BEFORE any token: `≤ $X` is a
  ceiling · `≥ $X FLOOR` means at least one task is unbounded — name
  the reason (a missing `max_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_tokens` is the max OUTPUT tokens, and that is what the sum
  prices; `input_per_million` has 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.0075` and would bill
  about `$2.46` in 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 `FLOOR` on 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 id `nika catalog` prints) reports
  `$0.0000 FLOOR · no catalog price (local/unknown model)` while
  `mistral/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
  in `shell:` explicitly · `capture: stdout|stderr|combined|structured`
  · **last resort**: run the native-first interrogation first (below)
- `invoke:` — a tagged union carrying EXACTLY ONE of `tool:` or
  `workflow:`, plus `args:`. `tool:` reaches a builtin or an MCP tool
  (HTTP fetch is `tool: "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 meanings
- `agent:` — a bounded multi-turn loop (`prompt`, `tools` allowlist,
  `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:

```yaml
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 refuses
  `NIKA-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-001` too, so a parent cannot pass check without its
  children present on disk.
- **One target per `invoke:`.** `tool:` and `workflow:` 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: a `permits: {}` 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 boundary` and
  `child tool nika:write is outside the parent boundary` (spec 14 laws 3
  and 4). The parent's `permits:` must be the union of what every child
  touches. `nika check --infer-permits` will 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 boundary` also fires
  when the parent grants X and the CHILD's own `permits:` 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.composed` and totals, including calling-task
  multipliers. Verify `judged.composition` and `judged.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:

1. **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:wait` |
   | FILE | `nika:read` · `nika:write` · `nika:edit` · `nika:glob` · `nika:grep` |
   | DATA | `nika:jq` · `nika:json_diff` · `nika:json_merge_patch` · `nika:validate` · `nika:convert` · `nika:uuid` · `nika:date` · `nika:hash` · `nika:decide` |
   | NETWORK | `nika:fetch` · `nika:notify` |
   | INTROSPECTION | `nika:compose` (agent-loop only · calling a child is `invoke: { workflow: … }` · §Composition) · `nika:inspect` |
   | MEDIA | `nika:chart` · `nika:image_generate` · `nika:image_fx` · `nika:tts_generate` |

   The NAMES above are canon — that is the whole set. The argument
   CONTRACTS are not: read them from `nika catalog --tools`
   (`--json` for 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:jq` or an `extract:` 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).
2. **Which MCP tool replaces it?** A product API deserves an MCP
   server, never a helper script.
3. **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):

```yaml
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 a
  `for_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),
  so `nika test` goldens drift red on every run. `nika check` teaches
  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 is `after: { <id>: success }`.
  Use the predicates in the task-modifier table above; `nika spec --schema`
  and `nika check` own the accepted spellings (`NIKA-DAG-005` otherwise).
- Models are `provider/name` (`ollama/llama3.2:3b` local-first ·
  `mock/echo` offline preview).
- Timeouts are quoted Go-durations (`timeout: "7m"`) — give local
  providers ≥300s: thinking models routinely think past 30s.
- Determinism is declared, not hoped: `run:` carries `entropy:`
  (`ambient` — the default when `run:` is absent — · `none` ·
  `{ seeded: <n> }`) and `clock:` (`system` · `virtual`). A
  contradictory declared pair refuses at parse; an `entropy: none`
  that still consumes randomness refuses at check.
- Structured output: give `infer:` a `schema:`; add
  `additionalProperties: false` for a deterministic shape.
  Numeric facts are `type: integer` + a numeric `enum`. The model
  extracts facts; `nika:jq` / `nika:decide` is the law
  (`13-extract-then-law`).
- Auth rides `headers: { x-api-key: "${{ secrets.KEY }}" }` (masked ·
  declared in `secrets:` with its `egress:` sink) — never `exec: curl`
  for 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):

```yaml
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)
