Pipeline Template Engine
The pipeline template engine provides template rendering for pipeline step parameters and workflow task parameters. It replaces the previous ad-hoc string-replacement logic with a unified, expressive syntax based on Go’s text/template.
Source: pkg/pipeline/template/
Design
Why text/template
The old approach used strings.ReplaceAll to swap {{event.id}} and {{steps.archive.url}} with literal values. This had several limitations:
- No support for conditionals (
if/else), loops (range), or transformations. - No built-in utility functions (
join,split,default,json,len). - Silent fallback: unresolved placeholders were left verbatim in output, hiding configuration errors.
- Each new field required a new
ReplaceAllcall, making the code rigid.
Go’s text/template was chosen because:
- It is a standard library package with zero dependencies.
- It does not HTML-escape output (no
html/templateneeded – pipelines never generate HTML). - It supports branching, iteration, pipelining, and custom functions.
- Pre-existing YAML pipeline definitions can be migrated transparently via a regex preprocessor.
Architecture
YAML config (step params)
|
v
RenderContext.RenderParams() / RenderString()
|
+--> template.Engine.Render() / RenderString()
|
+--> preprocessTemplate() -- regex rewrites old syntax
+--> text/template.Parse() -- compiles with FuncMap
+--> Execute() -- renders with TemplateData
|
v
rendered map[string]any / string
TemplateData
The data context exposed to templates has three namespaces:
| Dot-path | Type | Source |
|---|---|---|
.Event.* |
map[string]any |
DataEvent fields + Data KV |
.Steps.* |
map[string]map[string]any |
Previous step results, by name |
.Env.* |
map[string]string |
Environment variables (optional) |
.Event keys (populated by RenderContext.templateData()):
id = EntityID (alias: entity_id)
event_id = EventID
event_type = EventType
source = Source
capability = Capability
operation = Operation
backend = Backend
app = App
entity_id = EntityID
idempotency_key = IdempotencyKey
uid = UID
topic = Topic
+ any key from event.Data KV
When event.Data contains keys that overlap with top-level DataEvent fields, the Data values take precedence (they are copied first, then top-level fields overwrite them).
Syntax Reference
Data Access
Basic dot-access on the data context:
{{.Event.url}} # field url from event
{{.Event.id}} # entity ID
{{.Steps.step1.url}} # field url from step "step1"
{{.Env.HOME}} # environment variable
Index notation for keys that contain special characters:
{{index .Event "some-key"}}
{{index .Steps "step-1" "result-field"}}
Built-in Functions (12)
| Function | Description | Example |
|---|---|---|
event field |
Read a field from the event | {{event "url"}} |
step name field |
Read a field from a step result | {{step "archive" "url"}} |
join elems sep |
Join a slice into a string | {{join .Event.tags ","}} |
split str sep |
Split a string into a slice | {{index (split .Event.csv ",") 0}} |
contains str substr |
Check if a substring is present | {{if contains .Event.title "ERROR"}}alert{{end}} |
default def val |
Return def if val is nil or empty string |
{{default "guest" .Event.username}} |
json val |
Marshal a value to JSON | {{json .Event.metadata}} |
len val |
Return length of string/slice/map (0 for nil) | {{len .Event.tags}} |
jsonpath jsonStr path |
Extract string value from JSON via gjson path | {{jsonpath (json .Event.data) "items.0.id"}} |
jsonpathExists jsonStr path |
Check if a JSON path exists | {{if jsonpathExists (json .Event.data) "error"}}...{{end}} |
jsonpathRaw jsonStr path |
Extract raw value from JSON (interface{}) | {{json (jsonpathRaw (json .Event.data) "nested")}} |
These functions are registered into text/template.FuncMap and are available in any template expression.
JSON Path Extraction (gjson)
The three jsonpath* functions use gjson path syntax for extracting data from JSON strings. The path syntax supports:
| Syntax | Meaning | Example path |
|---|---|---|
field |
Top-level field | url |
parent.child |
Nested field | data.nested.key |
array.N |
Array index (0-based) | items.0 |
array.# |
Array length | items.# |
array.#.field |
All array elements’ field | items.#.name |
array.#(cond)# |
Filter array by condition | users.#(age>20)# |
Basic extraction:
{{jsonpath (json .Event.data) "nested.deep"}}
{{jsonpath (step "api" "result") "data.id"}}
{{jsonpath (step "api" "result") "items.1.name"}}
When the source is already a JSON string (e.g., a step result from a capability invocation), use jsonpath directly:
# Step "api" returned: {"data": {"items": [{"id": "x"}, {"id": "y"}]}}
{{jsonpath (step "api" "result") "data.items.1.id"}} → "y"
{{jsonpath (step "api" "result") "data.items.#"}} → "2"
{{jsonpath (step "api" "result") "data.items.#.id"}} → ["x","y"]
Conditional extraction:
{{if jsonpathExists (json .Event.data) "error"}}
Error: {{jsonpath (json .Event.data) "error.message"}}
{{end}}
Raw value access:
jsonpathRaw returns the underlying Go value (interface{}), useful when you need to chain with other functions or iterate:
{{range jsonpathRaw (json .Event.data) "items.#"}}
{{json .}} # each item as JSON
{{end}}
Filtered array queries:
gjson supports array filtering with #(condition)# syntax:
{{jsonpath (json .Event.data) "users.#(age>28).name"}}
Operators supported in filters: ==, !=, <, >, <=, >=, % (mod), %~ (regex match).
Composing with other functions:
{{default "unknown" (jsonpath (json .Event.data) "metadata.source")}}
{{if contains (jsonpath (step "log" "result") "status") "ok"}}pass{{end}}
{{printf "ID: %s" (jsonpath (step "api" "result") "data.id")}}
Conditionals
Use Go template control structures:
{{if .Event.url}}has-url{{else}}no-url{{end}}
{{if eq .Event.status "done"}}completed{{else}}pending{{end}}
{{if ne .Event.status "failed"}}ok{{end}}
{{if and .Event.x .Event.y}}both{{end}}
{{if or .Event.a .Event.b}}either{{end}}
{{if not .Event.missing}}absent{{end}}
{{if gt .Event.count 3.0}}high{{end}}
{{with .Event.user}}{{.name}}{{end}}
Note on boolean evaluation:
- nil / zero-value / empty string → falsy
- non-empty string / non-zero number → truthy
Loops
{{range .Event.items}}{{.}},{{end}}
{{range $index, $value := .Event.items}}{{$index}}:{{$value}};{{end}}
{{range .Event.items}}x{{else}}empty{{end}}
Function return values are valid range pipelines:
{{range split .Event.csv ","}}{{.}}-{{end}}
{{range split "a,b,c" ","}}[{{.}}]{{end}}
Pipelining and variables
{{$v := step "archive" "url"}}{{if $v}}URL: {{$v}}{{end}}
{{$parts := split .Event.csv ","}}{{len $parts}} items: {{join $parts "|"}}
Composing Functions
Functions can be chained with parentheses:
{{json (event "metadata")}}
{{contains (step "log" "output") "ERROR"}}
{{default "none" (step "prev" "title")}}
{{jsonpath (json .Event.data) "nested.field"}}
{{jsonpath (step "api" "result") "items.0.id"}}
Formatting
The printf built-in is always available:
{{printf "id-%s" .Event.id}}
{{printf "%02d" .Event.count}}
Backward Compatibility
Three regex-based preprocessors rewrite old syntax before template parsing.
1. Event fields: {{event.x}} → {{event "x"}}
Input: {{event.url}}
Output: {{event "url"}}
2. Step references: {{steps.s.field}} → {{step "s" "field"}}
Input: {{steps.archive.url}}
Output: {{step "archive" "url"}}
3. Legacy workflow references: {{stepName.id}} → {{step "stepName" "id"}}
Input: {{myStep.id}} or {{myStep.result}}
Output: {{step "myStep" "id"}} or {{step "myStep" "result"}}
Note: this regex only matches .id and .result suffixes, so {{foo.bar}} passes through unchanged.
All three preprocessors run in order: event. → steps. → stepName. (id/result).
Compatibility table
| Old syntax (still works) | New syntax (recommended) | Go template equivalent |
|---|---|---|
{{event.url}} |
{{event "url"}} |
{{.Event.url}} |
{{event.id}} |
{{event "id"}} |
{{.Event.id}} |
{{steps.archive.url}} |
{{step "archive" "url"}} |
{{index .Steps.archive "url"}} |
{{step1.id}} |
{{step "step1" "id"}} |
{{index .Steps.step1 "id"}} |
{{step1.result}} |
{{step "step1" "result"}} |
{{index .Steps.step1 "result"}} |
YAML Usage Notes
Quoting in YAML
Go template delimiters {{ and }} can confuse YAML parsers. Follow these rules:
Single-line templates with conditionals: wrap the value in quotes.
params:
action: '{{if eq .Event.status "done"}}archive{{else}}skip{{end}}'
Multi-line templates: use YAML literal block scalar | or folded block scalar >.
params:
message: |
{{if .Event.user}}
User: {{.Event.user}}
{{else}}
Anonymous
{{end}}
Simple field references: no quoting needed.
params:
entity: "{{event.id}}" # ok
link: { { event.url } } # ok, but quoting is safer
Escaping quotes inside templates
When using string literals inside template conditionals in YAML strings, escape double quotes:
# Inside a YAML double-quoted string, escape Go template quotes
action: "{{if eq .Event.status \"done\"}}archive{{else}}skip{{end}}"
# Alternative: use YAML single quotes (no escaping needed, but YAML parser-dependent)
action: '{{if eq .Event.status "done"}}archive{{else}}skip{{end}}'
Error Handling
Missing fields with event / step functions
The event and step functions return "" (empty string) for missing keys, matching the previous behavior:
{{event "nonexistent"}} → "" (empty string, no error)
{{step "x" "y"}} → "" (empty string, no error)
Missing fields with dot-access
Direct .Event.field access on a non-existent key renders the Go zero value:
{{.Event.nonexistent}} → "<no value>" (Go's nil representation)
Pipeline authors should prefer {{event "field"}} for safe rendering, or use {{default "fallback" .Event.field}} to provide a fallback.
Invalid template syntax
Syntax errors (unbalanced braces, malformed control structures) return an error from both RenderString() and Render():
{{if .Event.x}}} → error: "template parse: unexpected }"
{{if missing end}} → error: "template parse: unexpected EOF"
Usage in Code
Pipeline (RenderContext)
import "github.com/flowline-io/flowbot/pkg/pipeline/template"
rc := pipeline.NewRenderContext(event)
rc.RecordStepResult("step1", map[string]any{"id": "abc", "url": "https://x.com"})
params := map[string]any{
"entity": "{{event.id}}",
"ref_url": "{{steps.step1.url}}",
}
rendered, err := rc.RenderParams(params)
// or single string:
result, err := rc.RenderString("id={{event.id}} url={{event.url}}")
Workflow (resolveParams)
The resolveParams function uses the same template engine internally:
params := types.KV{"output": "{{step1.result}}"}
results := map[string]string{"step1": "my-output"}
resolved, err := workflow.resolveParams(params, results)
// resolved["output"] → "my-output"
Workflow results are mapped: each result string is exposed as both id and result fields:
results["step1"] = "output-string"
→ Steps["step1"]["id"] = "output-string"
→ Steps["step1"]["result"] = "output-string"
Standalone engine
e := template.New()
data := &template.TemplateData{
Event: map[string]any{"url": "https://example.com"},
Steps: map[string]map[string]any{
"archive": {"id": "a1"},
},
}
s, err := e.RenderString("{{.Event.url}} -> {{step \"archive\" \"id\"}}", data)
// s = "https://example.com -> a1"
params, err := e.Render(map[string]any{
"action": "{{if .Event.url}}present{{end}}",
}, data)
// params["action"] = "present"
Testing
go test ./pkg/pipeline/template/...
Test coverage includes: plain text passthrough, event fields, step fields, env fields, conditions (if/else/eq/ne/and/or/not/gt), loops (range/range-index/range-else), nested condition+loop, all 12 built-in functions (including jsonpath, jsonpathExists, jsonpathRaw for gjson-based JSON extraction), old syntax compatibility, error propagation, invalid templates, and nil/empty data.