nano SIEM
Playbooks

Templating & Runs

How {{...}} tokens resolve against alert + case + entity context when a playbook attaches to a case

Templating & Runs

A playbook stored in the library is symbolic — its /query, /pivot, /enrichment, and /action steps reference the firing alert, the case, and extracted entities via {{...}} tokens. When the playbook attaches to a case (as a playbook_runs row), the tokens resolve against a snapshot of that case's context.

This page documents the token grammar, the snapshot shape, and resolution rules. Use the same grammar when you're authoring playbooks by hand, reviewing adaptive ones PIVT composed, or reasoning about what an AI agent sees when it works an alert-assigned playbook.

Hit GET /api/playbook-runs/{id}/resolved to fetch a run's step tree with tokens substituted; the response carries a has_context flag (false for legacy or manual-attach runs without context) and the set of any paths that couldn't be resolved against the snapshot.

The run-context snapshot

When a playbook attaches to a case, nano freezes a run_context JSON snapshot on the playbook_runs row. The snapshot is captured once, at attach time, and never re-computed — so re-runs of the same playbook on a different case resolve independently, and late-run re-evaluations on the same case don't drift if the case's live entity list changes mid-investigation.

Snapshot shape:

{
  "case_id": "case_01hz…",
  "alert_id": "alert_01hz…",
  "rule": {
    "id": "rule_01hz…",
    "name": "AWS · root key used outside bastion",
    "severity": "high"
  },
  "source_type": "cloudtrail",
  "top_matched_event": {
    "timestamp": "2026-04-23T18:05:12.000Z",
    "user": "jsmith",
    "src_ip": "10.0.0.5",
    "src_host": "WS-JSMITH",
    "process_name": "aws-cli",
    "...": "...every UDM field on the triggering row..."
  },
  "entities": {
    "user":      ["jsmith"],
    "src_ip":    ["10.0.0.5"],
    "src_host":  ["WS-JSMITH"],
    "file_hash": [],
    "...":       "..."
  }
}

Alert-driven attach (a rule fires and creates a case): every field is populated. entities is derived from the same entity extraction that feeds case_entities.

Manual attach (an analyst picks a playbook from the case view): alert_id, rule, top_matched_event, and source_type are null. entities reflects whatever entities are already on the case. Tokens that reference null paths resolve to the empty string — they do not abort the run.

Token grammar

{{case.id}}          case typeid
{{case.title}}       case title
{{case.severity}}    p1 · p2 · p3 · p4

{{alert.id}}         alert typeid (null for manual attach)
{{alert.<udm_field>} alias for {{event.<udm_field>}}

{{rule.id}}          rule typeid
{{rule.name}}        rule name
{{rule.severity}}

{{source_type}}      e.g. cloudtrail, edr, auth

{{event.<udm_field>} any UDM field from top_matched_event
{{event.user}}       → "jsmith"
{{event.src_ip}}     → "10.0.0.5"

{{entity.<type>}}    first entity of that type
{{entity.user}}      → "jsmith"
{{entity.user[0]}}   → explicit index
{{entity.ip[1]}}     → second ip entity (or "" if missing)

{{entities.<type>}}  the array (use with {{join}})
{{join entities.user ","}} → "jsmith,bob"

Entity vs. event

  • {{entity.user}} resolves to the extracted entity — a deduplicated value pulled from every matched event on the case. Use this when you want "the user behind this investigation", stable across however many events the alert joined on.
  • {{event.user}} resolves to the user field on the single top matched event that triggered the rule. Use this when you specifically want the triggering row.

Most playbooks want {{entity.*}}. {{event.*}} is there for the cases where you need the exact triggering row — unusual source port, the specific process command line, etc.

UDM fields (for event.* and alert.*)

The .<udm_field> suffix on event.* and alert.* must be a canonical UDM field name on the triggering matched event. See UDM Fields for the full 525+ set. Common ones:

user, src_ip, dest_ip, src_host, dest_host,
process_name, command_line, file_path, file_hash,
url, url_domain, auth_type, auth_result, source_type

Entity types (for entity.* and entities.*)

entity.* does not use UDM field names — the entity extractor normalizes the raw UDM into a small set of nine discrete types, keyed in run_context.entities. src_ip and dest_ip both become entity.ip; src_user / dest_user both become entity.user; file_hash and process_hash both become entity.hash. Use these nine:

{{entity.<type>}}Extracted from (UDM fields + patterns)
useruser · src_user · dest_user · user_name
hostsrc_host · dest_host
ipsrc_ip · dest_ip
domaindomain patterns in messages
hashfile_hash · process_hash + MD5 / SHA1 / SHA256 patterns
urlurl + URL patterns
filefile_path · file_name
processprocess_name + command-line parsing
emailemail patterns in messages

So: {{event.src_ip}} (UDM field on the triggering event) is valid, {{entity.src_ip}} is not — the entity type is just ip. See Entities & Entity Graph for how extraction works at the case level.

Helpers

Inline helpers that can wrap any token:

HelperUsageResult
lower{{lower entity.user}}lowercased
upper{{upper entity.user}}uppercased
default{{default entity.user "n/a"}}value or fallback if empty
join{{join entities.user ","}}array → delimited string

Resolution rules

  1. Missing scalar → empty string. {{entity.hash}} on a case without a hash entity renders as "", not null and not an error.
  2. Missing array → []. {{entities.hash}} renders as []; the {{join …}} helper handles empty arrays as "".
  3. Unknown namespace → run-time error. {{widget.foo}} (there's no widget namespace) surfaces as a resolution error in the active-run UI — a subtle "missing context" indicator on the step card. It does not fail at attach time, so authors can reference forward-looking context without breaking their save.
  4. Case matters. {{entity.User}} is not {{entity.user}}. UDM field names are snake_case, lowercase.
  5. Quote what needs quoting. Tokens inside a where: clause that you'd normally quote as strings still need quoting: where: user = "{{entity.user}}" not where: user = {{entity.user}}. The resolver doesn't wrap scalars in quotes; whatever you wrote around the token is preserved verbatim.

Resolved step example

Given a playbook step:

/query: last 30d of auth events for this user
  from:   auth-events
  window: 30d
  where:  user = "{{entity.user}}"

…attached to a case where the extracted entities contain user: ["jsmith"], the active-run UI renders:

/query: last 30d of auth events for this user
  from:   auth-events
  window: 30d
  where:  user = "jsmith"

The stored playbook doc is never mutated — the symbolic form stays put in playbooks.doc. Resolution runs on read, against the frozen run_context JSON on the playbook_runs row, each time the active-run surface renders the run.

Why the frozen snapshot

An investigation can span hours or days. During that time:

  • New events can hit the same case (more entities get added).
  • The firing rule can be edited (title changed, severity tuned).
  • The triggering alert can be re-classified.

Resolving against live case state every render would mean the same /action: rotate {{entity.user}} step could target different users at different moments — a correctness hazard. The run_context snapshot pins the context to "the moment the playbook started running", so the resolved steps stay stable for the duration of the run.

Re-attaching the same playbook to the same case creates a new playbook_runs row with a fresh snapshot — so if the case has picked up new entities since the first run, the new run sees them.

AI agents working alert-assigned playbooks

PIVT agents (the Shadow Investigator, and the @runbook-driven agent loop) consume the resolved form of each step. That means when the rule assigns an adaptive or library playbook, the agent sees:

  • Step labels + params with {{...}} already substituted.
  • The same {{entity.*}} / {{event.*}} / {{rule.*}} namespaces humans see.
  • The danger / approval metadata on /action steps (used to gate whether to propose an action or to hand it off as a manual checkbox to the on-call analyst).

Writing playbooks with the grammar above keeps humans and agents on the same page — literally.

See also

  • Authoring — the slash-command grammar + visual builder.
  • Library — categories, lifecycle, approvals.
  • UDM Fields — every field that can follow {{entity.*}} / {{event.*}}.
  • Case Entities — how entities gets populated.
On this page

On this page