RunReveal

Creating Notification Templates

Template System: RunReveal uses Handlebars-style templating (via Raymond) to create dynamic notifications that adapt to your detection data.

Notification Template Example

Using the Template Builder

RunReveal provides a visual template builder in the dashboard to create and manage your notification templates.

🛠️ Quick Start: Creating Your First Template

1
Navigate
Go to Notification Channels → Templates
2
Create
Click "Create Template" button
3
Configure
Add title & body templates
4
Save
Save and assign to detections

Step-by-Step Guide

1
Access the Template Builder

Navigate to Notification Channels in the RunReveal dashboard, then click on the Templates tab.

Path: Dashboard → Notification Channels → Templates

2
Create a New Template

Click the Create Template button. Give your template a descriptive name that reflects its purpose (e.g., "Critical Security Alert", "Daily Digest Summary").

critical-alertsdaily-summaryslack-security
3
Configure the Title Template

Enter your title template in the Title field. This appears as the email subject line or message header. Keep it concise and include key information like severity and detection name.

[{{detection.severity}}] {{detection.displayName}} - {{detection.resultCount}} results

4
Configure the Body Template

Enter your body template in the Body field. This is the main content of your notification. Use Handlebars syntax for dynamic content, markdown for formatting, and helpers for advanced features.

Variables
{{detection.name}}
Conditionals
{{#ifEquals...}}
Tables
{{table results}}
5
Preview and Test the Template

The template editor shows a Preview of how your markdown and formatting will render. Note that the preview displays your template structure with placeholder values—it does not use real detection data.

💡

Testing with Real Data: To see your template with actual detection data, use the Send Test button on your notification channel after saving the template. This sends a test notification using sample detection data to your configured destination (Slack, email, etc.).

Preview Shows
  • Markdown rendering
  • Template structure
  • Formatting validation
Send Test Shows
  • Real variable substitution
  • Conditional logic evaluation
  • Channel-specific formatting
6
Save and Assign to Detections

Click Save Template to create your template. Then assign it to detections by editing the detection and selecting your template from the Notification Template dropdown.

💡 Tip: You can also specify templates in Detection-as-Code using the notificationTemplate field.

Assigning Templates to Detections

🖱️
Via Dashboard UI
  1. Go to Detections → Detection Queries
  2. Edit the detection you want to customize
  3. Find the "Notification Template" dropdown
  4. Select your custom template
  5. Save the detection
💻
Via Detection-as-Code
Add the template name to your detection YAML:
notificationNames:
- slack-security
# Override default template
notificationTemplate: critical-alerts

Template Structure

Templates consist of two parts:

📌 Title Template

Purpose: Short summary text
Used in email subject lines, Slack message titles, and notification headers
Keep it concise (50-100 characters)

📄 Body Template

Purpose: Full notification content
Used in email bodies, Slack message content, and detailed notifications
Can include rich formatting, tables, and links

Core Concepts

Channels, templates, and overrides

Before writing your first template, it helps to understand how the pieces fit together:

  • Notification channelwhere an alert is delivered (a Slack workspace, an email address, a webhook URL, PagerDuty, etc.). You configure channels under Notification Channels.
  • Templatehow an alert is formatted. A template is simply a Title string and a Body string, both written in Handlebars. The Title becomes the email subject / message header; the Body becomes the message content.
  • Assignment / overridewhich template is used for a given alert.

When RunReveal sends a detection notification, it renders whichever template it resolves first, in this order:

  1. Detection-level template — a template you assigned directly to the detection (the notificationTemplate field in Detection-as-Code, or the Notification Template dropdown on the detection). This overrides everything else.
  2. Channel default — the built-in default for that channel (default_slack, default_email, default_webhook, and so on) when the detection has no template of its own.
  3. Built-in fallback — a minimal system template used only when nothing else matches.

Every template renders against the same data object. Two top-level values are always available:

  • channel — the channel type string (slack, email, webhook, discord, jira, pagerduty, linear, google-chat, …). Use it to branch formatting per channel.
  • detection — the executed detection. The exact fields under detection depend on whether the detection is a SQL query or a Sigma rule — this difference is the single most common source of confusion and is covered in Detection data shapes: SQL vs Sigma below.

Other notification types populate a different top-level key instead of detection — AI agent responses render under agent, and health-check alerts under health. This page focuses on detection notifications.

1. Inserting Dynamic Values

Access detection properties using dot notation. Variables are wrapped in double curly braces.

Basic Syntax

1
detection.displayName
Display name of the detection
2
detection.severity
Severity level (Critical, High, Medium, Low)
3
detection.riskScore
Risk score value (0-100)
4
detection.resultLink
URL to view full results in RunReveal

2. Conditional Logic

Conditional helpers allow you to show or hide content based on detection properties.

Comparison Helpers

Equality Checks
  • ifEquals - Check if values are equal
  • ifNotEquals - Check if values differ
Numeric Comparisons
  • ifGreaterThan - Greater than
  • ifLessThan - Less than
  • ifGreaterThanOrEqual - Greater or equal
String Operations
  • ifContains - String contains
  • ifStartsWith - String starts with
  • ifEndsWith - String ends with
Empty Checks
  • ifNotEmpty - Value exists
  • ifEmpty - Value is empty

💡 Example Use Cases

  • • Show critical alert banner only when severity equals "Critical"
  • • Display high-risk warning when risk score is greater than 80
  • • Show error message only when detection.error is not empty
  • • Customize content based on channel type (email vs Slack)

3. Data Iteration

Loop through detection results and arrays to display multiple items.

Loop Helper

Basic Loop Structure
loop- Iterate through detection.results array
item- Current item in the loop
index- Current index (0-based)
first- True if first item
last- True if last item
Common Patterns
  • • Loop through detection results
  • • Iterate over categories array
  • • Process MITRE techniques
  • • Display extracted fields
Best Practices
  • • Always check ifNotEmpty before looping
  • • Use index for numbering items
  • • Use first/last for special formatting
  • • Limit display to first N items if needed

4. Table Generation

Automatically format detection results as tables for better readability.

🔍

Auto-Detect Columns

The table helper automatically detects columns from your result data.
table detection.results
Use when you want all columns displayed
🎯

Specify Columns

Control exactly which columns appear in your table.
tableWithColumns detection.results "User:item.actorEmail|Action:item.action"
Pass one "Header:field|Header:field" spec string

5. Markdown Rendering

Convert markdown content to HTML for rich formatting in your notifications.

📝 Markdown Support

Use the markdownToHTML helper to render formatted content:
Supported Markdown Features
Bold & Italic
Headers
Lists
Links
Code blocks
Tables
Blockquotes
Horizontal rules

Handlebars helper & partial reference

RunReveal renders templates with the Raymond Handlebars engine, plus a set of custom helpers. You do not need to read the Go source to use them — everything supported is listed below.

Built-in Handlebars syntax

These come from Handlebars itself and are always available:

SyntaxPurpose
{{value}}Insert a value (HTML-escaped)
{{{value}}}Insert a value without escaping
{{#if value}}…{{else}}…{{/if}}Render a block when value is truthy
{{#unless value}}…{{/unless}}Render a block when value is falsy
{{#each array}}…{{/each}}Iterate an array or object
{{#with object}}…{{/with}}Set the block's context to object
{{lookup object key}}Look up a dynamic key/index on an object or array

Inside an {{#each}} block you can use {{this}} (current item), {{@index}} (0-based position), {{@key}} (map key), {{@first}}, and {{@last}}.

#with is especially useful for reaching into a specific result row — see indexed access below.

Conditional helpers

Convenience wrappers that read better than the generic condition helper. Each is a block helper — the block renders only when the condition is true. All except the empty checks take a left-hand value and a right-hand value.

HelperTrue when
ifEquals a ba equals b
ifNotEquals a ba does not equal b
ifGreaterThan a ba > b (numeric)
ifLessThan a ba < b (numeric)
ifGreaterThanOrEqual a ba >= b (numeric)
ifLessThanOrEqual a ba <= b (numeric)
ifContains a bstring a contains substring b
ifNotContains a bstring a does not contain b
ifStartsWith a bstring a starts with b
ifEndsWith a bstring a ends with b
ifEmpty aa is empty/nil/zero-length
ifNotEmpty aa is present and non-empty
{{#ifGreaterThan detection.riskScore 80}}
> 🔴 High-risk detection — review immediately.
{{/ifGreaterThan}}

The generic condition helper

All of the above are shorthands for a single dispatcher helper. Use it directly when you want one consistent form (it also supports an {{else}} branch and a few array operators the shorthands don't expose):

{{#condition "isNotEmpty" detection.results ""}}
Rows returned: {{detection.resultCount}}
{{else}}
No results.
{{/condition}}

Supported operator strings: equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual, contains, notContains, startsWith, endsWith, isEmpty, isNotEmpty, arrayContains, arrayNotContains, arrayLength, arrayLengthGreaterThan, arrayLengthLessThan. Single-operand operators (isEmpty, isNotEmpty) ignore the third argument — pass "".

Data & formatting helpers

HelperDescription
{{table array}}Render an array of objects as a Markdown table, auto-detecting columns from the first row.
{{tableWithColumns array "spec"}}Render a table with an explicit column spec (see below).
{{#loop array}}…{{/loop}}Iterate an array, exposing {{item}}, {{index}}, {{first}}, and {{last}}; object fields are also merged into the block so you can write {{fieldName}} directly.
{{json value}}Serialize a value to a JSON string.
{{markdownToHTML markdownString}}Convert a Markdown string to HTML (used mainly in email bodies).

tableWithColumns column spec

tableWithColumns takes the array and a single spec string. The spec is a pipe-separated list of Header:fieldPath pairs. Field paths use dot notation and may start with an optional item. prefix:

{{tableWithColumns detection.results "User:item.actorEmail|Action:item.action|Source IP:item.srcIP"}}

A common mistake is passing bare column names as separate arguments (for example tableWithColumns detection.results "user" "action"). That does not work — the helper reads only the first string and expects the Header:field|Header:field form, so bare names produce an empty table. Always use the single "Header:field|…" spec string.

Partials

Partials are pre-written snippets you include with {{> name}}. These are handy for SQL detections where you want tabular output without writing the markup yourself:

PartialExpands to
{{> partials.mdTable}}A Markdown table of detection.results.
{{> partials.htmlTable}}An HTML <table> of the detection columns/values.
{{> partials.csv}}Comma-separated columns and rows.
{{> partials.jsonString}}The result rows serialized as a JSON array.
{{> partials.resultLink}}The detection.resultLink URL.
{{> partials.duration}}The detection run time, formatted.

The older partials.query.results.* and partials.query.helper.* names are still accepted as aliases for backward compatibility.

The table, CSV, JSON, and HTML-table partials all read detection.results / detection.columns / detection.values, which only exist on SQL detections. For Sigma detections, format detection.event fields directly instead — see the next section.


Detection data shapes: SQL vs Sigma

Both SQL and Sigma detections render under detection, and both share a common set of fields (detection.displayName, detection.severity, detection.riskScore, detection.resultLink, detection.categories, and so on). What differs is how the matched data is exposed, and this is the difference that trips people up most often:

  • A SQL detection runs a scheduled query and can match many rows. Its data lives in detection.results — an array of row objects.
  • A Sigma detection matches a single streaming event in real time. There is no results array; the matched event lives in detection.event — a single object.

Templates written for SQL detections often silently render nothing on Sigma detections. Sigma detections have no detection.results, detection.resultCount, detection.columns, or detection.values, so helpers like {{table detection.results}} and blocks like {{#ifNotEmpty detection.results}} produce empty output. For Sigma, read detection.event.* instead.

SQL detections expose the full query result set:

FieldTypeDescription
detection.resultsarray of objectsOne object per matched row, keyed by column name.
detection.columnsarray of stringsColumn names, in query order.
detection.valuesarray of arraysRow values as positional arrays (parallel to columns).
detection.resultCountnumberNumber of rows matched.
detection.resultLinkstringLink to view the full results in RunReveal.

The data your template receives looks like this:

{
  "channel": "slack",
  "detection": {
    "displayName": "Failed Logins",
    "severity": "High",
    "detectionType": "sql",
    "resultCount": 2,
    "results": [
      { "actorEmail": "[email protected]", "action": "login", "srcIP": "1.2.3.4" },
      { "actorEmail": "[email protected]", "action": "login", "srcIP": "5.6.7.8" }
    ],
    "resultLink": "https://app.runreveal.com/dash/history?alertID=..."
  }
}

Because detection.results is an array, you typically render it as a table or loop over it:

## {{detection.displayName}} ({{detection.resultCount}} matches)
 
{{#ifNotEmpty detection.results}}
{{table detection.results}}
{{/ifNotEmpty}}
{{#ifEmpty detection.results}}
_No results._
{{/ifEmpty}}
 
[View full results]({{detection.resultLink}})

Accessing a single result row

To pull one value out of a specific row, index into the array with bracketed notation:

First actor: {{detection.results.[0].actorEmail}}

When you want several fields from the same row without repeating the index, wrap the row in a {{#with}} block — inside the block, columns are addressed directly:

{{#with detection.results.[0]}}
First actor: {{actorEmail}} took action {{action}}
{{/with}}

Template Variables Reference

Every template renders against two top-level values: channel and detection. The tables below list the properties available on detection — the fields shared by all detections, then the fields specific to SQL and Sigma detections.

Common fields (all detections)

PropertyTypeDescription
detection.idstringUnique execution ID for this run.
detection.detectionIDstringID of the detection definition.
detection.namestringMachine name (slug) of the detection.
detection.displayNamestringHuman-readable detection name.
detection.descriptionstringDetection description.
detection.notesstringDetection notes.
detection.detectionTypestringEither sql or sigma.
detection.severitystringSeverity (Critical, High, Medium, Low).
detection.riskScorenumberRisk score (0–100).
detection.categoriesarrayCategory tags.
detection.mitreAttacksarrayMITRE ATT&CK tactic IDs.
detection.mitreTechniquesarrayMITRE ATT&CK technique IDs.
detection.triggeredbooleanWhether the detection triggered a notification.
detection.executedAttimestampWhen the detection ran.
detection.workspaceIDstringWorkspace ID.
detection.resultLinkstringLink to view the alert in RunReveal.
detection.errorstringError message if the run failed.
detection.fieldsarrayExtracted key fields for the alert, when present.

SQL detection fields

Present when detection.detectionType is sql.

PropertyTypeDescription
detection.resultsarray of objectsMatched rows, keyed by column name.
detection.columnsarrayColumn names in query order.
detection.valuesarray of arraysRow values as positional arrays.
detection.resultCountnumberNumber of rows matched.
detection.querystringThe query that ran.
detection.paramsobjectQuery parameters.
detection.runTimenumberQuery run time (nanoseconds).

Sigma detection fields

Present when detection.detectionType is sigma.

PropertyTypeDescription
detection.eventobjectThe single matched event (see below).
detection.sourceTypestringSource type (e.g. okta, aws-cloudtrail).
detection.sourceIDstringID of the source.
detection.sourceNamestringHuman-readable source name.

Streaming event fields (Sigma only)

Addressed under the detection.event. prefix (for example detection.event.eventName). Normalized fields sit at the top level of the event; original log fields live under detection.event.rawLog.

Field (detection.event.*)TypeDescription
eventNamestringNormalized event name.
eventIDstringEvent ID.
eventTimetimestampWhen the event occurred.
sourceTypestringSource type.
serviceNamestringService that produced the event.
actorobjectActor object — also actor.id, actor.email, actor.username.
srcIPstringSource IP. Also srcASOrganization, srcCity, srcASCountryCode, etc.
dstIPstringDestination IP. Also dstASOrganization, dstCity, dstASCountryCode, etc.
resourcesarrayResources involved in the event.
enrichmentsarrayEnrichment data attached to the event.
tagsobjectEvent tags.
receivedAttimestampWhen RunReveal received the event.
rawLogobjectThe original, unmodified log. Nested fields via detection.event.rawLog dot-paths.

Channel Variable

channel - The notification channel type (email, slack, discord, webhook, jira, pagerduty, linear, google-chat)

Use this variable to customize formatting for different channels.

Best Practices

✅ Do

  • • Keep titles concise (50-100 characters)
  • • Always check ifNotEmpty before iterating
  • • Include error handling sections
  • • Provide investigation steps
  • • Link to full results
  • • Test with empty results
  • • Use markdown for rich formatting

❌ Don't

  • • Assume results always exist
  • • Create overly long titles
  • • Skip error handling
  • • Hard-code values that should be dynamic
  • • Forget to test edge cases
  • • Use complex nested conditionals unnecessarily
  • • Ignore channel-specific formatting needs

Ready-to-Use Templates

Copy-paste starting points for every channel. Each scenario has an SQL detection tab (scheduled query, tabular detection.results) and a Sigma detection tab (single streaming event, detection.event) — pick the tab that matches your detection type. Each tab shows the Title, Body, and a Preview of the rendered output using sample data.

Slack

Slack messages use mrkdwn, which does not render Markdown tables — loop over detection.results to list rows instead (as below). Tables render normally in email and webhook payloads.

Title

[{{detection.severity}}] {{detection.displayName}}{{detection.resultCount}} results

Body

*{{detection.displayName}}*
Severity: `{{detection.severity}}` | Risk: `{{detection.riskScore}}`
 
{{#loop detection.results}}
{{item.actorEmail}}{{item.action}} from {{item.srcIP}}
{{/loop}}
 
<{{detection.resultLink}}|View in RunReveal>

Preview

Failed Logins Severity: High | Risk: 75

[email protected] — login from 1.2.3.4
[email protected] — login from 5.6.7.8

View in RunReveal

Discord

Like Slack, Discord doesn't render Markdown tables, so loop over the rows. Discord also doesn't linkify [label](url) in normal messages, so include the raw detection.resultLink URL.

Title

[{{detection.severity}}] {{detection.displayName}}{{detection.resultCount}} results

Body

**{{detection.displayName}}**
Severity: {{detection.severity}} | Risk: {{detection.riskScore}}
 
{{#loop detection.results}}
{{item.actorEmail}}{{item.action}} from {{item.srcIP}}
{{/loop}}
 
{{detection.resultLink}}

Preview

Failed Logins Severity: High | Risk: 75

[email protected] — login from 1.2.3.4
[email protected] — login from 5.6.7.8

https://app.runreveal.com/dash/history?alertID=abc123

Google Chat

Title

[{{detection.severity}}] {{detection.displayName}}

Body

**{{detection.displayName}}**
Severity: {{detection.severity}} | Risk: {{detection.riskScore}}
 
{{#loop detection.results}}
{{item.actorEmail}}{{item.action}} from {{item.srcIP}}
{{/loop}}
 
[View in RunReveal]({{detection.resultLink}})

Preview

Failed Logins Severity: High | Risk: 75

[email protected] — login from 1.2.3.4
[email protected] — login from 5.6.7.8

View in RunReveal

Email

Title

[{{detection.severity}}] {{detection.displayName}}

Body

# {{detection.displayName}}
 
{{detection.description}}
 
**{{detection.resultCount}}** results found.
 
{{#ifNotEmpty detection.results}}
{{table detection.results}}
{{/ifNotEmpty}}
 
[View full results]({{detection.resultLink}})

Preview

Failed Logins

Multiple failed logins

2 results found.

actorEmailactionsrcIP
[email protected]login1.2.3.4
[email protected]login5.6.7.8
View full results

Webhook (JSON body)

Title

{{detection.displayName}}

Body

{
  "detection": "{{detection.displayName}}",
  "severity": "{{detection.severity}}",
  "resultCount": {{detection.resultCount}},
  "results": {{json detection.results}},
  "link": "{{detection.resultLink}}"
}

Preview

{
  "detection": "Failed Logins",
  "severity": "High",
  "resultCount": 2,
  "results": [
    { "actorEmail": "[email protected]", "action": "login", "srcIP": "1.2.3.4" },
    { "actorEmail": "[email protected]", "action": "login", "srcIP": "5.6.7.8" }
  ],
  "link": "https://app.runreveal.com/dash/history?alertID=abc123"
}

Jira, Linear & incident.io

For issue trackers the Title becomes the issue summary and the Body the description. Linear and incident.io render Markdown; Jira converts basic formatting. Keep to fields plus a link.

Title

RunReveal: {{detection.displayName}}

Body

{{detection.description}}
 
**Severity:** {{detection.severity}}
**Risk score:** {{detection.riskScore}}
**Results:** {{detection.resultCount}}
 
{{#loop detection.results}}
- {{item.actorEmail}}{{item.action}} from {{item.srcIP}}
{{/loop}}
 
[View in RunReveal]({{detection.resultLink}})

Preview

Multiple failed logins

Severity: High Risk score: 75 Results: 2

View in RunReveal

PagerDuty & VictorOps

Paging channels are intentionally terse: the Title is the alert summary and the Body a short description. The same template works for SQL and Sigma detections.

Title

[{{detection.severity}}] {{detection.displayName}}

Body

{{detection.description}}
 
Severity: {{detection.severity}} | Risk: {{detection.riskScore}}
Results: {{detection.resultCount}}
View: {{detection.resultLink}}

Preview

Multiple failed logins

Severity: High | Risk: 75
Results: 2
View: https://app.runreveal.com/dash/history?alertID=abc123

Testing Your Templates

Test Scenarios

Data Variations
  • • Empty results (resultCount = 0)
  • • Single result
  • • Multiple results (10+)
  • • Missing optional fields
Severity & Risk
  • • High risk scores (above 80)
  • • Low risk scores (below 20)
  • • Different severity levels
  • • Error conditions