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
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 ofworkflow: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/v1for RDF / conformance tooling, but the author never types a URL.
---, 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
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.
${{ 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:
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):
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
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
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 target —
workflow:is a filesystem path or a pinnedregistry: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’sinputs:, and what the parent declares inreturns:must fit the child’soutputs:(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).
Supply values at launch · --var
nika run takes one --var key=value per input (repeatable):
inputs::
- A supplied value overrides a declared
default:.--var bullets=5wins overdefault: 3for 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 checkflags 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=5arrives as a number,--var deep=trueas 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 structuredEvent 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.Read next
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).