AGPL-3.0-or-later · forever.

pick a workflow

“I want to…”

turn meeting notes into owned actions

There’s a file for that.

Start from the outcome. Anything you ask an AI to do more than once belongs in a file you can read before it runs. Every card below is a real workflow, pulled from the language’s own test suite, never mocked, with its plan (the tasks and what they wait on) and the exact YAML that runs it.

  • 26workflowsspec-valid
  • 5audienceseveryone → researchers
  • 4tiersT1 → T4
  • 161tasksacross all workflows

01 · for everyone3 workflows

Everyone

No coding background needed. If you can read a checklist, you can read these files.

7.1.1

Turn meeting notes into owned actions

T1

A transcript goes in, a task list comes out. each item typed {owner, task, due}, checked, ready for your tracker.

invokeinfer
meeting-actions.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: meeting-actions  description: "Transcript → typed action items {owner, task, due}"# A NON-thinking local model, deliberately: a thinking model can spend the# whole `max_tokens` budget in its think block and return before the JSON# (engine#428). Schema showcases pick a model that answers directly.model: ollama/llama3.2:3binputs:  transcript_path:    type: string    default: "examples/fixtures/meeting-transcript.txt"    description: "Path to the raw meeting transcript"permits:  tools: ["nika:log", "nika:read", "nika:write"]  fs:    # The sample transcript, named as a LITERAL. `permits:` cannot interpolate    # (NIKA-AUTH-007), so this line does not follow `inputs.transcript_path`    # around: pointing the var at your own file means editing this entry too,    # or the read is refused at run with NIKA-SEC-004. That is the wall doing    # its job — a boundary you can read is a boundary a reviewer can check.    read: ["examples/fixtures/meeting-transcript.txt"]    write: ["out/action-items.json"]tasks:  transcript:    invoke:      tool: "nika:read"      args: { path: "${{ inputs.transcript_path }}" }  extract:    with:      transcript: ${{ tasks.transcript.output }}    infer:      max_tokens: 900                  # a ceiling makes the cost report a number, not UNBOUNDED      prompt: |        Extract every action item from this meeting transcript ·        ${{ with.transcript }}        One entry per commitment somebody actually made. `due` only when a        date or deadline was stated — never invent one.      schema:                          # the contract the model must satisfy        type: object        additionalProperties: false        required: [actions]        properties:          actions:            type: array            items:              type: object              additionalProperties: false              required: [owner, task]              properties:                owner: { type: string }                task: { type: string }                due: { type: string }  save:    with:      extract_actions: ${{ tasks.extract.output.actions }}    invoke:      tool: "nika:write"      args:        # `.json` content is ONE interpolation of a real value — the engine        # serializes it. Typing `{ "actions": ${{ … }} }` by hand emits        # unquoted fields and the artifact stops being JSON.        path: out/action-items.json        content: "${{ with.extract_actions }}"        create_dirs: true  trace:    after:      extract: success    invoke:      tool: "nika:log"      args:        level: info        message: "Action items extracted to out/action-items.json"outputs:  actions:    value: ${{ tasks.extract.output.actions }}    description: "Typed action items, tracker-ready"

audited · 3 tasks · 3 waves · permits declared · 1 hintnika 0.107.0

the plan
transcriptinvokenika:read
extractinfer
saveinvokenika:writetraceinvokenika:log

Nobody re-reads the transcript. The tracker import is already done.

7.1.2

Watch a price and get pinged when it moves

T1

No AI involved at all. one fetch, one compare, one ping. A robot you can already trust.

invoke
price-watch.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: price-watch  description: "Watch a product price, ping me when it drops below my target"const:  product_api: "https://api.shop.example.com/v1/products/macbook-air"  alert_below: 899secrets:  alerts_webhook:    source: env    key: ALERTS_WEBHOOK_URL    egress:                       # sanction the one send · the secret IS the URL      - to: "nika:notify"        host_from_self: truepermits:  tools: ["nika:fetch", "nika:notify"]  net:    # TWO hosts, for two different reasons.    #    # `api.shop.example.com` is what `const.product_api` names — a literal    # `check` can read, so a mismatch here is caught before the run.    #    # `hooks.slack.com` is the ALERT host, and it is the entry people leave    # out. `host_from_self:` above sanctions the FLOW (this secret may be the    # URL) — it does not grant the capability. The host stays unknown at    # check, so it is judged at RUN against this bound: name the escalation    # host here or the send is refused mid-run, the first time the price    # actually drops. Swap it for wherever YOUR webhook lives.    http: ["api.shop.example.com", "hooks.slack.com"]tasks:  check:    invoke:      tool: "nika:fetch"      args:        url: "${{ const.product_api }}"        mode: jq        jq: "."    output:                           # named jq bindings over the raw response      price: ".price"      name: ".name"    on_error:      # Offline rehearsal · a sample ABOVE the target, so the gate below stays      # closed and the dry run ends green with the alert skipped. The alert      # path is therefore NOT exercised offline — which is exactly why the      # `net:` bound above has to be right by reading, not by running.      recover: { price: 949, name: "MacBook Air (offline sample)" }  alert:    with:      price: ${{ tasks.check.price }}     # value edges · the named bindings cross the boundary here      name: ${{ tasks.check.name }}    when: ${{ with.price < const.alert_below }}    invoke:      tool: "nika:notify"      args:        channel: webhook        target: "${{ secrets.alerts_webhook }}"        message: "Price drop · ${{ with.name }} is now ${{ with.price }} (target ${{ const.alert_below }})"        severity: infooutputs:  price: ${{ tasks.check.price }}

audited · 3 tasks · 3 waves · permits declarednika 0.107.0

the plan
checkinvokenika:fetch
alertinvokenika:notify

Not everything needs an LLM. The engine alone is a robot you can already trust.

7.1.3

Chase overdue invoices without the awkward part

T2

The reminders get drafted for you. nothing is saved until you read them and type yes.

invokeinfer
invoice-chaser.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: invoice-chaser  description: "Ledger CSV → overdue filter → drafted reminders → human gate → drafts file"model: ollama/qwen3.5:4b   # local · zero key · swap for groq/llama-3.3-70b (drafting is a fast-model job)const:  ledger_csv: "examples/fixtures/invoices.csv"permits:  tools: ["nika:convert", "nika:jq", "nika:prompt", "nika:read", "nika:write"]  fs:    # Two files, both spelled out — `const.ledger_csv` in, the drafts file    # out. A const is baked in and a run cannot move it, so the boundary is    # exact: no glob, and nothing else on disk is reachable either way.    read: ["examples/fixtures/invoices.csv"]    write: ["out/reminders-to-send.md"]tasks:  ledger:    invoke:      tool: "nika:read"      args: { path: "${{ const.ledger_csv }}" }  rows:    with:      csv: ${{ tasks.ledger.output }}    invoke:      tool: "nika:convert"      args:        input: "${{ with.csv }}"        from: csv        to: json        has_header: true  overdue:    with:      rows: ${{ tasks.rows.output }}    invoke:      tool: "nika:jq"      args:        input: "${{ with.rows }}"        expression: 'map(select(.status == "overdue"))'  drafts:    with:      overdue: ${{ tasks.overdue.output }}    when: ${{ size(with.overdue) > 0 }}    infer:      max_tokens: 900       # a ceiling makes the cost report a number, not UNBOUNDED      prompt: |        Draft one short, polite payment reminder per overdue invoice ·        ${{ with.overdue }}        Markdown · one section per client · firm but warm.  approve:    with:      drafts: ${{ tasks.drafts.output }}    when: ${{ with.drafts != null }}   # nothing drafted · nothing to approve    invoke:      tool: "nika:prompt"      args:        message: "Save the reminder drafts for sending?"        default: false  save:    with:      drafts: ${{ tasks.drafts.output }}      approved: ${{ tasks.approve.output }}    when: ${{ with.drafts != null && with.approved == true }}   # zero overdue → drafts skipped (null) · don't write nothing    invoke:      tool: "nika:write"      args:        path: out/reminders-to-send.md        create_dirs: true        content: "${{ with.drafts }}"outputs:  overdue:    value: ${{ tasks.overdue.output }}    description: "The overdue rows the reminders were drafted for"
the plan
ledgerinvokenika:read
rowsinvokenika:convert
overdueinvokenika:jq
draftsinfer
approveinvokenika:prompt
saveinvokenika:write

Friday’s awkward chore shrinks to reading the drafts and typing yes.

02 · for founders & ops4 workflows

Founders & ops

The Monday-morning chores, briefs, queues and radars, described once, done every week.

7.2.1

Start the week with the numbers already gathered

T4

Market, repo pulse and the KPI sheet collected in parallel. the ping even reports its own cost.

invokeexecinfer
ceo-monday-brief.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: ceo-monday-brief  description: "news + repo pulse + KPIs → one synthesis → dated brief → a ping on every outcome"model: ollama/qwen3.5:4b   # local · zero key · `--model mock/echo` rehearses the whole fileinputs:  alert:    type: bool    default: false    description: "Send the founders ping. OFF by default so a rehearsal touches no webhook."  kpi_sheet:    type: string    default: "./examples/fixtures/ceo-kpis.csv"    description: "The weekly numbers · columns incl. weekly_revenue"const:  watch_query: "AI workflow engines"secrets:  founders_webhook:    source: env    key: FOUNDERS_WEBHOOK_URL    egress:                       # sanction the one send · the secret IS the URL      - to: "nika:notify"        host_from_self: truepermits:  exec: ["git"]  tools: ["nika:convert", "nika:date", "nika:fetch", "nika:jq", "nika:notify", "nika:prompt", "nika:read", "nika:write"]  net:    http:      - "hn.algolia.com"          # the market-signal search · the only host this brief READS      # `host_from_self:` above sanctions the FLOW (the secret may be the URL) —      # it does not grant the capability. The host stays unknown at check, so it      # is judged at RUN against this bound: name the escalation host here or the      # send is refused mid-run, after the tokens are already spent.      - "hooks.slack.com"         # swap for wherever FOUNDERS_WEBHOOK_URL points  fs:    # The sheet arrives as an INPUT, so `check` reads past it — and the wall does    # not move when the input does: point --var kpi_sheet at anything else and the    # run refuses it NIKA-SEC-004. That asymmetry is the point of a permits block.    read: ["./examples/fixtures/ceo-kpis.csv"]    # One dated brief per run, written DIRECTLY in ./briefs/. `*` matches a single    # segment and never crosses `/`, so nothing here can steer the write into a    # subtree — which is why this is not `./briefs/**`.    write: ["./briefs/*-monday.md"]tasks:  # ── the human gate · FIRST, before anything untrusted enters ──  #  # This run holds all three legs of the lethal trifecta: a private read (the  # numbers), untrusted ingress (a public news API), and external egress (a  # write and a webhook). NEP-0002 asks for one blocking human decision that  # DOMINATES every path to every egress-capable task — which forces it here,  # at the root, not in front of the save.  #  # A gate placed after the fetch could never dominate: the brief still reaches  # `save` along its own data edge, a path the human is not standing on. And a  # human skimming a model's summary of an attacker's page is a weak check  # anyway — by then the payload is already inside the run. So the decision  # taken here is the honest one: authorize the CAPABILITY ENVELOPE up front.  approve:    invoke:      # blocking · no `default:` · a gate with a default is not a gate, and the      # checker enforces both directions: add `default: false` here and this      # file turns RED with NIKA-SEC-009, because a defaulted prompt dominates      # nothing. Leaving it blocking costs one [headless-prompt] hint instead —      # that hint is the price of a real gate, not a defect to paper over.      # The run pauses (exit 4, not a failure) — answer and resume with      #   nika run <file> --resume <trace> --answer approve=true      tool: "nika:prompt"      args:        message: |          This Monday brief will:            · fetch public news from hn.algolia.com            · run git in this repo            · read ${{ inputs.kpi_sheet }}            · write ./briefs/<date>-monday.md            · ping the founders webhook (armed: ${{ inputs.alert }})          Proceed? [yes/no]  # ── branch 1 · market signal · the untrusted leg ──  news:    with:      go: ${{ tasks.approve.output }}    when: ${{ with.go == true }}    invoke:      tool: "nika:fetch"      args:        url: "https://hn.algolia.com/api/v1/search?query=${{ const.watch_query }}"        mode: jq        jq: ".hits[:10] | map(.title)"    on_error:      # The honest dry run · offline the host resolves nowhere and this literal      # takes over, so the join below is exercised on both paths with the same      # binding. It is labelled in the brief so nobody mistakes it for signal.      recover: ["(offline rehearsal · no market signal was fetched)"]  # ── branch 2 · engineering pulse ──  pulse:    with:      go: ${{ tasks.approve.output }}    when: ${{ with.go == true }}    exec:      # Default capture (stdout), so a non-zero exit FAILS the task and the      # recovery below is what handles it. The tempting alternative — `capture:      # structured` — turns the exit code into data and the task reports success      # whatever happened; that is only honest when something downstream reads      # `exit_code`, and the checker flags the case where nothing does      # ([swallowed-exit]). Here the failure is visible in the run (« 1      # recovered ») and labelled in the brief, which is the same information      # with none of the silence.      command: ["git", "--no-pager", "shortlog", "-sn", "--since=1 week ago", "HEAD"]    on_error:      recover: "(no engineering pulse · git could not be read here)"  # ── branch 3 · the numbers · the private leg ──  sheet:    with:      go: ${{ tasks.approve.output }}    when: ${{ with.go == true }}    invoke:      tool: "nika:read"      args: { path: "${{ inputs.kpi_sheet }}" }  rows:    with:      csv: ${{ tasks.sheet.output }}    # A SKIPPED producer hands its consumer `null`, and the consumer runs anyway.    # Measured: without this line a « no » at the gate reaches nika:convert with    # nothing and the run FAILS (NIKA-BUILTIN-CONVERT-002) instead of landing    # nothing — a refusal must be a quiet no-op, never an error. Every task    # downstream of a gated one carries its own admission test.    when: ${{ with.csv != null }}    invoke:      tool: "nika:convert"      args:        input: "${{ with.csv }}"        from: csv        to: json        has_header: true  revenue:    with:      rows: ${{ tasks.rows.output }}    when: ${{ with.rows != null }}    invoke:      tool: "nika:jq"      args:        # CSV cells arrive as STRINGS whatever they look like, so `tonumber`        # before `add`. Money is arithmetic, never a model's opinion of it.        input: "${{ with.rows }}"        expression: "map(.weekly_revenue | tonumber) | add"  # ── the join · three branches land here, one call ──  brief:    with:      go: ${{ tasks.approve.output }}      # Every binding is SHALLOW (`.output`, never `.output.field`). Measured:      # a skipped producer's `.output` reads as `null` and the join survives it,      # while a deep field of the same skipped producer is NIKA-VAR-001 ·      # unresolved template reference — bindings resolve before `when:` decides,      # so a deep read into a « no » path kills the run the gate was meant to      # spare. Reach one level, gate, then reach deeper downstream.      news: ${{ tasks.news.output }}      pulse: ${{ tasks.pulse.output }}      rows: ${{ tasks.rows.output }}      revenue: ${{ tasks.revenue.output }}    when: ${{ with.go == true }}    infer:      max_tokens: 1200               # the cost report becomes a ceiling, not UNBOUNDED      prompt: |        Market signal · ${{ with.news }}        Engineering pulse · ${{ with.pulse }}        Weekly rows · ${{ with.rows }}        Revenue, summed by the engine · ${{ with.revenue }}        Write my Monday brief · 5 sections · market · product · numbers ·        risks · the ONE decision this week needs.      thinking:        # A reasoning budget, honoured by seats that support extended thinking        # and ignored by the ones that do not — declaring it never fails a run.        enabled: true        budget_tokens: 4000  stamp:    with:      go: ${{ tasks.approve.output }}    when: ${{ with.go == true }}    invoke:      tool: "nika:date"      args: { op: now }    output:      day: ".[:10]"                  # nika:date returns the ISO string itself · slice YYYY-MM-DD  save:    with:      go: ${{ tasks.approve.output }}   # every effect re-reads the decision · a « no » lands nothing      day: ${{ tasks.stamp.day }}      brief: ${{ tasks.brief.output }}    when: ${{ with.go == true }}    invoke:      tool: "nika:write"      args:        path: "./briefs/${{ with.day }}-monday.md"        content: "${{ with.brief }}"        create_dirs: true  # ── the ping · a TASK, not a cleanup hook ──  #  # `after: {save: terminal}` admits all four settled states, so this fires  # whether the brief landed, failed, or was skipped by a « no » at the gate.  # It is deliberately not an `on_finally:` hook: cleanup errors are LOGGED and  # DO NOT PROPAGATE (03 §on_finally · best-effort lane), so a webhook that  # dies there dies in silence — measured: an unresolved secret in a cleanup  # hook leaves the run green with nothing sent. An alert you cannot trust to  # fail loudly is not an alert.  alert:    after:      save: terminal    with:      armed: ${{ inputs.alert }}      outcome: ${{ tasks.save.status }}   # observe WHAT happened · same pass-set as the after edge      day: ${{ tasks.stamp.day }}    when: ${{ with.armed == true }}       # OFF by default · a rehearsal touches no webhook    invoke:      tool: "nika:notify"      args:        channel: webhook        target: "${{ secrets.founders_webhook }}"        message: "Monday brief · ${{ with.outcome }} · briefs/${{ with.day }}-monday.md"        severity: infooutputs:  brief: ${{ tasks.brief.output }}  revenue:    value: ${{ tasks.revenue.output }}    description: "Total weekly revenue, summed by jq — the number nobody guessed"
the plan
approveinvokenika:prompt
newsinvokenika:fetchpulseexecgitsheetinvokenika:readstampinvokenika:date
rowsinvokenika:convert
revenueinvokenika:jq
briefinfer
saveinvokenika:write
alertinvokenika:notify

The ping ends with the run’s own cost line. The workflow reports its own bill.

7.2.2

Know what competitors shipped, every Monday

T3

It reads their sites while you sleep. one brief on your desk at 8am, with what it signals.

invokeinfer
competitor-radar.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: competitor-radar  description: "sitemap → what changed this window → parallel page reads → one brief"model: ollama/qwen3.5:4b   # local · zero key · swap for anthropic/claude-sonnet-4-6 for a sharper readrun:  clock: system            # `pages` declares a `timeout:` · name the clock it ridesinputs:  sitemap_url:    type: string    default: "https://nika.sh/sitemap.xml"    description: "Whose sitemap to read · must agree with the host in permits.net.http"  window_days:    type: integer    default: 7    description: "How far back « recently » reaches"  max_pages:    type: integer    default: 6    description: "Politeness cap · the fan-out never exceeds this many fetches"permits:  tools: ["nika:date", "nika:emit", "nika:fetch", "nika:jq", "nika:write"]  net:    # ONE host covers BOTH fetches, and that is a claim worth being explicit    # about: `map` reads the sitemap, then `pages` fetches whatever `.loc`    # values came back — runtime URLs this file never sees. A sitemap is    # single-host by protocol, so the site's own host is the honest wall: if    # their sitemap ever points somewhere else, that fetch is refused instead    # of followed. Change `inputs.sitemap_url` and this line changes with it.    http: ["nika.sh"]  fs:    # ONE file, named exactly, because the path IS a literal — there is no    # uuid or date in it, so there is nothing for a wildcard to stand in for.    # A bound is only as wide as the uncertainty it has to cover.    #    # `create_dirs: true` below makes the missing `./radar/` without needing    # a grant of its own: measured, with only this line, the directory is    # created and the brief lands. (Builtins that take an `output_dir:`    # instead — `nika:image_generate` and family — are the opposite case:    # `check` judges the directory ARGUMENT while the run gates each final    # file path, so those need the directory AND its children. The bound    # follows what the builtin names.)    write: ["./radar/competitor-brief.md"]tasks:  # ── how far back does « recently » reach ───────────────────────────  # Three date ops rather than a hardcoded string, because a radar that ships  # with a date baked in is wrong the day after you write it. `format` to  # %Y-%m-%d puts the cutoff in the same shape sitemaps use for `lastmod`,  # which is what makes the string comparison below sound.  now:    invoke:      tool: "nika:date"      args: { op: now }  cutoff:    with:      now: ${{ tasks.now.output }}    invoke:      tool: "nika:date"      args:        op: subtract        base: "${{ with.now }}"        duration: "${{ inputs.window_days }}d"  cutoff_day:    with:      cutoff: ${{ tasks.cutoff.output }}    invoke:      tool: "nika:date"      args:        op: format        input: "${{ with.cutoff }}"        format: "%Y-%m-%d"  # ── what they publish, and when it last moved ──────────────────────  map:    invoke:      tool: "nika:fetch"      args:        url: "${{ inputs.sitemap_url }}"        mode: sitemap                  # → [{loc, lastmod, changefreq, priority}, …]  # `lastmod` is optional in the sitemap protocol, so `// ""` sends an entry  # without one outside the window instead of crashing the filter. Newest  # first, then the politeness cap — the fan-out width is decided here and  # nowhere else.  recent:    with:      entries: ${{ tasks.map.output }}      cutoff: ${{ tasks.cutoff_day.output }}    invoke:      tool: "nika:jq"      args:        input: { entries: "${{ with.entries }}", cutoff: "${{ with.cutoff }}" }        expression: >-          .cutoff as $c          | .entries          | map(select((.lastmod // "") >= $c))          | sort_by(.lastmod) | reverse          | .[:${{ inputs.max_pages }}]          | map(.loc)  # ── read them all at once ──────────────────────────────────────────  pages:    with:      urls: ${{ tasks.recent.output }}    for_each: ${{ with.urls }}    max_parallel: 4                    # be a good guest · four in flight, no more    fail_fast: false                   # one dead page must not kill the radar    on_error:      recover: null                    # that page yields null at its index · the batch lives    timeout: "30s"                     # somebody else's server, somebody else's bad day    retry:      max_attempts: 3      backoff_strategy: exponential      jitter: true                     # a swarm that retries in lockstep is a small DDoS    invoke:      tool: "nika:fetch"      args:        url: "${{ item }}"        mode: article                  # readability extraction · the prose, not the chrome  # ── the fan-in · a for_each output is an array in ITEM ORDER ───────  # which is what lets `transpose` put each page's text back beside its own  # URL. The `select` is where the pages that never answered leave.  readable:    with:      urls: ${{ tasks.recent.output }}      pages: ${{ tasks.pages.output }}    invoke:      tool: "nika:jq"      args:        input: ["${{ with.urls }}", "${{ with.pages }}"]        expression: >-          transpose          | map(select(.[1] != null))          | map({ url: .[0], text: .[1] })  digest:    with:      readable: ${{ tasks.readable.output }}      cutoff: ${{ tasks.cutoff_day.output }}    when: ${{ size(with.readable) > 0 }}   # a quiet week costs nothing    infer:      max_tokens: 1500                     # one page · a ceiling, not a hope      prompt: |        These are the pages a competitor published or changed since        ${{ with.cutoff }} ·        ${{ with.readable }}        Write the Monday brief · what they shipped · what it signals · what        we should watch. One page, plain words, no filler.  save:    with:      digest: ${{ tasks.digest.output }}    when: ${{ with.digest != null }}   # a skipped task reads null downstream · gate on the VALUE    invoke:      tool: "nika:write"      args:        path: "./radar/competitor-brief.md"        content: "${{ with.digest }}"        create_dirs: true  # The « it is ready » signal, as a LOCAL journal event. No webhook, no  # secret, no host: the brief is on disk and the event says so. A radar that  # needed an outbound credential to tell you it had finished would be a  # strictly larger blast radius for no extra information.  record:    with:      urls: ${{ tasks.recent.output }}      readable: ${{ tasks.readable.output }}      cutoff: ${{ tasks.cutoff_day.output }}    invoke:      tool: "nika:emit"      args:        event_type: "competitor.radar.scan"        payload:          since: ${{ with.cutoff }}          selected: ${{ with.urls }}          read_ok: ${{ with.readable }}outputs:  since:    value: ${{ tasks.cutoff_day.output }}    description: "The computed window start · what « recently » meant on this run"  pages:    value: ${{ tasks.recent.output }}    description: "The URLs the sitemap said had changed inside that window"  brief:    value: ${{ tasks.digest.output }}    description: "The Monday brief · null when the week was quiet"
the plan
nowinvokenika:datemapinvokenika:fetch
cutoffinvokenika:date
cutoff_dayinvokenika:date
recentinvokenika:jq
pagesinvokenika:fetch
readableinvokenika:jq
digestinferrecordinvokenika:emit
saveinvokenika:write

Monday 8am, everything they shipped last week is on your desk, with what it signals.

7.2.3

Wake up to a sorted support queue

T2

Overnight tickets get tagged, drafted and batched. humans start at 9am on the hard ones.

invokeinfer
support-triage.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: support-triage  description: "Ticket queue → typed triage → urgent escalation → triage board"# A NON-thinking local model, deliberately: a thinking model can spend the# whole `max_tokens` budget in its think block and return before the JSON# (engine#428). Schema showcases pick a model that answers directly.model: ollama/llama3.2:3b   # local · zero key · swap for groq/llama-3.3-70b (triage wants speed)const:  queue_path: "examples/fixtures/support-queue.json"secrets:  oncall_webhook:    source: env    key: ONCALL_WEBHOOK_URL    egress:                       # sanction the one send · the secret IS the URL      - to: "nika:notify"        host_from_self: truepermits:  tools: ["nika:jq", "nika:notify", "nika:read", "nika:uuid", "nika:write"]  fs:    read: ["examples/fixtures/support-queue.json"]    # One board per batch id. `*` matches a single segment and never crosses    # `/`, so the uuid can never steer the write out of out/ — `out/**` would    # let it.    write: ["out/triage-*.json"]  net:    # `host_from_self:` above sanctions the FLOW (the secret may be the URL) —    # it does not grant the capability. The host stays unknown at check, so it    # is judged at RUN against this bound: name the escalation host here or the    # send is refused mid-run, after the tokens are already spent.    http: ["hooks.slack.com"]tasks:  batch:    invoke:      tool: "nika:uuid"      args: { version: v7 }  queue:    invoke:      tool: "nika:read"      args: { path: "${{ const.queue_path }}" }  triage:    with:      queue: ${{ tasks.queue.output }}    infer:      max_tokens: 1500                 # the whole queue in one call · a ceiling, not UNBOUNDED      prompt: |        Triage every ticket in this queue ·        ${{ with.queue }}        For each · classify category and urgency, draft a 2-sentence first reply.      schema:        type: object        additionalProperties: false    # a deterministic shape across providers        required: [tickets]        properties:          tickets:            type: array            items:              type: object              additionalProperties: false              required: [id, category, urgency, first_reply]              properties:                id: { type: string }                category: { type: string, enum: [billing, bug, how-to, account, other] }                urgency: { type: string, enum: [low, normal, high, critical] }                first_reply: { type: string }  urgent:    with:      tickets: ${{ tasks.triage.output.tickets }}    invoke:      tool: "nika:jq"      args:        input: "${{ with.tickets }}"        expression: 'map(select(.urgency == "high" or .urgency == "critical"))'  escalate:    with:      urgent: ${{ tasks.urgent.output }}      batch: ${{ tasks.batch.output }}    when: ${{ size(with.urgent) > 0 }}    invoke:      tool: "nika:notify"      args:        channel: webhook        target: "${{ secrets.oncall_webhook }}"        message: "Urgent tickets in triage batch ${{ with.batch }} · ${{ with.urgent }}"        severity: warning  board:    with:      tickets: ${{ tasks.triage.output.tickets }}      batch: ${{ tasks.batch.output }}    invoke:      tool: "nika:write"      args:        # A `.json` artifact takes ONE interpolated value as `content:` and        # lets the engine serialize it. Typing `{ "tickets": ${{ … }} }` by        # hand emits unquoted fields and the board stops being JSON.        path: "out/triage-${{ with.batch }}.json"        content: "${{ with.tickets }}"        create_dirs: trueoutputs:  tickets:    value: ${{ tasks.triage.output.tickets }}    description: "The classified queue with drafted first replies"
the plan
batchinvokenika:uuidqueueinvokenika:read
triageinfer
urgentinvokenika:jqboardinvokenika:write
escalateinvokenika:notify

By 9am the board is tagged, drafted and batched. Humans handle the hard ones.

7.2.4

Screen forty resumes with one fair rubric

T3

Candidate #1 and #40 get the same questions. scored on a local model, so the personal data stays home.

invokeinfer
resume-screener.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: resume-screener  description: "glob CVs → local-model rubric per candidate → deterministic shortlist"model: ollama/qwen3.5:4b   # PII stays on the machine · the whole screen is offlineconst:  role: "Senior Rust engineer"  shortlist_size: 5  # The committed rehearsal inbox. Point this at ./hiring/inbox (or wherever  # your exports land) and change `permits.fs.read` to match, same edit.  cv_glob: "./examples/fixtures/cvs/*.md"permits:  tools: ["nika:glob", "nika:jq", "nika:read", "nika:write"]  # NO `net:` category. That absence is the whole sovereignty claim: with no  # host granted, a CV that talks the model into exfiltrating itself still  # has nowhere to go — the boundary refuses before the request is made.  fs:    # TWO entries, and both earn their place. `nika:glob` opens the ROOT of    # its pattern before it lists anything, and a directory grant does not    # cover its children while a child grant does not cover the directory —    # so each is named. Measured: with only the second line, the run dies    # `NIKA-SEC-004 · ./examples/…/cvs resolves outside permits.fs.read`    # before a single CV is read.    #    # `*` is one segment and never crosses `/`, which is the right width    # here: CVs land flat in the inbox. `cvs/**` would also work and would    # additionally hand over every sub-tree someone drops in there.    read:      - "./examples/fixtures/cvs"      # the directory the glob opens      - "./examples/fixtures/cvs/*"    # the CVs one level inside it    # One brief, one path. Nothing else on this machine is writable by this    # workflow — not `./hiring/**`, not a sibling of the brief.    write: ["./hiring/shortlist-brief.md"]tasks:  # ── the pool is discovered at runtime · the count is never written down ──  pool:    invoke:      tool: "nika:glob"      args: { pattern: "${{ const.cv_glob }}" }  cvs:    with:      paths: ${{ tasks.pool.output }}    for_each: ${{ with.paths }}    max_parallel: 8    fail_fast: false                   # one unreadable CV must not stop the batch    on_error:      recover: null                    # null holds the index so the zip below stays aligned    invoke:      tool: "nika:read"      args: { path: "${{ item }}" }  # A for_each output is an array in ITEM ORDER, so `transpose` pairs each  # path with its own text. The `select` is where the unreadable CVs leave.  pairs:    with:      paths: ${{ tasks.pool.output }}      texts: ${{ tasks.cvs.output }}    invoke:      tool: "nika:jq"      args:        input: ["${{ with.paths }}", "${{ with.texts }}"]        expression: >-          transpose          | map(select(.[1] != null))          | map({ path: .[0], text: .[1] })  # ── one rubric, applied identically to everyone ────────────────────  # `with.cv_path` reads `${{ item.path }}` — a pre-fan-out import that is  # re-evaluated per iteration, which is how a loop-local reaches the prompt  # under a name that means something.  screened:    with:      pairs: ${{ tasks.pairs.output }}      cv_path: ${{ item.path }}    for_each: ${{ with.pairs }}    max_parallel: 2                    # a local model shares one GPU · don't thrash it    fail_fast: false    on_error:      recover: null    infer:      max_tokens: 1200                 # a rubric row, not an essay      prompt: |        Role · ${{ const.role }}        Candidate file · ${{ with.cv_path }}        CV ·        ${{ item.text }}        Score this candidate against the role. Quote evidence from the CV        for every rating: no rating without a quote.      # `additionalProperties: false` on EVERY object node — the top level and      # each nested one. Without it a provider is free to add keys, and the      # jq below would be sorting a shape that changes between runs.      schema:        type: object        additionalProperties: false        required: [file, fit, strengths, concerns]        properties:          file: { type: string }          fit: { type: string, enum: [strong, possible, weak] }          years_relevant: { type: integer }          strengths: { type: array, items: { type: string } }          concerns: { type: array, items: { type: string } }  # ── the model judged · jq decides ──────────────────────────────────  # Ranking lives HERE, not in a prompt, so the order is reproducible: same  # scores in, same order out. `.fit != "strong"` sorts false (0) before true  # (1), which puts the strong candidates first; `// 0` covers the candidates  # whose CV never stated a number.  ranked:    with:      screened: ${{ tasks.screened.output }}    invoke:      tool: "nika:jq"      args:        input: "${{ with.screened }}"        expression: >-          map(select(. != null))          | map(select(.fit != "weak"))          | sort_by(.fit != "strong", -(.years_relevant // 0))  shortlist:    with:      ranked: ${{ tasks.ranked.output }}    invoke:      tool: "nika:jq"      args:        input: "${{ with.ranked }}"        expression: ".[:${{ const.shortlist_size }}]"  brief:    with:      shortlist: ${{ tasks.shortlist.output }}    when: ${{ size(with.shortlist) > 0 }}   # nobody cleared the bar · spend nothing    infer:      max_tokens: 2000      prompt: |        Write the screening brief for these shortlisted candidates ·        ${{ with.shortlist }}        One paragraph each · lead with the evidence quotes · end with the        suggested interview focus.  save:    with:      brief: ${{ tasks.brief.output }}    when: ${{ with.brief != null }}    # a skipped task reads null downstream · gate on the VALUE    invoke:      tool: "nika:write"      args:        path: "./hiring/shortlist-brief.md"        content: "${{ with.brief }}"        create_dirs: trueoutputs:  ranked:    value: ${{ tasks.ranked.output }}    description: "Everyone who was not scored weak · strong first, then by relevant years"  shortlist:    value: ${{ tasks.shortlist.output }}    description: "The top `const.shortlist_size` of the ranking · what the brief covers"
the plan
poolinvokenika:glob
cvsinvokenika:read
pairsinvokenika:jq
screenedinfer
rankedinvokenika:jq
shortlistinvokenika:jq
briefinfer
saveinvokenika:write

Candidate #1 and #40 get the same rubric, and the PII never leaves the machine.

03 · for developers7 workflows

Developers

The boring parts of shipping run themselves, with a human gate exactly where it counts.

7.3.1

Have the standup note already written

T1

It reads yesterday’s commits and writes your three bullets. you glance, tweak one word, go.

invokeexecinfer
standup-digest.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: standup-digest  description: "Read yesterday's commits, write today's standup note"model: ollama/qwen3.5:4b   # local · zero key · swap for groq/llama-3.3-70b (a fast one-liner job)permits:  tools: ["nika:date", "nika:write"]  exec: ["git"]              # the ONE program this workflow may run  fs: { write: ["out/standup-note.md"] }tasks:  # No deps between these two → the engine runs them in parallel.  today:    invoke:      tool: "nika:date"      args: { op: now }  history:    exec:      command: ["git", "log", "--since=yesterday", "--oneline", "--no-merges"]  digest:    with:      today: ${{ tasks.today.output }}      history: ${{ tasks.history.output }}    infer:      max_tokens: 400          # a standup note is short · the ceiling says so      prompt: |        Date · ${{ with.today }}        Commits since yesterday ·        ${{ with.history }}        Write my standup note · 3 bullets · done / doing / blocked.        Plain words · no fluff. If there are no commits, say so in one line.  save:    with:      digest: ${{ tasks.digest.output }}    invoke:      tool: "nika:write"      args:        path: out/standup-note.md        create_dirs: true        content: "${{ with.digest }}"outputs:  note: ${{ tasks.digest.output }}

audited · 4 tasks · 3 waves · permits declarednika 0.107.0

the plan
todayinvokenika:datehistoryexecgit
digestinfer
saveinvokenika:write

Every morning: the note is already written. You glance, you tweak one word, you go.

7.3.2

Ship release notes in your own voice

T2

git log in, changelog out, team pinged. zero copy-paste on release day.

execinferinvoke
release-notes.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: release-notes  description: "git log → typed release notes → CHANGELOG insert → team ping"# A NON-thinking local model, deliberately: a thinking model can spend the# whole `max_tokens` budget in its think block and return before the JSON# (engine#428). Schema showcases pick a model that answers directly.model: ollama/llama3.2:3binputs:  announce:    type: bool    default: false    description: "Actually send the team ping (needs TEAM_WEBHOOK_URL)"const:  since_tag: "v0.80.0"  changelog_src: "examples/fixtures/CHANGELOG.md" # point this at YOUR CHANGELOG.md  changelog_out: "out/CHANGELOG.md"                        # the working copy this run editssecrets:  team_webhook:    source: env    key: TEAM_WEBHOOK_URL    egress:                       # sanction the one send · the secret IS the URL      - to: "nika:notify"        host_from_self: truepermits:  tools: ["nika:edit", "nika:notify", "nika:read", "nika:write"]  exec: ["git"]                   # the ONE program this workflow may run  fs:    # The workflow never edits your changelog in place — it copies it and    # edits the copy, so you move the result when the diff looks right.    #    # The working copy appears in BOTH lists, and that is not redundancy:    # `nika:edit` reads the file before it writes it, so a write-only grant    # is refused with NIKA-SEC-004 · outside permits.fs.read. Every in-place    # builtin owes both halves.    read: ["examples/fixtures/CHANGELOG.md", "out/CHANGELOG.md"]    write: ["out/CHANGELOG.md"]  net:    # `host_from_self:` above sanctions the FLOW (the secret may be the URL) —    # it does not grant the capability. The host stays unknown at check, so it    # is judged at RUN against this bound: name the announce host here or the    # send is refused mid-run, after the tokens are already spent.    http: ["hooks.slack.com"]tasks:  history:    exec:      command: ["git", "log", "${{ const.since_tag }}..HEAD", "--oneline", "--no-merges"]    on_error:      # No such tag here (or not a repo at all) → a sample log takes over and      # the rest of the chain is exercised exactly as it would be on the day.      recover: |        a1b2c3d feat(api): pagination cursors on every list endpoint        d4e5f6a fix(auth): refresh tokens no longer rotate on read        7g8h9i0 refactor(store): drop the legacy write path  notes:    with:      history: ${{ tasks.history.output }}    infer:      max_tokens: 1000      prompt: |        Write release notes from these commits ·        ${{ with.history }}        Tone · plain, direct, no marketing fluff.      schema:        type: object        additionalProperties: false        required: [headline, body]        properties:          headline: { type: string }          breaking: { type: array, items: { type: string } }          body: { type: string }  # Copy-then-edit · `nika:edit` is strictly in place and throws when `find:`  # matches nothing (NIKA-BUILTIN-EDIT-001), so its target must exist and must  # carry the anchor. Read yours, write the working copy, edit THAT.  existing:    invoke:      tool: "nika:read"      args: { path: "${{ const.changelog_src }}" }  copy:    with:      existing: ${{ tasks.existing.output }}    invoke:      tool: "nika:write"      args:        path: "${{ const.changelog_out }}"        content: "${{ with.existing }}"        create_dirs: true        overwrite: true  changelog:    with:      notes_headline: ${{ tasks.notes.output.headline }}      notes_body: ${{ tasks.notes.output.body }}    after:      copy: success              # state, no data · the anchor is on disk before we edit    invoke:      tool: "nika:edit"      args:        path: "${{ const.changelog_out }}"        find: "# Changelog"      # a LITERAL string · not a regex        count: 1                 # the top anchor only · never every occurrence        replace: |          # Changelog          ## ${{ const.since_tag }}..HEAD · ${{ with.notes_headline }}          ${{ with.notes_body }}  announce:    with:      notes_headline: ${{ tasks.notes.output.headline }}    when: ${{ inputs.announce == true }}   # OFF by default · a rehearsal must not ping the team    after:      changelog: success    invoke:      tool: "nika:notify"      args:        channel: webhook        target: "${{ secrets.team_webhook }}"        message: "Release notes ready · ${{ with.notes_headline }}"        severity: infooutputs:  headline: ${{ tasks.notes.output.headline }}  body: ${{ tasks.notes.output.body }}
the plan
historyexecgitexistinginvokenika:read
notesinfercopyinvokenika:write
changeloginvokenika:edit
announceinvokenika:notify

Release day: one command, a changelog in your voice, zero copy-paste.

7.3.3

Hear about dependency releases only when they matter

T2

It diffs the release feeds against last run. no ping means nothing shipped.

invokeinfer
release-radar.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: release-radar  description: "dependency release feed → diff vs last run → only the NEW ships"model: ollama/qwen3.5:4b   # local · zero key · swap for any provider in the catalogconst:  releases_feed: "https://github.com/tokio-rs/tokio/releases.atom"  state_path: "out/release-radar.json"permits:  tools: ["nika:fetch", "nika:jq", "nika:json_diff", "nika:read", "nika:write"]  fs:    # The state-file pattern touches exactly ONE path, on both sides: read    # last run's state, write the next. `const.state_path` names it and a run    # cannot move a const, so the same literal is the whole boundary —    # `create_dirs:` makes out/ underneath it on the first run.    read: ["out/release-radar.json"]    write: ["out/release-radar.json"]  net:    http: ["github.com"]   # the feed's host · net entries are exact names, never globstasks:  # First run has no state file · recover to an empty list.  no_state:    invoke:      tool: "nika:jq"      args: { input: [], expression: "." }  previous:    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: ${{ tasks.no_state.output }}  feed:    invoke:      tool: "nika:fetch"      args:        url: "${{ const.releases_feed }}"        mode: feed    output:      entries: "[.items[] | {title, url, published}]"    on_error:      # No network → this sample stands in for the RAW feed response, so it      # carries `items:` exactly as `mode: feed` returns it and the `output:`      # jq above runs over it unchanged. Recovering the BINDING's shape      # instead makes the jq throw NIKA-VAR-004 — the recovery must mirror the      # response, never the binding.      recover:        items:          - { title: "tokio 1.48.0", url: "https://github.com/tokio-rs/tokio/releases/tag/tokio-1.48.0", published: "2026-07-21T09:14:00Z" }          - { title: "tokio 1.47.1", url: "https://github.com/tokio-rs/tokio/releases/tag/tokio-1.47.1", published: "2026-07-02T16:40:00Z" }  fresh:    with:      previous: ${{ tasks.previous.output }}      entries: ${{ tasks.feed.entries }}    invoke:      tool: "nika:json_diff"      args:        before: "${{ with.previous }}"        after: "${{ with.entries }}"  digest:    with:      fresh: ${{ tasks.fresh.output }}      entries: ${{ tasks.feed.entries }}    when: ${{ size(with.fresh) > 0 }}    infer:      max_tokens: 500      prompt: |        New releases appeared on our dependency radar (RFC 6902 patch        against last run) ·        ${{ with.fresh }}        Full current feed · ${{ with.entries }}        Write 3 bullets · what shipped · whether it looks breaking ·        what to check in our code.  save_state:    with:      entries: ${{ tasks.feed.entries }}    invoke:      tool: "nika:write"      args:        path: "${{ const.state_path }}"        content: "${{ with.entries }}"        create_dirs: true        overwrite: trueoutputs:  new_entries:    value: ${{ tasks.fresh.output }}    description: "RFC 6902 ops · empty = nothing new since last run"  # Untyped on purpose: `digest` is `when:`-gated, so on a quiet week it is  # SKIPPED and this reads null. Exporting it is also what keeps those tokens  # from being dead spend — an infer whose output nothing consumes is paid for  # and thrown away, and `check` says so.  briefing: ${{ tasks.digest.output }}
the plan
no_stateinvokenika:jqpreviousinvokenika:readfeedinvokenika:fetch
freshinvokenika:json_diffsave_stateinvokenika:write
digestinfer

No ping, nothing shipped. A ping, something worth a look. You stop checking tabs.

7.3.4

Give big PRs a reviewer per file

T3

One read-only reviewer per changed file, in parallel, under budget. attention finally scales with the diff.

execinvokeagentinfer
pr-review-fanout.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: pr-review-fanout  description: "changed files → one read-only review agent each → merged REVIEW.md"model: ollama/qwen3.5:4b   # local tool-calling model · swap for anthropic/claude-sonnet-4-6 for depthconst:  base_ref: "main"  review_root: "src/"      # the swarm's territory · keep in step with permits.fs.readpermits:  tools: ["nika:done", "nika:grep", "nika:jq", "nika:read", "nika:write"]  # The argv form (`exec.command:`) runs one named program with its arguments  # and no shell between them, which is what makes a program allowlist mean  # something. A `exec.shell:` task is refused against a list — measured:  # `NIKA-SEC-004 · a shell-string command cannot be verified against a  # permits.exec program allowlist (a pipeline can launch any program) — use  # the array form`. Only `exec: true` admits a shell, and this workflow  # deliberately never needs one.  #  # An allowlisted program is not an unconfined one: `git` runs inside the  # `fs:` boundary below, which is why the header's second `Needs ·` line is  # about git's global config rather than about git.  exec: ["git"]  fs:    # `./src/**` covers the directory AND every descendant at any depth, which    # is what both consumers need: `nika:grep` opens `./src` itself, and the    # swarm reads files nested under it. A bare `./src` would cover only the    # directory entry and refuse every file inside it.    #    # This bound is why `files` below filters the diff down to `const.review_root`:    # `git diff` happily reports `Cargo.toml` or `.github/workflows/ci.yml`, and    # an agent handed one of those would die `NIKA-SEC-004` mid-swarm. The claim    # and the territory are kept equal on purpose.    read: ["./src/**"]    write: ["./REVIEW.md"]tasks:  # `--diff-filter=d` drops deletions: a file that no longer exists cannot be  # reviewed, and asking an agent to read it just burns a turn on an error.  changed:    exec:      command: ["git", "diff", "--name-only", "--diff-filter=d", "${{ const.base_ref }}...HEAD"]  files:    with:      changed: ${{ tasks.changed.output }}    invoke:      tool: "nika:jq"      args:        input: "${{ with.changed }}"        expression: >-          split("\n")          | map(select(length > 0))          | map(select(startswith("${{ const.review_root }}")))  # The deterministic pass. Debt does not need a model to be found, and this  # runs whether or not anything changed.  todo_sweep:    invoke:      tool: "nika:grep"      args:        pattern: "TODO|FIXME|HACK"        path: "./src"  reviews:    with:      files: ${{ tasks.files.output }}    for_each: ${{ with.files }}    max_parallel: 4                    # four reviewers in flight · not forty    fail_fast: false                   # one reviewer's bad day is not the swarm's    on_error:      recover: null                    # a budget-exhausted review yields null · filtered below    agent:      system: >-        You are a precise code reviewer. Read the file, then finish with ONLY        the schema'd object ({file, findings: [{severity, message, line}]} ·        severity exactly one of blocker|high|med|low), then call nika:done.      prompt: "Review ${{ item }} · bugs first, then risky patterns. Read it before judging."      tools:        - "nika:read"                  # read-only swarm · least privilege        - "nika:done"      max_turns: 6                     # read · think · answer · a little slack      max_tokens_total: 30000          # per reviewer · the swarm's ceiling is this × the file count      # `additionalProperties: false` on BOTH object nodes. Without it on the      # inner one, a provider may add keys to a finding and the sort below is      # ordering a shape that changed between runs.      schema:        type: object        additionalProperties: false        required: [file, findings]        properties:          file: { type: string }          findings:            type: array            items:              type: object              additionalProperties: false              required: [severity, message]              properties:                severity: { type: string, enum: [blocker, high, med, low] }                message: { type: string }                line: { type: integer }  # The fan-in.  #   `. // []`      · a clean diff SKIPS the swarm (an empty for_each resolves  #                    null), and the fan-in has to survive its own good news.  #   `select(. != null)` · the reviewers that recovered leave here, by value.  #   `.findings[] + {file}` · flatten [[finding]] → [finding], each carrying  #                    the file it came from.  #   the rank object · `sort_by(.severity)` would be ALPHABETICAL, which puts  #                    `low` above `med`. Severity order is a decision, so it is  #                    written down.  merged:    with:      reviews: ${{ tasks.reviews.output }}    invoke:      tool: "nika:jq"      args:        input: "${{ with.reviews }}"        expression: >-          (. // [])          | map(select(. != null))          | map(.findings[] + { file: .file })          | sort_by({ blocker: 0, high: 1, med: 2, low: 3 }[.severity])  summary:    with:      merged: ${{ tasks.merged.output }}      todo_sweep: ${{ tasks.todo_sweep.output }}    infer:      max_tokens: 2500                 # a review page · not the whole diff back      prompt: |        Merge these review findings into REVIEW.md · blockers first ·        ${{ with.merged }}        Open TODO debt found by grep ·        ${{ with.todo_sweep }}        End with a verdict · ship / fix-first / redesign.  save:    with:      summary: ${{ tasks.summary.output }}    invoke:      tool: "nika:write"      args:        path: "./REVIEW.md"        content: "${{ with.summary }}"outputs:  findings:    value: ${{ tasks.merged.output }}    description: "Every finding, blockers first, each attributed to its file"  review:    value: ${{ tasks.summary.output }}    description: "The page a human reads · also written to ./REVIEW.md"
the plan
changedexecgittodo_sweepinvokenika:grep
filesinvokenika:jq
reviewsagent
mergedinvokenika:jq
summaryinfer
saveinvokenika:write

Big PRs get deep reviews, because attention now scales with the diff.

7.3.5

Ship only when everything is green, and a human says go

T4

Tests, lint and audit run in parallel; a person signs the GO. it ships on time or not at all, and the record shows which.

invokeexec
release-train.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: release-train  description: "parallel gates → one verdict → human GO → hold until the window → ship · verify · record"run:  # Pin the clock out loud. Every `timeout:` and every absolute `until:` below  # rides it; leaving it undeclared is the honest default (the ambient system  # clock) but never a stated choice, and the checker says so ([run-clock]).  # `clock: virtual` swaps in a simulated clock for tests (F-P3).  clock: systeminputs:  version:    type: string    required: true    description: "The version you intend to ship · gate 1 checks it against ./VERSION"  depart:    type: bool    default: false    description: "Actually run the release script. OFF: the train rehearses and records that it stayed."  hold_for:    type: string    default: "15m"    description: "How far out the departure window sits, measured from the moment the departure is SIGNED"permits:  exec: ["./scripts/release.sh"]  # ONE program · the departure, and nothing else  tools: ["nika:assert", "nika:date", "nika:emit", "nika:fetch", "nika:jq", "nika:prompt", "nika:read", "nika:wait"]  net:    # ONE host, exact — the post-ship version probe and nothing else. This train    # deliberately owns no webhook: see the `record` task for why an    # always-fires task must never be the one that leaves the machine.    http: ["api.example.com"]  fs:    # Three files, read-only, named one by one. The gates below are the release    # checks a spec repo can actually run; in a Rust shop they are `cargo test`    # / `clippy` / `audit` under `exec:` with `capture: structured`, and the    # `permits.exec` list grows instead. The WAVE and the fold are the lesson —    # the identity of the three checks is yours to choose.    read:      - "./VERSION"      - "./CHANGELOG.md"      - "./schemas/workflow.schema.json"tasks:  t0:    invoke:      tool: "nika:date"      args: { op: now }  # ── the gate wave · three checks, one wave, no shared state ──  declared:    invoke:      tool: "nika:read"      args: { path: "./VERSION" }  notes:    invoke:      tool: "nika:read"      args: { path: "./CHANGELOG.md" }  schema:    invoke:      tool: "nika:read"      args: { path: "./schemas/workflow.schema.json" }  # ── fold · three answers become one board, computed, not narrated ──  board:    with:      declared: ${{ tasks.declared.output }}      notes: ${{ tasks.notes.output }}      schema: ${{ tasks.schema.output }}    invoke:      tool: "nika:jq"      args:        # The four inputs arrive as an ARRAY and are destructured by name, so        # the expression reads like the checklist it is. Building the verdict in        # jq rather than in a model keeps it deterministic and free.        input:          - "${{ with.declared }}"          - "${{ with.notes }}"          - "${{ with.schema }}"          - "${{ inputs.version }}"        expression: |          . as [$declared, $notes, $schema, $asked]          | { version_matches: (($declared | rtrimstr("\n")) == $asked),              notes_present:   ($notes  | test("\\[" + $asked + "\\]")),              schema_parses:   (try ($schema | fromjson | has("$id")) catch false) }          | . + { all_green: (.version_matches and .notes_present and .schema_parses) }  gates_green:    with:      board: ${{ tasks.board.output }}    invoke:      tool: "nika:assert"      args:        # A RED board stops the train here: everything downstream is cancelled,        # and `record` below still lands the fact that it never left.        condition: "${{ with.board.all_green }}"        message: "A release gate is RED: the train does not depart · ${{ with.board }}"  t1:    after:      gates_green: success           # state, no data · the clock stops on a green board    invoke:      tool: "nika:date"      args: { op: now }  gate_time:    with:      t0: ${{ tasks.t0.output }}      t1: ${{ tasks.t1.output }}    invoke:      tool: "nika:date"      args:        # `start:`/`end:` want real ISO timestamps — the literal "now" is not a        # value here and fails NIKA-BUILTIN-DATE-001. Take the reading in a task,        # then pass it as data; that is also what makes the run replayable.        op: diff        start: "${{ with.t0 }}"        end: "${{ with.t1 }}"        unit: minutes  # ── the human signs the departure ──  conductor:    with:      gate_time: ${{ tasks.gate_time.output }}      board: ${{ tasks.board.output }}    invoke:      # Blocking · no `default:`. The alternative shape is a `default: false`      # prompt (see templates/human-gated-ship): fail-closed, never pauses, and      # a yes can then only come from a TTY. This file blocks instead so the      # decision can also be handed over — `--resume <trace> --answer      # conductor=true` — which is how a release actually gets signed when the      # person who ran it is not the person who approves it. Either way the      # checker leaves a [headless-prompt] hint: that is the price of a gate.      tool: "nika:prompt"      args:        message: |          Board · ${{ with.board }}          Green in ${{ with.gate_time }} min.          Ship ${{ inputs.version }} · departure window opens in ${{ inputs.hold_for }}?          (depart armed: ${{ inputs.depart }})  # ── hold until the window · an absolute instant, computed AFTER the signature ──  #  # The window opens `hold_for` from the moment the departure was SIGNED, which  # is why this reading is taken here and not reused from `t1`. Measured, and  # the reason this task exists: the run pauses at the prompt, so `t1` is  # minutes or hours old by the time a human answers — resuming with `t1+ 15m`  # produced `NIKA-BUILTIN-WAIT-002 · until: … is in the past` on the very  # first resume. An absolute instant must be computed from a reading taken  # after the last thing that can block. (Resume it a SECOND time and this  # reading is itself a cache hit — `--from signed_at` re-takes it.)  signed_at:    with:      signed: ${{ tasks.conductor.output }}    when: ${{ with.signed == true }}    invoke:      tool: "nika:date"      args: { op: now }  window:    with:      at: ${{ tasks.signed_at.output }}    when: ${{ with.at != null }}    invoke:      tool: "nika:date"      args:        op: add        base: "${{ with.at }}"        duration: "${{ inputs.hold_for }}"  hold:    with:      window: ${{ tasks.window.output }}    when: ${{ with.window != null }}    invoke:      # `until:` is an INSTANT, not a nap: restart the run and it still targets      # the same moment, where a `duration:` would start counting again. The      # instant is computed from `t1` above precisely so this file cannot rot —      # a literal `until: "2026-07-28T09:00:00Z"` is correct for one day and      # then fails NIKA-BUILTIN-WAIT-002 · the timestamp is in the past.      tool: "nika:wait"      args:        until: "${{ with.window }}"        timeout: "48h"             # `timeout:` caps an absolute wait · until-only  # ── the irreversible step · armed, and only after the hold ──  ship:    after:      hold: success    with:      depart: ${{ inputs.depart }}    when: ${{ with.depart == true }}   # dry-run by default · a rehearsal ships nothing    exec:      # argv, never a shell string: `${{ inputs.version }}` is substituted as ONE      # argument and cannot break out into a second command.      #      # Default capture (stdout) on purpose. `capture: structured` would turn a      # non-zero exit into DATA and this task would report success even when the      # release script died — which is exactly what `after: {ship: success}`      # below must not be lied to about. The checker flags that shape when      # nothing reads `exit_code` ([swallowed-exit]); here the honest answer is      # not to read the code but to let a failed release FAIL, NIKA-EXEC-001.      command: ["./scripts/release.sh", "${{ inputs.version }}"]    timeout: "30m"  verify:    after:      ship: success                  # never probe prod for a release that did not go out    invoke:      tool: "nika:fetch"      args:        url: "https://api.example.com/v1/version"        mode: jq        jq: ".version"    retry:      # A GET is idempotent, so retrying it replays nothing — the checker warns      # [retry-effects] when you retry something that is not.      max_attempts: 5      backoff_strategy: exponential      backoff_ms: 5000  live:    with:      version_live: ${{ tasks.verify.output == inputs.version }}   # the whole check crosses as ONE boundary expression    invoke:      tool: "nika:assert"      args:        condition: "${{ with.version_live }}"        message: "Prod does not report the shipped version: investigate before announcing"  # ── the departure record · lands on EVERY outcome ──  #  # `after: {live: terminal}` admits success · failure · skipped · cancelled, so  # this runs when the train departed, when a gate was red, and when nobody  # armed it. `nika:emit` is the right verb for a record that must never fail:  # it crosses no boundary, needs no host, and cannot be refused mid-flight.  #  # It is also the only verb that can safely sit here, and that is a rule worth  # keeping: AN ALWAYS-FIRES TASK MUST NOT BE THE ONE THAT LEAVES THE MACHINE.  # A `terminal` edge admits `cancelled`, so the task is reachable along a path  # where the human never answered — no gate can dominate it, and the moment  # such a task can egress, NIKA-SEC-009 fires (measured: hanging a  # `nika:notify` here turns this file RED, and moving the prompt to the root  # does not save it). If you want the outcome on a webhook, put the send under  # the signature — `t4-ceo-monday-brief` shows that shape — and leave the  # unconditional record to `emit`.  record:    after:      live: terminal    with:      outcome: ${{ tasks.live.status }}   # observe the verdict · same pass-set as the after edge    invoke:      tool: "nika:emit"      args:        event_type: "release.train.departed"        payload:          version: "${{ inputs.version }}"          departed: "${{ inputs.depart }}"          # The engine's word, not a paraphrase: `cancelled` here means the          # verification never got to run, which is the truth on a rehearsal and          # on a red board alike. `departed:` above is what disambiguates them.          status: "${{ with.outcome }}"outputs:  board:    value: ${{ tasks.board.output }}    description: "The gate board · one boolean per check plus the verdict that stopped or released the train"  shipped: ${{ tasks.verify.output }}
the plan
t0invokenika:datedeclaredinvokenika:readnotesinvokenika:readschemainvokenika:read
boardinvokenika:jq
gates_greeninvokenika:assert
t1invokenika:date
gate_timeinvokenika:date
conductorinvokenika:prompt
signed_atinvokenika:date
windowinvokenika:date
holdinvokenika:wait
shipexec./scripts/release.sh
verifyinvokenika:fetch
liveinvokenika:assert
recordinvokenika:emit

No green gates, no departure. No human GO, no departure. It ships on time or not at all, and the journal records which.

7.3.6

Get paged only for changes nobody approved

T3

It diffs live prod against the signed-off baseline. silence means prod matches exactly.

invokeinfer
config-drift-sentinel.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: config-drift-sentinel  description: "live config vs sanctioned baseline → typed drift → triaged, explained alert"model: ollama/qwen3.5:4b   # local · zero key · swap for anthropic/claude-haiku-4-5 (explain is cheap)inputs:  config_url:    type: string    default: "https://api.internal.example.com/v1/config"    description: "The live-config endpoint · the placeholder resolves nowhere, which is what makes the default run a rehearsal"const:  # The committed rehearsal baseline. Point this at your real intended state  # and change `permits.fs.read` in the same edit — a permit is a literal you  # can read, it cannot interpolate a const (`NIKA-AUTH-007`).  baseline_path: "./examples/fixtures/config-baseline.json"  # Sanctioned drift · an RFC 7396 merge patch applied to the baseline before  # the diff. `{}` means « nothing is pre-approved ».  #  # This is a BARE LITERAL, deliberately. A typed constant is `{ type, value }`  # and nothing else: an object missing either key is read as a literal object  # (`01-envelope.md:273`). Writing `{ type: object, default: {} }` here would  # declare nothing — `${{ const.approved_overrides }}` would resolve to that  # whole three-key map. Measured, with `{ replicas: 6 }` as the baseline:  #   {"default":{},"description":"…","replicas":6,"type":"object"}  # No error, no warning. The sentinel would just spend every morning diffing  # prod against a config nobody ever intended.  approved_overrides: {}  # An RFC 6902 patch path starting with one of these can take the service  # down. Everything else is recorded and read in the morning. The list lives  # here, in the file, instead of inside a prompt: paging policy is reviewable.  paging_prefixes: ["/replicas", "/feature_flags", "/upstreams", "/limits"]secrets:  oncall_webhook:    source: env    key: ONCALL_WEBHOOK_URL    egress:                       # sanction the one send · the secret IS the URL      - to: "nika:notify"        host_from_self: truepermits:  tools: ["nika:emit", "nika:fetch", "nika:hash", "nika:jq", "nika:json_diff", "nika:json_merge_patch", "nika:notify", "nika:read"]  net:    # TWO hosts, two different jobs.    #    # `host_from_self:` above sanctions the FLOW (this secret may be the URL)    # — it does not grant the capability. The webhook host stays unknown at    # check time, so it is judged at RUN against this list: name the    # escalation host here or the page is refused mid-run, after the drift    # has already been found. Measured, all three cases · with the host    # absent, or with some other host named, `check` is green and the run    # dies `NIKA-SEC-004 · <host> resolves outside the declared net.http    # boundary`; with it named, the permit clears and the send goes out.    http:      - "api.internal.example.com"   # the config endpoint this sentinel polls      - "hooks.slack.com"            # where the page goes · swap for your own  fs:    # Read-only by design: a drift sentinel OBSERVES prod, it never edits it.    # There is no `write:` key at all, so every path on this machine is denied    # for writes — the baseline is the one file this workflow may open.    read: ["./examples/fixtures/config-baseline.json"]tasks:  # ── what prod says it is · as BYTES ────────────────────────────────  # `mode: raw` keeps the response as the string the server sent, which is  # what `fingerprint` hashes below. The recovery value is that same string:  # a real HTTP body, one benign step off the baseline (log_level info →  # debug), so the triage downstream has something honest to triage. Point  # `--var config_url=` at a live endpoint and this branch never runs.  live_raw:    on_error:      recover: '{"service":"checkout-api","replicas":6,"log_level":"debug","feature_flags":{"beta_ui":false,"instant_refunds":true,"queue_v2":false},"limits":{"request_timeout_ms":2500,"max_body_bytes":1048576},"upstreams":{"ledger":"https://ledger.internal.example.com","risk":"https://risk.internal.example.com"}}'    retry:      max_attempts: 3      backoff_strategy: exponential    invoke:      tool: "nika:fetch"      args:        url: "${{ inputs.config_url }}"        mode: raw  # ── what prod is supposed to be · also bytes ───────────────────────  baseline_raw:    invoke:      tool: "nika:read"              # returns a STRING · a file is text until you parse it      args: { path: "${{ const.baseline_path }}" }  # Provenance over the exact response bytes, taken BEFORE the parse: two  # different JSON texts that mean the same thing hash differently, and that  # is the point — this attests the artifact, not the interpretation.  # `nika:hash` requires `content:` to be a string; handing it the parsed  # object fails `NIKA-BUILTIN-HASH-001` at run while `check` stays green.  fingerprint:    with:      raw: ${{ tasks.live_raw.output }}    invoke:      tool: "nika:hash"      args:        algo: blake3        content: "${{ with.raw }}"        encoding: hex  # ── text becomes data, in one place ────────────────────────────────  # Both sides are parsed together so the « where did this stop being a  # string » question has exactly one answer in this file. `json_merge_patch`  # and `json_diff` take OBJECTS: feeding either one the raw string fails  # `NIKA-BUILTIN-JSON_MERGE_PATCH-001` at run, and `check` cannot see it  # coming — the type only exists once the tool has run.  parsed:    with:      live: ${{ tasks.live_raw.output }}      baseline: ${{ tasks.baseline_raw.output }}    invoke:      tool: "nika:jq"      args:        input: { live: "${{ with.live }}", baseline: "${{ with.baseline }}" }        expression: "{ live: (.live | fromjson), baseline: (.baseline | fromjson) }"  expected:    with:      baseline: ${{ tasks.parsed.output.baseline }}    invoke:      tool: "nika:json_merge_patch"     # RFC 7396 · objects merge, null deletes      args:        target: "${{ with.baseline }}"        patch: "${{ const.approved_overrides }}"  # ── the difference, as data ────────────────────────────────────────  drift:    with:      expected: ${{ tasks.expected.output }}      live: ${{ tasks.parsed.output.live }}    invoke:      tool: "nika:json_diff"            # RFC 6902 · [{op, path, value}, …]      args:        before: "${{ with.expected }}"        after: "${{ with.live }}"  # ── triage · which of those operations is worth a phone call ───────  # `any($p[]; …)` is true when the operation's path starts with any prefix.  # A log_level change survives the diff and dies right here, which is the  # difference between a sentinel people keep and one they mute.  paging:    with:      patch: ${{ tasks.drift.output }}    invoke:      tool: "nika:jq"      args:        input: { patch: "${{ with.patch }}", prefixes: "${{ const.paging_prefixes }}" }        expression: >-          .prefixes as $p          | .patch          | map(select(.path as $path | any($p[]; . as $pre | $path | startswith($pre))))  explain:    with:      patch: ${{ tasks.drift.output }}    when: ${{ size(with.patch) > 0 }}   # a clean scan spends nothing    on_error:      recover: "(explanation unavailable · the model call failed · the raw patch is attached)"    infer:      max_tokens: 600                   # three bullets · a ceiling, not a hope      prompt: |        This RFC 6902 patch is UNSANCTIONED config drift in production ·        ${{ with.patch }}        Explain in 3 bullets · what changed · likely blast radius · first check.  # ── the page carries the FACT · never the payload ──────────────────  #  # All three trifecta legs are structural here: the baseline is a private  # read, the live config is untrusted ingress, and the webhook is external  # egress. Interpolating the diff — or the explanation derived from it —  # into this message would be a realized flow: someone who can move prod  # config could shape the diff until it echoes baseline values out through  # the webhook. So the page says THAT drift happened and where to look; the  # drift itself goes to the journal below, which never leaves the machine.  # `when:` may still read the patch — a gate decides, it does not transmit.  alert:    with:      paging: ${{ tasks.paging.output }}    when: ${{ size(with.paging) > 0 }}    invoke:      tool: "nika:notify"      args:        channel: webhook        target: "${{ secrets.oncall_webhook }}"        message: "Unsanctioned config drift on a paging path · details in the config.drift.scan journal event"        severity: critical  # `nika:emit` is a LOCAL event · it needs no `net:` grant because nothing  # leaves the machine. The full patch, the provenance fingerprint and the  # explanation land here, where whoever answers the page reads them. On a  # clean scan `explain` is gated off and resolves null — the scan is still  # recorded, which is how you prove the sentinel ran at all.  record:    with:      patch: ${{ tasks.drift.output }}      paging: ${{ tasks.paging.output }}      live_hash: ${{ tasks.fingerprint.output }}      explanation: ${{ tasks.explain.output }}    invoke:      tool: "nika:emit"      args:        event_type: "config.drift.scan"        payload:          patch: ${{ with.patch }}          paging: ${{ with.paging }}          live_hash: ${{ with.live_hash }}          explanation: ${{ with.explanation }}outputs:  drift:    value: ${{ tasks.drift.output }}    description: "RFC 6902 operations · empty when prod matches the sanctioned state"  paging:    value: ${{ tasks.paging.output }}    description: "The subset of those operations that is worth a phone call"  live_hash:    value: ${{ tasks.fingerprint.output }}    description: "blake3 of the exact response bytes · the provenance receipt"
the plan
live_rawinvokenika:fetchbaseline_rawinvokenika:read
fingerprintinvokenika:hashparsedinvokenika:jq
expectedinvokenika:json_merge_patch
driftinvokenika:json_diff
paginginvokenika:jqexplaininfer
alertinvokenika:notifyrecordinvokenika:emit

Silence means prod matches exactly what was signed off. That is the whole alert policy.

7.3.7

Have the postmortem drafted before the retro

T4

Logs, status history and the runbook gathered in parallel. a typed timeline, verified before any draft.

invokeexecinfer
incident-war-room.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: incident-war-room  description: "parallel evidence → typed timeline → settle + recheck → refuse or draft → record"model: ollama/qwen3.5:4b   # local · zero key · `--model mock/echo` rehearses the whole fileinputs:  alert:    type: bool    default: false    description: "Ping on-call when the run settles. OFF by default so a rehearsal touches no webhook."  settle:    type: string    default: "30s"    description: "How long to let the fix propagate before the confirming re-poll"  status_url:    type: string    default: "https://status.internal.example.com/v1/services/checkout-api"    description: "The service status endpoint · polled twice"const:  service: "checkout-api"  log_window: "90 minutes ago"  runbook: "./examples/fixtures/checkout-api-runbook.md"secrets:  oncall_webhook:    source: env    key: ONCALL_WEBHOOK_URL    egress:                       # sanction the one send · the secret IS the URL      - to: "nika:notify"        host_from_self: truepermits:  exec: ["git"]  tools: ["nika:assert", "nika:fetch", "nika:notify", "nika:prompt", "nika:read", "nika:wait", "nika:write"]  net:    http:      # The status host is judged here even though the URL arrives as an input:      # an input moves the ARGUMENT, never the wall. Point --var status_url at      # another host and the poll is refused NIKA-SEC-004 until you name it here.      - "status.internal.example.com"      # `host_from_self:` above sanctions the FLOW (the secret may be the URL) —      # it does not grant the capability. The host stays unknown at check, so it      # is judged at RUN against this bound: name the escalation host here or the      # send is refused mid-run, after the tokens are already spent.      - "hooks.slack.com"         # swap for wherever ONCALL_WEBHOOK_URL points  fs:    # The write path is built from `const.service`, so it is pinned before the    # run starts — but `check` reads straight past a const embedded in a longer    # string, so it is not declared for you. Written out literally, because a    # permit bound IS the wall and cannot interpolate (NIKA-AUTH-007).    read: ["./examples/fixtures/checkout-api-runbook.md"]    write: ["./incidents/checkout-api-postmortem.md"]tasks:  # ── the human gate · FIRST, before any evidence is gathered ──  #  # All three trifecta legs are live here: a private read (the runbook),  # untrusted ingress (the status endpoint), and external egress (git, the  # status host, the write, the webhook). NEP-0002 requires a blocking human  # gate that DOMINATES every path to every egress-capable task, so it belongs  # at the root — a gate placed later cannot dominate, because the gathered  # evidence reaches the postmortem along its own data edge.  #  # On-call is already at the keyboard during an incident, so one confirmation  # up front costs nothing and authorizes the whole envelope at once.  approve:    invoke:      # blocking · no `default:` · a gate with a default is not a gate, and the      # checker agrees in both directions: add `default:` here and NIKA-SEC-009      # fires instead, because a defaulted prompt no longer dominates anything.      # The [headless-prompt] hint is the price of a real gate — pay it here.      # Pauses the run (exit 4) — resume with      #   nika run <file> --resume <trace> --answer approve=true      tool: "nika:prompt"      args:        message: |          Incident war-room for ${{ const.service }} will:            · read git history for the last ${{ const.log_window }}            · poll ${{ inputs.status_url }} (twice, ${{ inputs.settle }} apart)            · read ${{ const.runbook }}            · write ./incidents/${{ const.service }}-postmortem.md            · ping the on-call webhook (armed: ${{ inputs.alert }})          Proceed? [yes/no]  # ── the gather wave · all three run in parallel, one per verb ──  # « What shipped just before it broke? » is the first question of every  # incident, and the runbook says so out loud.  deploys:    with:      go: ${{ tasks.approve.output }}    when: ${{ with.go == true }}    exec:      command: ["git", "--no-pager", "log", "--oneline", "--since=${{ const.log_window }}"]    on_error:      # Missing evidence is EVIDENCE. The stand-in is labelled so the timeline      # below is told the deploy log was unreadable and cannot quietly invent a      # release that never happened.      recover: "(deploy log unavailable · git could not be read here)"  status_history:    with:      go: ${{ tasks.approve.output }}    when: ${{ with.go == true }}    invoke:      tool: "nika:fetch"      args:        url: "${{ inputs.status_url }}"        mode: jq        jq: ".history"    retry:      # A status page mid-incident is exactly the endpoint that flaps. Retry is      # safe here because a GET is idempotent — the checker warns [retry-effects]      # when you retry something that is not.      max_attempts: 3      backoff_strategy: exponential      jitter: true    on_error:      recover: []                 # offline · no history to reconstruct from  runbook:    with:      go: ${{ tasks.approve.output }}    when: ${{ with.go == true }}    invoke:      tool: "nika:read"      args: { path: "${{ const.runbook }}" }  # ── reconstruct · a TYPED timeline, not a paragraph ──  timeline:    with:                               # three value edges · the wave joins here      deploys: ${{ tasks.deploys.output }}      history: ${{ tasks.status_history.output }}      runbook: ${{ tasks.runbook.output }}    when: ${{ with.runbook != null }}   # « no » at the gate → nothing to reconstruct    infer:      max_tokens: 1500      prompt: |        Deploys in the window ·        ${{ with.deploys }}        Status history · ${{ with.history }}        Runbook · ${{ with.runbook }}        Reconstruct the incident timeline as events. Every event cites the        evidence it came from; if a source says it is unavailable, say the        timeline is incomplete rather than filling the gap.      schema:        # `additionalProperties: false` on EVERY object node — without it the        # shape is a suggestion and each provider returns a slightly different        # object. A typed timeline is the whole point of this task.        type: object        additionalProperties: false        required: [events]        properties:          events:            type: array            items:              type: object              additionalProperties: false              required: [at, what, evidence]              properties:                at: { type: string }                what: { type: string }                evidence: { type: string }  # ── settle, then confirm recovery before claiming it ──  settle:    after:      timeline: success              # state, no data · wait only once there is something to confirm    invoke:      tool: "nika:wait"      args: { duration: "${{ inputs.settle }}" }  recheck:    after:      settle: success    with:      go: ${{ tasks.approve.output }}   # the second network touch re-reads the decision too    when: ${{ with.go == true }}    invoke:      tool: "nika:fetch"      args:        url: "${{ inputs.status_url }}"        mode: jq        jq: ".current.state"    on_error:      # The rehearsal's stand-in says the service came back, so the green path      # is the one you see offline. Point --var status_url at a real endpoint      # and anything other than « operational » blocks the draft below — that      # refusal is the designed path, not a bug in the file.      recover: "operational"  confirmed:    with:      recovered: ${{ tasks.recheck.output == 'operational' }}   # the whole check crosses as ONE boundary expression    invoke:      tool: "nika:assert"      args:        condition: "${{ with.recovered }}"        message: "Service is NOT back to operational: postmortem draft blocked"  # ── the draft · only after recovery is proven ──  postmortem:    with:      events: ${{ tasks.timeline.output }}    after:      confirmed: success             # state, no data · recovery proven or no draft    infer:      max_tokens: 2000      prompt: |        Timeline · ${{ with.events }}        Write the postmortem draft · summary · impact · root-cause        hypotheses · 3 follow-up actions with owners left blank.      thinking:        # A reasoning budget, honoured by seats that support extended thinking        # and ignored by the ones that do not — declaring it never fails a run.        enabled: true        budget_tokens: 4000  save:    with:      postmortem: ${{ tasks.postmortem.output }}    invoke:      tool: "nika:write"      args:        path: "./incidents/${{ const.service }}-postmortem.md"        content: "${{ with.postmortem }}"        create_dirs: true  # ── the record · lands on EVERY outcome, including the refused one ──  #  # `after: {save: terminal}` admits all four settled states, so this task runs  # when the draft lands AND when `confirmed` refused it and `save` was  # cancelled — which is precisely the outcome on-call needs to hear about.  # It is a TASK, not an `on_finally:` hook: cleanup errors are LOGGED and DO  # NOT PROPAGATE (03 §on_finally · best-effort lane), so a webhook that dies  # in a hook dies in silence — measured, the run stays green with nothing  # sent. An alert you cannot trust to fail loudly is not an alert.  alert:    after:      save: terminal    with:      armed: ${{ inputs.alert }}      outcome: ${{ tasks.save.status }}   # observe WHAT happened · same pass-set as the after edge    when: ${{ with.armed == true }}       # OFF by default · a rehearsal touches no webhook    invoke:      tool: "nika:notify"      args:        channel: webhook        target: "${{ secrets.oncall_webhook }}"        message: "${{ const.service }} war-room settled · postmortem draft ${{ with.outcome }}"        severity: warningoutputs:  timeline:    value: ${{ tasks.timeline.output }}    description: "The reconstructed, typed incident timeline"  postmortem: ${{ tasks.postmortem.output }}
the plan
approveinvokenika:prompt
deploysexecgitstatus_historyinvokenika:fetchrunbookinvokenika:read
timelineinfer
settleinvokenika:wait
recheckinvokenika:fetch
confirmedinvokenika:assert
postmorteminfer
saveinvokenika:write
alertinvokenika:notify

The postmortem draft is ready before the retro is scheduled.

04 · for creators3 workflows

Creators

One voice, many surfaces. The publishing chores run themselves; the voice stays yours.

7.4.1

Say it once, publish it everywhere

T1

One post becomes a thread, a LinkedIn version and a newsletter blurb. rewritten in parallel, same voice on every channel.

invokeinfer
social-repurpose.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: social-repurpose  description: "One post → thread + LinkedIn + newsletter, in parallel"model: ollama/qwen3.5:4b   # local · zero key · swap for mistral/mistral-large or any providerconst:  # The sample post ships with the repo. Point this at your own and move the  # read permit with it — `permits:` cannot interpolate (NIKA-AUTH-007).  post_path: "examples/fixtures/launch-post.md"permits:  tools: ["nika:read", "nika:write"]  # One file in, one file out · each named exactly, no subtree granted.  fs:    read: ["examples/fixtures/launch-post.md"]    write: ["out/social-bundle.md"]tasks:  post:    invoke:      tool: "nika:read"      args: { path: "${{ const.post_path }}" }  # Three rewrites · no deps between them · they run concurrently.  thread:    with:      post: ${{ tasks.post.output }}    infer:      max_tokens: 700      prompt: "Turn this post into a 6-tweet thread · keep the voice · ${{ with.post }}"  linkedin:    with:      post: ${{ tasks.post.output }}    infer:      max_tokens: 500      prompt: "Rewrite this post for LinkedIn · hook first · ${{ with.post }}"  newsletter:    with:      post: ${{ tasks.post.output }}    infer:      max_tokens: 200      prompt: "Write a 3-sentence newsletter blurb for this post · ${{ with.post }}"  bundle:    with:      t: ${{ tasks.thread.output }}      l: ${{ tasks.linkedin.output }}      n: ${{ tasks.newsletter.output }}    invoke:      tool: "nika:write"      args:        path: out/social-bundle.md        create_dirs: true        # Prose artifact → a `content: |` block with interpolations is right        # here. A `.json` artifact is the opposite case: one `${{ … }}` value,        # never hand-typed braces (that emits unquoted fields).        content: |          # Social bundle          ## Thread          ${{ with.t }}          ## LinkedIn          ${{ with.l }}          ## Newsletter          ${{ with.n }}outputs:  bundle_path: ${{ tasks.bundle.output }}

audited · 5 tasks · 3 waves · permits declarednika 0.107.0

the plan
postinvokenika:read
threadinferlinkedininfernewsletterinfer
bundleinvokenika:write

Write once, publish everywhere, same voice on every channel.

7.4.2

Turn a rival’s best page into your content brief

T2

It reads what actually ranks. your writer starts from the gaps, not a blank page.

invokeinfer
seo-content-brief.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: seo-content-brief  description: "Competitor sitemap → top page → gap analysis → typed brief"# A NON-thinking local model, deliberately: a thinking model can spend the# whole `max_tokens` budget in its think block and return before the JSON# (engine#428). Schema showcases pick a model that answers directly.model: ollama/llama3.2:3binputs:  topic:    type: string    # Slug-shaped on purpose: this value lands in a FILENAME below, and a    # caller-supplied string that reaches a path is the tainted-value case the    # checker polices (NIKA-AUTH-008). Keep it to one path segment — `*` in    # the write bound never crosses `/`, so `a/b` is refused, by design.    default: "rust-async-runtimes"    description: "The keyword/topic you want to rank for (one path segment)"const:  competitor_sitemap: "https://competitor.example.com/sitemap.xml"permits:  tools: ["nika:fetch", "nika:write"]  fs:    # One brief per topic, written DIRECTLY in out/briefs/. `*` matches a    # single path segment and never crosses `/`, so a topic carrying a slash    # is refused instead of steering the write into a subtree — which is    # exactly why this is `out/briefs/*.json` and not `out/briefs/**`.    write: ["out/briefs/*.json"]  net:    # Both fetches stay on the competitor's own host: the sitemap, then a URL    # that same sitemap listed. Net entries are exact host names, never globs —    # add YOUR competitor's host here when you point the const at it, or the    # fetch is refused at run and the recover path quietly takes over.    http: ["competitor.example.com"]tasks:  map:    invoke:      tool: "nika:fetch"      args:        url: "${{ const.competitor_sitemap }}"        mode: sitemap    output:      top: ".[:5] | map(.loc)"    on_error:      # The recovery stands in for the RAW response, so it has the shape a      # sitemap fetch returns — a root array of {loc, …} — and the `output:`      # jq above slices it exactly as it would slice the live one. Recover the      # BINDING's shape instead and the jq runs against the wrong thing and      # throws NIKA-VAR-004.      recover:        - { loc: "https://competitor.example.com/blog/async-runtimes-compared" }        - { loc: "https://competitor.example.com/blog/tokio-vs-smol" }        - { loc: "https://competitor.example.com/blog/choosing-an-executor" }  top_page:    with:      map_top: ${{ tasks.map.top[0] }}    invoke:      tool: "nika:fetch"      args:        url: "${{ with.map_top }}"        mode: article    on_error:      recover: |        Async runtimes compared — we benchmarked four executors on the same        workload. Tokio wins on ecosystem breadth; smol wins on binary size.        We did not cover structured concurrency, cancellation safety, or how        any of this behaves under a blocking call, which is where most teams        actually get hurt.  brief:    with:      map_top: ${{ tasks.map.top }}      top_page: ${{ tasks.top_page.output }}    infer:      max_tokens: 1200      prompt: |        Topic to rank for · ${{ inputs.topic }}        Competitor's top URLs · ${{ with.map_top }}        Their best page on it ·        ${{ with.top_page }}        Write a content brief that BEATS this page · find the gaps they        missed · angle for search intent.      schema:        type: object        additionalProperties: false        required: [title, angle, outline, keywords]        properties:          title: { type: string }          angle: { type: string }          outline: { type: array, items: { type: string } }          keywords: { type: array, items: { type: string } }  save:    with:      brief: ${{ tasks.brief.output }}    invoke:      tool: "nika:write"      args:        # `.json` content is ONE interpolated value — the engine serializes it.        path: "out/briefs/${{ inputs.topic }}.json"        content: "${{ with.brief }}"        create_dirs: trueoutputs:  brief:    value: ${{ tasks.brief.output }}    description: "The typed content brief"
the plan
mapinvokenika:fetch
top_pageinvokenika:fetch
briefinfer
saveinvokenika:write

Your writer starts from gaps and search intent, grounded in pages that actually rank.

7.4.3

Get the docs in French by lunch

T3

Every file found, translated in parallel, filed back in place. same folder layout, same voice.

invokeinfer
localization-factory.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: localization-factory  description: "glob docs → parallel read → parallel translate → mirror tree"model: ollama/qwen3.5:4b   # local default · swap for mistral/mistral-large (EU model for EU locales)const:  lang: "fr"  # The committed rehearsal tree. Point this at ./docs (or wherever yours  # lives) and change `permits.fs.read` to match, in the same edit.  source_root: "./examples/fixtures/docs"permits:  tools: ["nika:glob", "nika:jq", "nika:read", "nika:write"]  fs:    # `nika:glob` opens the ROOT of its pattern, so the bound has to cover    # the directory AND everything under it — `**` does both (it matches the    # dir itself and any descendant at any depth). A bound naming only the    # `.md` files refuses the glob before it lists anything:    # `NIKA-SEC-004 · ./examples/…/docs resolves outside permits.fs.read`.    read: ["./examples/fixtures/docs/**"]    # The mirror is as deep as the source tree, so this one earns its `**`:    # every locale lands under ./i18n/ and nothing lands outside it. Naming    # the locale (`./i18n/fr/**`) would be tighter but would have to be    # re-edited for every value of `const.lang` — the bound is the tree the    # factory owns.    write: ["./i18n/**"]tasks:  # ── the collection is discovered, never declared ────────────────────  files:    invoke:      tool: "nika:glob"      args:        pattern: "${{ const.source_root }}/**/*.md"        exclude: ["**/node_modules/**"]  # ── stage 1 · read every file, 8 at a time ─────────────────────────  texts:    with:      files: ${{ tasks.files.output }}    for_each: ${{ with.files }}    max_parallel: 8                    # local disk · the only cost is file handles    invoke:      tool: "nika:read"      args: { path: "${{ item }}" }  # ── the zip · path + text, and the source root comes off ───────────  # `texts` is an array in the SAME ORDER as `files`, so transpose pairs  # them index-for-index. `ltrimstr` makes each path relative to the source  # root — that stripped path is what the mirror tree is built from.  pairs:    with:      files: ${{ tasks.files.output }}      texts: ${{ tasks.texts.output }}    invoke:      tool: "nika:jq"      args:        input: ["${{ with.files }}", "${{ with.texts }}"]        expression: >-          transpose          | map({ path: (.[0] | ltrimstr("${{ const.source_root }}/")), text: .[1] })  # ── stage 2 · translate every file, 3 at a time ────────────────────  translated:    with:      pairs: ${{ tasks.pairs.output }}    for_each: ${{ with.pairs }}    max_parallel: 3                    # rate-limit the provider, not the disk    fail_fast: false                   # finish the batch · one bad file is not a batch failure    on_error:      recover: null                    # null holds the index open so the zip below stays aligned    infer:      max_tokens: 4000                 # a doc page, not a book · the cost ceiling is a real number      prompt: |        Translate to ${{ const.lang }} · keep the markdown structure, leave        code blocks untouched, and keep the original tone ·        ${{ item.text }}  # ── the fan-in · drop the files that failed, keep their paths ──────  # Second transpose, same law: `translated` is index-aligned with `pairs`  # because `recover: null` filled the failures in place. `select(.[1] !=  # null)` is where a failed translation leaves the batch — by VALUE, after  # the fact, not by aborting the fan-out.  bundle:    with:      pairs: ${{ tasks.pairs.output }}      translated: ${{ tasks.translated.output }}    invoke:      tool: "nika:jq"      args:        input: ["${{ with.pairs }}", "${{ with.translated }}"]        expression: >-          transpose          | map(select(.[1] != null))          | map({ path: .[0].path, text: .[1] })  # ── stage 3 · write the mirror ─────────────────────────────────────  mirror:    with:      bundle: ${{ tasks.bundle.output }}    for_each: ${{ with.bundle }}    max_parallel: 8    invoke:      tool: "nika:write"      args:        path: "./i18n/${{ const.lang }}/${{ item.path }}"        content: "${{ item.text }}"        create_dirs: true              # the mirror's subdirectories do not exist yetoutputs:  translated:    value: ${{ tasks.bundle.output }}    description: "Every file that made it through, as {path, text} · the mirror's manifest"
the plan
filesinvokenika:glob
textsinvokenika:read
pairsinvokenika:jq
translatedinfer
bundleinvokenika:jq
mirrorinvokenika:write

“Can we have the docs in French?” Yes, by lunch, with the original paths preserved.

05 · for researchers & students3 workflows

Researchers & students

Reading, digging and cleaning at machine pace, with a record you can check afterwards.

7.5.1

Turn “get me up to speed” into a brief you can audit

T4

A fast model plans, an agent digs inside hard budgets, a careful model writes. every step leaves a record.

invokeinferagent
deep-research-brief.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: deep-research-brief  description: "plan → budgeted research agent → thinking synthesis → brief on disk"model: ollama/qwen3.5:4b   # local · zero key · `--model mock/echo` rehearses the whole fileinputs:  topic:    type: string    default: "local-first AI runtimes"    description: "What to research · free text (--var topic=…), and it never touches a path directly"permits:  tools: ["nika:done", "nika:fetch", "nika:jq", "nika:write"]  net:    # There is no `*` here and there never will be: `net.http` entries are exact    # host names. An agent told to « research the web » therefore cannot — the    # corpus it may reach is a decision taken HERE, before the loop starts,    # rather than discovered afterwards in a trace. Add the sources you actually    # trust; the agent's fetch is judged against this list at RUN, and `check`    # cannot see it coming because the agent chooses its own URLs.    http:      - "arxiv.org"      - "export.arxiv.org"      - "en.wikipedia.org"      - "hn.algolia.com"  fs:    # One brief per topic, written DIRECTLY in ./research/. `*` matches a single    # segment and never crosses `/`, so even a slug that somehow kept a slash is    # refused rather than steered into a subtree — `./research/**` would let it    # through. The slug task below makes that doubly hard; the wall makes it    # impossible, and only one of the two is a guarantee.    write: ["./research/*.md"]tasks:  # The topic is free text — « Local-First AI Runtimes: a survey/2026 » — and it  # ends up in a filename. Deriving the slug in jq keeps that arithmetic out of  # the model AND out of the path: the expression is total, so there is no input  # for which it returns something containing a separator.  slug:    invoke:      tool: "nika:jq"      args:        input: "${{ inputs.topic }}"        expression: 'ascii_downcase | gsub("[^a-z0-9]+"; "-") | gsub("^-|-$"; "")'  plan:    infer:      max_tokens: 400              # planning is short · cap it and the cost report is a ceiling      prompt: "Break '${{ inputs.topic }}' into 4 sharp research queries · no overlap."      schema:        # `additionalProperties: false` on every object node — without it the        # shape is a suggestion and each provider answers with a slightly        # different object. `plan.output.queries` is read below, so it has to be        # a promise, not a hope.        type: object        additionalProperties: false        required: [queries]        properties:          queries:            type: array            items: { type: string }  investigate:    with:      queries: ${{ tasks.plan.output.queries }}    agent:      system: |        You are a rigorous researcher. Work the queries one by one · fetch        sources · keep verbatim quotes · note what each source actually        supports, and what it does not. If a fetch is refused, record that the        source was out of bounds rather than substituting another.        Call nika:done when the plan is exhausted.      prompt: "Research plan · ${{ with.queries }}"      tools:        # Every id here must also appear in `permits.tools` above — `check`        # refuses the mismatch statically (NIKA-SEC-004 · agent tool … is        # outside permits.tools). Note what is NOT granted: `nika:write`. The        # agent's product is the typed object it returns, so it never needs a        # file of its own, and the workflow keeps the only pen in the room.        - "nika:fetch"                 # the corpus named in permits.net.http        - "nika:done"                  # the loop-completion sentinel      max_turns: 25                    # the leash · a loop that cannot end is a bill      max_tokens_total: 150000         # and the second leash, in tokens      schema:        type: object        additionalProperties: false        required: [findings, sources]        properties:          findings:            type: array            items: { type: string }          sources:            type: array            items: { type: string }  brief:    with:      findings: ${{ tasks.investigate.output.findings }}      sources: ${{ tasks.investigate.output.sources }}    infer:      max_tokens: 2000      prompt: |        Findings · ${{ with.findings }}        Sources · ${{ with.sources }}        Write the executive brief · what's true · what's contested · what it        means · what to do. Two pages max. Anything the sources do not support        goes in « contested », never in « true ».      thinking:        # A reasoning budget, honoured by seats that support extended thinking        # and ignored by the ones that do not — declaring it never fails a run.        enabled: true        budget_tokens: 8000  save:    with:      slug: ${{ tasks.slug.output }}      brief: ${{ tasks.brief.output }}    invoke:      tool: "nika:write"      args:        path: "./research/${{ with.slug }}.md"        content: "${{ with.brief }}"        create_dirs: trueoutputs:  brief: ${{ tasks.brief.output }}  sources:    value: ${{ tasks.investigate.output.sources }}    description: "Every source the agent actually used"  slug:    value: ${{ tasks.slug.output }}    description: "The filename stem the brief landed under · ./research/<slug>.md"
the plan
sluginvokenika:jqplaninfer
investigateagent
briefinfer
saveinvokenika:write

“Get me up to speed by Thursday” becomes a pipeline you can audit end to end.

7.5.2

Check a dense document without it leaving your machine

T2

A local model reads the clauses. the document never touches the internet, and two checks gate the memo.

invokeinfer
contract-guard.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: contract-guard  description: "Local-model clause extraction → schema gate → risk memo"# Local · the whole review runs offline, zero cloud. Also a NON-thinking# model on purpose: a thinking model can spend the whole `max_tokens` budget# in its think block and return before the JSON (engine#428).model: ollama/llama3.2:3binputs:  contract_path:    type: string    default: "examples/fixtures/contract.md"    description: "Path to the contract (markdown or plain text)"permits:  tools: ["nika:assert", "nika:read", "nika:validate", "nika:write"]  fs:    # `permits:` cannot interpolate (NIKA-AUTH-007), so this literal does not    # follow `inputs.contract_path` around: passing --var without editing this    # line is refused at run with NIKA-SEC-004. Deliberate — a reviewer can    # see every file this workflow may open by reading four words.    read: ["examples/fixtures/contract.md"]    write: ["out/risk-memo.md"]tasks:  contract:    invoke:      tool: "nika:read"      args: { path: "${{ inputs.contract_path }}" }  clauses:    with:      contract: ${{ tasks.contract.output }}    infer:      max_tokens: 1600      prompt: |        Extract every risk-bearing clause from this contract ·        ${{ with.contract }}        Quote each clause verbatim · classify its risk.      schema:        type: object        additionalProperties: false        required: [clauses]        properties:          clauses:            type: array            items:              type: object              additionalProperties: false              required: [quote, type, risk]              properties:                quote: { type: string }                type: { type: string, enum: [liability, termination, ip, payment, data, other] }                risk: { type: string, enum: [low, medium, high] }  check:    with:      clauses: ${{ tasks.clauses.output }}    invoke:      tool: "nika:validate"      args:        data: "${{ with.clauses }}"        format: json        schema:          type: object          required: [clauses]          properties:            clauses:              type: array              minItems: 1        # an empty extraction is a FAILED extraction  gate:    with:      extraction_ok: ${{ tasks.check.output.valid == true }}   # the whole condition crosses as ONE boundary expression    invoke:      tool: "nika:assert"      args:        condition: "${{ with.extraction_ok }}"        message: "Clause extraction failed the schema gate: refusing to write the memo"  memo:    with:      clauses: ${{ tasks.clauses.output.clauses }}    after:      gate: success              # state, no data · the assert passed or the memo is cancelled    infer:      max_tokens: 1200      prompt: |        Write a one-page risk memo from these clauses ·        ${{ with.clauses }}        Order by risk · high first · cite the quoted text.  save:    with:      memo: ${{ tasks.memo.output }}    invoke:      tool: "nika:write"      args:        path: out/risk-memo.md        content: "${{ with.memo }}"        create_dirs: trueoutputs:  clauses:    value: ${{ tasks.clauses.output.clauses }}    description: "Typed risk-bearing clauses, verbatim quotes"  memo: ${{ tasks.memo.output }}
the plan
contractinvokenika:read
clausesinfer
checkinvokenika:validate
gateinvokenika:assert
memoinfer
saveinvokenika:write

Legal-grade caution, sovereign by default. Ollama runs the whole review offline.

7.5.3

Stop re-running the whole night for three bad rows

T2

A checkpoint splits good rows from bad. rejects land in quarantine, the pipeline keeps going.

invoke
etl-quarantine.nika.yamlthe room →walkthrough ↗
yaml
nika: v1workflow:  id: etl-quarantine  description: "CSV batch → schema gate → quarantine the bad · aggregate the good"const:  batch_csv: "examples/fixtures/orders.csv"permits:  tools: ["nika:convert", "nika:jq", "nika:read", "nika:validate", "nika:write"]  # The batch comes in at ONE path and leaves at TWO · each named exactly.  # Note what is NOT granted: `out/**` would have handed the whole artifact  # tree to a pipeline that only ever writes these two files.  fs:    read: ["examples/fixtures/orders.csv"]    write: ["out/quarantine/orders-rejected.json", "out/reports/daily-totals.json"]tasks:  # A deterministic empty fallback · the recover target when parsing dies.  empty_batch:    invoke:      tool: "nika:jq"      args: { input: [], expression: "." }  raw:    invoke:      tool: "nika:read"      args: { path: "${{ const.batch_csv }}" }  rows:    with:      csv: ${{ tasks.raw.output }}    invoke:      tool: "nika:convert"      args:        input: "${{ with.csv }}"        from: csv        to: json        has_header: true    on_error:      recover: ${{ tasks.empty_batch.output }}    # malformed CSV → empty batch · pipeline lives  check:    with:      rows: ${{ tasks.rows.output }}    invoke:      tool: "nika:validate"      args:        data: "${{ with.rows }}"        format: json        schema:          type: array          items:            type: object            required: [order_id, amount, currency]            properties:              order_id: { type: string }              amount: { type: string }              currency: { type: string, enum: [EUR, USD, GBP] }  good:    with:      rows: ${{ tasks.rows.output }}      valid: ${{ tasks.check.output.valid }}    when: ${{ with.valid == true }}    invoke:      tool: "nika:jq"      args:        input: "${{ with.rows }}"        expression: 'group_by(.currency) | map({currency: .[0].currency, orders: length, total: (map(.amount | tonumber) | add)})'  quarantine:    with:      valid: ${{ tasks.check.output.valid }}      errors: ${{ tasks.check.output.errors }}    when: ${{ with.valid == false }}    invoke:      tool: "nika:write"      args:        path: out/quarantine/orders-rejected.json        content: "${{ with.errors }}"        create_dirs: true  report:    with:      totals: ${{ tasks.good.output }}    when: ${{ with.totals != null && size(with.totals) > 0 }}   # good is SKIPPED (null) on the quarantine path · guard before size()    invoke:      tool: "nika:write"      args:        path: out/reports/daily-totals.json        content: "${{ with.totals }}"        create_dirs: trueoutputs:  # Deliberately untyped · on the quarantine path `good` is SKIPPED and  # this is null — a declared `type: array` would refuse the branch the  # file exists to demonstrate.  totals: ${{ tasks.good.output }}

audited · 7 tasks · 5 waves · permits declarednika 0.107.0

the plan
empty_batchinvokenika:jqrawinvokenika:read
rowsinvokenika:convert
checkinvokenika:validate
goodinvokenika:jqquarantineinvokenika:write
reportinvokenika:write

“Re-run the whole night” turns into “fix three rows tomorrow morning”.

26 workflows · four tiers · nika-spec/examples · every file audited before it runs: plan, cost, secrets