the spec · 01
Envelope
Every Nika workflow starts with one header line.
01 · Envelope #
Every Nika workflow starts with one header line. That line names the language and pins the contract version. Everything else is the workflow id, optional defaults, and the task graph.
Minimal envelope #
nika: v1workflow: id: my-workflow-idtasks: my_task: ...The nika: header, a workflow: object with an id, and a non-empty
tasks: map. That's the whole minimum to be a valid Nika workflow.
Full envelope #
nika: v1 # required · language + contract versionworkflow: # required · the workflow object (W1: the map) id: scrape-and-summarize # required · kebab-case · unique within file description: "Fetch + summarize" # optional · human-readable# Workflow-level default model · any task may override · <provider>/<name>model: ollama/qwen3.5:4b # optional · anthropic/claude-sonnet-4-6 for cloud# Named types · referenced by returns:/outputs: (09-types.md)types: Summary: { object: { title: string, bullets: { array: string } } }# Typed workflow inputs · supplied by the caller at launch · available as ${{ inputs.<name> }}inputs: topic: # every entry is typed (the full TypeExpr of 09-types) type: string required: true description: "Subject to research"# Named constants · fixed values baked into the workflow · available as ${{ const.<name> }}const: output_dir: "./output" # bare literal# Non-sensitive runtime config · supplied by the deployment · available as ${{ config.<name> }}config: log_level: { type: string, default: "info" }# Sensitive values · vault-backed · masked in logs · available as ${{ secrets.<name> }}secrets: api_key: source: vault # never inline · a reference to a store key: prod/anthropic/api-key# The declared capability boundary · the file IS the blast radius (optional · default-deny once present)permits: net: { http: ["api.anthropic.com"] } # only this host · everything else denied fs: { write: ["./output/**"] } # no reads · writes only under ./output exec: false # this workflow runs zero shells# Tasks (the DAG) · map keyed by task idtasks: some_task: ...# What this workflow returns · ${{ tasks.<id>.output }} refs · untyped OR typedoutputs: summary: ${{ tasks.summarize.output }}YAML profile (normative) #
A workflow file is YAML 1.2 · core schema, restricted by the Nika YAML profile (the R11 law set · `canon/laws/yaml-profile.yaml`). Two consequences authors hit ·
- Anchors & aliases (`&x` / `*x`) are FORBIDDEN: the profile refuses
an anchor even when it is never referenced (LAW-GRAMMAR-0101/0102 ·
dedicated diagnostics
NIKA-YAML-001/NIKA-YAML-002; the reference engine already refuses them at parse). What a Nika file shows a reviewer IS what the engine sees — aliasing expands invisible bytes into the document and opens the reference-bomb class, so repeated blocks are repeated in the source, not aliased. - Merge keys (`<<:`) are FORBIDDEN too: YAML 1.2 dropped them (they
were a 1.1 extension) · parser support varies · a portable workflow
MUST NOT use them · refused with
NIKA-YAML-003(LAW-GRAMMAR-0103).
The complete closed profile (duplicate keys · custom tags · non-string
keys · NaN/Infinity · depth and size caps · UTF-8 NFC · no BOM) is the
R11 law set above; its dedicated prose chapter ships with the
law-projection wave. Until then the law file is normative and
05-errors allocates the
NIKA-YAML namespace.
(YAML 1.2 core also kills the 1.1 traps: no is a string, not false ·
3:22 is a string, not sexagesimal. The quoted-duration rule of
03 §timeout
adds the belt to those suspenders.)
Field-by-field #
`nika` · **required · the contract version** #
nika: v1The first line of every workflow. The key nika declares « this is a
Nika workflow »; the value v1 pins the language contract version.
v1 is the only value for the entire lifetime of the v1 contract. Minor
additions to the language (a new optional field, a new builtin) are
additive and never change this value. There is no `nika: v2` — ever:
while the reference engine is pre-1.0, deep grammar changes happen INSIDE
v1 (per the pre-1.0 stability contract);
after engine 1.0.0, changes are additive only. (This is the language
version, independent of any engine version.)
Anti-pattern · do not write nika: v1.0 · nika: "1" · or
nika: 1.0. The value is exactly v1.
Why one field, not `apiVersion` + `schema`? Earlier drafts used a Kubernetes-styleapiVersion: nika.sh/v1(the superseded ADR-021 form) plus a separateschema: nika/workflow@v1(superseded too). That is two version-ish fields and ceremony a workflow file does not need. Modern specs converge on a single version marker: OpenAPI writesopenapi: 3.1.0, Docker Compose dropped itsversion:field entirely. Nika takes the middle, proven path: one field, the language name as the key, the contract version as the value. The engine's internal canonical URI stayshttps://nika.sh/spec/v1for RDF / conformance tooling, but the author never types a URL.
`workflow` · **required · an OBJECT · `{id, description}`** #
workflow: id: scrape-and-summarize description: "Fetch article, summarize in 3 bullets, write to disk"The workflow object — a stable home for the file's identity and metadata.
- `id` · required · kebab-case (
^[a-z][a-z0-9-]*$) · unique within file · used in journal events, traces and error messages. - `description` · optional · free-form text · not used by the engine
for execution · useful for
nika lslistings + LSP hover hints.
The presence of workflow: is also the document-type discriminator:
it marks this file as a workflow. Future Nika document types (if any ever
ship) would use their own top-level key; there is no separate kind:
field in v1.
Dead forms (rejected with a migration teaching · W1 « the map ») ·
a scalar workflow: some-id is NIKA-PARSE-020; a top-level
description: is NIKA-PARSE-021 — both moved INTO the object. No alias
survives: the old spellings left the parser in the same window that
introduced the object.
`model` · *optional · default model · `<provider>/<name>`* #
model: ollama/qwen3.5:4b # local# model: anthropic/claude-sonnet-4-6 # cloud · same shapeDefault model for any infer: or agent: verb in this workflow, as a single
`<provider>/<name>` string (the LiteLLM / OpenRouter / Vercel convention:
there is no separate provider: field). The provider prefix selects the
backend and decides local-vs-cloud (ollama/ · lmstudio/ = local · the rest
= cloud). See stdlib/providers-v0.1.md for the
17-provider catalog.
A task may override this. If absent · each infer:/agent: task must specify
its own model:.
`types` · *optional · named type declarations* #
types: Summary: object: title: string bullets: { array: string }Named, PascalCase, acyclic type declarations — referenced by task
returns: and typed outputs:. The grammar, the subtyping lattice and
the JSON-Schema lowering are the whole of 09-types.md;
the envelope only owns the block's position. Unknown names are
NIKA-TYPE-001 · recursion is NIKA-TYPE-002.
`inputs` · *optional · typed workflow inputs* #
inputs: topic: type: string # the full TypeExpr of 09-types.md · named types · unions · shapes · refinements required: true # default false · the caller MUST supply a value default: "Rust async 2026" # used when the caller omits it · MUST conform to type: description: "Subject to research"Inputs available in every task via ${{ inputs.<name> }} substitution.
Every entry is a typed declaration (type: required). The type speaks
the full TypeExpr of 09-types.md — the flat 6-enum
(string · number · integer · boolean · array · object) is dead
and bool is the one boolean spelling (R3b · LAW-GRAMMAR-0211). An unknown
type name or a form outside the grammar refuses NIKA-TYPE-001; a
default: that does not conform to its type: refuses at check
(NIKA-DEFAULT-001 · the P0 soundness hole is closed). Typed inputs: let
the engine validate what a caller passes and generate a callable schema:
this is what powers nika.run_workflow over MCP (a caller like an agent
host sees the typed inputs and knows exactly what to pass) and UI
generation. Typed inputs: are the input half of that callable
contract; typed `outputs:`
(below) are the output half.
Supplying values at launch · the caller provides inputs when starting
the run; how is an engine CLI concern. The reference engine's surface ·
nika run flow.nika.yaml --var topic="Rust async 2026" (repeatable · one
--var key=value per input). A supplied value overrides a declared
default: and satisfies a required: true input (conformance rule 5
below · a missing required input rejects before execution). A value parses
as JSON when it parses (--var limit=5 is a number · --var deep=true a
boolean) · else it rides as a string. An unknown key is refused before
the run, with the declared set listed: a typo that silently did nothing
would be the worst outcome. The file stays the contract · every input a
caller can pass is declared in inputs:.
`config` · *optional · non-sensitive runtime config* #
config: log_level: { type: string, default: "info" } region: { type: string } # no default · the deployment supplies itNon-sensitive configuration available via ${{ config.<name> }}, supplied
by the deployment or environment (engine launch concern · default: is the
declared fallback). Each entry is a typed declaration (type: required ·
full TypeExpr · a default: MUST conform · NIKA-DEFAULT-001). Values may
appear in logs and traces. For anything secret, use secrets: instead.
Declared-only · no ambient OS fallback · a ${{ config.X }} read
resolves ONLY against this block (an undeclared entry is NIKA-VAR-001):
the engine never silently reads the OS environment — every value a workflow
depends on is visible in the file (sovereignty + portability).
`const` · *optional · named constants* #
const: output_dir: "./output" # bare literal · any YAML value retries: 3 window: # typed constant · object carrying BOTH type and value type: integer value: 30 # MUST conform to type: (NIKA-DEFAULT-001)Fixed values baked into the workflow, available via ${{ const.<name> }}.
Either a bare literal or a typed constant { type, value } (the
type: speaks the full TypeExpr; the value: MUST conform to it).
The discriminator (normative) · an object carrying BOTH type and value
keys IS a typed constant; an object missing either key is a bare literal
object constant — so a literal that legitimately contains a type key
(settings: { type: "custom" }) is never misread. Constants are immutable
across the run and never caller-supplied: a value the caller may override
is an inputs: declaration, not a constant.
See 04-variables.md for the full substitution grammar.
Dead forms (rejected with a classification teaching · the E-split · R3a). The pre-flipvars:andenv:envelope fields are dead:vars:refusesNIKA-VALUES-001,env:refusesNIKA-VALUES-002. Classify each old use into the authority its role commands — a typed parameter is aninputs:declaration, a fixed value is aconst:entry, non-sensitive runtime configuration is aconfig:declaration, a governed store reference is asecrets:entry (classify-not-rename · no alias survives · LAW-SURFACE-0201/0202 · LAW-GRAMMAR-0201/0202).
`secrets` · *optional · vault-backed · masked* #
secrets: api_key: source: vault # vault | env | file · never an inline value key: prod/anthropic/api-key github_token: source: env # read from the OS env var below · still masked key: GITHUB_TOKEN signing_pem: source: file # read file contents at resolve time · masked path: ~/.keys/signing.pemThe shape is discriminated by `source:` (required) · vault and env
require key: · file requires path: · an entry with neither (or an
inline literal value) is rejected (NIKA-PARSE · validation_error).
Sensitive values available via ${{ secrets.<name> }}. A secret is always
a reference to a store, never an inline literal. The engine masks
resolved secret values in logs, traces, and journal events.
`source` · closed enum (the only three v0.1 values) ·
- `source` · `key` means · Use
- `vault` (default) · path in the local `nika-vault` · the sovereign default
- `env` · name of an OS environment variable · 12-factor / CI secrets
- `file` · path to a file holding the value · Docker / k8s mounted secrets
The config / secrets split is the modern secure-workflow default: non-sensitive
config in config: (appears in logs), masked references in secrets: (never
logged). Note source: env reads a secret from an OS env var and still masks
it, which is different from the plain config: block.
`egress` · *optional · sanctioned destinations (declassification)* #
By default, a secrets.<name> value reaching ANY effect, an exec: or
invoke: sink, OR an infer:/agent: prompt (prompt: + system:),
is a blocking leak: the engine masks its own output but cannot follow a
secret a subprocess, a tool, or a third-party provider re-emits (nika check
refuses the workflow — NIKA-SEC-006, the taint path in the diagnostic;
a tainted value reaching outputs: is NIKA-SEC-007 ·
10 §secret flow). An infer:/agent: prompt is a
provider-egress sink like any other: a secret in it LEAVES the run to the
provider, so it needs an explicit sanction (egress: [{ to: "infer" }] /
{ to: "agent" }) exactly as a tool sink does.
The ONE carve-out is the infer/agent output, NOT the prompt: a model
response is not a verbatim echo of its prompt, so tasks.<id>.output of an
infer:/agent: task is never tainted by a prompt secret (it does not
re-leak downstream).
But legitimate workflows MUST send a secret to other sinks: a webhook-URL
secret to nika:notify, an API key in a nika:fetch header. The optional
egress: list on a secret sanctions exactly those destinations · the
author declassifies their OWN secret, in-file, statically checkable.
secrets: stripe_key: source: env key: STRIPE_API_KEY egress: - to: "nika:fetch" # the SPECIFIC sink · a tool id or "exec" host: "api.stripe.com" # the static-literal destination host slack_webhook: source: env key: SLACK_WEBHOOK_URL egress: - to: "nika:notify" host_from_self: true # the secret value IS the URL (host unknown statically)Default-deny. An absent / empty egress: is the current behavior,
unchanged: NO sanctioned egress, every exec:/invoke: reach is a leak.
Semantics (normative) · a secret→sink edge is sanctioned iff ALL hold ·
- Layer · Rule
- **① confidentiality** · the sink's id equals an `egress[].to:`: a tool id (`nika:fetch` · `mcp:<server>/<tool>`), `exec`, or the provider-egress sinks `infer` / `agent` (an infer/agent prompt). `to:` is SPECIFIC: a clearance for `nika:fetch` never authorizes `exec` (no cross-tool laundering).
- **② integrity** · for a network sink, either `host:` equals the effect's **static-literal** destination host (a templated `${{ }}`-derived host does NOT sanction · it stays the runtime check), OR `host_from_self: true` AND the destination arg is **exactly** `${{ secrets.<this> }}` (not concatenated) AND **no other secret co-occurs** in the same effect payload. A sink with no addressable host (`{ to }` alone) clears this layer.
- **③ capability** · when a [`permits:`](#permits--optional--the-declared-capability-boundary) block is present and the host is statically known, the host MUST ALSO be in `permits.net.http`: `egress:` NARROWS the capability boundary, never widens it. `host_from_self` (host unknown statically) degrades to the runtime `permits` check.
to: is required. host: and host_from_self: are mutually exclusive (a host
is a literal you can check OR the secret itself, never both). Sanctioning the
egress clears the send, not the capture: a sanctioned exec:/tool can
still embed the secret in its captured output, so that output stays tainted and
re-leaks if it reaches an unsanctioned sink downstream.
The workflow boundary is a sink too — `to: "outputs"`. Because the
capture stays tainted, the return value of an authenticated call
(tasks.<id>.output of a fetch that sent an API key) taints every
outputs: entry it reaches — and outputs: is where a result LEAVES the
run, so the check reports it as an egress. That over-approximation is
deliberate (the provider saw the key; its response is not provably
clean), and so is the release valve: the secret's owner declassifies the
workflow boundary itself, in-file, exactly like any other sink —
secrets: API_KEY: source: env key: EXAMPLE_API_KEY egress: - to: "nika:fetch" # the send - to: "outputs" # the return value derived from the responseto: "outputs" is sink-only (no host: — the workflow boundary has no
address) and as SPECIFIC as every other rule: it clears the outputs:
report for taints originating from THIS secret and nothing else — it
never authorizes a send, and a nika:fetch clearance never authorizes
the boundary. Absent the rule, the report stands (default-deny).
The general untrusted→decision integrity lattice lands surface by surface:
the permit-parameterization taint (untrusted values under a present
permits: block) is normative in 10 §the
permit-parameterization taint (NEP-0004); the remaining decision surfaces
stay a documented follow-up covered by the static guards above.
`permits` · *optional · the declared capability boundary* #
permits: # the workflow's entire blast radius, declared in-file fs: { read: ["./data/**"], write: ["./out/**"] } # path globs (gitignore-style) net: { http: ["api.example.com", "*.github.com"] } # host allowlist — the SSRF boundary, in-file exec: false # may this workflow run shells? false | true | ["git", "cargo"] (program allowlist) tools: ["nika:read", "nika:write", "mcp:browser/*"] # the builtin/MCP surface it may invoke env: ["CI_COMMIT_SHA"] # engine env names passed through to child processes · exact names · composed, never inheritedpermits: makes the file itself the security boundary: an auditable
property of the workflow, not a runtime flag. The key stays optional in
the grammar (LAW-AUTH-0320 · no new field, no version marker); an absent
block is not a free pass: an absent `permits:` block declares zero
authority (NEP-0003 · LAW-AUTH-0324 · fail-closed). DeclaredPermits :=
∅, so every effect the body requires is refused at check
(NIKA-AUTH-006 · before any token · the diagnostic carries the inferred
block inline, ready to paste), and any effect attempted at run time fails
the task (NIKA-SEC-004 · defense in depth for the dynamic cases a static
judge cannot see). A workflow whose required set is empty (pure compute)
passes, with an informational hint naming the explicit form; permits: {}
is the authored spelling of "I touch nothing". Under composition the
absence is a wall, the zero wall: a parent's grants never flow down to a
child implicitly (NIKA-COMP-002).
Semantics (normative) · once `permits:` is present, every category is DEFAULT-DENY unless listed, and every bound in the block is a LITERAL — interpolation never reaches this block (an interpolated host/glob/program is a hard refusal, `NIKA-AUTH-007` · untrusted values under the block are re-gated on their canonical resolved form, [10](./10-authority.md) §the permit-parameterization taint) ·
- Category · When listed · When the `permits:` block is present but this category is omitted
- `fs.read` / `fs.write` · only the matching path globs are allowed · **no** filesystem read / write
- `net.http` · only the listed hosts (**exact names only** · an entry carrying a `*` anywhere but as the whole bare-`*` entry is refused at check, `NIKA-AUTH-010` · the bare `*` stays the explicit, visible escape) · tightens the engine SSRF floor — the one loosening is the exact-loopback declassification below · **no** outbound network
- `exec` · `false` = no shells · `true` = any (still blocklist-gated) · array = only those program names (argv `command[0]`) · treated as `false` · **no** `exec:`
- `tools` · only the matching `nika:` / `mcp:` ids (globs ok) · **no** `invoke:` of any tool
- `env` · only the named engine variables pass through to child processes (exact names · the runner env floor rides beneath) · **no** passthrough · a child sees the runner env floor plus the task `env:` map only
A program allowlist verifies the argv form only (normative) · when
exec: is an array of program names, a task whose command: is a shell
STRING is refused under that allowlist — at check and at run. A
leading-token heuristic is unsound ("git log; rm -rf /" leads with git);
the array command: form is the shape an allowlist can actually verify.
A path grant names an effective path identity, re-judged at dispatch
(normative · NEP-0009) · an fs.read / fs.write entry is not a string
prefix the sandbox mounts blindly: at dispatch the engine resolves the
grant's literal prefix to its effective form (the longest existing
ancestor canonicalized, the not-yet-existing tail carried lexically) and
re-judges that identity against the declared set ·
- Escape = refusal, never rewrite — a grant whose effective target
escapes the declared set (a planted symlink pointing out of the judged
tree) is REFUSED before spawn (
NIKA-SEC-004class) and the refusal is attested in the run journal (fs.path_mismatch, carrying the judged prefix and the resolved target). The engine MUST NOT silently mount the resolved target under the judged name — the receipt never lies: judged = mounted. - Platform parity — the same verdict holds on every enforcement arm
(the kernel path-walk arm and the mount-projection arm alike); a
conformance pair asserts it, so
check ≡ run ≡ jaildescends to symlinks. - A write grant whose target does not exist yet stays legal — the identity is judged on the longest existing ancestor.
- The residual window is declared — a parallel task of the same run
swapping the symlink between the dispatch re-gate and the mount is a
documented residual; the fd-pin projection (
--bind-fd, bwrap ≥ 0.10.0) closes it and is a named follow-on, not v1.
Exact-loopback declassification (normative) · an exact loopback
literal in net.http — the bare localhost name, a 127.x.y.z v4
literal, or the v6 loopback ::1 (the bracketed [::1] authority
spelling is accepted) — is the author's declassification of the SSRF
floor for that host only (the secrets `egress:`
precedent: the owner's explicit act, co-located with the boundary). The
clearing is exact-host (never a prefix, subnet, or resolution: a
permitted localhost does not clear 127.0.0.1, and a public DNS name
resolving to loopback stays refused) and host-level (ports do not
participate in permits). It never extends past loopback: a glob entry
(refused outright at check, NIKA-AUTH-010), the *.localhost family,
and RFC1918 / link-local / CGN / metadata targets stay floor-blocked even
when named — an exact entry naming one is an inert dead grant, flagged at
check under the floor parity (NIKA-SEC-005). DNS-rebinding stays covered: the engine
re-checks every resolved address and every redirect hop; a permitted
loopback name admits only its own loopback resolution, and a redirect
to any un-permitted floor host still refuses (NIKA-SEC-005). An
engine MUST NOT auto-write a loopback grant (e.g. from permits
inference) — the explicit act stays the author's.
The sandboxed egress proxy is the permit's exact projection (normative
· NEP-0008) · when a sandboxed exec child runs under a net.http
allowlist, the engine mediates every outbound connection through a
loopback egress proxy plus an OS fence that refuses every other
destination. Four laws hold ·
- DNS is resolved by the proxy, never by the client — the OS fence
refuses the confined client's own resolver (fail-closed), and the
proxy re-checks every resolved address against the SSRF floor
inside the dial loop (no resolve-then-connect window: a permitted
name that resolves to a blocked address is refused at dial,
NIKA-SEC-005). - The port floor is normative — the engine's dangerous-egress-port
denylist rides beneath every permit; no permit overrides it (a
net.httpgrant can never admit ssh/smtp/docker/kube/database ports). - One run, one allowlist — a run projecting two distinct
net.httpsets refuses fail-closed at the second projection; there is no ambient merge of egress boundaries inside one run. - A floor-blocked entry is a dead grant — an exact entry naming a
private / link-local / CGN / metadata / otherwise non-public address
can never take effect and is refused at check under the floor parity
(
NIKA-SEC-005); the only declassifiable target is the exact loopback literal of the paragraph above.
The proxy is TLS-blind by design: it observes the CONNECT authority (host · port) and relayed byte counts only, never the content — and every verdict (allowed · refused · bytes relayed) is journaled.
The environment category (normative · NEP-0005 · LAW-AUTH-0326) · a
child process environment (the exec subprocess · a stdio mcp:* tool
server) is composed, never inherited: the runner env floor ∪ the
declared env: passthrough (resolved from the engine's environment at
spawn) ∪ the task's explicit env: map (authored values · applied after
the passthrough, so an authored entry wins on the same name), minus the
dangerous-name floor. The runner env floor is the fixed list PATH,
HOME, TMPDIR, LANG, LC_ALL, TZ, USER, LOGNAME · a normative
MAXIMUM: an engine MUST NOT pass any undeclared name beyond it and MAY
pass fewer. An env: entry is an exact POSIX name
([A-Za-z_][A-Za-z0-9_]* · no globs) and a permit BOUND: an interpolated
entry is NIKA-AUTH-007 (a bound MUST be a literal · 10).
The dangerous-name floor (dynamic-linker injection · shell startup
sourcing · tool command hooks · interpreter pre-exec hooks · IFS · the
engine's canonical DANGEROUS_ENV_VARS list) is never passable: an
env: entry naming one is an inert dead grant, flagged at check
(NIKA-AUTH-009 · the SSRF dead-grant teaching applied to the env
plane), and a task-map entry naming one is stripped last. A declared name
absent from the engine environment passes nothing (no error · no
empty-string synthesis). Under composition (14)
the effective category is the exact-name intersection child ∩ parent.
env: is not inferable: permit inference MUST NOT invent the list (a
subprocess's environment reads are opaque) · the undeclared-read failure
mode is the child tool's own missing-variable error, and the repair is
one declared line (env: [NAME]).
So permits: {} is a workflow provably limited to pure compute (infer: +
CEL + nika:jq): zero fs, zero net, zero shell, zero tools, zero env
passthrough (its children see the runner env floor plus their task env:
maps, nothing else). That property is checkable BEFORE the run.
The engine MUST enforce `permits:` on BOTH surfaces ·
- Statically (
nika check) · anika:write ./etc/xoutsidefs.write, anika:fetchto an unlisted host, anexec:underexec: false, aninvoke:of an unlisted tool → a lint error (the run is refused before it starts). The SSRF floor is checked statically too: a literal URL — or anet.httpentry — naming a target the floor always refuses (loopback · private · link-local/metadata ·NIKA-SEC-005· 05-errors) is flagged atcheck, with or without apermits:block; outside the exact-loopback declassification above, no grant can admit such a target, so blessing it would be a false green (the run could never succeed). A DECLASSIFIED loopback host is the mirrored truth: the check stops flagging exactly where the run stops refusing (the entry is live, not a dead grant), and the clearing is stated informationally rather than silently un-flagged. - At runtime · any effect escaping the declared set fails the task
NIKA-SEC-004(security_error· never fed back to anagent:model: a capability boundary is not negotiation material). This catches the dynamic cases a static check cannot (a host computed at run time — including one that resolves to a floor-blocked address,NIKA-SEC-005).
permits.net.http and the agent tools: whitelist compose: the agent
whitelist scopes ONE task's tools; permits.tools scopes the WHOLE workflow
(the union ceiling). An agent may never be granted a tool outside permits.
`run` · *optional · the run's entropy + clock declaration* #
run: entropy: ambient # none | ambient | { seeded: <u64> } clock: system # system | virtualEvery source of randomness and time a run consumes is declared, never
ambient (normative · NEP-0010). The block is optional; absent, the run
behaves exactly as entropy: ambient + clock: system (the status quo).
The key set is closed ({entropy, clock} only — a typo'd declaration is
refused in both parse modes, like permits:).
- `entropy: { seeded: N }` forces the deterministic seams and pins the
run's seed: two runs of the same file with the same
Nproduce byte-identical journals.entropy: nonedemands strict determinism (any structural randomness is a finding).entropy: ambientis the honest status quo — legal, and named as what it is. - `clock: virtual` substitutes the engine's injected virtual clock for
wall time: a
timeout:budget reads against the declared clock, and time becomes a contract input. - The dimensions couple (normative): byte-identical journals require
deterministic TIME as much as deterministic randomness, so the only
legal pairs are
ambient × system(the status quo) andnone | seeded × virtual(the deterministic states). A declared contradiction (ambient × virtual·none | seeded × system) is refused at parse (NIKA-PARSE-026·NIKA-PARSE-027). The contradiction is judged on the DECLARED pair:clock: virtualalone (entropy left implicit) stays legal — a test configuration that makes no determinism claim. Andentropy: nonewith a structural randomness source consumed (a live retry jitter ·nika:uuid) is refused at check (NIKA-PARSE-028). - One run, one clock: the declaration lives at the envelope, never per task — a run has exactly one entropy source and one clock, so the composition stays auditable.
`tasks` · **required · a non-empty MAP · the key IS the identity** #
tasks: task_1: ... task_2: ...The DAG. Each key names one task (snake_case · ^[a-z][a-z0-9_]*$ ·
CEL-safe); duplicate keys are rejected loudly at parse. Source order is
presentation only — the graph alone determines scheduling. See
03-dag.md for the task model.
Dead forms (rejected with a migration teaching · W1 « the map ») ·
a tasks: SEQUENCE is NIKA-PARSE-022; an id: field inside a task is
NIKA-PARSE-023 — the map key replaced both. No alias survives.
`outputs` · *optional · the workflow's return value · untyped OR typed* #
outputs: # Untyped form — just a reference to a task output summary: ${{ tasks.synthesize.output }} # Typed form — declares the return shape · powers the callable-workflow output schema report: value: ${{ tasks.write_report.output }} type: string # the full TypeExpr of 09-types.md (R3b · one type language on both halves) description: "The final markdown brief"outputs: declares what the workflow returns, the symmetric twin of
inputs: (what it takes in). Each entry is a name bound to a
${{ tasks.<id>.output }} reference (or any ${{ ... }} expression), in the
untyped form (bare reference) or the typed form
({ value, type, description }).
This single block serves three consumers ·
- `nika run`: prints this object as the workflow result (without
outputs:, the CLI result is engine-defined and implicit). - `nika.run_workflow` over MCP: a caller (agent host · parent workflow)
receives exactly this shape. Together with typed
inputs:it forms the complete callable contract · typed in, typed out. - Schema generation: typed outputs generate the output half of the
callable schema (typed
inputs:generate the input half).
If outputs: is omitted, the workflow still runs; its result is
engine-defined (a reusable/callable workflow SHOULD declare outputs:). The
referenced task ids must exist (parse-time validated).
`outputs:` (envelope · plural) ≠ `output:` (task · singular). The workflow-leveloutputs:is the return contract; the task-leveloutput:(04-variables.md) defines named jq bindings on one task. Plural-at-the-top, singular-per-task: the same split GitHub Actions uses forworkflow_call.outputsvs stepoutputs.
What leaves a run · the export contract (normative) #
Exactly three things cross the run boundary · nothing else is promised ·
- The `outputs:` object: when declared, a conformant engine's run command prints it as a single JSON object on stdout (and returns the same shape over MCP). Diagnostics · progress · logs go to stderr · never interleaved into the stdout JSON.
- The exit code: maps 1:1 to the workflow final state
(05 §workflow-level semantics) ·
success→ 0 ·failure→ non-zero ·cancelled→ non-zero (distinct from failure's code · engine-documented). Parse/validation rejection (the run never started) is also non-zero. - Tool side effects: whatever the workflow's tools wrote (files · notifications · MCP calls). The language does not track these; they are the workflow's business.
A schema'd machine run report (per-task statuses · timings · costs ·
ProofChain-compatible provenance) is deferred: see
08 §Horizon postures H10 ·
until then nika:inspect exposes run introspection inside the run, and
the completion event (05) is the engine-side hook.
YAML conventions · no traps #
A Nika file is YAML 1.2 (which is a strict superset of JSON: every Nika workflow can also be written as JSON). YAML 1.2 is mandated specifically to avoid the classic YAML 1.1 footguns that bite generated configs:
- Trap (YAML 1.1) · What happens · The rule
- `region: no` · parsed as boolean `false` (the « Norway problem ») · YAML 1.2 keeps it a string · still, **quote** bare words that look boolean (`no` · `yes` · `on` · `off`)
- `id: 0755` · parsed as octal `493` · **quote** numbers with leading zeros · `"0755"`
- `at: 12:30` · parsed as sexagesimal `750` · **quote** colon-bearing scalars
- `v: 1.10` · parsed as float `1.1` (trailing zero lost) · **quote** version-like strings · `"1.10"`
One rule that removes all of them · when a scalar could be misread as a number, boolean, or date, quote it. When in doubt, quote.
Expressions · a bare ${{ … }} reference is a safe plain scalar
(prompt: ${{ inputs.topic }} is fine). But quote any expression that
contains : # [ { , or > so YAML does not misparse it ·
when: "${{ tasks.x.status == 'ok' && tasks.y.count > 3 }}" # quoted · contains > and :prompt: ${{ inputs.topic }} # bare ok · no special charsA conformant engine parses YAML 1.2. Authoring tools (and the AI writing these files) should quote-by-default for the four ambiguous-scalar cases above.
What the envelope is NOT #
- It is NOT a place to inline credentials. Use
secrets:with asourcereference. - It is NOT a place for engine runtime config (global timeouts · concurrency limits). Those live in engine config files, out of scope of the spec.
- It is NOT a place for imports / includes. v1 is single-file workflows. (Static composition is a candidate for a later additive minor: see 08-out-of-scope.md.)
File naming (normative) #
- Canonical filename ·
<name>.nika.yaml. Every tool that CREATES a workflow file (nika new·nika init· scaffolds · templates) MUST emit this form, and every teaching surface writes it. - `.nika.yml` · accepted by matchers (editors · schema catalogs ·
hooks) so no real file is ever orphaned — and taught against: a tool
that notices it SHOULD flag « non-canonical filename · rename to
.nika.yaml» (a dedicated profile diagnostic may be allocated by a future law; the convention is normative today). Tools MUST NOT emit it. - Bare `.nika` · RESERVED. Never emitted, never claimed by tooling.
- Media type ·
application/vnd.nika+yamlis the reserved media type for workflow documents (vendor-tree registration per RFC 6838 is a post-1.0 gesture). Do not invent alternatives.
One suffix, one grammar: a split-suffix ecosystem (.yml and .yaml
both canonical) fragments globs, schema catalogs and CI matchers
forever — that lesson is upstream, and this door closes pre-1.0.
Examples #
Minimal #
nika: v1workflow: id: hellotasks: greet: infer: prompt: "Hello" model: anthropic/claude-haiku-4-5Full · with typed inputs (callable over MCP) #
nika: v1workflow: id: research-pipeline description: "Research a topic and write a markdown brief"model: anthropic/claude-sonnet-4-6inputs: topic: type: string required: true description: "Subject to research" output_path: type: string default: "./brief.md"tasks: research: infer: prompt: "Research the topic · ${{ inputs.topic }} · in 5 paragraphs" write: with: content: ${{ tasks.research.output }} invoke: tool: "nika:write" args: path: "${{ inputs.output_path }}" content: "${{ with.content }}"Conformance #
A v0.1-compliant engine MUST ·
- Reject any workflow missing
nika:orworkflow:with a clear error - Accept exactly
nika: v1· reject any other value (v1.0·1·v2…) with a clear error - Validate
workflowidentifier kebab-case format - Make workflow-level
model,inputs,config,const,secretsavailable to all tasks as defaults - Validate typed
inputs(type + required) before execution · reject missing required inputs · refuse every declareddefault:/ typedconst:value that does not conform to its declaredtype:(NIKA-DEFAULT-001) - Validate each typed
outputsvalue against its declaredtype:at run end · a value that does not match its declared type fails the run (NIKA-VAR-009·validation_error): the callable contract is enforced on BOTH halves (typed in viainputs, typed out viaoutputs) · symmetric with rule 5 - Mask resolved
secretsvalues in all logs · traces · journal events - Enforce a declared
permits:block on both surfaces: refuse statically-detectable escapes at check time, and fail any runtime effect outside the boundary withNIKA-SEC-004· oncepermits:is present every category is default-deny unless listed - Compose every child process environment (§permits env · NEP-0005): the runner env floor ∪ the declared
env:passthrough ∪ the taskenv:map, minus the dangerous-name floor · never inherit the engine environment
🦋 Next · [02 · The 4 verbs](./02-verbs.md)
Multi-line strings · canonical `|` #
For multi-line prompt: · system: · command: · or any free-form text
field · use the literal block indicator | (keeps newlines verbatim) ·
prompt: | Line 1 keeps its newline Line 2 keeps its newline Line 3 ends with a trailing newlineYAML multi-line forms · ranked for Nika ·
- Form · Newlines · Trailing newline · Verdict
- ` · ` · preserved · preserved
- ` · -` · preserved · STRIPPED
- `>` · folded to SPACES · preserved · **❌ forbidden in prompts** · whitespace-collapses · corrupts LLM intent
- `>-` · folded to SPACES · STRIPPED · **❌ forbidden in prompts** · same whitespace issue
Why forbid `>` and `>-` in prompts · they collapse newlines into
spaces · which often changes LLM behavior (intended paragraphs become
one long line). Engines MAY warn or reject > / >- for prompt/system/
command fields at parse time.
Single-line strings · prefer unquoted when no special chars · use
double-quoted "..." when escaping is needed (\n · \t · etc.) ·
use single-quoted '...' for literal strings with quotes.
# Unquoted (preferred)prompt: Say hello in French.# Double-quoted (when escaping needed)prompt: "Line 1\nLine 2 (escaped newline)"# Single-quoted (literal · no escapes)prompt: 'He said "hi"'# Multi-line literal (most common · use this for any prompt > 1 line)prompt: | You are a helpful assistant. Answer the user's question in 3 sentences.nika-spec@6ac29351a · 01-envelope.md · sha256 51afdfd3ba80adfc… · the pack upstream