Kubesense

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:

PathExampleNotes
.Trigger.alert.rule_nameHigh 5xx rate
.Trigger.alert.severitycritical
.Trigger.alert.value4.2The value that breached
.Trigger.alert.statefiring or resolved
.Trigger.alert.starts_at2026-08-19T12:00:00ZWhen the incident began, not this evaluation
.Trigger.alert.labels.<name>.Trigger.alert.labels.clusterOnly the labels the alert carries — see above
.Trigger.alert.fingerprinta1b2c3…Identifies the series
.Trigger.alert.time_window6hThe rule's evaluation window
.Trigger.alert.eval_start / .eval_end2026-08-19T06:00:00ZThe 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 typeFields
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 samplesThe same three. Rows carry timestamp, app_service, resource, duration (nanoseconds — use humanDuration) and status_code
Condition.result — true or false
Build payloadThe 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

PathNotes
.Run.idUseful as an Idempotency-Key on an HTTP step
.Run.workflow_name
.Run.started_at
.Run.scheduled_forSchedule 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 input

It 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.

FunctionWhat it doesExample
defaultFalls back when the value is missing or empty{{ .Trigger.alert.labels.service | default "unknown" }}
lenNumber of items{{ len .Steps.top_errors.rows }}
first / lastFirst or last n items{{ first 5 .Steps.errors.rows }}
whereKeep items whose field equals a value{{ where "status" "500" .Steps.logs.rows }}
sortBySort by field, asc or desc{{ sortBy "count" "desc" .Steps.errors.series }}
pluckPull one field out of every item{{ pluck "service" .Steps.errors.rows }}
uniqRemove duplicates{{ uniq (pluck "service" .Steps.errors.rows) }}
joinJoin a list into a string{{ join ", " (pluck "service" .Steps.errors.rows) }}
toJSONRender as JSON — the workhorse of HTTP bodies{{ toJSON .Steps.errors.series }}
jsonPathRead a nested value by path{{ jsonPath .Steps.api.body "data.id" }}
truncateCut to n characters{{ truncate 200 .Steps.logs.rows[0].body }}
roundRound to n decimal places{{ round 2 .Trigger.alert.value }}
dateFormatFormat a timestamp{{ dateFormat "15:04 MST" .Trigger.alert.starts_at }}
humanDurationNanoseconds 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 wantWrite
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

SymptomCauseFix
<no value>The path doesn't exist in this run — usually a label the alert doesn't carryAdd | default "…", or wrap in {{ if }}
Problem: references .Steps.x, which does not existThe step was renamed or deleted; renaming does not rewrite templatesUpdate the reference by hand
Problem: does not run before this stepReading a step that isn't upstreamAdd it under Runs after
HTTP step fails on invalid JSONA Build payload step must render valid JSONQuote strings: "{{ .Run.id }}", not {{ .Run.id }}
Nothing renders at allA 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.