reference

Plugins

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.

the flow

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.

built-in adapters

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

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.

matches on hook_event_name

github-actions

github-actions.json

GitHub Actions workflow runs. Success is a normal bubble, failure is urgent. The bubble links straight to the run.

matches on workflow_run · switches on workflow_run.conclusion

sentry

sentry.json

Sentry issue alerts. fatal and error pin open as urgent; warning floats worry bubbles. Click-through lands on the issue in Sentry.

matches on data.issue.id · switches on data.issue.level

stripe

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.

matches on object=event · switches on type

zz-generic

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.

matches on title exists

where they live

Adapters are plain .json files loaded on startup from two directories, in order:

  1. adapters/ next to the axol binary — the five bundled adapters above live here.
  2. ~/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.

two paths in, one adapter

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.

local A tool on your Mac POSTs JSON directly to 127.0.0.1:47329. Makefiles, CI steps, Claude Code hooks, hand-rolled scripts.
remote A third-party webhook travels through a neuromast + lateral line and lands at the same loopback port, body unchanged.
adapter matches the payload shape, renders an envelope via the template language, and hands it to the validator → bubble.
envelope · validator · bubble — see the four stages below

the four stages

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:

incoming

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"
  }
}
adapter

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":  "" }]
    }
  }
}
envelope

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" }]
}
bubble

Urgent bubbles pin open until clicked; clicking runs the first action in the envelope — here, opening the workflow run in the browser.

main CI failed

adapter fields

Each adapter file is a JSON object with these top-level keys:

FieldTypeRequiredPurpose
namestringFree-form identifier, shown in NSLog output when the adapter fires. No functional role — purely for debugging.
matchobjectPredicate that decides whether this adapter claims the incoming payload. See match predicate.
switchstringDot-path to a field whose value selects a template from cases. Omit for a flat adapter.
casesobjectwith switchMap of switch-value → envelope template. The matching case renders; if no case matches the value, nothing renders.
templateobjectalt to casesA single envelope template, used when there's no branching. Mutually exclusive with switch/cases.
Either / or: every adapter has exactly one of template or switch + cases. A file missing both silently does nothing when matched.

the match predicate

Every 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.

Skipping specific cases

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."

Loud validation at load time

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 template

Two ways to ship an envelope from an adapter, depending on whether one incoming payload shape maps to one alert or to several:

Flat template

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":   ""
  }
}

Switch + cases

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.

template language

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
That's it. The language is deliberately small — no conditionals, no loops, no user-defined helpers. If an adapter needs more, it's a sign the transformation should happen in the sending tool instead.

default icon set

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.

action vocabulary

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.

end-to-end example: GitHub Actions

Wiring GitHub Actions to Axol end-to-end, using the remote-alerts bridge:

1. Create the adapter

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" }]
    }
  }
}

2. Point GitHub at your neuromast

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).

3. Push something

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.

share your adapter

If you wrote an adapter for a service other people use, open a PR — we'll bundle it. Adapters are declarative JSON with a closed action vocabulary, so review is shallow and turnaround is fast.

No plugin registry yet. For now "bundled" means the file lives in the repo at axol/adapters/ and ships with the binary. If the collection grows we'll carve out a proper contributors' directory — for now, PRs are the directory.