.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) ·):
model: later for a local or cloud seat. The file is the contract;
--var / SDK inputs supply caller values.
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 oftasks:is itself the document-type discriminator — atasks:key makes a workflow, its absence makes the project file (nika.yaml, the one document that still spellsnika: 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/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:
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
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.
${{ 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 ·
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):
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):
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.
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.
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”:
args: that fit the child’s
inputs:, and reads the child’s outputs: back as that task’s output:
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. Measured on 0.120.1:nika checkstays clean and printsHINT [inputs] required input(s) with no default · pass at run time: --var name=….nika runrefuses before any task:NIKA-1708 · missing required inputs(exit 3). The SDK throwsNikaOperationErrorwith that code. This is admission, not a task-levelNIKA-VAR-001. - 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 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 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.
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
${{ }} syntax, scopes, CEL + jq, taint.