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 · the loop
- 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.yamlsourcenika: v1workflow: id: weekly-radar - 02 · the inputs
Declare what can change
Every value states its role. inputs: are the parameters a caller supplies: typed, documented, validated before anything runs. const: is the wiring baked into the file; changing it means editing the file, on purpose.
Use it anywhere as ${{ inputs.topic }}. A default makes the file runnable bare; --var overrides it per run.
inputs · constsourceinputs: topic: type: string default: "local-first AI tooling" description: "Subject to research · --var topic=… overrides"const: output_dir: "./radar"Next week the topic changes. What do you edit?
- 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.
modelsource# fully local · no cloud neededmodel: ollama/llama3.2:3b# or swap to any cloud provider:# model: mistral/mistral-large - 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.
taskssourcetasks: digest: infer: prompt: "One weekly radar on ${{ inputs.topic }}, five bullets" max_tokens: 1024 - 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. The binding IS the data edge. Order with no data gets its own line: after: { fetch_news: success }.
withsourcefetch_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?
- 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. One human question opens the run · each source binds the answer and runs only on yes (a gate you merely order after would fire on « no »). The picture below is the plan drawn from these six steps: follow the arrows, not the line order.
tasks · the whole plansourcetasks: approve: invoke: tool: "nika:prompt" # one human question opens the run fetch_news: with: go: "${{ tasks.approve.output }}" when: ${{ with.go == true }} invoke: tool: "nika:fetch" repo_log: with: go: "${{ tasks.approve.output }}" when: ${{ with.go == true }} exec: command: ["git", "log", "--since=1 week"] read_notes: with: go: "${{ tasks.approve.output }}" when: ${{ with.go == true }} invoke: tool: "nika:read" digest: with: news: ${{ tasks.fetch_news.output }} log: ${{ tasks.repo_log.output }} notes: ${{ tasks.read_notes.output }} infer: prompt: "One weekly radar, five bullets" save: with: brief: ${{ tasks.digest.output }} invoke: tool: "nika:write"the plan the runtime draws from this exact file · every arrow points from a step to the step that waits for it - 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. The radar’s human gate is exactly this: bind the answer, run on true.
whensourcealert: 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?
- 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_errorsourceresearch: 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?
- 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 · outputssourcetasks: digest: infer: prompt: "…" output: result: ".choices[0].message.content"outputs: brief: ${{ tasks.digest.output.result }}
Errors are data, not noise.
typed · greppableEvery 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.
{ "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. Youron_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 · the registered file itself, byte for byte. This exact text passes the engine's audit; the verdict below is nika check's real answer, and its hints are your next lessons (one of them is the price of a human gate that actually gates).
nika: v1workflow: id: weekly-radar description: "gate → parallel gather (fetch · git log · notes) → one synthesis → save"inputs: topic: type: string default: "local-first AI tooling" description: "Subject to research · --var topic=… overrides" notes_path: type: string default: "examples/fixtures/notes.md" description: "Your own notes file · the permits below grant the default"const: output_dir: "./radar"model: ollama/llama3.2:3bpermits: exec: ["git"] tools: ["nika:fetch", "nika:read", "nika:write", "nika:prompt"] net: { http: ["hnrss.org"] } fs: read: ["examples/fixtures/notes.md"] write: ["./radar/*"]tasks: approve: invoke: # blocking · no `default:` · a gate with a default is not a gate · the # checker enforces both directions: `default: false` here turns this # file RED with NIKA-SEC-009, because a defaulted prompt dominates # nothing. Blocking costs one [headless-prompt] hint instead · that # hint is the price of a real gate, not a defect to paper over. # Unattended the run pauses (exit 4) · answer and resume with # nika run <file> --resume <trace> --answer approve=true tool: "nika:prompt" args: mode: confirm message: "Run the weekly radar? It fetches hnrss.org, reads the notes file and writes ./radar/." fetch_news: with: go: "${{ tasks.approve.output }}" when: ${{ with.go == true }} invoke: tool: "nika:fetch" args: url: "https://hnrss.org/frontpage" repo_log: with: go: "${{ tasks.approve.output }}" when: ${{ with.go == true }} exec: command: ["git", "log", "--since=1 week"] read_notes: with: go: "${{ tasks.approve.output }}" when: ${{ with.go == true }} invoke: tool: "nika:read" args: path: "${{ inputs.notes_path }}" 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 ${{ inputs.topic }}, five bullets: ${{ with.news }} ${{ with.log }} ${{ with.notes }}" max_tokens: 1024 save: with: brief: ${{ tasks.digest.output }} invoke: tool: "nika:write" args: path: "${{ const.output_dir }}/radar.md" content: "${{ with.brief }}"outputs: brief: ${{ tasks.digest.output }}nika check weekly-radar.nika.yamlnika check · weekly-radar.nika.yaml ✔ PLAN 4 waves · 6 tasks · max parallelism 3 wave 1 approve (invoke · nika:prompt) wave 2 fetch_news (invoke · nika:fetch) · repo_log (exec · git) · read_notes (invoke · nika:read) wave 3 digest (infer · ollama/llama3.2:3b) wave 4 save (invoke · nika:write) ✔ MODELS 1 model resolves in this binary · local servers not probed (nika doctor --ping) ⚠ COST bounded portion $0.0000 no total ceiling · 1 unpriced task · prompts, exec + mcp unpriced · prices 2026-07-28 digest ollama/llama3.2:3b UNBOUNDED — no catalog price (local/unknown model) ○ ENERGY unpriced — no sourced Wh figure for any task model · a local model draws your watts · never 0 Wh (NEP-0018) ✔ SECRETS no declared secret reaches an effect · model echo untracked ✔ TYPES deep references fit the shapes tasks declare · builtin output has none ✔ TOOLS every named nika: tool is canonical · globs + mcp: not checked ✔ ARGS every builtin invoke arg key is declared + required args present ✔ SCHEMA no known-unsatisfiable form in an authored schema: · $ref opaque ✔ GATES no task proven dead · status literals in vocabulary ✔ WRITES no two unordered tasks write the same static path · computed paths at run ✔ PERMITS literal + const: args fit the boundary · computed paths + symlinks are the RUN's verdict · exec outside the fs bounds ✔ TRIFECTA no lethal trifecta over the declared permits: without a human gate ✔ JOURNEY internal · 0 sources · 2 destinations · 6 model endpoints · no secret reaches a cloud destination ↳ HINT [headless-prompt] `nika:prompt` on `approve` declares no `default:` — unattended (CI, or an agent handing it over) the run pauses at this gate awaiting a human (exit 4 · the resume line taught on the frame); at a terminal it asks directly. Answer it in one pass with `nika run <file> --answer approve=<value>`, or declare the `default:` the unattended path should take ↳ HINT [inputs] `read_notes` reads `examples/fixtures/notes.md` which does not exist here — create it (or point its var elsewhere) · the run would fail at that wave ⚠ audited · 6 tasks · 4 waves · permits declared · est unbounded · 1 unpriced task · 2 hints · risk unbounded — no dollar meter for a local/unknown model · cap a cloud seat on the run: `nika run <file> --max-cost-usd <usd>`
real output · nika 0.108.0 · nika check weekly-radar.nika.yaml · re-captured at every release
11 · the loop
Then the loop around it
Reading the file is half of it. The other half is the arc the binary teaches: see it work, make it yours, audit it, run it, read back what happened. Five commands, captured from one real run, at how Nika works.
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