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 →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.
Two pieces, both borrowed from fish biology, each doing one job:
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.
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.
Each stage pulses in turn as a webhook traces the path from sender to bubble.
204. ~60 KB bundle.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.The loopback receiver (127.0.0.1:47329, hardened at the socket level) parses the POST body and walks it through four stages:
adapters/ and ~/Library/Application Support/Axol/adapters/ are tried alphabetically; first match wins. The winner renders an envelope via the template language. Spec →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.Remote alerts transports the body here; plugins translate it.
The /hooks/{source} route resolves auth in a fixed order:
HOOK_SCHEME_<SOURCE> is set for this URL segment, verify the request's signature with that scheme.?key=<SHARED_SECRET> matches (constant-time compare).401.| 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. |
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.
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.
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.
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.
/app/api/permission/{source} with a tool_name, optional tool_input, and a request_id. Neuromast 202-accepts immediately, returns the id.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.hookSpecificOutput shape. Lateral-line forwards the decision to /app/api/decision/{request_id}.GET /app/api/decision/{request_id} (poll or optional ?wait=N long-poll).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."
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 }
}
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"
}
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.
- 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.
Step-by-step setup for the cloud side of the lateral line, per target.
RecommendedOther deploy targets
wrangler deploy to the edge. Same runtime as Webflow Cloud, your own domain.
fly launch + fly deploy.