Skip to main content
What is a .nika file, and what happens when you run it? A workflow is a YAML contract: identity, model, permits, tasks, outputs. The engine admits it (nika check / the check inside nika run), then executes the task graph. Four verbs only: infer · exec · invoke · agent. Fetching a URL is invoke: { tool: "nika:fetch" }, not a fifth verb. Qualified on nika 0.120.1 (9d554c84c) with mock/echo (a local simulation — output is prefixed mock(echo) ·):
Swap model: later for a local or cloud seat. The file is the contract; --var / SDK inputs supply caller values.
Envelope law: nika-spec 01-envelope. Live binary is the implementation you download.

Why the mark is the name (not a K8s envelope, not a version)

The envelope is one header line · nika: <identifier> — the language name as the key, the workflow’s own name as the value. Earlier drafts explored a Kubernetes-style apiVersion + kind + metadata + spec envelope, and until 0.108 the line read nika: v1 with a separate workflow: { id } object; the nine-key envelope (0.109) folded the identity onto the mark and dropped the version: a grammar change before 1.0 ships with its migration (nika check --fix moves the id, keeps the description as a comment) and after 1.0 the grammar is additive only, so a number the author types would never change.
  • No separate kind: field: the presence of tasks: is itself the document-type discriminator — a tasks: key makes a workflow, its absence makes the project file (nika.yaml, the one document that still spells nika: v1). The rule holds without the filename: a registry blob, an HTTP body, a stdin pipe still say what they are.
  • The engine’s internal canonical URI stays https://nika.sh/spec/v1 for RDF / conformance tooling, but the author never types a URL.
Multi-document YAML is supported: one file may declare multiple documents separated by ---, parsed into Vec<RawDocument>.

Anatomy of a workflow

The workflow AST (nika-schema::raw::RawWorkflow) models exactly the envelope’s fields — nothing hidden, nothing extra: Nine keys, closed. The three value authorities are a closed family: inputs · const · secrets. The pre-flip vars:, env: and config: blocks are dead (NIKA-VALUES-001 / NIKA-VALUES-002 / NIKA-PARSE-005) and no alias survives — each old use classifies into the authority its role commands (a deployment dial becomes an inputs: entry with required: false and a default:). The workflow: object, types:, policy: and assert: left the envelope with the same release; every refusal names where the role went (see the reference). (The env: field inside an exec: block is a different thing and is alive: it is the child process’s environment map.)

What a .nika file is made of. One file that uses all nine envelope keys and binds each of the four verbs once. Every label is the specification’s own wording, and the verdict is the real nika check of that exact file. The camera and the fold are illustration.

Parser internals (after the useful envelope)

Every field is span-carrying (Spanned<T>): source spans survive all the way into the diagnostic layer (ADR-010 miette bridge). The RawWorkflow AST matches the nine envelope keys — nothing hidden.

Execution pipeline

ASCII by intent: Mintlify renders fine, but the diagram above is plain text and pastes into terminals and issue trackers without a renderer.

Tasks, edges, fan-out

tasks.* crosses a task boundary through exactly two doors — with: (data) and after: (control) — and the engine computes the graph FROM those doors: a with: binding referencing ${{ tasks.X.* }} both names the data the task consumes and declares a typed edge; an after: entry ({producer: predicate}) orders on state and carries no data. There is no third way to connect two tasks — depends_on is dead (NIKA-PARSE-024 · nika check --fix migrates it). Nika builds the DAG, topologically sorts, and runs tasks in parallel where the graph allows.

A workflow is a graph. nika inspect draws the tasks and edges of the built-in pr-review-fanout example; nika check plans them in waves, and tasks that share a wave run side by side. Output captured from the real CLI; the waves lighting up illustrate the plan, and nothing runs.

The task’s body consumes its bindings (${{ with.data }}), never the global tasks.* namespace: a reference outside the boundary is NIKA-VAR-021, and nika check --fix hoists it into with: for you. For an ordering with no data (run the deploy after the tests, consume nothing), use after: — a map {producer: predicate} whose predicate names the producer states that admit this task:
The predicate set is closed (NIKA-DAG-005 otherwise): success · failure · skipped · terminal — where terminal admits any terminal state, cancelled included (the always-pattern: « run once X is settled, whatever happened »). The participial spellings succeeded and failed are dead; nika check --fix respells them. Fan-out over a collection (files here is a jq extract binding on list_files, e.g. extract: { files: 'split("\n")' } — the collection crosses the boundary through with:, then for_each: reads the binding):
Conditional execution: when: is a local business condition, evaluated after the gate admits the task. It reads local namespaces only (inputs · const · secrets · with · the for_each locals) — a tasks.* reference inside when: is refused at parse time (NIKA-VAR-021): the binding creates the edge, when: reads the binding.
when: evaluating false settles the task skipped (a decision — downstream value edges pass, reading null), and the gate always applies: when: refines it, never replaces it. To run a task whatever happened upstream (the report / cleanup class), say it in the graph — after: { pipeline: terminal } — and observe the outcome through with: { outcome: ${{ tasks.pipeline.status }} } when the body branches on it. Retry on failure: retry: is the one shape (and on_error: catches what retries can’t fix):
Field names are the spec’s: the map key is the task’s identity (no id:/name: field), when: (not condition:), with:/after: (not depends_on:), retry.max_attempts (not max_retries:). The JSON Schema rejects the wrong names at check time.

Inputs · inputs

Workflow inputs live in inputs:, available in every task via ${{ inputs.<name> }}. Every entry is a typed declaration (type: is required, and speaks the full type grammar): that is what lets the engine validate what a caller passes and generate a callable schema for nika.run_workflow over MCP. A value the caller may override belongs here — a deployment dial too, as an input with required: false and a default:. A value the file owns and nobody overrides is a const: entry; a credential is a secrets: reference. Three authorities, one role each — pick by the role the value plays.
research-brief.nika
This file declares no permits: block and needs none: it runs zero shells, touches no file, reaches no host. Every effect-bearing workflow carries one — absent means zero authority, and the check refuses the body with NIKA-AUTH-006 before a single token is spent.
There is no include: / import: — a workflow never splices another file’s text into its own. Reuse happens through composition: one workflow calls another as a typed, bounded step. See below.

Composition · one workflow calls another

invoke: is a tagged union: exactly one of tool: (a builtin or an MCP tool) or workflow: (another workflow). Same verb, same call site — the field IS the semantics. There is no fifth verb and no nika:* builtin that launches a workflow. The child is an ordinary workflow. Nothing marks it as “a child”:
word-count.nika
The parent calls it by static path, passes args: that fit the child’s inputs:, and reads the child’s outputs: back as that task’s output:
brief-with-stats.nika

The rules the checker enforces

  • Static target — workflow: is a filesystem path or a pinned registry:owner/name@version. A ${{ }}-templated target is refused (NIKA-COMP-001): a call graph you cannot draw before the run is a call graph you cannot bound.
  • Typed call — the parent’s args: must fit the child’s inputs:, and what the parent declares in returns: must fit the child’s outputs: (NIKA-COMP-004). The child’s contract is derived from its body, never annotated twice.
  • Zero implicit authority — a child never gains a capability its parent lacks (NIKA-COMP-002).
  • Acyclic — a static cycle or self-launch is refused (NIKA-COMP-003).
The parent’s permits: must cover the child’s effects. Authority flows down, never up: the child above invokes nika:jq, so the parent declares nika:jq too — drop it and the check fails NIKA-COMP-002. On that parent nika check also prints an advisory NIKA-DRIFT-001 hint saying the entry “admits no tool the body invokes” — it is counting only the parent’s own tasks. Under composition the entry is load-bearing: keep it. The hint is advisory and does not fail the check.
Each child keeps its own hash-chained trace; the parent’s trace records the child’s outcome, so a run is a forest of chains rather than one flattened stream. Full contract: spec 14 · Composition.

Supply values at launch · --var

nika run takes one --var key=value per input (repeatable):
Four rules govern the flag, and the file stays the contract — every input a caller can pass is declared in inputs::
  • A supplied value overrides a declared default:. --var bullets=5 wins over default: 3 for this run.
  • It satisfies required: true. A required input with no default must be supplied at launch. Measured on 0.120.1: nika check stays clean and prints HINT [inputs] required input(s) with no default · pass at run time: --var name=…. nika run refuses before any task: NIKA-1708 · missing required inputs (exit 3). The SDK throws NikaOperationError with that code. This is admission, not a task-level NIKA-VAR-001.
  • The value parses as JSON when it parses. --var bullets=5 arrives as a number, --var deep=true as a boolean, --var tags='["a","b"]' as an array. Anything that does not parse as JSON rides as a string — no quoting gymnastics for the common case.
  • An unknown key is refused before the run, with the declared set listed — a typo that silently did nothing would be the worst outcome:
--var reaches inputs: and nothing else. A const: entry is immutable across the run by construction and is never a launch flag; a deployment’s value is an input with a default:, so --var may still override it.

Outputs and events

Every task emits a structured Event stream during execution (nika-kernel::infra::event_sink::Event). Consumers subscribe via EventSink and receive one JSON object per line (NDJSON on stdout by default). See Events for the event model and Bindings for how the DAG propagates values.
The workflow grammar is locked and implemented: the nika-schema crate ships the envelope, the analyzer and semantic validation (type-checked bindings, secret-flow audit, cycle detection). That is exactly what nika check runs on your file today.

Bindings

Template expressions: ${{ }} syntax, scopes, CEL + jq, taint.

Events

The typed event stream every run emits.

Verbs

The 4 verbs each task picks from.

YAML reference

Full schema specification (generated from AST).