Variable Interpolation
AWF uses Go template syntax ({{.var}}) for variable interpolation in workflow definitions.
Syntax
Variables are enclosed in double curly braces with a dot prefix:
command: echo "Hello, {{.inputs.name}}!"Variable Categories
Input Variables
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}}State Variables
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)Output
The standard output (stdout) from the executed step:
analyze:
type: agent
provider: claude
prompt: "Analyze: {{.states.read_file.Output}}"ExitCode
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.
TokensUsed
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_exceededTokensInput / TokensOutput
Separate 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.
TokensEstimated
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}}.
Response (Structured Outputs)
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.
JSON (Explicit Output Formatting)
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.
Workflow Metadata
Access workflow execution information:
{{.workflow.id}}
{{.workflow.name}}
{{.workflow.duration}}Example:
log_result:
type: step
command: |
echo "Workflow {{.workflow.name}} ({{.workflow.id}}) completed"Environment Variables
Access system environment variables:
{{.env.VARIABLE_NAME}}Example:
deploy:
type: step
command: |
deploy.sh --env={{.env.DEPLOY_ENV}} --token={{.env.API_TOKEN}}AWF Directory Context
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: doneLocal-Before-Global Resolution
When 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)
Local Workflows (2-tier)
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)
Pack Workflows (3-tier)
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 fallbackNo 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.
Override Example
# 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}}.
Template Helper Functions
When interpolating template expressions, the following helper functions are available:
split
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
Join an array into a string with separator:
{{join (split .states.agents.Output ",") " | "}}Returns: apple | banana | orange
readFile
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
trimSpace
Remove leading and trailing whitespace:
Result: {{trimSpace .states.process.Output}}Useful for cleaning multiline outputs or removing shell command trailing newlines.
Example: String Manipulation
# 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}}Loop Context Variables
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}}"Loop Item JSON Serialization
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.
Nested Loop Parent Access
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}}"Error Variables (in hooks)
Available in error hooks:
{{.error.type}}
{{.error.message}}Security Considerations
Shell Injection
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)Secret Masking
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.comInterpolation in Different Contexts
Agent Provider
provider: "{{.inputs.agent}}"Resolves the provider name before registry lookup. Works in both type: agent (single-shot) and mode: conversation steps.
Commands
command: echo "{{.inputs.message}}"Working Directory
dir: "{{.inputs.project_path}}"Timeout
timeout: "{{.inputs.timeout}}"Conditional Expressions
transitions:
- when: "inputs.mode == 'full'"
goto: full_processNote: In when expressions, use variable names without {{}} and without the dot prefix.
Loop Items
items: "{{.inputs.files}}"Or literal JSON:
items: '["a.txt", "b.txt", "c.txt"]'Loop Bounds (max_iterations)
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.
Loop Conditions
The while and until conditions support interpolation:
while: "{{.states.check.Output}} != 'done'"
until: "{{.states.counter.Output}} >= {{.inputs.threshold}}"Template Parameters
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.
Common Patterns
Multi-line Commands
command: |
echo "Step 1: Process {{.inputs.file}}"
process-file "{{.inputs.file}}"
echo "Step 2: Analyze output"
analyze "{{.states.process.Output}}"Conditional Values
Use shell conditionals:
command: |
if [ "{{.inputs.verbose}}" = "true" ]; then
echo "Verbose mode enabled"
fi
run-command --verbose={{.inputs.verbose}}JSON in Commands
Escape quotes properly:
command: |
curl -X POST -d '{"file": "{{.inputs.file}}"}' https://api.example.comDebugging
Use --dry-run to see resolved values:
awf run my-workflow --dry-run --input file=test.txtOutput shows interpolated commands without executing them.
See Also
- Workflow Syntax - Complete YAML reference
- Input Validation - Validation rules
- Templates - Workflow templates