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 · a skeleton, not a runnable file

The four value authorities

Every value a workflow declares lands under exactly one of four 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: and env: are dead envelope fields (NIKA-VALUES-001 / NIKA-VALUES-002), and so are the ${{ vars.X }} / ${{ env.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, non-sensitive runtime configuration a config: declaration, 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 and policy

Two optional envelope keys that most workflows never need — and that the checker enforces the moment they appear. types: declares named PascalCase types once; returns: (and outputs:) then reference them by name instead of restating the shape. policy: declares the limits the run must obey, checked before any token is spent.
typed-triage.nika.yaml
The type grammar is closed: the primitives (null · bool · integer · number · string · bytes · uri · path · duration · timestamp), a PascalCase reference to one of your own, 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; a recursive types: graph is NIKA-TYPE-002. The policy rule set is closed per minor. Rather than trust a list here, ask the binary — a refused policy: block prints the whole current set:
Today that set is six families — require · forbid · allow · limits · prefer · optimize — carrying require.human_gate_before · forbid.exec_after (both over exec/write/net/tools) · allow.providers · limits.max_tasks · prefer.providers · optimize (cost/latency/quality). A violated hard rule is NIKA-POLICY-001 at check time, naming rule, task and witness.

A task

The eighteen modifiers above are the complete task surface. returns:, declassify: and inert: are the three that carry a contract of their own:
inert-fetch.nika.yaml
Drop the inert: line and that same file fails the check with NIKA-SEC-008. Both doors are greppable by design: a reviewer can find every place a law was waived, and each one carries its written justification into the run receipt. tasks.* crosses a task boundary through exactly two doorswith: (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

nika: v1 is a single version marker: v1 is the only value for the entire lifetime of the v1 contract. Minor additions (a new optional field, a new builtin) are additive and never change it (no @minor bumps, no nika migrate for them). There is no nika: v2 — ever: pre-1.0 grammar changes happen inside v1 (the pre-1.0 stability contract) · after engine 1.0.0 the grammar is additive only.