aiagent/skill/embedded/builtin/generate-message-template/SKILL.md
Nightingale message templates use Go text/template / html/template syntax (email uses text/template, others use html/template and then escape). Users edit them on the "Notification Management → Message Templates" page; once saved they are referenced by notification rules, and when an alert fires the variables are substituted with rendered data and sent to each channel.
This skill focuses on writing/modifying the template fragment itself, and does not cover creating notification rules or channel configuration.
The renderData passed in when the backend executes:
| key | type | description |
|---|---|---|
.events | []*AlertCurEvent | The list of alert events in the current batch, usually just 1 |
.domain | string | The n9e site URL, used to build redirect links |
Automatically injected shorthands (no need to write by hand):
{{ $events := .events }}
{{ $event := index $events 0 }}
{{ $labels := $event.TagsMap }}
{{ $value := $event.TriggerValue }}
So inside the template you can directly use $event.xxx, $labels.agent_hostname, and $value.
$event Fields (*AlertCurEvent)| field | type | description |
|---|---|---|
.Id | int64 | Alert event ID (used to build the redirect link) |
.RuleId | int64 | Alert rule ID |
.RuleName | string | Rule name |
.RuleNote | string | Rule note |
.Severity | int | Alert severity: 1=Critical, 2=Warning, 3=Info |
.PromQl | string | Alert trigger expression |
.RuleAlgo | string | Rule algorithm type |
.TriggerTime | int64 | Trigger time, unix seconds |
.TriggerValue | string | Metric value at trigger time (already a string) |
.FirstTriggerTime | int64 | First abnormal time (for consecutive alerts) |
.LastEvalTime | int64 | Most recent evaluation time, used as the recovery time on recovery |
.IsRecovered | bool | Whether it has recovered |
.NotifyCurNumber | int | Which notification number this send is |
.TargetIdent | string | Monitored object (usually agent_hostname) |
.TargetNote | string | Object note |
.GroupId / .GroupName | int64 / string | Business group |
.Cluster | string | Data source cluster name |
.Cate | string | Data source type (prometheus / host / mysql / ...) |
.RunbookUrl | string | Runbook link |
| field | type | description |
|---|---|---|
.TagsMap | map[string]string | Event labels, preferred: {{$labels.agent_hostname}} |
.TagsJSON | []string | A string array like ["k=v", ...] (commonly used in early templates) |
.AnnotationsJSON | map[string]string | Annotations, commonly .AnnotationsJSON.recovery_value |
.TriggerValuesJson.ValuesWithUnit | map | Trigger values with units (multi-value scenarios) |
| field | type | description |
|---|---|---|
.NotifyUsersObj | []*User | The user objects to notify this time (with Username/Nickname/Phone/Email) |
.NotifyGroupsObj | []*UserGroup | Associated user groups |
User objects are commonly used in DingTalk/Feishu/Lark templates to build @-mentions; see the
atsfamily of helpers below.
tplx.TemplateFuncMap)timeformat <unix> — defaults to "2006-01-02 15:04:05"; pass a second argument to override: {{timeformat $event.TriggerTime "15:04:05"}}timestamp — current time stringnow.Unix — current unix seconds (now is a Go template builtin)humanizeDuration <sec> — "3m15s"humanizeDurationInterface <interface> — same as above but accepts an interfacetoTime <unix> — returns a time.Time, can be chainedparseDuration <"5m"> — returns a time.DurationformatDecimal <v> <n> — keep n decimal places ({{formatDecimal $event.TriggerValue 2}})humanize <v> — K/M/G units (base 1000, SI)humanize1024 <v> — Ki/Mi/Gi (base 1024)humanizePercentage <v> / humanizePercentageH <v> — percentageadd / sub / mul / div <a> <b> — arithmeticprintf "%.2f" <v> — Go fmt.SprintftoUpper / toLower / title — case conversioncontains <s> <sub> — substringmatch <regex> <s> — regex match (bool)reReplaceAll <regex> <repl> <s> — regex replacesplit <s> <sep> / join <slice> <sep>stripPort <host:port> / stripDomain <host.domain>b64enc / b64dec — base64escape <s> — URL path escapeunescaped <s> — output raw HTML (no escaping)safeHtml <s> — same as aboveurlconvert <s>label <key> <labelMap> / value <key> <m> / strvalue <v>first <slice> — take the firsttagsMapToStr <map> — join labels into k=v,k=vsortByLabel <items> <key> — sort by labelats <users> <platform> — generate an at fragmentbatchContactsAts <contacts> <platform> — batch atbatchContactsAtsInFeishuEmail <contacts> / batchContactsAtsInFeishuId <contacts> — Feishu onlybatchContactsJoinComma <contacts> / batchContactsJsonMarshal <contacts>mappingAndJoin <map> <kvSep> <itemSep>jsonMarshal <v> — serialize to a JSON stringmapDifference <a> <b> — set differencenotify_channel_ident)A message template is bound to a specific channel; the channel ident determines the text engine and escaping behavior:
| Ident | Engine | Description |
|---|---|---|
email | text/template | No re-escaping; HTML templates can write tags directly |
slackwebhook / slackbot | html/template | After rendering, " / \n are escaped and wrapped into template.HTML; usually written in Markdown |
Others (dingtalk / feishu / feishucard / larkcard / wecom / tx-sms / ali-voice …) | html/template | After rendering, " \n \r are JSON-string-escaped, suitable for stuffing directly into a webhook payload |
Important:
html/templateautomatically escapes<>&. Where you don't want escaping, use{{unescaped "…"}}or{{safeHtml .X}}, otherwise DingTalk will render a literal<.
dingtalk, "Feishu card" is feishucard, "email" is email. Different channels differ significantly in style.$event.IsRecovered. Only SMS/voice may omit it.$labels.<key> rather than $event.TagsJSON.$event.TriggerValue; for two decimal places use {{formatDecimal $event.TriggerValue 2}}.timeformat.{{.domain}}/share/alert-his-events/{{$event.Id}}.{{$event.Severity}}{{if eq $event.Severity 1}}Critical{{else if eq $event.Severity 2}}Warning{{else}}Info{{end}}Critical / Warning / Info@everyone: add a line @all at the end of the template (DingTalk matches by splitting on spaces). To at an exact phone number: {{range $event.NotifyUsersObj}}@{{.Phone}} {{end}}.{{batchContactsAtsInFeishuEmail $event.NotifyUsersObj}} to build <at email=...></at> fragments. gotemplate ``` code block, and at the end provide a variable/function explanation with each non-trivial variable/function on its own line.When replying to the user:
<template content>
**Variable explanation**: list the non-trivial fields and functions used in the template (such as $event.TargetIdent, formatDecimal, timeformat), one per line.target_ident or some tag), state directly in the introduction what assumption you made, rather than asking back.The language follows the user's input (use Chinese if the input is Chinese).
#### {{if $event.IsRecovered}}<font color="#008800">💚{{$event.RuleName}}</font>{{else}}<font color="#FF0000">💔{{$event.RuleName}}</font>{{end}}
---
{{$duration := sub now.Unix $event.FirstTriggerTime}}{{if $event.IsRecovered}}{{$duration = sub $event.LastEvalTime $event.FirstTriggerTime}}{{end}}
- **Alert Level**: S{{$event.Severity}}
{{- if $event.RuleNote}}
- **Rule Note**: {{$event.RuleNote}}
{{- end}}
{{- if $event.TargetIdent}}
- **Target**: {{$event.TargetIdent}}
{{- end}}
{{- if not $event.IsRecovered}}
- **Trigger Value**: {{$event.TriggerValue}}
- **Trigger Time**: {{timeformat $event.TriggerTime}}
- **Duration**: {{humanizeDurationInterface $duration}}
{{- else}}
- **Recovery Time**: {{timeformat $event.LastEvalTime}}
- **Duration**: {{humanizeDurationInterface $duration}}
{{- end}}
- **Event Tags**:
{{- range $k, $v := $labels}}
{{- if ne $k "rulename"}}
- {{$k}}: {{$v}}
{{- end}}
{{- end}}
[Event Detail]({{.domain}}/share/alert-his-events/{{$event.Id}}) | [Mute 1h]({{.domain}}/alert-mutes/add?__event_id={{$event.Id}})
{{- if $event.IsRecovered}}
**Severity/State:** S{{$event.Severity}} Recovered
**Alert Name:** {{$event.RuleName}}
**Event Tags:** {{$event.TagsJSON}}
**Recovery Time:** {{timeformat $event.LastEvalTime}}
{{- else}}
**Severity/State:** S{{$event.Severity}} Triggered
**Alert Name:** {{$event.RuleName}}
**Event Tags:** {{$event.TagsJSON}}
**Trigger Time:** {{timeformat $event.TriggerTime}}
**Trigger Value:** {{$event.TriggerValue}}
{{- if $event.RuleNote}}
**Alert Description:** {{$event.RuleNote}}
{{- end}}
{{- end}}
Severity/State: S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}} Rule: {{$event.RuleName}} Target: {{$event.TargetIdent}}
- **Host**: {{$event.TargetIdent}}
{{- if $labels.ip}}
- **IP**: {{$labels.ip}}
{{- end}}
Note:
target_identis usually the hostname; if you need the IP, prefer the labelsip,instance,host.
- **Trigger Value**: {{formatDecimal $event.TriggerValue 2}}
TriggerValueis itself a string;formatDecimalfirst converts it to a float and then formats it, and returns non-numeric values unchanged.
...template body...
{{- range $event.NotifyUsersObj}}@{{.Phone}} {{end}}
DingTalk identifies @-mentioned users by "space + phone number". Or uniformly use
@all.
{{- if $event.IsRecovered}}
{{- if $event.AnnotationsJSON.recovery_value}}
- **Recovery Value**: {{formatDecimal $event.AnnotationsJSON.recovery_value 4}}
{{- end}}
- **Recovery Time**: {{timeformat $event.LastEvalTime}}
{{- end}}
The recovery value is written into
AnnotationsJSON.recovery_valueby the alert engine on recovery, and only exists when there is a recovery value, so guard it withif.
- **Severity**: {{if eq $event.Severity 1}}Critical{{else if eq $event.Severity 2}}Warning{{else}}Info{{end}}
The template itself does not do filtering — filtering should go into the attributes / label_keys of the notification rule. The template is only responsible for display. If the user asks about this, point it out.
DingTalk/email use <font color>, Feishu cards use the template field to fill in a color keyword:
{}
#### {{if $event.IsRecovered}}<font color="#008800">✅ {{$event.RuleName}}</font>{{else}}<font color="#FF0000">🚨 {{$event.RuleName}}</font>{{end}}
{}
{{if $event.IsRecovered}}turquoise{{else}}{{if eq $event.Severity 1}}red{{else if eq $event.Severity 2}}orange{{else}}grey{{end}}{{end}}
Feishu cards only recognize enumerated colors:
red / orange / yellow / green / turquoise / blue / indigo / purple / carmine / grey; hex color codes are ignored.
{{$duration := sub now.Unix $event.FirstTriggerTime}}
{{- if $event.IsRecovered}}{{$duration = sub $event.LastEvalTime $event.FirstTriggerTime}}{{end}}
- **Duration**: {{humanizeDurationInterface $duration}}
Triggered state:
current time - FirstTriggerTime; recovered state:LastEvalTime - FirstTriggerTime.humanizeDurationInterfaceoutputs a human-readable form like1h3m5s.
[View Dashboard]({{.domain}}/dashboards/123?ident={{urlquery $event.TargetIdent}}&host={{urlquery (index $labels "host")}})
[Mute 1h]({{.domain}}/alert-mutes/add?__event_id={{$event.Id}})
Stuffing
{{$event.TargetIdent}}directly into a URL will, when it contains spaces, Chinese, or&, get double-escaped on the IM side (typical symptom:&becomes&and the link won't open). Wrap every variable that goes into a URL query inurlquery.
- **Application**: {{index $labels "app-name"}}
- **K8s Cluster**: {{index $labels "k8s.io/cluster"}}
- **Business Dashboard**: {{index $event.AnnotationsJSON "dashboard_url"}}
$labels.app-namewill be parsed by Go template as "appminusname" and is bound to fail. As long as a key is not pure alphanumerics plus underscore, always fetch it withindex; the same goes for Annotations.
- **Trigger Value**: {{if or (eq $event.TriggerValue "+Inf") (eq $event.TriggerValue "NaN")}}N/A{{else}}{{formatDecimal $event.TriggerValue 2}}{{end}}
A
/0in PromQL returns+Inf, and an aggregation over missing data returnsNaN. Rendered directly into Feishu/DingTalk they appear as literal+Inf, which looks like a bug.
{{if gt $event.Id 0}}
[Event Detail]({{.domain}}/share/alert-his-events/{{$event.Id}})
{{else}}
(Edge-mode event — view details on the central server)
{{end}}
Known issue: in Edge mode the event Id is written asynchronously, so at render time it is 0, and the redirect link would land on
/alert-his-events/0(404). Add agt $event.Id 0guard at the template layer.
{{range $i, $e := .events}}
{{- if $i}}
---
{{end -}}
- Rule: {{$e.RuleName}}
- Target: {{$e.TargetIdent}}
- Trigger Value: {{$e.TriggerValue}}
- Time: {{timeformat $e.TriggerTime}}
{{end}}
In the default scenario one notification has exactly one event, and the template renders with
$event = index .events 0. When one notification aggregates multiple events (subscription aggregation, batch send) you mustrange .eventsto expand them.
html/template HTML-escapes. Tags such as <font> and <at> in DingTalk/Feishu/Lark must be wrapped in unescaped or placed in content where Go template recognizes them itself. Email uses text/template and is not subject to this.TriggerValue is a string: to compare magnitudes directly use parseDuration/custom logic; for normal display-only purposes leave it as is.{{$events := .events}} — the system injects them automatically.NotifyRecovered=1 reuse the same template to send the recovery message.$labels.agent_hostname works, but $labels.app-name does not — use index $labels "app-name".int64); don't use {{$event.TriggerTime}} directly as text — use timeformat or toTime..events may have multiple entries; the default DingTalk/Feishu templates only render entry 0 ($event); if the user wants a batch display, use {{range .events}}..domain for links: don't hardcode http://localhost:17000, otherwise switching environments breaks it.❌ {{$event.TriggerValue | printf "%.2f"}} — for printf, the first argument is the format, and TriggerValue is a string.
✅ {{formatDecimal $event.TriggerValue 2}}
❌ Displaying {{$event.TriggerTime}} directly — it outputs unix seconds.
✅ {{timeformat $event.TriggerTime}}
❌ Writing <font color="red"> in DingTalk without escaping — html/template will escape it into <font>.
✅ Put it inside a conditional branch (Go template's {{if}} branch literals do not get the inner tags HTML-escaped), or wrap the whole thing in {{unescaped "…"}}. Nightingale's official DingTalk template writes <font> directly and it works, because the outer area is a plain-text section (not inside an attribute value) — just follow the official sample.
❌ Forgetting to split the recovery branch — the trigger time in a recovery notification is misleading.
✅ Wrap all dynamic content first with {{if $event.IsRecovered}}…{{else}}…{{end}}.
❌ {{if .IsRecoverd}} — a missing e in the spelling. Go template silently takes the else branch for a nonexistent field (without erroring), so the consequence is that the recovery notification sends the triggered-state copy, and it is very hard to track down.
✅ {{if $event.IsRecovered}}. Similar spelling traps: Resoverd, Recoverd, recovered (lowercase) all fail.
❌ {{if lt $value 10}} or {{if gt $event.TriggerValue 80}} — TriggerValue is a string; comparing with lt/gt against a number will report incompatible types, or be interpreted as a string lexicographic comparison and yield a wrong result. The current version has no built-in toFloat helper. Put numeric threshold checks back into the alert rule's condition expression, and keep the template layer for display only.
❌ Building a URL with ?ident={{$event.TargetIdent}} directly — a TargetIdent containing spaces/Chinese/& will break the link.
✅ ?ident={{urlquery $event.TargetIdent}}, pass every variable that goes into a URL query through urlquery.
❌ {{$labels.app-name}} or {{$labels.k8s.io/cluster}} — keys containing hyphens/dots/slashes fail to parse.
✅ {{index $labels "app-name"}}, {{index $labels "k8s.io/cluster"}}.