Skip to main content
A binding is a template expression wrapped in ${{ … }}. It resolves at runtime against a typed scope — inputs, task outputs, environment variables, secrets — 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

${{ <expression> }}
Four examples, each a valid Templatable::Template:
prompt: "${{ vars.target }}"
prompt: "${{ tasks.fetch_issue.output.title }}"
prompt: "${{ with.model_family }}"
command: "echo ${{ env.HOME }}/output.txt"
Whitespace inside ${{ }} is ignored. Nested ${{ }} is rejected at parse time.

Scopes

Every binding resolves against one of five named scopes. The resolver refuses unknown top-level names at validation time, so ${{ tsaks.foo }} is caught before the run starts.
ScopeSourceExample
vars.*Workflow inputs, declared in the envelope vars: block (typed or untyped)vars.target
with.*Task-level scope, declared in a per-task with: block (pre-bind + alias)with.model_family
tasks.<name>.outputThe result record of a completed task (.output · .status · .error · .duration_ms)tasks.greet.output
env.*Non-sensitive runtime config, declared in the envelope env: blockenv.HOME, env.REGION
secrets.*Vault/env/file-backed secret references (masked in logs)secrets.api_key
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.

Typed resolution

Nika knows the expected type of every binding target. If vars.count is declared integer and you drop it into a field typed as a list, validation fails before the run starts.
vars:
  count:
    type: integer

tasks:
  - name: loop
    for_each: "${{ vars.count }}"      # fails — for_each needs a list
    infer:
      model: ollama/<model>     # <provider>/<name>
      prompt: "Iteration ${{ item }}"
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).
  • vars.* from the CLI → tainted (user-provided at runtime).
  • context.*, env.*, nika.*trusted (workflow author owns them).
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):
- name: pull_user_input
  invoke:
    tool: nika:fetch
    args:
      url: https://api.example.com/comment

- name: run_it
  depends_on: [pull_user_input]
  exec:
    command: "${{ tasks.pull_user_input.output }}"   # NIKA-SEC taint violation
Fix — never interpolate tainted data into a command string; pass it as data instead:
- name: run_it
  depends_on: [pull_user_input]
  exec:
    command: tee
    args: ["/tmp/comment.txt"]
    stdin: "${{ tasks.pull_user_input.output }}"   # data channel, not code channel
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):
FormExample
Field accessvars.topic · tasks.build.status
Index accesstasks.list.output[0] · obj['key-with-dash']
Comparison== != < <= > >=
Boolean&& || !
Membershipstatus in ['success', 'skipped']
Sizesize(items) > 0 — the one v0.1 function, the empty-check idiom
Literalstrue · 42 · 3.14 · 'str' · null
Grouping( … )
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 decides · jq transforms
- id: digest
  when: ${{ size(tasks.fetch.output) > 0 }}      # CEL · condition
  invoke:
    tool: "nika:jq"
    args:
      input: "${{ tasks.fetch.output }}"          # CEL · reference
      expression: ".items | map(.title) | join(\"\\n\")"   # jq · transform
CEL macros and string functions are reserved for later minors — growth is additive, never breaking.

Multi-line

YAML block scalars compose cleanly with bindings:
prompt: |
  You are reviewing the following pull request.

  Title: ${{ tasks.fetch_pr.output.title }}
  Body:  ${{ tasks.fetch_pr.output.body }}

  Provide three concrete improvements.
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. A bare vars: value IS the default; a typed var declares whether it’s required:
vars:
  tone: "neutral"          # default — override from the CLI
  topic:
    type: string
    required: true         # validated before anything runs
If vars.optional_field is absent and has no default:, the workflow fails to load — 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:, condition:, depends_on:) lives on the task envelope, not inside bindings. This is deliberate: the DAG is a static artifact, inspectable by the analyzer and renderable in the Olympus visualizer; 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.