Templating
Bosun workflows use a templating system to create dynamic, repeatable, and flexible tasks. Templating allows you to inject values, manipulate data, and generate instructions or configurations on the fly, making your workflows adaptable to various scenarios without hardcoding every detail.
Templates are typically enclosed in double curly braces {{ ... }} and can be used in any field that accepts a string value. Under the hood, Bosun leverages the Tera templating engine (inspired by Jinja2 and Django templates).
Templates can include:
- References to outputs of earlier steps: Accessing data generated by earlier parts of the workflow.
- Loop variables: Using the current item when iterating over a list.
- Built-in functions and filters: Performing operations like splitting strings, slicing lists, or encoding data.
Tera helpers such as split, slice, filter, json_encode, format, and upper are all available—you can mix them the same way the upstream Tera examples do. Bosun resolves the expressions before executing a step so every agent, prompt, or shell command receives a fully rendered string.
- Use
json_encode()before handing values tofor_eachor structured prompts so Bosun receives valid JSON. - Prefer
quoteor%{}shell expansions instead of direct interpolation when using templates insideruncommands to avoid globbing surprises.
Accessing Step Outputs (outputs)
Each step in a Bosun workflow can produce an output. You can reference these outputs in subsequent steps using either:
outputs.<id>where<id>is the step ID you assigned in the manifest.outputs.<index>where<index>is the zero-based position of the step. This still works, but IDs are easier to maintain in graph manifests.
Example 1: Processing Files with for_each
Consider this workflow snippet that finds configuration files and processes each one:
steps:
- id: scan_configs
name: Get configuration files
run: find . -name "*.config.json"
- id: process_configs
name: Process each configuration
for_each:
from: '{{ outputs.scan_configs | split(pat="\n") | slice(end=inputs.maxFiles) | json_encode() }}'
agent:
extends: Coding
instructions: "Validate and standardize the configuration in file {{ for_each.value }}"
role: "You are an expert in configuration management."
constraints:
- "Ensure all configuration values are valid JSON"
- "Apply standard formatting to the file"
- "Report any invalid configurations without modifying them"
- "Focus solely on the specified file"
starts_with: scan_configs
ends_with: process_configs
edges:
- from: scan_configs
to: process_configs
In this example:
scan_configsstep: Runsfindto list.config.jsonfiles and stores the results under the IDscan_configs.for_eachstep: Reads the output viaoutputs.scan_configs, splits it into a list, slices it using an input value (see below), and iterates over each path.- Agent instructions:
{{ for_each.value }}inserts the current file path so the agent edits the correct file.
Example 2: Accepting Numeric Inputs
Task inputs can be typed as numbers. Use the Number type in your manifest and reference the value directly inside templates:
inputs:
maxFiles:
type: Number
required: false
description: Limit how many files to process (defaults to 3).
default: 3
Bosun fills the default value whenever a run or UI trigger omits the input. Defaults are resolved before any step executes, so the template context always exposes a concrete value. You can still override maxFiles by providing values via the CLI values block or the UI input form.
Inside templates the numeric input behaves like any other value. In the snippet above it controls how many files are processed: slice(end=inputs.maxFiles).
Default expressions are rendered with the same Tera engine, but they are evaluated eagerly inside the manifest. They can reference other declared inputs (e.g. {{ inputs.baseDir }}/src) or session_id. Runtime-only context such as outputs, errors, or for_each is unavailable at this stage and will cause validation errors in the manifest.
Example 3: Dynamic run Commands
Templating can also be used directly within run commands to create dynamic shell scripts or file operations:
steps:
- id: get_current_date
name: Get current date
run: date +%Y-%m-%d
- id: create_dated_report
name: Create a dated report file
run: echo "Report generated on {{ outputs.get_current_date }}" > report-{{ outputs.get_current_date }}.txt
starts_with: get_current_date
ends_with: create_dated_report
edges:
- from: get_current_date
to: create_dated_report
Here:
- The
get_current_datestep captures the current date. - The
create_dated_reportstep uses{{ outputs.get_current_date }}twice: once to include the date in the file content and again to dynamically name the report file (for examplereport-2023-10-27.txt).
Template validation before a run
Bosun validates every template when you save a manifest or trigger a run. The validator walks each string field (including nested for_each steps) and guarantees that every expression only references data that exists inside the manifest. Unknown references block the run before any step code executes, so you can fix template issues without burning credits.
Allowed context globals:
session_idinputsoutputserrorsfor_eachstep_statuses
Pre-runtime fields: repositories and working directories
Repository definitions and every step-level working_directory render before any code runs. Bosun performs two passes:
- Repository definitions render first and can only reference
inputsandsession_id. - After the repositories are cloned, Bosun renders each
working_directorywith access toinputs,session_id, andrepositories.
The repositories object exposes one entry per manifest repository (plus a primary alias). Each entry contains name and path, where path is the absolute executor path to that checkout.
Runtime-only globals such as outputs, errors, step_statuses, or nested for_each data remain unavailable to pre-runtime fields and cause validation errors when referenced from repositories or working_directory values.
inputs:
service_branch:
type: String
required: true
repositories:
- name: primary
primary: true
base_branch: "{{ inputs.service_branch }}"
- name: docs
provider:
type: github
url: https://github.com/acme/docs
steps:
- id: run-docs-tests
name: Run docs tests from repo clone
working_directory: "{{ repositories.docs.path }}/website"
run: bun test
starts_with: run-docs-tests
ends_with: run-docs-tests
edges: []
In this manifest the primary repository tracks a user-provided branch, and the run step scopes itself to the docs repository checkout using the resolved path.
Within each global the validator enforces more granular rules:
inputs.<name>must point at a declared input orvaluesentry.outputs.<id>anderrors.<id>must reference a step that already has thatid(numeric indexes continue to work for both collections).step_statuses.<id>exposes the success state for any step with anid.for_each.valueandfor_each.idxare only valid inside the body of afor_eachstep, andfor_each.outputs.<nested_id>only works when the nested step includes thatid.
Example: instruction interpolation in a for_each
This snippet mirrors the linter example and shows how templating behaves inside iterators:
steps:
- id: lint_errors
run: ./scripts/list-lint-errors.sh
- id: fix_lint_errors
name: Fix each linter error with an agent
for_each:
from: '{{ outputs.lint_errors | split(pat="\n") | filter(value) | slice(end=inputs.maxErrors) | json_encode() }}'
agent:
extends: Coding
instructions: |
Apply automatic lint fixes to {{ for_each.value }}.
Focus solely on the specified file: {{ for_each.value }}
role: "You are a meticulous code formatter and linter expert."
constraints:
- "Only apply fixes that do not change code logic."
starts_with: lint_errors
ends_with: fix_lint_errors
edges:
- from: lint_errors
to: fix_lint_errors