Skip to main content
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:
AttributeDescription
expressionThe full {{ expr }} string (without braces)
namespaceThe first segment of the path (e.g. inputs, env, plan)
reasonHuman-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 }}"