Skip to main content
A workflow is a YAML document that names a goal, lists one or more tasks, and declares how values flow between them. Nika parses the file with source spans, validates it against the catalog, compiles it to a DAG, and executes tasks in topological order, parallelizing whatever the dependency graph allows.
Implementation status. The envelope below is the canonical spec contract (nika-spec · 01-envelope). The engine implements it end-to-end today: nika check parses and audits it, nika run executes it. Live state →

Minimal workflow

Two required lines (nika: + workflow:) and a non-empty tasks:, the whole minimum to be a valid workflow. Fields carry source spans through Spanned<T> so diagnostics point at the exact byte range.

Why one version marker (not a K8s envelope)

The envelope is one header line · nika: v1. Earlier drafts explored a Kubernetes-style apiVersion + kind + metadata + spec envelope, but the spec rejected it: that is two version-ish fields and ceremony a workflow file does not need. Modern specs converge on a single version marker: OpenAPI writes openapi: 3.1.0, Docker Compose dropped its version: field entirely. Nika takes the proven path: the language name as the key, the contract version as the value.
  • No separate kind: field: the presence of workflow: is itself the document-type discriminator. Future document types (if any ever ship) use their own top-level key.
  • 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: The four value authorities are a closed family: inputs · config · const · secrets. The pre-flip vars: and env: blocks are dead (NIKA-VALUES-001 / NIKA-VALUES-002) and no alias survives — each old use classifies into the authority its role commands. (The env: field inside an exec: block is a different thing and is alive: it is the child process’s environment map.) Every field is span-carrying (Spanned<T>): source spans survive all the way into the diagnostic layer (ADR-010 miette bridge).

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 doorswith: (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.
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 · failed · skipped · terminal — where terminal admits any terminal state, cancelled included (the always-pattern: « run once X is settled, whatever happened »). Fan-out over a collection (files here is a jq output binding on list_files, e.g. output: { 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 (vars · env · 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 value the file owns and nobody overrides is a const: entry; a deployment dial is a config: declaration; a credential is a secrets: reference. Four authorities, one role each — pick by the role the value plays.
research-brief.nika.yaml
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.yaml
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.yaml

The rules the checker enforces

  • Static targetworkflow: 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; without it the task that reads it fails loud (NIKA-VAR-001, naming the exact flag to pass). nika check flags it earlier, statically: HINT [inputs] required input(s) with no default · pass at run time: --var topic=….
  • 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 a config: value comes from the deployment — neither is a launch flag.

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, filters, 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).