${{ … }}. 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
Templatable::Template:
${{ }} 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.
| Scope | Source | Example |
|---|---|---|
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>.output | The result record of a completed task (.output · .status · .error · .duration_ms) | tasks.greet.output |
env.* | Non-sensitive runtime config, declared in the envelope env: block | env.HOME, env.REGION |
secrets.* | Vault/env/file-backed secret references (masked in logs) | secrets.api_key |
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. Ifvars.count
is declared integer and you drop it into a field typed as a list,
validation fails before the run starts.
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:fetchoutput → tainted (HTTP body, third-party controlled).execstdout → tainted (subshell could have been influenced).infer/agentoutput → tainted (prompt injection vector).invokeoutput → tainted (MCP servers are third-party).vars.*from the CLI → tainted (user-provided at runtime).context.*,env.*,nika.*→ trusted (workflow author owns them).
exec.command)
without an explicit sanitizer raises a NIKA-SEC-family error (the
runtime taint layer · security model):
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):
| Form | Example |
|---|---|
| Field access | vars.topic · tasks.build.status |
| Index access | tasks.list.output[0] · obj['key-with-dash'] |
| Comparison | == != < <= > >= |
| Boolean | && || ! |
| Membership | status in ['success', 'skipped'] |
| Size | size(items) > 0 — the one v0.1 function, the empty-check idiom |
| Literals | true · 42 · 3.14 · 'str' · null |
| Grouping | ( … ) |
\| 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:
Multi-line
YAML block scalars compose cleanly with bindings:Null handling
Defaults live in declarations, not in expressions. A barevars: value
IS the default; a typed var declares whether it’s required:
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 —
forandifblocks are not supported inside expressions. - ✅ Scope lookup + CEL evaluation + type check + taint propagation.
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.
Related ADRs
- 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 insidespec:(workflow) or inside reusable skill / agent documents, and resolve against the same scope shape.
Read next
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.