Message Templates
Anywhere a workflow accepts text — a Slack message, an HTTP request body or URL, a header value, a condition — you can insert values from the run using {{ }}.
*{{ .Trigger.alert.rule_name }}* is firing on `{{ .Trigger.alert.labels.service }}`
Top error: {{ .Steps.top_errors.rows[0].body }}The syntax is Go's text/template. You rarely need more than the patterns on this page.
Why you might see <no value>
If a message arrives reading Threshold alert is firing on <no value>, the template asked for something the run doesn't have. This is the single most common surprise, so it's worth understanding before anything else.
<no value> is what Go prints for a missing map key. It is not an error and the step still succeeds — the message just goes out with a hole in it.
The usual cause is a label the alert doesn't carry:
{{ .Trigger.alert.labels.service }}An alert only has the labels its rule attaches, plus the labels of the series that breached. If the rule doesn't group by service, there is no service label, and you get <no value>.
Check which labels your alert actually has by opening a firing instance under Alerts → Alert Events — the labels shown there are exactly what .Trigger.alert.labels contains.
Then give the template a fallback:
{{ .Trigger.alert.labels.service | default "unknown" }}Or leave the phrase out entirely when the label is absent:
{{ if .Trigger.alert.labels.service }}on `{{ .Trigger.alert.labels.service }}`{{ end }}Both work for any missing value, not just labels.
info: A dry run renders every template against real data and shows you the result before anything is sent. It is the fastest way to catch a <no value> — run one before you publish.
What data is available
Four top-level objects. The builder's variable picker (type {{ in any template field) lists what a given workflow actually has; this is the full set.
.Trigger — what started the run
Always present: .Trigger.type is alert, schedule or manual.
For an alert trigger:
| Path | Example | Notes |
|---|---|---|
.Trigger.alert.rule_name | High 5xx rate | |
.Trigger.alert.severity | critical | |
.Trigger.alert.value | 4.2 | The value that breached |
.Trigger.alert.state | firing or resolved | |
.Trigger.alert.starts_at | 2026-08-19T12:00:00Z | When the incident began, not this evaluation |
.Trigger.alert.labels.<name> | .Trigger.alert.labels.cluster | Only the labels the alert carries — see above |
.Trigger.alert.fingerprint | a1b2c3… | Identifies the series |
.Trigger.alert.time_window | 6h | The rule's evaluation window |
.Trigger.alert.eval_start / .eval_end | 2026-08-19T06:00:00Z | The exact interval the rule judged. Absent if the rule's window can't be parsed |
.Steps — output of earlier steps
Addressed by step id: .Steps.<step_id>.<field>. A step can only read steps that run before it — add the dependency under Runs after, or the builder flags a forward reference.
| Step type | Fields |
|---|---|
| Query | .series (list), .count, .queries.<label> for a multi-query step. In scalar output mode also .value and .has_data |
| Log samples | .rows (list), .count, ._truncated — true when the row limit cut the results short |
| Trace samples | The same three. Rows carry timestamp, app_service, resource, duration (nanoseconds — use humanDuration) and status_code |
| Condition | .result — true or false |
| Build payload | The parsed JSON itself, so .Steps.payload.anything.you.built |
| HTTP request | .status, .body (parsed when the response is JSON), .headers.<Name> |
Index a list with [0], counting from zero:
{{ .Steps.top_errors.rows[0].body }}.Run — this run
| Path | Notes |
|---|---|
.Run.id | Useful as an Idempotency-Key on an HTTP step |
.Run.workflow_name | |
.Run.started_at | |
.Run.scheduled_for | Schedule triggers only — the time the run was meant to happen, not when a worker picked it up |
.Inputs — values supplied to a manual run
.Inputs.<name>, using the defaults declared on the workflow when none is supplied.
The $NAME shorthand
Inside a query step's filters, a value may also use the $NAME form that dashboards and the explorer use:
$workload the alerting workload
$severity the alert's severity
$<input name> a manual run's inputIt resolves the alert's labels first, then the alert's own fields, then the run's inputs. A name nothing supplies is left as written rather than blanked, so a mistake shows up as an empty result you can trace instead of a silent match-nothing.
$NAME is expanded before templates are rendered, so one value may use both — {{ }} for anything the shorthand cannot express. It applies only where a query is defined; message bodies, URLs and headers use {{ }} alone.
Helper functions
Pipe a value into a function with |. These are the complete set.
| Function | What it does | Example |
|---|---|---|
default | Falls back when the value is missing or empty | {{ .Trigger.alert.labels.service | default "unknown" }} |
len | Number of items | {{ len .Steps.top_errors.rows }} |
first / last | First or last n items | {{ first 5 .Steps.errors.rows }} |
where | Keep items whose field equals a value | {{ where "status" "500" .Steps.logs.rows }} |
sortBy | Sort by field, asc or desc | {{ sortBy "count" "desc" .Steps.errors.series }} |
pluck | Pull one field out of every item | {{ pluck "service" .Steps.errors.rows }} |
uniq | Remove duplicates | {{ uniq (pluck "service" .Steps.errors.rows) }} |
join | Join a list into a string | {{ join ", " (pluck "service" .Steps.errors.rows) }} |
toJSON | Render as JSON — the workhorse of HTTP bodies | {{ toJSON .Steps.errors.series }} |
jsonPath | Read a nested value by path | {{ jsonPath .Steps.api.body "data.id" }} |
truncate | Cut to n characters | {{ truncate 200 .Steps.logs.rows[0].body }} |
round | Round to n decimal places | {{ round 2 .Trigger.alert.value }} |
dateFormat | Format a timestamp | {{ dateFormat "15:04 MST" .Trigger.alert.starts_at }} |
humanDuration | Nanoseconds to a readable duration | {{ humanDuration .Steps.q.value }} |
Functions compose, innermost first:
Top 3 services: {{ join ", " (first 3 (uniq (pluck "service" .Steps.errors.rows))) }}Loops and conditions
Repeat a block over a list with range, where . is the current item:
{{ range first 5 .Steps.top_errors.rows }}
• `{{ .service }}` — {{ truncate 80 .body }}
{{ end }}Show a section only when it applies:
{{ if gt (len .Steps.top_errors.rows) 0 }}
Found {{ len .Steps.top_errors.rows }} matching log lines.
{{ else }}
No matching logs in the window.
{{ end }}Comparison helpers: eq, ne, lt, le, gt, ge. Condition steps use the same syntax and must render true or false:
{{ gt (len .Steps.top_errors.rows) 0 }}Slack formatting
Slack uses its own markup, not Markdown:
| You want | Write |
|---|---|
| Bold | *bold* |
| Italic | _italic_ |
Code | `code` |
| Code block | ```…``` |
| Link | <https://example.com|View> |
A long message is split across several Slack blocks automatically, so you don't need to shorten a payload to fit — though truncate still helps readability.
Worked example
A Slack message for a 5xx alert that stays readable when data is missing:
*{{ .Trigger.alert.rule_name }}* — {{ .Trigger.alert.severity }}
{{ if .Trigger.alert.labels.service }}Service: `{{ .Trigger.alert.labels.service }}`{{ end }}
Value: {{ round 2 .Trigger.alert.value }} (window {{ .Trigger.alert.time_window | default "n/a" }})
{{ if gt (len .Steps.top_errors.rows) 0 }}
*Top errors*
{{ range first 3 .Steps.top_errors.rows }}
• {{ truncate 120 .body }}
{{ end }}
{{ else }}
_No matching log lines in the alert's window._
{{ end }}And an HTTP body forwarding the same run to another system:
{
"alert": {{ toJSON .Trigger.alert }},
"evidence": {{ toJSON (first 20 .Steps.top_errors.rows) }},
"run_id": "{{ .Run.id }}"
}Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
<no value> | The path doesn't exist in this run — usually a label the alert doesn't carry | Add | default "…", or wrap in {{ if }} |
Problem: references .Steps.x, which does not exist | The step was renamed or deleted; renaming does not rewrite templates | Update the reference by hand |
| Problem: does not run before this step | Reading a step that isn't upstream | Add it under Runs after |
| HTTP step fails on invalid JSON | A Build payload step must render valid JSON | Quote strings: "{{ .Run.id }}", not {{ .Run.id }} |
| Nothing renders at all | A missing or mismatched }} | Check every {{ has a closing }} |
warning: Deleting a step does not remove references to it from other steps' templates, and renaming a step does not rewrite them either. Both leave a problem in the Problems list naming the step that still points at the old id.