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 isNIKA-VALUES-003.
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”.
tools:, which must name every builtin the file invokes, even the
pure-compute ones.
The run declaration
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
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:
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
returns:,
declassify: and inert: are the three that carry a contract of their own:
inert-fetch.nika.yaml
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 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.
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.
Verbs
See individual pages:infer: LLM callexec: shell commandsinvoke: built-in or MCP tool (HTTP fetch isinvoke: 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.