claude-code
claude-code.json
Claude Code hooks. Pins open for permission prompts (Notification), announces session start/stop, and lets the bubble focus the right terminal via X-Claude-PID.
Adapters translate whatever shape a tool emits into Axol's envelope. This is the canonical spec — match rules, template language, and the closed action vocabulary.
Raw payloads arrive with whatever shape their source happened to use. An adapter picks the one it recognizes, renders it into Axol's envelope, and hands it off to the bubble.
Five adapters ship with Axol. Load order is alphabetical by filename, so claude-code is checked first and zz-generic last. Each card below links to the adapter JSON on GitHub.
claude-code.json
Claude Code hooks. Pins open for permission prompts (Notification), announces session start/stop, and lets the bubble focus the right terminal via X-Claude-PID.
github-actions.json
GitHub Actions workflow runs. Success is a normal bubble, failure is urgent. The bubble links straight to the run.
sentry.json
Sentry issue alerts. fatal and error pin open as urgent; warning floats worry bubbles. Click-through lands on the issue in Sentry.
stripe.json
Stripe webhook events — scoped to the ones you actually want to hear about. Failed invoices, failed charges, and new disputes are urgent; refunds are normal. Everything else falls through.
zz-generic.json
The catch-all. Sorts last (zz- prefix) and fires for any payload that has a title field — so a minimal envelope-shaped POST works without a dedicated adapter.
Adapters are plain .json files loaded on startup from two directories, in order:
adapters/ next to the axol binary — the five bundled adapters above live here.~/Library/Application Support/Axol/adapters/ — your own. Drop a file in, restart Axol, done.Load order is alphabetical by filename within each directory, and the first adapter whose match predicate fires wins. To build a wildcard fallback, name it something like zz-generic.json so it sorts last.
Axol has two ways to hear about an alert — a local process curl-ing the loopback port, or a webhook riding the remote-alerts bridge. Both paths end at the same place: the adapter registry, which matches on the payload shape and renders the envelope. Plugins are where the two paths converge.
127.0.0.1:47329. Makefiles, CI steps, Claude Code hooks, hand-rolled scripts.
template language, and hands it to the validator → bubble.
Every alert — whether it's a direct envelope POST or a third-party payload routed through an adapter — travels through the same four-stage pipeline on Axol's side:
Whatever JSON your tool already emits. GitHub sends this payload to a webhook on every workflow run.
{
"workflow_run": {
"conclusion": "failure",
"head_branch": "main",
"html_url": "https://github.com/.../runs/42"
}
}
match picks the adapter. switch + cases choose a template by field value. placeholders pull from the payload.
{
"match": { "field": "workflow_run", "exists": true },
"switch": "workflow_run.conclusion",
"cases": {
"failure": {
"title": "",
"body": "CI failed",
"priority": "urgent",
"icon": "github",
"actions": [{ "type": "open-url",
"url": "" }]
}
}
}
Axol's common shape. Everything that reaches the bubble goes through this validator: priority clamped, actions from a closed set, URLs http(s):// only, file reveals restricted to $HOME. Adapters can't smuggle in shell execution.
{
"title": "main",
"body": "CI failed",
"priority": "urgent",
"icon": "github",
"actions": [{ "type": "open-url",
"url": "https://github.com/.../runs/42" }]
}
Urgent bubbles pin open until clicked; clicking runs the first action in the envelope — here, opening the workflow run in the browser.
Each adapter file is a JSON object with these top-level keys:
| Field | Type | Required | Purpose |
|---|---|---|---|
name | string | ✓ | Free-form identifier, shown in NSLog output when the adapter fires. No functional role — purely for debugging. |
match | object | ✓ | Predicate that decides whether this adapter claims the incoming payload. See match predicate. |
switch | string | — | Dot-path to a field whose value selects a template from cases. Omit for a flat adapter. |
cases | object | with switch | Map of switch-value → envelope template. The matching case renders; if no case matches the value, nothing renders. |
template | object | alt to cases | A single envelope template, used when there's no branching. Mutually exclusive with switch/cases. |
template or switch + cases. A file missing both silently does nothing when matched.
match predicateEvery adapter needs a match block that picks whether the incoming payload belongs to it. Supported forms:
{ "field": "workflow_run", "exists": true }
{ "field": "workflow_run.conclusion", "equals": "failure" }
{ "field": "message", "matches": "waiting for" }
Dotted paths drill into nested objects. exists fires on any non-null value; equals is exact; matches is a case-insensitive substring. When more than one check is set on the same predicate, all populated conditions must hold (AND). First adapter to match wins.
Any case (or a flat template) can include a skip_if predicate with the same three forms. When it fires, the event is dropped silently instead of rendering — useful for filtering out chatty built-in events (the bundled claude-code.json uses this to swallow the "waiting for your input" notification).
A skipped event is different from a no-match event: it records as "intentionally swallowed" in NSLog, so you can tell "my filter fired" apart from "no adapter claimed this payload."
Adapter files are validated when Axol starts. Unknown top-level keys (typo'd swtich or stray temlate), unknown predicate keys (e.g. match_es, eqals), and wrong-typed values all cause the adapter to be rejected with a NSLog line naming the offending key. You'll see this in Console.app — silent "my adapter just doesn't work" was the old behaviour and has been retired.
switch + cases vs flat templateTwo ways to ship an envelope from an adapter, depending on whether one incoming payload shape maps to one alert or to several:
Use when the match fires and you always want the same envelope shape. Single template object at the top level:
{
"name": "generic",
"match": { "field": "title", "exists": true },
"template": {
"title": "",
"body": "",
"icon": ""
}
}
Use when one source's payload can mean different things — e.g. GitHub's workflow_run emits success / failure / cancelled conclusions that deserve different priorities and copy. The switch value picks the case by exact string match:
{
"match": { "field": "workflow_run", "exists": true },
"switch": "workflow_run.conclusion",
"cases": {
"success": { "title": "", "body": "CI passed", "priority": "normal", "icon": "github" },
"failure": { "title": "", "body": "CI failed", "priority": "urgent", "icon": "github" },
"cancelled":{ "title": "", "body": "CI cancelled", "priority": "low", "icon": "github" }
}
}
If the switch field resolves to a value that doesn't appear as a case key, nothing renders for that event — silently dropped like a missed match. Add a wildcard by naming a case whatever string your field will commonly produce, or pair with a fallback adapter sorted later in the directory.
Every string value in a template runs through a tiny substitution engine. Whole strings that are just preserve their native type (so "pid": "" yields "pid": 12345, not "12345").
top-level substitution
dot-path into objects
fallback when missing or empty
last path component
strip surrounding whitespace
Set icon on the envelope to one of these names and Axol renders the matching glyph in the speech bubble. Most names resolve to SF Symbols; claude and github are bundled Bootstrap Icons SVGs. Unknown strings fall through as a literal prefix, so "icon": "🚢" still works for quick one-offs.
Actions are a closed set. Unknown action types are silently dropped by the envelope validator — adapters can't invent new ones.
{ "type": "focus-pid", "pid": 12345, "label": "Open terminal" }
{ "type": "open-url", "url": "https://…", "label": "View run" }
{ "type": "reveal-file", "path": "/Users/me/…", "label": "Open in Finder" }
{ "type": "noop", "label": "Dismiss" }
open-url is restricted to http:// and https://; reveal-file paths must exist and live inside $HOME. The first action in the envelope is what fires when the bubble is clicked.
Wiring GitHub Actions to Axol end-to-end, using the remote-alerts bridge:
Save as ~/Library/Application Support/Axol/adapters/github.json:
{
"name": "github-ci",
"match": { "field": "workflow_run", "exists": true },
"switch": "workflow_run.conclusion",
"cases": {
"success": {
"title": "",
"body": "CI passed",
"priority": "normal",
"icon": "github",
"source": "github-ci",
"actions": [{ "type": "open-url", "url": "", "label": "View run" }]
},
"failure": {
"title": "",
"body": "CI failed",
"priority": "urgent",
"icon": "github",
"source": "github-ci",
"actions": [{ "type": "open-url", "url": "", "label": "View run" }]
}
}
}
In the GitHub repo's Settings → Webhooks, add your neuromast's public URL — e.g. https://<your-neuromast>/app/api/hooks/github. Set the content type to application/json and copy the webhook secret into HOOK_SECRET_GITHUB on the neuromast environment (see the deploy guides).
Next failed workflow run pops an urgent pink bubble on your desktop with the GitHub mark and a "View run" action. No code written, one file added.