11 — Score authoring

This chapter is the developer reference for writing Tempo scores from scratch. It assumes you’ve read §2.3 — Scores and §7 — Score Editor. The Score Editor covers most use cases without you ever opening a JSON file; this chapter is for the cases where you want to author a score the editor can’t fully express, or you want to share a score with the community via the public catalog.

The canonical machine-readable schema lives at schema/score.schema.json in the public catalog repo. This chapter is the prose narration of that schema, plus the unwritten rules of “what makes a good score.”


11.1 — JSON schema overview

A score is a single JSON object. The minimum viable score:

{
  "providerIdentifier": "com.example.my-tool",
  "displayName": "My Tool"
}

That’s a valid score. It does almost nothing — every event from com.example.my-tool would render with the default Tempo styling, no severity logic, no custom actions — but it’s enough to show “My Tool” in the source panel and to load without errors.

A score with all the bells:

{
  "providerIdentifier": "com.example.my-tool",
  "displayName": "My Tool",
  "color": "#8E8E93",
  "severityDefault": {
    "severity": "info",
    "label": "Info"
  },
  "severityRules": [
    {
      "match": { "status": "error" },
      "severity": "error",
      "label": "Failed"
    },
    {
      "match": { "status": "warning" },
      "severity": "warning",
      "label": "Warn"
    }
  ],
  "grouping": ["${metadata.host}/${metadata.run_id}"],
  "groupingWindow": "6h",
  "defaultActions": [
    {
      "label": "Open dashboard",
      "systemIcon": "globe",
      "trigger": { "openURL": "https://example.com/dashboard" }
    },
    {
      "label": "Copy host",
      "systemIcon": "doc.on.clipboard",
      "trigger": { "copyToClipboard": "${metadata.host}" }
    }
  ]
}

Every top-level field other than providerIdentifier and displayName is optional. Tempo applies sensible defaults when a field is missing.

Where score files live

User-installed scores live in ~/Library/Application Support/Tempo/Scores/. Tempo loads them at launch and reloads when files change. The file name should match <providerIdentifier>.json — Tempo doesn’t enforce this strictly, but the convention makes the directory navigable and the score-vs-provider relationship clear at a glance.

Bundled scores ship with the app in Tempo.app/Contents/Resources/ and are seeded into the user-scores directory on first launch (with a version marker so the seeder doesn’t overwrite user edits).


11.2 — Field reference

providerIdentifier (required)

A stable, namespaced identifier for the source. Pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$, minimum length 3.

Conventions:

  • Reverse-DNS for vendors and well-known tools — com.kopia, com.ubiquiti.unifi, org.home-assistant, io.uptimekuma
  • scripts.<language>.<name> for shell/Python/Ruby scripts — scripts.shell.check_disk, scripts.python.log_scan
  • local.<name> for senders running on the same Mac as Tempo — local.check_disk, local.backup_notify
  • lab.<host>.<name> for senders on other LAN hosts — lab.nas01.smart_check

The identifier is the machine identity of the sender. It’s used for token binding (a token bound to com.kopia rejects events declaring com.example), for upsert deduplication, and for prefix-walking score resolution (a score for scripts covers every scripts.*.*).

displayName (required)

Human-readable label shown in the source panel. Free text, minimum 1 character.

If you don’t want to set this in the score, the user can override it in their local install via Manage Sources → rename source. The score’s displayName is the default.

color

Accent colour for the source, as #RRGGBB hex. Pattern enforced.

If omitted, Tempo uses a neutral gray (#8E8E93 is a common fallback). Strongly recommended to set this — distinct colours are how the source panel stays scannable.

severityDefault

Fallback severity assigned to events when no severityRules rule matches. Object with two fields:

{
  "severity": "info",
  "label": "Info"
}
  • severity (required) — one of info, ok, warning, error, critical
  • label (optional) — custom badge text. If omitted, the severity name uppercased

severityRules

An ordered array of rules. Each rule has:

  • match (required) — an object of key/value conditions. All conditions must match (logical AND). Values support glob-style wildcards (*, ?)
  • severity (required) — the severity to assign when this rule matches
  • label (optional) — custom badge text for this rule
"severityRules": [
  { "match": { "outcome": "error" }, "severity": "error", "label": "Failed" },
  { "match": { "outcome": "warning" }, "severity": "warning", "label": "Warn" },
  { "match": { "outcome": "ok" }, "severity": "info", "label": "OK" }
]

Evaluation: top-to-bottom, first match wins. Order matters — put more specific rules above more general ones.

💡 Note: the runtime also supports a richer rule shape with color overrides and presentation templates (titleTemplate, subtitleTemplate). The public catalog schema is intentionally narrower — those features are local-only and don’t ship in catalog scores. Use the Score Editor for the richer shape; manage your own scores in ~/Library/Application Support/Tempo/Scores/ for distribution.

grouping and groupingWindow

Stack grouping configuration. See §2.6 — Stack and grouping and §7.5 — Stack grouping.

  • grouping — array of templates, with ${metadata.xxx} placeholders. Tempo picks the first one that fully resolves
  • groupingWindow — duration string: 15m, 30m, 1h, 6h, 1d, 1w, or empty (no cutoff)
"grouping": [
  "${metadata.repo}/${metadata.path}",
  "${metadata.repo}",
  "${metadata.host}"
],
"groupingWindow": "1d"

Omit both for no grouping (every event renders as its own card).

defaultActions

Array of action buttons that appear on every event from this provider. Each action has:

  • label (required, ≥1 char) — button text
  • systemIcon (required, ≥1 char) — SF Symbol name
  • trigger (required) — an object with one of three shapes (covered in §11.4)
"defaultActions": [
  {
    "label": "Open dashboard",
    "systemIcon": "globe",
    "trigger": { "openURL": "https://example.com/" }
  }
]

Per-event actions sent in the payload itself are appended after the default actions, and override defaults of the same label.


11.3 — Severity rule syntax

Match conditions

A match object is a flat key/value map. Each key is a metadata field name; each value is a pattern to match.

Exact match

"match": { "outcome": "error" }

Matches when metadata.outcome == "error" exactly. Case-sensitive.

Wildcards

* matches any sequence of characters; ? matches any single character. (Glob-style, not regex.)

"match": { "alarmKey": "STA_*" }

Matches STA_ASSOC_FAILURE, STA_AUTH_FAILURE, STA_DEAUTH, etc.

"match": { "alarmKey": "*FAILURE" }

Matches anything ending in FAILURE.

"match": { "code": "E?00" }

Matches E100, E200, E300, etc.

Multiple conditions in one rule

{
  "match": { "outcome": "error", "severity": "critical" },
  "severity": "critical",
  "label": "CRITICAL FAILURE"
}

Logical AND: both outcome=error and severity=critical must be present in the metadata for the rule to fire.

Multiple rules

The rule array itself is logical OR: rule 2 fires if rule 1 didn’t, etc. First match wins.

Naming conditions

Match keys reference top-level metadata fields by name — no prefix required. Whatever your payload puts in metadata, the rule’s match object names it directly. For example, the bundled Kopia score uses "match": { "outcome": "error" } because the Kopia ingestion module emits an outcome field at the top of the metadata object; the rule names it as is.

Stringification is implicit: numbers, booleans and strings all collapse to their textual form, so a rule {"exit_code": 0} matches metadata values of 0, "0", or 0.0 interchangeably. Glob wildcards * and ? are supported in string values ({"key": "EVT_*_Connected"} collapses a family of provider-specific event keys).


11.4 — Action triggers reference

Three trigger types are supported in V1. Each is mutually exclusive — an action has exactly one trigger.

openURL

Opens a URL. macOS picks the handler based on the scheme.

"trigger": { "openURL": "https://example.com/" }

Allowed schemes (per the public catalog schema): http, https, ssh, mailto, tel, sms, facetime, vnc, rdp, plus documented app schemes (obsidian://, things://, etc.).

Rejected schemes: file, javascript, data, vbscript, anything not whitelisted.

The whitelist exists because URL handlers can do anything an app can do — file:// opens local resources, javascript: runs script in the browser context. Restricting to network and communication schemes keeps the action surface contained.

openTerminalWith

Opens Terminal.app and runs a command.

"trigger": { "openTerminalWith": "kopia snapshot list ${metadata.path}" }

Important: this trigger is not allowed in scores submitted to the public catalog. The public catalog reviews scores for safety, and a score that runs arbitrary shell commands is too high-risk to vet thoroughly. Catalog scores must use openURL (with ssh:// for shell access if needed) or copyToClipboard.

For your local install (drop a score into ~/Library/Application Support/Tempo/Scores/), openTerminalWith is fully supported. The restriction is purely about distribution — anything you run on your own Mac is your own decision.

copyToClipboard

Copies a string to the system clipboard.

"trigger": { "copyToClipboard": "${metadata.host}" }

No scheme restrictions — the value is a string, not a URL.

Interpolation

All three triggers support ${metadata.xxx} placeholder substitution at click time:

  • ${metadata.host} → the value of metadata.host from the event payload
  • ${title} → the event’s title
  • ${startDate} → the event’s timestamp (ISO 8601)
  • ${metadata.custom.disk_usage_percent} → reaches into the custom bucket

If a referenced field is missing from the payload, Tempo substitutes an empty string. The action still fires; the resolved URL or command may be malformed (ssh://admin@ with no host), in which case macOS will surface the error.

systemIcon

Each action takes an SF Symbol name as its icon. Common choices:

SF SymbolUse case
globeOpen URL (web)
lockSSH / login
terminalTerminal command
doc.on.clipboardCopy something
networkNetwork-related
houseHome (HA dashboard)
bookDocumentation
arrow.clockwiseRe-run / refresh
list.bulletList / log view
cloudCloud service
square.and.arrow.upOpen in another app

The full SF Symbols catalog is browsable in the SF Symbols app from Apple. Pick names that visually convey what the button does.


11.5 — .tempo-score installer file

A .tempo-score file is a single JSON file with the same shape as a regular score, but with a custom file extension. macOS recognises the extension via Tempo’s UTI registration; double-clicking a .tempo-score file opens Tempo and triggers the Score Review Sheet — a preview UI showing what’s about to be installed:

  • The provider identifier
  • The display name and colour
  • A preview of the rules and default actions
  • A diff if a score with this provider identifier is already installed (existing rules vs incoming rules)

The user clicks Install to apply, or Cancel to skip.

Why the special extension

A .tempo-score file is the friction-free distribution format. A user can:

  1. Click a .tempo-score link on a webpage
  2. Their browser downloads the file
  3. They double-click in Finder
  4. Tempo opens the review sheet
  5. They click Install

Total time: about ten seconds. No editor, no JSON, no reading docs.

Distribution

The public catalog at github.com/caereforge/tempo-scores hosts vetted .tempo-score files for community-contributed sources. Click a file in the GitHub UI → Raw → save as .tempo-score → double-click to install.

For your own scores you want to share, the same pattern works: put the file somewhere reachable (a Gist, a personal website, a Discord file upload), share the link, recipients double-click.

.tempo-score vs .json

Functionally identical content. The difference is the file extension:

  • .json — opens in your text editor by default; you’d have to manually copy it to ~/Library/Application Support/Tempo/Scores/
  • .tempo-score — opens in Tempo’s review sheet by default; one-click install

Use .tempo-score for distribution, .json for local editing in ~/Library/Application Support/Tempo/Scores/.


11.6 — tempo-validate CLI (V1.x)

🚧 V1.x roadmap: a command-line tool tempo-validate will ship in /contrib/ for offline score linting. Useful for CI pipelines that vet community contributions to the public catalog, or for local sanity-checks before installing a hand-edited score.

Expected behaviour

tempo-validate path/to/score.json

Validates the score against the public schema. Exits 0 on valid, non-zero with a descriptive error on invalid.

Likely extensions in the V1.x version:

  • Lint warnings for rules that reference metadata keys not commonly seen for the provider
  • Suggestions for missing fields (no color, no groupingWindow)
  • --strict mode that enforces the public-catalog rules (openTerminalWith rejected, etc.)

For now, you can validate by:

  1. Loading the score in Tempo (file watcher picks up changes; parse errors land in OSLog, filterable by app.tempo.tempo)
  2. Manually running jq to confirm the JSON parses: jq . path/to/score.json
  3. Checking the JSON Schema with any standard JSON Schema validator (the schema is at https://tempoapp.app/schema/score.schema.json)

11.7 — Best practices

A score that’s good to use is a score that’s been thought about. A few patterns worth following:

Severity calibration

  • critical is for the things that wake you up. WAN_DISCONNECTED, smoke detector, RAID degraded, root volume 100% full
  • error is for “something I need to look at today.” Backup failed, monitor down, build broken
  • warning is for “something I should be aware of, not urgent.” Low disk, one of three runs failed, deprecated API used
  • info is for “this happened, no action needed.” Backup succeeded, deploy completed, login event
  • ok is the same as info but signals positive outcome explicitly. Useful when the green “succeeded” pill is meaningful UX

If every event is critical, none of them are. The five severities matter only if you use them with discipline.

Action design

  • Idempotent. Clicking the action button twice in quick succession should not produce a double effect on the upstream side. (openURL is idempotent; openTerminalWith running a destructive command is not — be careful)
  • Visible side effects. The action’s label and SF Symbol should give the user a clear mental picture of what’s about to happen. “Open dashboard” is good; “Run thing” is not
  • Safe by default. Prefer read-only or transient actions (open URL, copy to clipboard, ping) over destructive ones (delete, restart, force-update). The action panel is one click away; treat it like a kitchen knife — sharp, but pointed at safe surfaces by default

Naming

  • Provider identifier: reverse-DNS for tools, local.* / lab.* for personal scripts, scripts.<lang>.<name> for language-specific senders
  • Display name: the brand name everyone uses. “Kopia” not “kopia-backup-tool”. “GitHub” not “GitHub Actions Webhook Adapter”
  • Action labels: short, verb-led. “Open dashboard” not “Click here to open the dashboard”. “Copy MAC” not “Copy device MAC address to clipboard”

Don’t overdo grouping

The temptation when writing your first score is to set up elaborate fallback chains. Resist. Most sources work fine with one or two grouping templates.

A good rule of thumb: write grouping that handles the most common event shape from your source. Add fallbacks only when you observe in the real feed that some events aren’t grouping the way you expected.

Use the Available keys strip

Before writing rules, send a few real events from the source. Open the Score Editor, look at the Available keys strip — those are the keys actually present in your events. Write rules against those, not against keys you imagine might be there.

Don’t ship secrets in the score

Scores are JSON. They’re shared via the public catalog, copy-pasted in Discord, attached to GitHub issues. Never put a token, an API key, or a credential in a score’s action triggers.

If you need a secret in an action, the right pattern is:

  • The user creates the token/secret on their own Mac (Keychain, env var, dotfile)
  • The action references the user-side secret indirectly (a script file the user wrote that reads from their Keychain)
  • The score itself is purely about which action runs, not about the secret it uses

11.8 — Worked example

Let’s write a score from scratch for a fictional tool: a custom log-scanning script that POSTs results.

The payload

The script POSTs:

{
  "title": "log_scan completed",
  "providerIdentifier": "scripts.python.log_scan",
  "metadata": {
    "host": "monitor-01.lab",
    "label": "Warning",
    "duration_ms": 3400,
    "custom": {
      "matches_found": 12,
      "log_file": "/var/log/syslog",
      "scan_pattern": "ERROR|FAIL"
    }
  }
}

Goal

A card that:

  • Shows “Log scan · {matches_found} matches” as the title
  • Coloured by severity: 0 matches → ok green, 1-9 → warning yellow, 10+ → error red
  • Has actions: “Open log file” (opens the scanned file in Console.app via file:// … wait, that’s not whitelisted), “Copy log file path”, “SSH to host”

The score

~/Library/Application Support/Tempo/Scores/scripts.python.log_scan.json:

{
  "providerIdentifier": "scripts.python.log_scan",
  "displayName": "Log Scan",
  "color": "#FF9F0A",
  "severityDefault": {
    "severity": "info",
    "label": "OK"
  },
  "severityRules": [
    {
      "match": { "label": "Critical" },
      "severity": "critical",
      "label": "${metadata.custom.matches_found} matches"
    },
    {
      "match": { "label": "Error" },
      "severity": "error",
      "label": "${metadata.custom.matches_found} matches"
    },
    {
      "match": { "label": "Warning" },
      "severity": "warning",
      "label": "${metadata.custom.matches_found} matches"
    },
    {
      "match": { "label": "OK" },
      "severity": "info",
      "label": "Clean"
    }
  ],
  "grouping": ["${metadata.host}/${metadata.custom.log_file}"],
  "groupingWindow": "1d",
  "defaultActions": [
    {
      "label": "SSH to host",
      "systemIcon": "lock.open",
      "trigger": { "openURL": "ssh://admin@${metadata.host}" }
    },
    {
      "label": "Copy log path",
      "systemIcon": "doc.on.clipboard",
      "trigger": { "copyToClipboard": "${metadata.custom.log_file}" }
    },
    {
      "label": "Tail log",
      "systemIcon": "list.bullet",
      "trigger": { "openTerminalWith": "ssh admin@${metadata.host} tail -100 ${metadata.custom.log_file}" }
    }
  ]
}

What this gets you

When a log_scan event arrives:

  • Title rendered as log_scan completed, with the severity pill coloured by metadata.label and labelled with the match count
  • Three action buttons in the action panel: SSH, Copy path, Tail log
  • Events from the same host + log file group together within a 1-day window
  • The source panel shows “Log Scan” with the orange (#FF9F0A) accent

Adjustments after observing real traffic

Once a few events have arrived, look at the Available keys strip in the Score Editor for scripts.python.log_scan. Maybe you discover:

  • metadata.custom.matches_found isn’t always set (the script forgot it on early-exit paths). Add a fallback in the labels: "label": "${metadata.custom.matches_found} matches" could resolve as matches (empty)
  • The tail log command needs sudo on some hosts. Adjust the action

The score is iterative — you ship the first version, run it for a few days, refine the rules and actions based on what the feed actually looks like.


Where to go from here