the spec · 02
The 4 verbs
Every task in a Nika workflow binds to **exactly one** of 4 verbs.
02 · The 4 verbs #
Every task in a Nika workflow binds to exactly one of 4 verbs. A verb is a distinct native execution model the engine itself implements: call a model, run a process, dispatch a tool, drive an agentic loop. That is the whole operation space.Everything callable (fetching a URL, a database query, a file write, cognitive recall) is a tool reached throughinvoke:. Everything about ordering (iteration, branching) is a DAG construct (for_each·when). The 4 verbs never change; tools and the stdlib grow freely.
The 4 verbs · summary #
- Verb · What it does · Stdlib it consumes
- `infer:` · Single LLM call · text · structured · vision · thinking · providers
- `exec:` · Shell command with sandboxing · (none · pure effect)
- `invoke:` · Call a builtin tool OR an MCP tool · builtins · MCP servers
- `agent:` · Multi-turn agentic loop with tool calls · providers · builtins · MCP
A task must specify exactly one of these. Multiple verbs on a single task is a validation error.
Where did `fetch` go? Fetching a URL is calling a tool, not a distinct execution model, so it is thenika:fetchbuiltin, reached throughinvoke:(the extract modes become itsmodeargument). Same reason a DB query (invoke: mcp:postgres/query) or a file write (invoke: nika:write) is not its own verb. See stdlib/builtins-v0.1.md.
What `${{ tasks.<id>.output }}` holds · per verb #
Every task also exposes the full result record (.status · .error ·
.started_at · .ended_at · .duration_ms · 04 §task output reference).
The .output shape depends on the verb (know this before you bind downstream) ·
- Verb · `.output` is · Structured when
- `infer:` · the model's reply · **string** · `schema:` set → **object** matching it
- `exec:` · **stdout string** (default) · `capture: structured` → `{ stdout, stderr, exit_code }`
- `invoke:` · the **tool's response** (tool-defined · string · object · or bytes) · per builtin / MCP tool schema · bytes are tool-determined (MCP image content · binary read) · flow opaquely · see 04 §value rendering
- `agent:` · the loop's **final message** · string · `schema:` set → **object** matching it
`infer:` · LLM call #
A single LLM call. The result is the model's response.
Minimal #
greet: infer: prompt: "Say hello in French"Full #
research: infer: prompt: "Research Rust async runtimes 2026 in 5 paragraphs" system: "You are a senior software architect." model: mistral/mistral-large # override default · <provider>/<name> temperature: 0.3 max_tokens: 2000 schema: # optional · structured output type: object required: [summary, paragraphs] properties: summary: { type: string } paragraphs: { type: array, items: { type: string } } thinking: # optional · extended thinking enabled: true budget_tokens: 4000 vision: # optional · vision input - source: file path: "./diagram.png"Fields #
- Field · Required · Type · Notes
- `prompt` · yes · string · User prompt · may use `${{ ... }}` substitution
- `system` · no · string · System prompt
- `model` · no · string · Override workflow default · `<provider>/<name>` · see stdlib/providers-v0.1.md
- `temperature` · no · number 0-2 · Sampling temperature
- `max_tokens` · no · integer · Max output tokens · provider-dependent default
- `schema` · no · object · raw JSON Schema · structured output validation — the **out-of-core hatch**; the typed door is task-level `returns:` ([09](./09-types.md) · both on one task = `NIKA-TYPE-003`)
- `thinking` · no · object · Extended thinking · `{ enabled, budget_tokens }`
- `vision` · no · array · Image inputs · each `{ source: file
Conformance #
A v0.1-compliant engine MUST ·
- Call the configured provider with the given prompt + system + parameters
- Return the model's response as the task output
- Validate the response against
schemaif present · MAY auto-retry validation failures internally before surfacingNIKA-INFER-002(engine-configurable · the same rule as 05 §structured output) - Reject any unknown field with a clear error (forward-compat) · or accept + warn (engine choice)
`exec:` · shell command #
Run a shell command. The result is the command's stdout (default) or a structured output.
Minimal #
build: exec: command: ["cargo", "build", "--release"]Full #
test: timeout: "60s" # task-level (applies to any verb · Go duration · see 03-dag) exec: command: ["cargo", "test", "--workspace", "--lib"] cwd: "./engine" env: RUST_LOG: "debug" stdin: "${{ inputs.input_data }}" capture: structured # stdout | stderr | structured | combinedFields #
- Field · Required · Type · Notes
- `command` · one of · array · **argv** — `["prog", "arg", …]` → direct `execve`, **NO shell** · each element substituted independently — an interpolated `${{ }}` value can never break out of its argument (see Security). Exactly one of `command` | `shell`.
- `shell` · one of · string · **The explicit shell door** — one line run via `/bin/sh -c` · pipes · redirects · globs. The blocklist applies HERE; interpolating untrusted values here is the author's own risk, visibly. Exactly one of `command` | `shell`. *(D1 · #75: semantics never fork on a YAML type — the field name carries the meaning. The old string `command:` is rejected at parse.)*
- `cwd` · no · string · Working directory · default = engine's cwd
- `env` · no · object · OS environment variables for **this subprocess** · key→value map · applied over the COMPOSED environment ([01 §permits](./01-envelope.md#permits--optional--the-declared-capability-boundary) env · never inherited)
- `stdin` · no · string · Stdin data · may use `${{ ... }}`
- `capture` · no · enum · `stdout` (default) · `stderr` · `combined` · `structured` (= `{ stdout, stderr, exit_code }`) — the **source**
- `decode` · no · enum · `text` (default) · `json` · `jsonl` · `bytes` — how the captured **string** becomes a value ([09 §decode](./09-types.md#decode--how-exec-bytes-become-a-value-normative)) · illegal with `capture: structured` (`NIKA-PARSE-025` — that capture already IS an object) · a non-parsing stream settles the task `failure` inside `on_error:` scope
`exec.env` ≠ the envelope `config:`: different scopes, neighboring words (the one overlap to know · the pre-flip envelopeenv:block is dead —NIKA-VALUES-002). The envelopeconfig:is workflow config read via${{ config.* }};exec.envis the OS environment of this subprocess. They are NOT auto-connected. To pass a workflow value into the process, do it explicitly:env: { API_BASE: "${{ config.API_BASE }}" }. The subprocess environment itself is COMPOSED, never inherited (NEP-0005 · 01 §permits): the runner env floor ∪ thepermits.env:passthrough ∪ this map, minus the dangerous-name floor · an ambient engine variable reaches the child only through a declaredpermits.env:name.
timeoutandretryare task-level fields (see 03-dag.md): they apply uniformly to every verb, so they are not repeated insideexec:.
Security #
A v0.1-compliant engine MUST ·
- Honor the two exec bodies ·
command:(array) runs throughexecvewith NO shell:command[0]is the program, the rest are argv passed verbatim, each element substituted independently.shell:(string) runs through/bin/sh -c— the shell-feature path (pipes · redirects · globs). Exactly one of the two; a stringcommand:(the pre-0.103 implicit-shell form) is rejected at parse (NIKA-PARSE·validation_error). - Implement a shell blocklist for dangerous commands on
shell:(see reference implnika-policyfor canonical list · 100+ patterns includingrm -rf /·chmod 777·curl … | sh· etc.) - Reject blocklist matches with a clear error
- Honor
timeoutwith a hard kill (Go-duration string · see 03-dag) - Sandbox
cwdif configured (engine-specific)
The argv form is the STRUCTURAL fix for command injection: prefer it. A${{ }}-interpolated value in the STRING form is shell-parsed:shell: "process ${{ item }}"withitem == "; rm -rf /"is a classic injection, and the blocklist is a detector (best-effort), not a guarantee. The ARRAY form removes the shell entirely:command: ["process", "${{ item }}"]passesitemas ONE argv element no matter what it contains; there is no shell to parse it. Rule of thumb · any command carrying a task output, a `var`, or any value not authored inline → use the array form.nika checkwarns (one-obvious-way) when an interpolated value lands in a stringcommand. The string form stays first-class for genuine shell pipelines ("cat x | grep y > z"). That is what/bin/sh -cis for.
Conformance #
The engine MUST ·
- Run the STRING command via the OS shell (
/bin/sh -con Unix ·cmd /con Windows, OR a sandboxed equivalent) · run the ARRAY command viaexecvewith no shell - Capture stdout/stderr as configured
- Return exit code in
structuredcapture mode - Fail the task on non-zero exit (
NIKA-EXEC-001·process_error) instdout/stderr/combinedcapture modes (unlesson_error:recovers · 05), EXCEPT `capture: structured`, where a non-zero exit is data, not failure · the task succeeds andexit_codeis the workflow's to branch on (when: ${{ tasks.test.output.exit_code != 0 }}) · the one-obvious-way split · default modes for « must succeed » · structured for « inspect the outcome »
`invoke:` · builtin or MCP tool call #
Call a builtin (nika: namespace) or an MCP tool (mcp: namespace). The result is the tool's response.
Tool reference grammar (canonical) #
<namespace>:<path> one colon introduces the namespace · `/` separates the pathnika:fetch builtin · HTTP + extraction (used to be a verb)nika:write builtin · flat namenika:connectome/recall builtin · grouped pathmcp:browser/navigate MCP · server `browser` · tool `navigate`mcp:postgres/query MCP · server `postgres` · tool `query`mcp:my-server/do_thing MCP · server names admit kebab-case · tools admit snake_caseThe mcp: form requires the slash: mcp:postgres alone (no tool) is a
parse error (NIKA-PARSE · validation_error) · server segment
[a-z][a-z0-9-]* · tool segment [A-Za-z0-9_-]+ (tool names are the MCP
server's to define).
One rule everywhere · the colon marks the namespace boundary (exactly once),
the slash separates the path within it. No ::, no mixed ./:. Globs are
clean · nika:* · nika:connectome/* · mcp:browser/*.
The namespace set is CLOSED at v1 · nika: and mcp: are the only two ·
any other namespace (custom:thing · x:tool) is rejected at parse time
(NIKA-PARSE · validation_error). A third namespace would be an additive
spec minor. It never appears silently.
Builtin call #
read_config: invoke: tool: "nika:read" args: path: "./config.yaml"See stdlib/builtins-v0.1.md for the canonical builtin list (28 builtins in v0.1).
Grouped paths are grammar-legal · v0.1 ships none. The reference grammar admitsnika:<group>/<tool>(thenika:connectome/recallillustration above is the reserved FUTURE shape), but the v0.1 builtin set contains only flat names · a groupednika:path is rejected against the closed 23 set today (NIKA-INVOKE-001). The seam exists so the Connectome tools land additively · zero workflow-shape change.
MCP call #
query_db: invoke: tool: "mcp:postgres/query" args: sql: "SELECT * FROM users WHERE id = $1" params: ["${{ inputs.user_id }}"]The MCP server postgres must be configured in the engine's MCP server registry.
Fields #
- Field · Required · Type · Notes
- `tool` · yes · string · Tool identifier · `nika:<path>` OR `mcp:<server>/<tool>`
- `args` · no · object · Tool arguments · schema is tool-specific
Conformance #
The engine MUST ·
- Resolve the tool identifier to its implementation (builtin or MCP server)
- Reject unknown tools with a clear error
- Pass
argsto the tool · validate against tool's schema if known - Return the tool's response as task output
`agent:` · multi-turn agentic loop #
Run an agentic loop · the model + a set of tools · iterating until completion or budget exhausted.
Minimal #
research: agent: system: "You are a research assistant." prompt: "Research the topic · ${{ inputs.topic }}" tools: ["nika:fetch"] # default-deny · grant explicitlyFull #
research: agent: system: "You are a research assistant. Use tools to gather info." prompt: "Research the topic · ${{ inputs.topic }} · and produce a markdown brief" model: mistral/mistral-large tools: # whitelist · default-deny (no tools if omitted) - "nika:fetch" - "nika:write" - "mcp:browser/*" # all tools from browser MCP server skills: # Agent Skills · SKILL.md paths (agentskills.io shape) - ".agents/skills/nika-authoring/SKILL.md" max_turns: 20 max_tokens_total: 100000 temperature: 0.3 schema: # optional · validate the final message as structured output type: object required: [findings] properties: findings: { type: array, items: { type: string } }Fields #
- Field · Required · Type · Notes
- `system` · no · string · System prompt
- `prompt` · yes · string · Initial user message (same field name as `infer:` · consistent)
- `model` · no · string · Override workflow default · `<provider>/<name>`
- `tools` · no · array · Tool whitelist · glob patterns · **default-deny** · if absent the agent gets NO tools (pure conversation · least-privilege). Grant explicitly.
- `skills` · no · array · [Agent Skill](https://agentskills.io) file paths (each an agentskills.io-shape `SKILL.md`) · **explicit static paths only** — no globs, no `${{ }}` templates (the `permits:` explicitness law) · loaded at **compose time** and injected into the system context (§Agent Skills below)
- `max_turns` · no · integer · Loop limit · default 10
- `max_tokens_total` · no · integer · Cumulative token budget · default engine-configurable
- `temperature` · no · number 0-2 · Sampling temperature
- `schema` · no · object · raw JSON Schema · validates the agent's **final message** as structured output (same contract as `infer.schema:` · the out-of-core hatch — the typed door is `returns:` · [09](./09-types.md)) · `.output` becomes the matching object
Loop semantics #
The agent loops · model response → if tool calls present, execute tools → feed results back to model → repeat. The loop terminates when ·
- Model returns a final response with no tool calls, OR
max_turnsreached, ORmax_tokens_totalexhausted, OR- A tool returns the canonical completion sentinel
nika:done(the builtin tool · see stdlib/builtins-v0.1.md)
nika:done is valid only inside an `agent:` loop's tool whitelist: it is
the loop-completion sentinel. Calling nika:done from a standalone invoke:
task (outside any agent loop) is an error (NIKA-BUILTIN-DONE-001). The
sentinel has no meaning without a loop to terminate. nika:done accepts an
optional `result:` arg (any JSON value) · when present the task's
.output is that value (and schema: validates IT) · when absent .output
is the final assistant message (string).
The agent may also grant itself `nika:compose`, a second loop-only
builtin (also valid ONLY inside an agent: whitelist · standalone is
NIKA-BUILTIN-COMPOSE-001). It lets the model self-check a workflow it is
drafting: pass a workflow_yaml string, get the full nika check verdict
back (conformance + secret-flow + permits + the termination/cost certificate)
as the tool result. It never executes the draft: verification yields an
artifact + its certificate, and running it stays a separate, gated decision.
See stdlib/builtins-v0.1.md §nika:compose.
Termination outcomes (normative) ·
- Case 1 or 4 (natural completion ·
nika:done) →status: success. - Case 2 (
max_turnsreached) → `status: failure` ·NIKA-AGENT-001·budget_error· the last assistant message is preserved aterror.details.partial_output(a budget that runs out did NOT produce the asked-for result: failing loudly beats returning an unfinished answer · recover the partial explicitly viaon_error:if it is acceptable). - Case 3 (
max_tokens_totalexhausted) → same shape ·NIKA-AGENT-002.
Tool-call errors inside the loop are fed back, not fatal · a failing tool
call returns its typed error to the MODEL as the tool result (the standard
agentic convention: the model sees the failure and adapts) · the loop
continues against its budgets. The ONE exception · a security_error
(whitelist violation NIKA-SEC-002 · blocklist) fails the task
immediately: security boundaries are not negotiation material for a model.
Tool whitelist · glob semantics #
The tools: whitelist uses gitignore-style globs for matching ·
tools: - "nika:read" # exact match - "nika:*" # any nika builtin - "mcp:browser/*" # any tool from the `browser` MCP server - "mcp:postgres/query" # exact match · postgres query onlyMatch rules (canonical) ·
*matches any sequence of characters EXCEPT the path separator/(somcp:browser/*matchesmcp:browser/navigatebut notmcp:browser/tab/open)**matches any sequence including/(rare · use sparingly)- The namespace colon is never crossed by
*(sonika:*never matches anmcp:tool) - Order matters · later rules override earlier rules
- Negation via
!prefix ·tools: ["mcp:browser/*", "!mcp:browser/navigate"]
A v0.1-compliant engine MUST implement these glob semantics canonically · NOT engine-specific. This is a portability invariant.
The task output is the final model response.
Agent Skills · `skills:` #
The skills: field makes the agent a consumer of the
Agent Skills format — the same SKILL.md shape
nika init already produces. Each entry is an explicit file path to a
skill file: a markdown document opening with a YAML frontmatter block
(--- fences) that carries a non-empty name and description; the
markdown body after the closing fence is the skill's instructions. Other
frontmatter keys (license · metadata · client-specific fields) are the
skill author's surface — a consumer MUST tolerate them.
review: agent: system: "You are a code reviewer." prompt: "Review the diff · ${{ tasks.diff.output }}" skills: - ".agents/skills/code-review/SKILL.md" - "docs/skills/security-pass/SKILL.md" tools: ["nika:read"]Resolution (normative) · paths are static — no globs, no
${{ }} templates (the same explicitness law as permits:; a template
in a skill path is a parse error). Relative paths resolve from the
working directory, like every other relative path in the language. The
files are read at compose time — before the run starts, by the
composition layer, never mid-loop by the runtime — so skill reads are
outside `permits.fs` (exactly like the workflow file itself: what
the agent will carry is fixed and auditable before any effect).
Injection (normative) · the resolved skills join the agent's system
context as ONE deterministic section, appended after the authored
system: (or standing alone when system: is absent) ·
<authored system>## Skills### <frontmatter name><frontmatter description><markdown body · trimmed>— one ### block per skill, in skills: source order. Same inputs,
same bytes (provider-cache-friendly · reproducible transcripts).
check≡run · skills: is fully validated statically. nika check
fails a workflow whose skill paths do not resolve (NIKA-AGENT-003 ·
the file is missing/unreadable) or whose files are not valid Agent
Skills (NIKA-AGENT-004 · no/unterminated/non-mapping frontmatter ·
missing/empty name or description) — see
05-errors.md. A run refuses on the same findings
before any token is spent. The skill TEXT also joins the referencing
task's resume identity: editing a SKILL.md re-runs the task (the same
law as an edited prompt — a resume never serves output produced under a
different skill).
Conformance #
The engine MUST ·
- Honor the
toolswhitelist · reject tool calls not in the whitelist · default-deny when `tools:` is absent (the agent gets no tools · least-privilege) - Enforce
max_turnsandmax_tokens_totalbudgets · terminate on exhaustion - Detect the
nika:donecompletion sentinel and exit gracefully - Return the final model response as task output
- Resolve
skills:at compose time · inject the## Skillssection in source order exactly as specified above · fail statically (NIKA-AGENT-003/NIKA-AGENT-004) on a missing or invalid skill file — never start a run with a half-composed context
Forward-compat #
The 4 verb names are ratified by the [v1 constitution](../design/v1-constitution.md), and the count is 4, absolute — under the constitutional hierarchy of three atomic calls and one bounded controller: call a model once (infer), apply a typed callable once (invoke), run a host process once (exec), and orchestrate a bounded sequence of the atomic calls (agent). Every other capability is either an invoke-able callable (an HTTP fetch → invoke: nika:fetch · a database query → invoke: mcp:postgres/query · a file write → invoke: nika:write · cognitive recall → invoke: nika:connectome/recall) or a DAG control-flow construct (iteration → for_each · branching → when). There is no nika: v2 — ever: while the reference engine is pre-1.0 a verb change would be a break INSIDE v1 (per the pre-1.0 stability contract), and after engine 1.0.0 the verb set is frozen with the rest of the grammar, so a fifth verb is effectively never.
The closure argument · why no case forces a 5th verb #
Every candidate operation decomposes along two orthogonal axes ·
- WHO executes: the execution model. There are exactly four · model
inference (
infer) · OS process (exec) · dispatch-a-call-and-await-its- result (invoke) · model-driven loop over tools (agent). Anything with a request/response shape (however exotic the backend) is by construction theinvokemodel. - WHEN it runs: ordering. Edges (
with:data ·after:control) · conditions (when) · iteration (for_each) · time-bounds (timeout) · recovery (retry·on_error·on_finally). Ordering never executes anything: it is DAG-side, or host-side when it concerns starting a run at all.
The recurring « doesn't X need a verb? » cases, stress-tested ·
- Candidate · Axis · Resolution
- **wait-for-human** (approval gate) · WHO · a call that resolves when a human answers · a **tool** · request/response with a long latency · `invoke:` model · **already shipped** as `nika:prompt` (blocking confirm · [stdlib/builtins-v0.1.md](../stdlib/builtins-v0.1.md))
- **sleep / delay** inside a run · WHO · a call that resolves after a duration · a **tool** · **already shipped** as `nika:wait` (relative `duration:` XOR absolute `until:`) · NOT a verb · nothing is executed, a result (elapsed) is awaited
- **cron / schedule** (start a run at time T) · neither · it precedes the run · **host concern** · a workflow describes a run, not its triggering · explicitly out-of-scope ([08](./08-out-of-scope.md))
- **streaming between tasks** · WHEN · changes what an *edge* delivers · a **DAG-edge semantic**, not an execution model · out-of-scope v0.1 · an additive edge attribute later · never a verb
- **sub-workflow call** · WHO · dispatch-and-await a run · a **tool** (workflow-as-tool · see [08](./08-out-of-scope.md) §composition) · the `invoke` model again
A 5th verb would have to name an execution model that is none of call-with-result, process, inference, or loop, and every concrete candidate inspected since the 4-verb lock (D-2026-05-22-N18) has resolved to a tool (WHO · request/response) or an ordering construct (WHEN). That is the closure: the verb set is closed under decomposition into WHO × WHEN.
Field additions to each verb are additive within nika: v1 (feature-detected · no minor version in the file). Field removal NEVER happens at v1.
A v0.1-compliant engine that encounters an unknown field on a verb may ·
- Reject with a clear error (strict mode · default for tests)
- Warn + ignore the field (lenient mode · default for production)
Engine config picks which mode.
🦋 Next · [03 · DAG shape](./03-dag.md)
nika-spec@6ac29351a · 02-verbs.md · sha256 d2b0f6a768d75faf… · the pack upstream