optional add-on

Remote alerts

How alerts from the wider internet reach Axol's speech bubble — the full path from a signed webhook to a rendered notification, end to end.

Deploy guides →

why

Axol's receiver binds to 127.0.0.1:47329 — loopback only, no exceptions. Anything you can curl from the same machine can talk to her, but GitHub / Stripe / Sentry / anything with a webhook has no way in.

The lateral line gives Axol a dangling antenna: a public, signed endpoint that queues remote alerts, and a shell script on your Mac that periodically fetches them and posts them to the loopback port. It's named after the row of sensory cells down a fish's flank (axolotls have one too) — each inbound webhook is one neuromast picking up pressure from the outside, and the script is the nerve pulling signal back toward the brain.

anatomy

Two pieces, both borrowed from fish biology, each doing one job:

neuromast

In fish: a cluster of sensory hair cells on the skin that detects pressure changes in the surrounding water — a single pickup point.

In Axol: one signed cloud endpoint. Accepts a webhook POST, verifies the sender's HMAC (or falls back to a shared query key), and parks the raw body in a short-TTL queue. An Astro app — runs on Cloudflare Workers + KV, or a Node runtime + Redis. ~60 KB bundle.

Scales to: as many neuromasts as you want. Each mount path or deploy target is its own sensor with its own auth config, all feeding the same lateral line downstream.

lateral line

In fish: the nerve pathway running along the flank, wiring every neuromast back to the brain — the channel that carries signals home.

In Axol: a 60-line bash script on your Mac, run by launchd every 15 seconds. Pulls each neuromast's queue with a bearer token, POSTs the bodies to 127.0.0.1:47329, acks on success. Cursor lives at ~/Library/Application Support/Axol/lateral-line.cursor.

Guarantees: at-least-once. Items stay queued if Axol's offline; a crashed ack causes at most one duplicate bubble. No cloud redeploy needed when adding sources — Axol's local adapter system handles translation.

data flow

Each stage pulses in turn as a webhook traces the path from sender to bubble.

sender
GitHub, Stripe, anything with a webhook. POSTs JSON to your neuromast's public URL — signed with HMAC (github / stripe / generic) or gated by a shared query key.
neuromast
An Astro app that runs on either Cloudflare Workers + KV or a Node runtime + Redis. Verifies the signature / key, parks the raw body in a short-TTL queue, returns 204. ~60 KB bundle.
lateral-line
A 60-line bash script run by launchd every 15 seconds. Fetches each neuromast's queue with a bearer token, POSTs each body unchanged to 127.0.0.1:47329, acks on success. At-least-once: if Axol is offline, items stay queued until she's back.
axol

The loopback receiver (127.0.0.1:47329, hardened at the socket level) parses the POST body and walks it through four stages:

  • Adapter match — JSON files in adapters/ and ~/Library/Application Support/Axol/adapters/ are tried alphabetically; first match wins. The winner renders an envelope via the template language. Spec →
  • Validator — closed vocabulary: priorities low | normal | high | urgent, actions focus-pid / open-url / reveal-file / noop. URLs must be http(s)://, file reveals must live in $HOME. No shell execution path.
  • Alert store — newest-first, capped at 5 entries, 5-minute seen-TTL.
  • Speech bubble — priority-styled, with worry bubbles rising for unresolved high/urgent alerts. Click fires the first action.

Remote alerts transports the body here; plugins translate it.

main CI passed · workflow #482

authentication

The /hooks/{source} route resolves auth in a fixed order:

  1. If HOOK_SCHEME_<SOURCE> is set for this URL segment, verify the request's signature with that scheme.
  2. Otherwise, accept if ?key=<SHARED_SECRET> matches (constant-time compare).
  3. Otherwise, 401.

HMAC schemes

Scheme Header Signed input Notes
github X-Hub-Signature-256 raw body Matches GitHub's default webhook signing.
stripe Stripe-Signature <ts>.<body> 5-minute replay window. Tolerates key rotation (multiple v1= values).
generic X-Signature-256 raw body Our own convention for ad-hoc senders. sha256=<hex> format.
Adding a scheme — one entry in neuromast/src/lib/hmac.ts. Verifiers use Web Crypto's subtle.sign (available natively in Workers); no deps.

Optional replay window for github / generic. Both schemes HMAC the raw body by default. Senders that care about replay protection can include an X-Signature-Timestamp header (unix seconds); neuromast verifies it's within 300 seconds and then signs <ts>.<body> instead — Stripe-style. Stripe's scheme already bakes the timestamp in, so this only matters for the other two.

delivery semantics

At-least-once. The forwarder only acks items after the local Axol returns 204. If Axol is offline, items stay queued and retry on the next tick. If the forwarder crashes between POSTing to Axol and calling /ack, you'll see one duplicate bubble — rare, and a fair trade for not silently dropping an alert.

Bounded queue. The queue index is capped at the 100 most-recent items. Older unacked entries age out of the index (though their bodies linger until the backing store's own expiration). For a single user this is effectively unlimited headroom — it's a backstop for a forwarder that's been offline for a week, not a broadcast queue.

Body size limit. /app/api/hooks/{source} rejects payloads larger than 1 MB with HTTP 413. Real-world webhooks (GitHub PR events, Stripe event objects) sit well under this ceiling.

No dedup on Axol's side. Axol today doesn't track inbound alert IDs, so the same payload POSTed twice renders two bubbles. If you care, that's a one-field addition in AlertStore.push.

queue item shape

Each entry the forwarder pulls back from /app/api/pull has this shape. body is the raw webhook payload, verbatim — Axol's adapters do the translation.

{
  "id":         "00001715881200000-a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "source":     "github",
  "receivedAt": 1715881200000,
  "body":       { "workflow_run": { "conclusion": "success", ... } }
}

id is monotonic (timestamp-prefixed UUID), so since=<last-id> on the next pull fetches only newer items.

approval flow

Neuromast can also run the loop in reverse — a remote caller asks Axol for a human decision, and the answer makes its way back. Built on the same queue, same auth, same forwarder; the only addition is two endpoints that park a pending request and record the decision.

flow

  1. Caller POSTs /app/api/permission/{source} with a tool_name, optional tool_input, and a request_id. Neuromast 202-accepts immediately, returns the id.
  2. Lateral-line pulls the envelope (kind: "permission") on its next tick and POSTs it to Axol's local /permission endpoint, which holds the connection open while Axol renders an Allow/Deny bubble.
  3. User clicks. Axol returns Claude Code's hookSpecificOutput shape. Lateral-line forwards the decision to /app/api/decision/{request_id}.
  4. Caller discovers the answer via GET /app/api/decision/{request_id} (poll or optional ?wait=N long-poll).

why it looks like this

The POST always returns fast (<1 s). That matters for callers with tight ack windows — Slack slash commands (3 s), PagerDuty webhooks (5–10 s), Stripe events (10 s). Those integrations can't block on a human clicking a button, so neuromast doesn't try to hide the async nature: it persists the pending request, returns an id, and lets the caller resume on its own schedule.

If your caller can hold (a CLI tool, a GitHub Actions step, your own script), include "hold": 25 in the body. Neuromast will wait up to 25 s for the answer before falling back to 202. Hold is capped at 25 s to stay inside Cloudflare Workers' idle-request budget and is advisory-inline: under KV replication lag it can still time out even when the user has clicked. Your caller should treat a 202 as "check back."

per-service defaults

Different services have different tolerances. Drop a SERVICES_CONFIG env var (JSON) on neuromast to set per-source defaults; per-request fields always win:

{
  "github-actions": { "hold_default": 20, "expires_default_s": 600 },
  "slack-command":  { "hold_default":  2, "expires_default_s":  30 },
  "stripe":         { "hold_default":  0, "expires_default_s": 300 }
}

request shape

Auth is the same ladder as /app/api/hooks/{source} — a per-source HMAC if you've configured HOOK_SCHEME_* + HOOK_SECRET_*, otherwise the SHARED_SECRET query key.

POST /app/api/permission/github-actions?key=<shared-secret>
Content-Type: application/json

{
  "request_id":   "run-4821-step-deploy-prod",
  "tool_name":    "Deploy",
  "tool_input":   { "env": "prod", "sha": "abc123" },
  "session_hint": "ci · actions #4821",
  "cwd":          "/repo/acme/api",
  "hold":         20,
  "expires_at":   "2026-04-18T20:05:00Z"
}

Response when held (200) — decision landed in time:

{
  "request_id": "run-4821-step-deploy-prod",
  "status":     "allow",
  "decided_at": "2026-04-18T19:57:12Z"
}

Response when accepted async (202) — use the id to poll:

{
  "request_id": "run-4821-step-deploy-prod",
  "status":     "pending",
  "expires_at": "2026-04-18T20:05:00Z"
}

polling the decision

GET /app/api/decision/{request_id}
GET /app/api/decision/{request_id}?wait=20   # optional long-poll

Returns { status: "pending" | "allow" | "deny" | "expired" }. The id is opaque to Axol; possession of it is the implicit auth for polling (caller minted it, caller knows it), which keeps the hot path secret-free.

example: GitHub Actions deploy gate

- name: Request human approval
  id: approve
  run: |
    resp=$(curl -sS -X POST \
      -H 'Content-Type: application/json' \
      --data '{
        "request_id":   "$-deploy-prod",
        "tool_name":    "Deploy to prod",
        "tool_input":   { "sha": "$" },
        "session_hint": "actions #$",
        "hold":         20
      }' \
      "$NEUROMAST_URL/app/api/permission/github-actions?key=$NEUROMAST_SHARED_SECRET")
    echo "$resp"
    echo "id=$(jq -r '.request_id' <<<"$resp")"      >> "$GITHUB_OUTPUT"
    echo "status=$(jq -r '.status' <<<"$resp")"     >> "$GITHUB_OUTPUT"

- name: Wait for decision (poll)
  if: steps.approve.outputs.status == 'pending'
  id: wait
  run: |
    for i in $(seq 1 60); do
      sleep 10
      resp=$(curl -sS "$NEUROMAST_URL/app/api/decision/$")
      status=$(jq -r '.status' <<<"$resp")
      if [ "$status" != "pending" ]; then
        echo "status=$status" >> "$GITHUB_OUTPUT"
        exit 0
      fi
    done
    echo "status=expired" >> "$GITHUB_OUTPUT"

- name: Deploy
  if: steps.approve.outputs.status == 'allow' || steps.wait.outputs.status == 'allow'
  run: ./deploy.sh

The initial POST returns in a second or two. If the user clicks within the 20 s hold, deploy runs immediately; otherwise the workflow polls, finishes within 10 min of the click, or times out with expired.

deploy guides

Step-by-step setup for the cloud side of the lateral line, per target.

Recommended
Webflow Cloud Astro + Cloudflare Workers under Webflow's tenancy. GitHub-connected deploys, generous free tier, and no separate Cloudflare account needed — the shortest path from zero to a working neuromast.

Other deploy targets