| title | Variable Interpolation |
|---|
AWF uses Go template syntax ({{.var}}) for variable interpolation in workflow definitions.
Variables are enclosed in double curly braces with a dot prefix:
command: echo "Hello, {{.inputs.name}}!"Access workflow input values:
{{.inputs.variable_name}}Example:
inputs:
- name: file_path
type: string
- name: max_tokens
type: integer
states:
initial: process
process:
type: step
command: |
process-file "{{.inputs.file_path}}" --tokens={{.inputs.max_tokens}}Access output, exit code, and token usage from previous steps:
{{.states.step_name.Output}} # Command output (raw text, or cleaned if output_format set)
{{.states.step_name.ExitCode}} # Exit code (0 for success, non-zero for failure)
{{.states.step_name.TokensUsed}} # Total tokens consumed by agent steps
{{.states.step_name.TokensInput}} # Input tokens (prompt + context)
{{.states.step_name.TokensOutput}} # Output tokens (assistant response)
{{.states.step_name.TokensEstimated}} # true if token counts are estimates, false if from provider
{{.states.step_name.Response.field}} # Parsed field from operation/agent structured output (heuristic)
{{.states.step_name.JSON.field}} # Parsed field from output_format: json (explicit)The standard output (stdout) from the executed step:
analyze:
type: agent
provider: claude
prompt: "Analyze: {{.states.read_file.Output}}"The exit code from the step's command execution. Use in transitions, expressions, and templates:
In transitions (numeric comparison):
transitions:
- when: "states.test_run.ExitCode == 0"
goto: success
- when: "states.test_run.ExitCode > 0"
goto: failureIn templates (as string):
report_failure:
type: step
command: |
echo "Test failed with exit code: {{.states.test_run.ExitCode}}"
notify-on-error --code={{.states.test_run.ExitCode}}Numeric range expressions:
transitions:
- when: "states.build.ExitCode >= 100 and states.build.ExitCode < 128"
goto: handle_user_error
- when: "states.build.ExitCode >= 128"
goto: handle_signalOn POSIX systems, exit codes are typically 0–255. Exit code 0 indicates success; non-zero indicates failure.
Transition priority: Transitions are evaluated on both success and failure paths. When a matching transition is found, it takes priority over on_success, on_failure, and continue_on_error. If no transition matches, the legacy routing applies as fallback.
Total tokens consumed by agent steps. Available for all agent step types:
run_agent:
type: agent
provider: claude
prompt: "Process this"
on_success: log_tokens
log_tokens:
type: step
command: |
echo "Tokens used: {{.states.run_agent.TokensUsed}}"Use in conditional expressions for token budgeting:
transitions:
- when: "states.agent_step.TokensUsed > inputs.token_limit"
goto: token_exceededSeparate input and output token counts. Available for all agent step types:
log_details:
type: step
command: |
echo "Input: {{.states.analyze.TokensInput}}, Output: {{.states.analyze.TokensOutput}}"In conversation mode (continue_from), TokensInput includes all prior turns. In single-turn mode, TokensInput is 0 and TokensOutput equals TokensUsed.
Boolean indicating whether token counts are exact (from the provider's JSON output) or estimated (len/4 approximation). Use to decide whether to trust the values for billing or budgeting:
transitions:
- when: "states.analyze.TokensEstimated == false and states.analyze.TokensUsed > inputs.budget"
goto: over_budget| Provider | TokensEstimated | Source |
|---|---|---|
| Claude | false |
result event usage field |
| Gemini | false |
result event stats field |
| Codex | false |
turn.completed event usage field |
| Copilot | false (output only) |
assistant.message event outputTokens |
| OpenCode | false |
step_finish event part.tokens field |
| OpenAI-Compatible | false |
API response usage field |
| Any provider (fallback) | true |
len(output) / 4 estimation |
Note: TokensUsed replaced deprecated states.step_name.Tokens field. If migrating from earlier versions, update workflow YAML expressions from {{.states.step_name.Tokens}} to {{.states.step_name.TokensUsed}}.
Both agent steps and operation steps return structured data accessible via Response when the output is valid JSON.
Agent steps automatically populate Response when the agent's output contains valid JSON (regardless of output_format setting):
{{.states.step_name.Response.field}} # Parsed field from agent JSON output
{{.states.step_name.Response}} # Full parsed JSON objectOperation steps (e.g., github.get_issue, http.request) always return structured data via Response:
{{.states.step_name.Response.title}} # Parsed field from operation result
{{.states.step_name.Response.number}} # Numeric field
{{.states.step_name.Response.labels}} # Array fieldExample with agent step returning JSON:
states:
initial: analyze
analyze:
type: agent
provider: claude
prompt: "Return JSON analysis with 'issues' and 'severity' fields"
on_success: process
on_failure: error
process:
type: step
command: |
echo "Severity: {{.states.analyze.Response.severity}}"
echo "Issues: {{.states.analyze.Response.issues}}"
on_success: done
done:
type: terminal
error:
type: terminal
status: failureIf agent returns:
{"issues": ["buffer overflow", "memory leak"], "severity": "high"}Then:
{{.states.analyze.Response.severity}}="high"{{.states.analyze.Response.issues}}=["buffer overflow", "memory leak"]
HTTP operation outputs follow the same pattern:
{{.states.step_name.Response.status_code}} # HTTP status (200, 404, etc.)
{{.states.step_name.Response.body}} # Response body (truncated at 1MB)
{{.states.step_name.Response.headers.Content-Type}} # Response header value
{{.states.step_name.Response.body_truncated}} # true if body was truncatedDifference from JSON:
Responseis populated automatically for all agent outputs if valid JSON is detected (heuristic)JSONis only populated whenoutput_format: jsonis explicitly set on the agent step- Use
Response.fieldfor automatic best-effort parsing; useJSON.fieldfor explicit, validated JSON output
See Agent Steps - Output Formatting for examples and Workflow Syntax - Operation State for available operations.
When an agent step uses output_format: json, the parsed JSON is accessible via JSON:
{{.states.step_name.JSON.field}} # Access a JSON object field
{{.states.step_name.JSON}} # Full parsed JSON objectKey differences from Response:
JSONis only populated whenoutput_format: jsonis explicitly set on the agent step (and validation passes)Responseis populated automatically for all agent outputs if valid JSON is detected (heuristic, regardless ofoutput_format)- Use
JSON.fieldfor strict, validated output; useResponse.fieldfor lenient, automatic parsing
When to use each:
JSON.field: You explicitly requestedoutput_format: jsonand want strict validationResponse.field: You want to access JSON from any agent output without requiringoutput_format: json
Example with output_format: json:
states:
initial: analyze
analyze:
type: agent
provider: claude
prompt: "Return JSON analysis with 'issues' and 'severity' fields"
output_format: json
on_success: process
on_failure: error
process:
type: step
command: |
# Both work — JSON is strict, Response is lenient
echo "Severity (JSON): {{.states.analyze.JSON.severity}}"
echo "Severity (Response): {{.states.analyze.Response.severity}}"
on_success: done
done:
type: terminal
error:
type: terminal
status: failureIf agent returns:
{"issues": ["buffer overflow", "memory leak"], "severity": "high"}Then both work identically:
{{.states.analyze.JSON.severity}}="high"{{.states.analyze.Response.severity}}="high"
Difference becomes apparent when output_format: json is omitted:
- Without
output_format: json, JSON validation is skipped JSONremains empty (never populated)Responsestill populates if valid JSON is detected
See Agent Steps - Output Formatting for detailed examples and best practices.
Access workflow execution information:
{{.workflow.id}}
{{.workflow.name}}
{{.workflow.duration}}Example:
log_result:
type: step
command: |
echo "Workflow {{.workflow.name}} ({{.workflow.id}}) completed"Access system environment variables:
{{.env.VARIABLE_NAME}}Example:
deploy:
type: step
command: |
deploy.sh --env={{.env.DEPLOY_ENV}} --token={{.env.API_TOKEN}}Access system directories configured per XDG standards:
{{.awf.config_dir}} # ~/.config/awf (or $XDG_CONFIG_HOME/awf)
{{.awf.data_dir}} # ~/.local/share/awf (or $XDG_DATA_HOME/awf)
{{.awf.cache_dir}} # ~/.cache/awf (or $XDG_CACHE_HOME/awf)
{{.awf.prompts_dir}} # Designated prompts directory within config_dir
{{.awf.scripts_dir}} # Designated scripts directory within config_dir
{{.awf.skills_dir}} # Designated skills directory within config_dir
{{.awf.workflows_dir}} # Designated workflows directory within config_dir
{{.awf.plugins_dir}} # Plugin installation directoryExamples:
analyze:
type: agent
provider: claude
prompt_file: "{{.awf.prompts_dir}}/code_review.md"
on_success: done
deploy:
type: step
script_file: "{{.awf.scripts_dir}}/deploy.sh"
on_success: doneWhen using {{.awf.prompts_dir}}, {{.awf.scripts_dir}}, or {{.awf.skills_dir}}, AWF implements local-before-global resolution. This enables per-project overrides of shared global files. The resolution applies to all uses of these variables:
- In
script_filefields —script_file: "{{.awf.scripts_dir}}/deploy.sh" - In
prompt_filefields —prompt_file: "{{.awf.prompts_dir}}/code_review.md" - In
commandfields —command: "source {{.awf.scripts_dir}}/helpers.sh && deploy" - In
dirfields —dir: "{{.awf.scripts_dir}}" - In skills discovery — Agent skills are resolved across multiple tiers:
.awf/skills/(project override) →.agents/skills/→.claude/skills/→$XDG_CONFIG_HOME/awf/skills/(global fallback)
For local workflows (no pack context), resolution is 2-tier:
- Local override —
<workflow_dir>/prompts/or<workflow_dir>/scripts/ - Global fallback —
~/.config/awf/prompts/or~/.config/awf/scripts/
Example: {{.awf.scripts_dir}}/deploy.sh checks:
<workflow_dir>/scripts/deploy.sh(local override)- Then
~/.config/awf/scripts/deploy.sh(global fallback)
When executing a workflow from an installed pack (e.g., awf run speckit/specify), resolution extends to 3 tiers:
- User override (highest priority) —
.awf/prompts/<pack>/...or.awf/scripts/<pack>/... - Pack embedded —
<pack_root>/prompts/...or<pack_root>/scripts/... - Global XDG (lowest priority) —
~/.config/awf/prompts/...or~/.config/awf/scripts/...
Example: Pack speckit references {{.awf.prompts_dir}}/specify/system-prompt.md:
1. .awf/prompts/speckit/specify/system-prompt.md ← user override (checked first)
2. .awf/workflow-packs/speckit/prompts/specify/... ← pack embedded
3. ~/.config/awf/prompts/specify/... ← global fallback
No new template variables are introduced. {{.awf.prompts_dir}} and {{.awf.scripts_dir}} are context-aware — they automatically resolve based on whether the workflow is local or from an installed pack.
# Project structure with pack override
my-project/
├── .awf/
│ ├── workflows/
│ │ └── deploy.yaml
│ ├── prompts/
│ │ └── speckit/ # User overrides for speckit pack
│ │ └── specify/
│ │ └── system-prompt.md
│ ├── scripts/
│ │ └── deploy.sh # Local override for local workflows
│ └── workflow-packs/
│ └── speckit/ # Installed pack
│ ├── manifest.yaml
│ ├── workflows/
│ ├── prompts/ # Pack-embedded prompts (overridden above)
│ └── scripts/
└── ...
# ~/.config/awf/scripts/deploy.sh exists globally but is superseded
# Local workflow: uses 2-tier resolution
states:
deploy:
type: step
script_file: "{{.awf.scripts_dir}}/deploy.sh" # Uses local scripts/deploy.sh if present
on_success: doneThe same behavior applies to prompt_file with {{.awf.prompts_dir}}.
When interpolating template expressions, the following helper functions are available:
Split a string into an array by delimiter:
{{split "apple,banana,orange" ","}}Returns: ["apple" "banana" "orange"]
Use in templates with range to iterate:
{{range split .states.select.Output ","}}
- {{trimSpace .}}
{{end}}Join an array into a string with separator:
{{join (split .states.agents.Output ",") " | "}}Returns: apple | banana | orange
Read and inline file contents (with 1MB size limit):
## Specification
{{readFile .states.spec_path.Output}}The file path is relative to the workflow directory. Fails if:
- File doesn't exist
- File exceeds 1MB (prevents accidental large file loading)
- Path is not readable
Remove leading and trailing whitespace:
Result: {{trimSpace .states.process.Output}}Useful for cleaning multiline outputs or removing shell command trailing newlines.
# Analysis Report
## Available Agents
{{range split .states.list_agents.Output ","}}
- {{trimSpace .}}
{{end}}
## Combined Skills
Skills: {{join .states.available_skills.Output ", "}}
## Research Summary
{{readFile .states.research_summary_path.Output}}
## Status
{{trimSpace .states.final_status.Output}}Available inside for_each and while loops:
{{.loop.Item}} # Current item value (for_each only)
{{.loop.Index}} # 0-based iteration index
{{.loop.Index1}} # 1-based iteration index
{{.loop.First}} # True on first iteration
{{.loop.Last}} # True on last iteration (for_each only)
{{.loop.Length}} # Total items (-1 for while loops)
{{.loop.Parent}} # Parent loop context (nested loops)Example:
process_files:
type: for_each
items: '["a.txt", "b.txt", "c.txt"]'
body:
- process_single
on_complete: done
process_single:
type: step
command: |
echo "Processing {{.loop.Item}} ({{.loop.Index1}}/{{.loop.Length}})"
echo "First: {{.loop.First}}, Last: {{.loop.Last}}"When {{.loop.Item}} contains complex types (objects, arrays), it is automatically serialized to JSON:
- Objects → JSON object:
{"name":"value","nested":{"key":"data"}} - Arrays → JSON array:
[1,2,3]or["a","b","c"] - Strings → Pass through unchanged:
"main.go"stays asmain.go(not quoted) - Numbers → Converted to string:
42becomes"42",3.14becomes"3.14" - Booleans → Converted to string:
truebecomes"true",falsebecomes"false"
This is especially useful when passing loop items to call_workflow:
# Parent workflow with objects
process_reviews:
type: step
command: |
echo '[{"file":"main.go","type":"fix"},{"file":"test.go","type":"chore"}]'
capture:
stdout: reviews_json
on_success: loop_reviews
loop_reviews:
type: for_each
items: "{{.states.process_reviews.Output}}"
body:
- call_child_workflow
call_child_workflow:
type: call_workflow
workflow: review-file
inputs:
review: "{{.loop.Item}}" # Passed as valid JSON object to child
# Child workflow receives properly formatted JSON
# {{.inputs.review}} = {"file":"main.go","type":"fix"}Note: String items pass through unchanged without JSON quoting. Numbers and booleans are converted to their string representations.
For nested loops, access outer loop context via {{.loop.Parent}}:
process:
type: step
command: |
echo "outer={{.loop.Parent.Item}} inner={{.loop.Item}}"
echo "outer_index={{.loop.Parent.Index}} inner_index={{.loop.Index}}"Chain for deeper nesting:
command: echo "level1={{.loop.Parent.Parent.Item}} level2={{.loop.Parent.Item}} level3={{.loop.Item}}"Available in error hooks:
{{.error.type}}
{{.error.message}}User-provided values in commands can be dangerous. AWF provides ShellEscape() in pkg/interpolation for escaping:
import "github.com/awf-project/cli/pkg/interpolation"
escaped := interpolation.ShellEscape(userInput)Variables with these prefixes are masked in logs:
SECRET_API_KEYPASSWORDTOKEN
Example:
# In logs: API_KEY=****
command: curl -H "Authorization: Bearer {{.env.API_KEY}}" https://api.example.comprovider: "{{.inputs.agent}}"Resolves the provider name before registry lookup. Works in both type: agent (single-shot) and mode: conversation steps.
command: echo "{{.inputs.message}}"dir: "{{.inputs.project_path}}"timeout: "{{.inputs.timeout}}"transitions:
- when: "inputs.mode == 'full'"
goto: full_processNote: In when expressions, use variable names without {{}} and without the dot prefix.
items: "{{.inputs.files}}"Or literal JSON:
items: '["a.txt", "b.txt", "c.txt"]'Loop bounds support interpolation and arithmetic:
# From input
max_iterations: "{{.inputs.retry_limit}}"
# From environment
max_iterations: "{{.env.MAX_RETRIES}}"
# Arithmetic expression
max_iterations: "{{.inputs.pages * .inputs.retries_per_page}}"Supported operators: +, -, *, /, %
Dynamic values are resolved at loop initialization (before first iteration).
Static validation warns about undefined variables during awf validate.
The while and until conditions support interpolation:
while: "{{.states.check.Output}} != 'done'"
until: "{{.states.counter.Output}} >= {{.inputs.threshold}}"Template parameters use a different syntax: {{parameters.name}}
# In template definition
command: "{{parameters.model}} -c '{{parameters.prompt}}'"
# Resolved at load time, not runtimeSee Templates for details.
command: |
echo "Step 1: Process {{.inputs.file}}"
process-file "{{.inputs.file}}"
echo "Step 2: Analyze output"
analyze "{{.states.process.Output}}"Use shell conditionals:
command: |
if [ "{{.inputs.verbose}}" = "true" ]; then
echo "Verbose mode enabled"
fi
run-command --verbose={{.inputs.verbose}}Escape quotes properly:
command: |
curl -X POST -d '{"file": "{{.inputs.file}}"}' https://api.example.comUse --dry-run to see resolved values:
awf run my-workflow --dry-run --input file=test.txtOutput shows interpolated commands without executing them.
- Workflow Syntax - Complete YAML reference
- Input Validation - Validation rules
- Templates - Workflow templates