Skip to main content
A binding is a template expression wrapped in ${{ … }}. It resolves at runtime against a typed scope (the four value authorities, task-scope bindings, task records) and substitutes into the field. What’s inside ${{ }} is CEL (Common Expression Language), the same expression language GitHub Actions and Kubernetes admission policies use. Nika does not invent a DSL. The parser preserves both forms of every templatable field through the AST: either a literal value or a Templatable::Template(String) holding the unresolved expression. See crates/nika-schema/src/types/templatable.rs for the exact types.
Two expression layers, locked by the spec: CEL inside ${{ }} (conditions + value substitution) and jq inside output: bindings + the nika:jq builtin (extraction + transform). There are no template filters: anything beyond a reference or a boolean is a jq expression or a task. Engine-side, taint propagation and typed scope lookup mature across admission rounds; the syntax below is the frozen nika: v1 contract.

Shape

Four examples, each a valid Templatable::Template:
Whitespace inside ${{ }} is ignored. Nested ${{ }} is rejected at parse time.

The six namespaces

Every binding resolves against one of six named namespaces. The resolver refuses unknown top-level names at validation time, so ${{ tsaks.foo }} is caught before the run starts — and a value-namespace read outside the closed family (${{ params.X }} and friends) refuses NIKA-VALUES-003. The first four are the value authorities — the closed family every workflow value is declared under, one role each, no alias. with and tasks are the runtime namespaces: legal in ${{ }}, never value authorities. ${{ vars.X }} and ${{ env.X }} are dead reads (NIKA-VALUES-001 / NIKA-VALUES-002). Each old use classifies into the authority its role commands — a typed parameter into inputs:, a fixed value into const:, non-sensitive runtime configuration into config:, a governed store reference into secrets:. Classification, never a bulk rename: the question is what the value plays, not what it was spelled. Task names are validated at parse time (snake_case, unique). A binding that references a task not present in the file fails with a miette diagnostic pointing at the exact byte range.

The reference boundary: where tasks.* may appear

The tasks namespace is boundary-only. A ${{ tasks.X.* }} reference is legal in exactly five places: with: values (the binding IS the edge) · after: keys (the entry names the producer) · on_error.recover: (a fallback reads a settled record) · on_finally: blocks (the parent task only) · workflow outputs: (the run’s exports read the settled world). Everywhere else — verb fields (prompt: · command: · args: · …), when:, for_each: — a tasks.* reference is refused at parse time (NIKA-VAR-021) with a machine-applicable fix: hoist it into with: and read the binding.
The task body is a pure function of its declared inputs (with · the four value authorities inputs · config · const · secrets · the loop locals): every cross-task dependency is visible at the boundary, named, and typed by its edge role.

Typed resolution

Nika knows the expected type of every binding target. If inputs.count is declared integer and you drop it into a field typed as a list, validation fails before the run starts.
Type-checking is the analyzer’s job, live today in nika check (every deep output reference is validated against its declared shape). The parser’s Templatable<T> carries T as a phantom type so downstream stages can’t conflate Templatable<u32> with Templatable<bool>.

Taint tracking (target design · Phase D)

External data is tainted by default. The taint set is maintained by the analyzer and propagates through bindings:
  • fetch output → tainted (HTTP body, third-party controlled).
  • exec stdout → tainted (subshell could have been influenced).
  • infer / agent output → tainted (prompt injection vector).
  • invoke output → tainted (MCP servers are third-party).
  • inputs.* → tainted (caller-supplied at launch).
  • config.* → tainted (deployment-supplied, from outside the file).
  • literals, const.*, secrets.*trusted (the file owns them).
Integrity is orthogonal to confidentiality: a secret is trusted (the author put it there) and confidential (it must not leak). One untrusted operand taints the whole interpolation. Passing tainted data to a privileged sink (notably exec.command) without an explicit sanitizer raises a NIKA-SEC-family error (the runtime taint layer · security model):
Fix: never interpolate tainted data into the command position; pass it as data instead:
Taint is a property of data, not of a principal (T3:A design from InferResponse.trust_level). Cross-link: ADR-014 (sealed kernel traits), nika-error::trust::TrustLevel.

The expression language: CEL, one small subset

What’s inside ${{ }} is the v0.1 CEL subset (normative grammar): There are no template filters (\| upper, \| truncate, …). Anything that transforms data is a jq expression, in an output: binding or through the nika:jq builtin. Two layers, one job each:
CEL macros and string functions are reserved for later minors: growth is additive, never breaking.

Multi-line

YAML block scalars compose cleanly with bindings (the pull-request fields crossed the boundary in with:; the prompt reads the bindings):
The parser preserves indentation and line breaks: the prompt reaches the provider exactly as you typed it, minus the leading YAML block indentation.

Null handling

Defaults live in declarations, not in expressions. Which declaration depends on who owns the value:
If inputs.optional_field is required and the caller supplies nothing, the run is refused before it starts: a miette diagnostic points at the declaration site and the usage site.

What bindings are not

  • ❌ Not a scripting language. No loops, no conditionals inside ${{ }}.
  • ❌ Not arbitrary JavaScript / Python eval. No dynamic code.
  • ❌ Not Jinja2 / Liquid in full: for and if blocks are not supported inside expressions.
  • ✅ Scope lookup + CEL evaluation + type check + taint propagation.
Workflow-level control flow (for_each:, when:, the with: / after: edges) lives on the task envelope, not inside bindings. This is deliberate: the DAG is a static artifact, inspectable by the analyzer and renderable by any graph surface — nika inspect <file> --format mermaid|dot|json; bindings are local decorations.
  • ADR-010: miette as the L4 diagnostic presentation layer. Every binding error points at the exact byte range in the source file.
  • ADR-014: sealed kernel traits. The resolver runs at L3 and never crosses into arbitrary user-provided evaluator code.
  • ADR-021: YAML envelope convention. ${{ }} bindings appear inside spec: (workflow) or inside reusable skill / agent documents, and resolve against the same scope shape.

Events

What gets emitted, when.

Workflows

The envelope that holds the tasks: bindings operate on.

Verbs

Each verb’s taint classification and sanitizer expectations.

YAML reference

Full binding grammar and filter catalog.