{{ ... }} templates in exactly two workflow attributes: the graph goal and node prompts. Every other attribute is literal text.
Template context
Goal templates can reference inputs and server-managed variables. Prompt templates receive those values plus the rendered workflow goal:
Environment variables and secrets are not available in goal or prompt templates. Use
{{ env.NAME }} and {{ secrets.NAME }} only in the configuration fields that support run-boundary interpolation.
Run config inputs
Define typed inputs in[run.inputs]:
run.toml
goal and node prompt attributes:
check.fabro
script, label, model, provider, condition, and all edge attributes — do not render templates. If one of them contains {{ … }} or {% … %}, the syntax is treated as literal text and Fabro records a detemplated_attribute warning suggesting you move the dynamic value into a prompt or goal.
Override individual inputs at run time with repeatable -I / --input flags:
foo= are accepted as empty strings. Arrays, inline tables, and datetimes are rejected.
Server-managed run config variables
Use server-managed variables for non-sensitive values that should be shared across runs, such as deployment environments, default branches, regions, or image tags:{{ vars.NAME }}:
workflow.toml
deploy.fabro
fabro variable list and fabro variable get DEPLOY_ENV show stored values. Do not store tokens, API keys, or credentials as variables; use fabro secret set for sensitive values.
Server-managed variables are stored in the shared SQLite database. When upgrading from file-backed storage, Fabro imports <storage_root>/variables.json once; existing SQLite rows win on name conflicts. After a successful import, the source file is renamed to variables.json.imported-<timestamp>.bak.
The server snapshots the variable store when it creates the run. Changing a variable later does not rewrite that run’s rendered goal, imported prompts, or @file prompt and goal contents.
goal
Agent and prompt nodes also receive the workflow goal at runtime:
example.fabro
Create a plan for: Implement the login feature.
A graph goal cannot contain {{ goal }} because that would reference itself. fabro validate reports goal_self_reference as an error; put the reusable text in an input or server-managed variable instead.
Expansion timing
Fabro keeps workflow structure static and renders workflow templates once:- Fabro parses the root DOT and imported
.fabrofiles without rendering them. - Literal
import,@file, graph-goal file, and child-workflow references are resolved. - The graph
goalis rendered with the{ inputs, vars }context. - Node
promptattributes are rendered with the{ goal, inputs, vars }context.
import paths, @file paths, child workflow paths, other file references, or any attribute besides prompt and goal.
Fabro renders the graph goal first and stores the rendered value back onto the graph. Prompts that use {{ goal }} receive that rendered value.
Undefined variables
Fabro renders undefined workflow variables as empty text and records atemplate_undefined_variable diagnostic. fabro validate reports that diagnostic as a warning so you can validate workflow structure before all inputs are known. Offline validation does not read a server’s variable store, so {{ vars.* }} references also warn there. Run-style commands such as fabro run, fabro create, and preflight use the server snapshot and promote any still-undefined reference to an error before proceeding.
Template includes
Prompt and goal templates support static MiniJinja loader dependencies such as{% include "partial.md" %}. Includes are resolved relative to the template file being rendered and can be nested.
Fabro discovers those static dependencies while building the run manifest so sandbox providers receive every required prompt file. Dynamic loader expressions such as {% include inputs.partial %} are rejected; use a literal include path and choose content with normal template conditionals instead.
Escaping
To emit literal template syntax, use MiniJinja escaping:{{ '{{' }} when needed.
Input merging
TOML[run.inputs] tables intentionally replace the inherited map wholesale rather than merging by key. Whichever TOML layer has the highest precedence and sets [run.inputs] wins its entire map.
CLI input flags are different: they are sparse per-key overrides applied after config resolution, so unrelated inherited inputs remain available. If a key is repeated on the CLI, the last value wins.