AGPL-3.0-or-later · forever.

learn · 5 minutes

One file, line by line.

Read one file. Understand everything.

A workflow is a file you can read, and nine small ideas make you fluent in it. Every fragment below is real, spec-correct YAML, read through the same editor surface you'll use in the playground.

9 steps · spec-correct

  1. 01 · the file

    A workflow is a file you can read

    The whole thing is one plain-text file. Two lines make it real: name the language, name the workflow. That header is the whole ceremony: no project setup, no boilerplate, no config.

    nika: v1 means the format is frozen. Files you write today won’t break.

    weekly-radar.nika.yaml
    nika: v1workflow:  id: weekly-radar
  2. 02 · the inputs

    Declare what can change

    Inputs live in vars. A bare value is a default you can override from the command line; a typed var documents itself and gets validated before anything runs.

    Use it anywhere as ${{ vars.topic }}. Change the input, not the file.

    vars
    vars:  output_dir: "./radar"  topic:    type: string    required: true    description: "Subject to research"

    Next week the topic changes. What do you edit?

  3. 03 · the model

    Pick a brain. Any brain.

    One line chooses the default model, any model: local Ollama, or any API. Start on your own machine (no key, no cloud) and swap providers whenever you want; nothing else changes.

    model
    # fully local · no cloud neededmodel: ollama/llama3.2:3b# or swap to any cloud provider:# model: mistral/mistral-large
  4. 04 · the verbs

    A task is a verb

    Each task does exactly one thing, with one of the four verbs. This one thinks: it sends a prompt to the model and keeps the answer as its output.

    infer thinks · exec runs a command · invoke uses a tool · agent delegates.

    tasks
    tasks:  digest:    after:      fetch_news: succeeded    infer:      prompt: "Summarize in 5 bullets: ${{ tasks.fetch_news.output }}"
  5. 05 · the plan

    The wiring is the plan. The plan is free.

    with: names what a task takes in — and each wire IS an edge of the plan. Tasks that don’t feed each other run in parallel automatically. You never schedule anything. The plan (which tasks wait on which) falls out of the file.

    fetch_news and repo_log run at the same time. digest waits for both. Order with no data is the same declaration: depends_on works with or without a with: binding.

    with
    fetch_news:  invoke:    tool: "nika:fetch"repo_log:  exec:    command: ["git", "log", "--since=1 week"]digest:  with:    news: ${{ tasks.fetch_news.output }}   # each wire in    log: ${{ tasks.repo_log.output }}      # is one edge  infer:    prompt: "Cross-reference ${{ with.news }} with ${{ with.log }}…"

    digest reads ${{ tasks.fetch_news.output }} in with:. What did that line just do?

  6. 06 · the waves

    Steps that wait, steps that run together

    A workflow is a to-do list where some steps wait for others. Steps that wait on nothing all start at the same time, automatically; you never schedule anything. Before anything runs, the runtime reads every with: wire and draws the plan: here, three sources start together, the digest waits for all three, and the save waits for the digest.

    Nothing in this file says parallel. The picture below is the plan drawn from these five steps: follow the arrows, not the line order.

    tasks · the whole plan
    tasks:  fetch_news:    invoke:      tool: "nika:fetch"  repo_log:    exec:      command: ["git", "log", "--since=1 week"]  read_notes:    invoke:      tool: "nika:read"  digest:    after:      fetch_news: succeeded      repo_log: succeeded      read_notes: succeeded    with:      news: ${{ tasks.fetch_news.output }}      log: ${{ tasks.repo_log.output }}      notes: ${{ tasks.read_notes.output }}    infer:      prompt: "One weekly radar, five bullets"  save:    after:      digest: succeeded    with:      brief: ${{ tasks.digest.output }}    invoke:      tool: "nika:write"
    The weekly-radar plan, drawn as a graphfetch_news, repo_log and read_notes wait on nothing, so all three run at the same time. digest waits for all three. save waits for digest. Time flows left to right.run together ×3waits for all threethenfetch_newsinvokerepo_logexecread_notesinvokedigestinfersaveinvoketime →
    the plan the runtime draws from this exact file · every arrow points from a step to the step that waits for it
  7. 07 · the branch

    Branch like an adult

    when: makes a task conditional, a yes/no test over what it imports. The wiring already orders it; when: decides whether an admitted step runs — and it reads the step’s own bindings, never the graph.

    when
    alert:  with:    errors: ${{ tasks.check.output.errors }}  when: ${{ with.errors > 0 }}  invoke:    tool: "nika:notify"

    check failed outright. What happens to alert and its when: test?

  8. 08 · the failure

    When things fail, you get data

    Errors come back typed: a stable code, a category, and whether retrying could help. Tasks declare their own retry policy and a fallback. No stack-trace archaeology.

    A failed call retries with backoff; if it still fails, the cached result steps in.

    retry · on_error
    research:  retry:    max_attempts: 3    backoff_ms: 1000  on_error:    recover: ${{ tasks.cache.output }}  infer:    prompt: "…"

    The error says transient: false. What does retrying buy you?

  9. 09 · the outputs

    Name what comes out

    output: binds pieces of a task result to names; the workflow declares what it returns. Downstream tasks (and you) read clean names, not raw API responses.

    output · outputs
    tasks:  digest:    infer:      prompt: "…"    output:      result: ".choices[0].message.content"outputs:  brief: ${{ tasks.digest.output.result }}
aside · the failure object

Errors are data, not noise.

typed · greppable

Every failure is a typed structure with a stable code, a category, and a transient flag that says whether retrying could help. Your workflow can read errors the same way it reads any other value, and recover.

error.json
{  "code": "NIKA-INFER-001",  "category": "provider_error",  "message": "the model call failed",  "transient": true,  "details": {    "provider": "ollama",    "status_code": 503,    "retry_after_secs": 30  },  "task_id": "research",  "attempt": 2}
  • codea stable, greppable identifier. The same failure always has the same name.
  • transienttrue means retry might work. The engine retries with backoff before giving up.
  • detailsstructured fields, not prose. Your on_error: can act on them.

10 · the whole file

Every idea above, in one file

The nine fragments compose into the workflow this page has been teaching. This exact text passes the engine's audit; the verdict below is nika check's real answer, and its hints are your next three lessons.

weekly-radar.nika.yaml
nika: v1workflow:  id: weekly-radarvars:  output_dir: "./radar"  topic:    type: string    required: true    description: "Subject to research"model: ollama/llama3.2:3btasks:  fetch_news:    invoke:      tool: "nika:fetch"      args:        url: "https://hnrss.org/frontpage"  repo_log:    exec:      command: ["git", "log", "--since=1 week"]  read_notes:    invoke:      tool: "nika:read"      args:        path: "./notes.md"  digest:    with:      news: ${{ tasks.fetch_news.output }}      log: ${{ tasks.repo_log.output }}      notes: ${{ tasks.read_notes.output }}    retry:      max_attempts: 3      backoff_ms: 1000    infer:      prompt: "One weekly radar on ${{ vars.topic }}, five bullets: ${{ with.news }} ${{ with.log }} ${{ with.notes }}"  save:    with:      brief: ${{ tasks.digest.output }}    invoke:      tool: "nika:write"      args:        path: "${{ vars.output_dir }}/radar.md"        content: "${{ with.brief }}"outputs:  brief: ${{ tasks.digest.output }}
what the engine says
nika check weekly-radar.nika.yamlnika check · weekly-radar.nika.yaml  PLAN     3 waves · 5 tasks · max parallelism 3      wave 1 fetch_news (invoke · nika:fetch) · repo_log (exec · git) · read_notes (invoke · nika:read)      wave 2 digest (infer · ollama/llama3.2:3b)      wave 3 save (invoke · nika:write)  MODELS   1 model resolves in this binary   COST     $0.0000 – $0.0000 FLOOR (unbounded tasks present)   digest  ollama/llama3.2:3b  UNBOUNDED — no max_tokens declared  SECRETS  no information-flow escapes  TYPES    every deep output reference fits its declared shape  TOOLS    every nika: tool names a canonical builtin  ARGS     every invoke arg key is declared + every required arg is present  SCHEMA   every authored schema: is satisfiable ○ PERMITS  no boundary declared (engine floor only) · `--infer-permits` writes one ↳ HINT     [cost] declare `max_tokens` on `digest` — the cost report becomes a hard ceiling instead of UNBOUNDED ↳ HINT     [permits] no `permits:` boundary declared — run `nika check --infer-permits` to generate the tightest one (default-deny once present) ↳ HINT     [inputs] `read_notes` reads `./notes.md` which does not exist here — create it (or point its var elsewhere) · the run would fail at that wave ↳ HINT     [inputs] required input(s) with no default · pass at run time: --var topic=…  audited · 5 tasks · 3 waves · permits none · est ≥$0.0000 · 4 hints

real output · nika 0.105.0 · nika check weekly-radar.nika.yaml · re-captured at every release

That's the whole language.

Nine ideas, four verbs, one file. Install it, write one, run it, or open the playground and check your file as you type. Or send us the task you repeat and get it back as a file.

9 steps · 4 verbs · every fragment spec-correct · real YAML, never pseudo-code