Skip to main content
Every recurring workflow shape has a complete, valid skeleton in the spec β€” gated by the conformance suite on every push, with a # SLOT: marker at every decision point. The path from intent to a correct file is mechanical: route β†’ copy β†’ fill slots β†’ nika check β†’ repair β†’ re-check.

Route by intent

Your intent sounds like…TemplateWhat it locks in
Β« take data, produce words, save them Β»chaindeterministic gather Β· one model job Β· explicit persist
Β« watch X, act when Y Β»gate-and-actjq extraction Β· CEL skip-gate Β· often zero model calls
Β« do this for EVERY item Β»fanoutruntime collection Β· the full leash (max_parallel Β· fail_fast Β· retry)
« only what changed since last run »etl-statestate read→diff→write · on_error: recover: quarantine
Β« research / review / open-ended Β»agent-loopplan-then-execute Β· default-deny tools Β· budgets Β· typed final message
Β« anything irreversible (deploy Β· send Β· publish) Β»human-gated-shipparallel gates Β· assert Β· nika:prompt GO Β· on_finally record
Composite jobs compose templates β€” a fanout whose merge feeds a human-gated-ship, an etl-state whose delta fans out. Start from the template matching the outer shape.
The YAML below is projected verbatim from nika-spec/templates/ (the source of truth, validated by the conformance runner) β€” these copies cannot drift.

chain

The default shape for Β« take real data, produce words, save them Β».
chain.nika.yaml
nika: v1
workflow: chain-template            # SLOT: kebab-case workflow id
description: "gather β†’ think β†’ persist"   # SLOT: one honest sentence

model: mock/echo                    # SLOT: provider/model (mock/echo = CI-safe)

vars:
  source: "./input.txt"             # SLOT: your inputs Β· typed where required

tasks:
  - id: gather
    invoke:                         # SLOT: the fact source Β· nika:read / nika:fetch / exec
      tool: "nika:read"
      args: { path: "${{ vars.source }}" }

  - id: think
    depends_on: [gather]
    infer:
      prompt: |
        # SLOT: the one model job Β· interpolate ${{ tasks.gather.output }}
        Summarize Β· ${{ tasks.gather.output }}

  - id: persist
    depends_on: [think]
    invoke:
      tool: "nika:write"
      args:
        path: "./output.md"         # SLOT: destination
        content: "${{ tasks.think.output }}"   # ALWAYS pass content Β· a write without it writes nothing

outputs:
  result: ${{ tasks.think.output }}  # SLOT: the callable contract

gate-and-act

Watch something, act only when a condition holds β€” often zero model calls.
gate-and-act.nika.yaml
nika: v1
workflow: gate-and-act-template     # SLOT: kebab-case workflow id
description: "watch a value Β· act only when the condition holds"   # SLOT

vars:
  source_url: "https://api.example.com/v1/value"   # SLOT: what to watch
  threshold: 100                    # SLOT: the trigger condition value

secrets:
  webhook:
    source: env
    key: ALERTS_WEBHOOK_URL         # SLOT: where the act lands
    egress:
      - to: "nika:notify"
        host_from_self: true        # the secret value IS the destination URL

tasks:
  - id: check
    invoke:
      tool: "nika:fetch"
      args:
        url: "${{ vars.source_url }}"
        mode: jq
        jq: "."
    output:
      value: ".value"               # SLOT: the jq path to the watched field

  - id: act
    depends_on: [check]
    when: ${{ tasks.check.value > vars.threshold }}   # SLOT: the CEL condition
    invoke:
      tool: "nika:notify"           # SLOT: the action Β· notify / write / exec
      args:
        channel: webhook
        target: "${{ secrets.webhook }}"
        message: "Threshold crossed Β· ${{ tasks.check.value }}"   # SLOT
        severity: warning

outputs:
  value: ${{ tasks.check.value }}

fanout

The same work for every item of a runtime collection, fully leashed.
fanout.nika.yaml
nika: v1
workflow: fanout-template           # SLOT: kebab-case workflow id
description: "discover N items Β· process in parallel Β· merge"   # SLOT

model: mock/echo                    # SLOT: provider/model

vars:
  collection_source: "./items"      # SLOT: where the collection comes from

tasks:
  - id: discover
    invoke:                         # SLOT: glob / fetch sitemap / exec + jq split
      tool: "nika:glob"
      args: { pattern: "${{ vars.collection_source }}/*.md" }

  - id: process
    depends_on: [discover]
    for_each: ${{ tasks.discover.output }}
    max_parallel: 4                 # SLOT: the polite ceiling
    fail_fast: false
    timeout: "60s"                  # SLOT: per-iteration bound
    retry:
      max_attempts: 3
      backoff_strategy: exponential
      jitter: true
    on_error:
      recover: null                 # a failed item yields null at its index Β· the batch lives
    infer:                          # SLOT: the per-item job (any verb)
      prompt: |
        Process this item Β· ${{ item }}

  - id: survivors
    depends_on: [process]
    invoke:                         # the null-aware fan-in Β· order preserved
      tool: "nika:jq"
      args:
        input: ${{ tasks.process.output }}
        expression: "[ .[] | select(. != null) ]"


  - id: merge
    depends_on: [survivors]
    infer:
      prompt: |
        # SLOT: the fan-in Β· the survivors array (failed items filtered)
        Merge these results into one report Β· ${{ tasks.survivors.output }}

outputs:
  report: ${{ tasks.merge.output }}

etl-state

Incremental runs: only what changed since last time, survive bad input.
etl-state.nika.yaml
nika: v1
workflow: etl-state-template        # SLOT: kebab-case workflow id
description: "read state Β· fetch fresh Β· diff Β· process the delta Β· save state"   # SLOT

vars:
  source_url: "https://api.example.com/v1/records"   # SLOT: the data source
  state_path: "./state/etl-state.json"               # SLOT: the cursor file

tasks:
  # First run Β· no state file yet Β· recover to an empty list.
  - id: empty
    invoke:
      tool: "nika:jq"
      args: { input: [], expression: "." }

  - id: previous
    invoke:
      tool: "nika:read"
      args: { path: "${{ vars.state_path }}" }
    on_error:
      on_codes: [NIKA-BUILTIN-READ-001]   # not-found ONLY Β· a permission error still fails loudly
      recover: ${{ tasks.empty.output }}

  - id: fresh
    invoke:
      tool: "nika:fetch"            # SLOT: fetch / read / exec β€” the fresh data
      args:
        url: "${{ vars.source_url }}"
        mode: jq
        jq: ".records"

  - id: delta
    depends_on: [previous, fresh]
    invoke:
      tool: "nika:json_diff"        # RFC 6902 Β· empty patch = nothing new
      args:
        before: "${{ tasks.previous.output }}"
        after: "${{ tasks.fresh.output }}"

  - id: process
    depends_on: [delta]
    when: ${{ size(tasks.delta.output) > 0 }}
    invoke:
      tool: "nika:jq"               # SLOT: the delta job (jq Β· infer Β· write…)
      args:
        input: "${{ tasks.delta.output }}"
        expression: "length"

  - id: save_state
    depends_on: [fresh]
    invoke:
      tool: "nika:write"
      args:
        path: "${{ vars.state_path }}"
        content: "${{ tasks.fresh.output }}"
        create_dirs: true
        overwrite: true

outputs:
  changes:
    value: ${{ tasks.delta.output }}
    type: array
    description: "RFC 6902 ops since last run Β· empty = no-op run"

agent-loop

Open-ended work: plan with a fast model, execute with a budgeted agent, validate the typed result. Never ship an unleashed agent.
agent-loop.nika.yaml
nika: v1
workflow: agent-loop-template       # SLOT: kebab-case workflow id
description: "plan β†’ budgeted agent β†’ typed result"   # SLOT

model: mock/echo                    # SLOT: agents want a tool-calling model

vars:
  goal:
    type: string
    required: true
    description: "What the agent must accomplish"   # SLOT

tasks:
  - id: plan
    infer:
      prompt: "Break '${{ vars.goal }}' into at most 4 concrete steps."   # SLOT
      schema:
        type: object
        required: [steps]
        properties:
          steps: { type: array, items: { type: string } }

  - id: execute
    depends_on: [plan]
    agent:
      system: "Work the plan step by step. Call nika:done when finished."   # SLOT
      prompt: "Plan Β· ${{ tasks.plan.output.steps }}"
      tools:                        # SLOT: the MINIMUM grant for the job
        - "nika:read"
        - "nika:done"
      max_turns: 15                 # SLOT: the loop bound
      max_tokens_total: 80000       # SLOT: the spend bound
      schema:                       # SLOT: the typed final-message contract
        type: object
        required: [findings]
        properties:
          findings: { type: array, items: { type: string } }

  - id: confirm
    depends_on: [execute]
    invoke:
      tool: "nika:assert"
      args:
        condition: "${{ size(tasks.execute.output.findings) > 0 }}"
        message: "Agent returned no findings β€” do not trust an empty run"   # SLOT

outputs:
  findings:
    value: ${{ tasks.execute.output.findings }}
    type: array
    description: "The agent's typed findings"   # SLOT

human-gated-ship

Anything irreversible: parallel gates, a hard assert, a human GO, and an on_finally record whatever happens.
human-gated-ship.nika.yaml
nika: v1
workflow: human-gated-ship-template  # SLOT: kebab-case workflow id
description: "verify in parallel Β· human GO Β· act Β· record"   # SLOT

permits:                            # SLOT: the blast radius Β· default-deny once present
  exec: ["echo"]                    # SLOT: ONLY the programs the gates + act run (argv form)
  tools: ["nika:assert", "nika:prompt", "nika:notify"]
  net: { http: ["hooks.slack.com"] }   # SLOT: the webhook host Β· nothing else may leave

secrets:
  webhook:
    source: env
    key: TEAM_WEBHOOK_URL           # SLOT: where the record lands
    egress:
      - to: "nika:notify"
        host_from_self: true        # the secret value IS the destination URL

tasks:
  # ── the verification wave Β· all checks run in parallel ──
  - id: check_a
    exec:
      command: ["echo", "ok"]        # SLOT: gate 1 (argv form Β· injection-safe)
      capture: structured

  - id: check_b
    exec:
      command: ["echo", "ok"]        # SLOT: gate 2
      capture: structured

  - id: gates
    depends_on: [check_a, check_b]
    invoke:
      tool: "nika:assert"
      args:
        condition: "${{ tasks.check_a.output.exit_code == 0 && tasks.check_b.output.exit_code == 0 }}"
        message: "A gate is RED β€” refusing to proceed"   # SLOT

  - id: human
    depends_on: [gates]
    invoke:
      tool: "nika:prompt"
      args:
        message: "All gates GREEN. Proceed?"   # SLOT: the decision, fully informed
        default: false

  - id: act
    depends_on: [human]
    when: ${{ tasks.human.output == true }}
    exec:
      command: ["echo", "shipped"]   # SLOT: the irreversible action (argv Β· program must be in permits.exec)
      # default capture Β· a failing ship fails LOUDLY (NIKA-EXEC-001) β€”
      # never `capture: structured` on the irreversible step (exit codes
      # would become data and a red ship would read as success)

  - id: record
    depends_on: [act]
    when: true                      # the always-pattern Β· runs on success, failure, OR refusal
    invoke:
      tool: "nika:notify"
      args:
        channel: webhook
        target: "${{ secrets.webhook }}"
        message: "Run finished Β· act=${{ tasks.act.status }}"   # SLOT Β· success | failure | skipped
        severity: info

outputs:
  acted: ${{ tasks.act.status }}

The instantiation protocol

1

Route

Pick the template whose intent row matches β€” never free-form a workflow when a template routes.
2

Copy + fill

Copy the skeleton, change ONLY the # SLOT: lines. Everything else is locked structure.
3

Check

nika check workflow.nika.yaml β€” the validator names the exact rule on every error.
4

Repair from the error

Fix exactly what the named rule says, re-check until clean. Don’t fix what the validator didn’t name.

See also

Writing Nika as an agent

The deterministic protocol these templates anchor.

Patterns

The 8 composition patterns the templates lock in.

Examples

20 full tiered workflows built from these shapes.

Templates source

The skeletons in the spec repo, conformance-gated.