Skip to main content
The machine-readable schema is at https://nika.sh/schema/workflow.json. Point your editor at it for live validation. See editor setup.

Top level

the envelope · illustration, not a runnable file
Nine keys, closed. nika: is the type discriminant and the identity: a tasks: key makes the document a workflow, its absence makes it a project file (nika.yaml, whose mark is nika: <project-name> — a kebab-case identity, the same line nika init --project-file writes), and the filename carries no verdict — a registry blob, an HTTP body, a stdin pipe (nika check -) must say what they are by their bytes alone. nika: v1 on a project file is the retired schema tag (the reader refuses it: the tag became the name). nika: v1 + workflow: { id } is the previous workflow envelope (through 0.108): nika check --fix migrates it, moving the identity onto nika: and the description: prose into a comment above it.

The three value authorities

Every value a workflow declares lands under exactly one of three authorities. The family is closed — a value-namespace read outside it is NIKA-VALUES-003.
The integrity column is not decoration: under a permits: block an untrusted value that reaches a verb argument is re-gated on its canonical resolved form, and an escape is refused NIKA-AUTH-008. That is the practical reason a destination the boundary pins belongs in const: — a caller cannot move it out from under the permit. vars:, env: and config: are dead envelope fields (NIKA-VALUES-001 / NIKA-VALUES-002 / NIKA-PARSE-005), and so are the ${{ vars.X }} / ${{ env.X }} / ${{ config.X }} reads. There is no alias: each old use classifies into the authority its role commands — a typed parameter is an inputs: declaration, a fixed value a const: entry, a deployment-supplied value an inputs: entry with required: false and a default:, a governed store reference a secrets: entry. nika check --fix migrates the provable cases. (The env: inside an exec: block is a different field and is alive: it is the child process’s environment map.)

permits: — the key is optional, the authority is not

An absent permits: block declares zero authority. Every effect the body requires is refused at check with NIKA-AUTH-006 — before a single token — and any effect attempted at run time fails the task with NIKA-SEC-004. A pure-compute workflow passes with an informational hint; permits: {} is the authored spelling of “I touch nothing”.
Once the block is present every category is default-deny unless listed — including tools:, which must name every builtin the file invokes, even the pure-compute ones.

The run declaration

Every source of randomness and time is declared, never ambient. Absent, the run behaves as entropy: ambient + clock: system. The two dimensions couple: only ambient × system and none | seeded × virtual are legal — byte-identical journals need deterministic time as much as deterministic randomness. A declared contradiction refuses at parse (NIKA-PARSE-026 · NIKA-PARSE-027); entropy: none with a live randomness source consumed refuses at check (NIKA-PARSE-028).

Types · inline, where the shape is used

A shape rides the verb’s own schema: (structured output) or a task’s returns: — inline, as a type expression. There is no named-type block any more (types: left the envelope with the nine-key one · NIKA-TYPE-002 retired with it).
typed-triage.nika
The type grammar is closed: the ten primitives (null · bool · integer · number · string · bytes · uri · path · duration · timestamp, always lowercase) or a constructor — object: · array: · map: · union: · enum: · plus the refinements integer:/number: (min/max) and string: (pattern/min_len/max_len). An unknown name is NIKA-TYPE-001.

What left the envelope (0.109)

The nine-key envelope retired a family of top-level keys. Each refusal names the key’s role and where it went — nika check says it on the line, this is the same list: nika check --fix migrates the provable cases (the identity · config: · the renamed task fields below); a structural move stays yours, with the refusal naming it.

A task

Those modifiers are the complete task surface. returns: and lift: are the two that carry a contract of their own:
inert-fetch.nika
Drop the lift: entry and that same file fails the check with NIKA-SEC-008. The door is greppable by design: a reviewer can find every place a law was opened, and each entry carries its written justification into the run receipt. (declassify: and inert: were the two earlier spellings; both merged into lift: — the law is a parameter of one door, not two keywords.) Cleanup is a task, not a task field: write it as its own task joined by after: { <parent>: unwind } — it runs once the parent has settled, whatever happened (graph_format: 3 projects it as a finally node). The old on_finally: list inside a task body is gone; so is on_error: fail_workflow — a failure that is not recovered or skipped already fails the run, there is nothing to declare. tasks.* crosses a task boundary through exactly two doors — with: (data · observations) and after: (control) — and the engine computes the graph FROM those doors. A with: binding that references ${{ tasks.X.* }} both names the data and declares a typed edge; an after: entry ({producer: predicate}) orders on state and carries no data. depends_on is dead (NIKA-PARSE-024 · nika check --fix migrates it), and a tasks.* reference outside the boundary — in a verb body, when:, or for_each: — is NIKA-VAR-021 with a machine-applicable fix (hoist into with:). The after: predicate set is closed: success · failure · skipped · terminal (terminal admits any settled state, cancelled included). Anything else is NIKA-DAG-005; the participial spellings succeeded and failed are dead forms and refuse with their respelling, which nika check --fix applies. The full per-field semantics live in the spec: 03 · The flow.

Timeouts

timeout: is a quoted Go-duration string ("90s", "7m", "1h30m") and bounds the entire task, wall-clock: retries and their backoff sleeps included. Exceeding it fails the task with NIKA-TIMEOUT-001 — catchable by on_error:, never retryable (the timeout already covered the retries by definition). On a for_each task the clock applies per iteration. On an infer: / agent: task the declared timeout: also governs the provider HTTP deadline: timeout: "7m" gives the provider round-trip those seven minutes — no internal HTTP default undercuts the declared budget.
When no timeout: is declared, the provider deadline defaults per provider class: A local model routinely needs minutes for one completion on consumer hardware — a 30s-everywhere default would silently kill every serious local-first workflow before the model finishes thinking. The class is keyed on the canonical provider id: a base_url override never flips it. Two honest bounds ride the transport (pinned by the engine’s wire tests):
  • 600s ceiling on a fully-silent connection. A non-streaming completion delivers zero bytes while the model computes, so the transport cannot tell thinking from dead. A longer timeout: still bounds the task, but only a connection that starts delivering can use it.
  • Streaming rides only an explicit budget. A declared timeout: bounds a streaming request; when none is declared, the idle-read guard reaps a stalled stream instead of capping a healthy one.
Normative home: stdlib · providers §Transport deadline.

Verbs

See individual pages:
  • infer: LLM call
  • exec: shell commands
  • invoke: built-in or MCP tool (HTTP fetch is invoke: nika:fetch)
  • agent: tool-calling loop

Full schema

The canonical JSON Schema is at https://nika.sh/schema/workflow.json. It’s 100% machine-authoritative. This doc is a readable summary.

Contract version policy

The envelope carries no version marker: nika: <identifier> is the mark and the name, and there is nothing to bump. Minor additions (a new optional field, a new builtin) are additive; a grammar change before 1.0 ships with its migration in nika check --fix and its refusal naming the move (the pre-1.0 stability contract) · after engine 1.0.0 the grammar is additive only. The project file nika.yaml carries no tasks: and names the project (nika: my-project); nika: v1 there is a retired schema tag, not an identity.