SirenSpec uses {{ expr }} syntax to embed dynamic values in workflow YAML. Expressions are
resolved at node execution time — values from upstream nodes are available once those nodes
complete.
Syntax
Basic placeholder
prompt: "Summarize this: {{ inputs.message }}"
Default filter
Use | default('value') to supply a fallback. It fires both when the key is missing and when the resolved value is an empty string (""):
prompt: "Context: {{ working.context | default('none provided') }}"
JSON-or-default filter
Use | json_or_default('value') when the value should parse as JSON but might not. It returns the fallback when the key is missing, when the value is an empty string, or when the value cannot be parsed as JSON. This covers LLM outputs that return "" or fenced markdown instead of a clean JSON array:
# upstream agent may emit "", prose, or ```json ... ``` — fall back to an empty list
for_each: "{{ plan.output | json_or_default('[]') }}"
Multiple placeholders per string
prompt: "Analyze {{ node_a.output }} against {{ node_b.output }}"
Namespaces
inputs.* — Workflow input fields
Resolves against the workflow’s top-level input.
prompt: "Analyze: {{ inputs.message }}"
The message field is always present. Additional fields defined in input: are also available.
env.* — Environment variables
Reads os.environ at node execution time, not at parse time.
system: "You are operating in the {{ env.APP_ENVIRONMENT }} environment."
Security note: env.* values are redacted (***) in execution traces. Use env vars
for non-sensitive runtime configuration — feature flags, environment names, region identifiers.
Never inject secrets or API keys into system: or prompt: fields; use HTTP headers:
in tool nodes instead.
Unset variables raise InterpolationError. Use a default if the variable is optional:
system: "Mode: {{ env.DEBUG_MODE | default('production') }}"
node_id.output — Canonical node output
Every node (agent, swrm, factory, workflow) writes its primary output to working.{node_id}.output
automatically. Reference it in downstream nodes:
prompt: "Summarize: {{ classifier.output }}"
For workflow nodes, the output is the sub-workflow’s output dict. Access sub-node outputs via:
prompt: "Sub-workflow sentiment: {{ run_child.output.sentiment }}"
Reference upstream nodes by their ID ({{ classifier.output }}), never via the
internal working namespace. {{ working.<node_id>.* }} does not resolve at runtime
and the load-time linter rejects it with the working_dot_node_id error, pointing you
to the canonical {{ <node_id>.output }} form. Reserve working.* for custom paths you
write to or seed in state:.
node_id.* — Arbitrary node context access
Any value written to the working dict is accessible by its path:
# If the 'classify' node writes: working.classify.output.sentiment
prompt: "Sentiment was {{ classify.output.sentiment }}"
node_id.agents.agent_id.output — SWRM sub-agent output
Inside a swrm synthesis prompt, each sub-agent’s output is available:
synthesis:
prompt: |
Sentiment: {{ analyze.agents.sentiment.output }}
Risk: {{ analyze.agents.risk.output }}
Synthesize an investment recommendation.
item — Loop iteration value (factory nodes)
Inside a factory node’s inputs: values, {{ item }} refers to the current list element:
nodes:
execute:
type: factory
for_each: "{{ plan.output }}"
inputs:
task: "{{ item }}"
Using {{ item }} outside a factory loop raises InterpolationError.
index — Loop iteration index (factory nodes)
Zero-based position of the current item in the list:
inputs:
task: "{{ item }}"
position: "{{ index }}"
total — Total item count (factory nodes)
The total number of items in the current factory loop. Available in agent prompts, swrm agent prompts, synthesis prompts, and factory inputs: templates:
inputs:
position: "{{ index }} of {{ total }}"
swrm:
agents:
- id: grader
prompt: "Grade paper {{ index }} of {{ total }}: {{ item }}"
synthesis:
prompt: "Final report for item {{ index }}/{{ total }}"
Error Behaviour
Any unresolvable expression raises InterpolationError with three attributes:
| Attribute | Description |
|---|
expression | The full {{ expr }} string (without braces) |
namespace | The first segment of the path (e.g. inputs, env, plan) |
reason | Human-readable description of what went wrong |
Example:
InterpolationError in '{{ plan.output }}' [plan]: Key 'output' not found
Use | default('fallback') to suppress the error and return a static value instead.
Circular Reference Detection
At workflow load time, SirenSpec scans all prompt templates for {{ node_id.* }} references
and builds a dependency graph. If node A’s prompt references node B and node B’s prompt
references node A, loading raises InterpolationError with namespace="circular_ref".
This check runs automatically in load_workflow() — no configuration needed.
Security
env.* values are never written to execution traces. The trace shows *** instead.
when: edge conditions use a separate, restricted eval() namespace — not the interpolation engine.
- YAML is loaded with
SafeLoader, so no code can be injected via YAML tags.
Examples
Sequential pipeline with node output chaining
nodes:
classify:
agent: classifier_agent
writes: working.classify.output
reply:
agent: reply_agent # system: "The intent was: {{ classify.output }}"
writes: output.reply
SWRM synthesis referencing sub-agent outputs
nodes:
analyze:
type: swrm
agents:
- id: sentiment
provider: openai
model: gpt-4o-mini
prompt: "Analyze sentiment: {{ inputs.message }}"
- id: risk
provider: anthropic
model: claude-haiku-4-5-20251001
prompt: "Identify risks: {{ inputs.message }}"
synthesis:
provider: anthropic
model: claude-haiku-4-5-20251001
prompt: |
Sentiment: {{ analyze.agents.sentiment.output }}
Risk: {{ analyze.agents.risk.output }}
Write a recommendation.
Factory node with loop variables
nodes:
plan:
agent: planner
writes: working.plan.output # outputs JSON list: ["task A", "task B"]
execute:
type: factory
agent: worker
for_each: "{{ plan.output }}"
inputs:
task: "{{ item }}"
position: "{{ index }} of {{ total }}"
concurrency: 4
writes: working.execute.outputs
aggregate:
agent: summarizer # system: "Summarize: {{ execute.output }}"
writes: output.report
Environment variables for runtime configuration
Use env.* to inject non-sensitive configuration into prompts — environment names,
feature flags, locale identifiers, and similar non-secret values:
agents:
analyzer:
model: "openai:gpt-4o-mini"
system: |
You are operating in {{ env.APP_ENVIRONMENT | default('production') }} mode.
Be concise and focus on actionable findings.
Credentials and API keys must never appear in system: or prompt: fields. Prompt
content can surface in logs, traces, and model context windows. Pass secrets via HTTP
headers: in tool nodes instead:
nodes:
fetch_data:
type: tool
tool: http
config:
url: "https://api.example.com/data"
headers:
Authorization: "Bearer {{ env.SERVICE_TOKEN }}"