pull-style · local-only

Gills

Small bash scripts on your Mac that poll the APIs Axol can't webhook, read their tokens from Keychain, and POST each new item straight to her loopback listener. Named after the feathery pink plumes axolotls wave to sample the water — same rhythmic reach, same pickup role.

Install a gill →

why

Axol's remote-alerts path is push-style: a service you own a webhook on (your GitHub repo, your Stripe account, your Sentry project) signs a POST, the neuromast queues it, lateral-line pulls it down, Axol's bubble pops. Works cleanly when you can write the webhook URL into the sender's settings.

It doesn't cover the things you want alerts on but don't admin — repos someone else owns that @mention you, review requests across every org you belong to, security advisories on the long tail of repos you watch. GitHub only exposes those through your notifications inbox, and that inbox is poll-only with a personal-access token. Same pattern for a handful of other services (Linear PATs, Google Calendar read-only scopes, RSS feeds).

Gills fill that gap with the inverse data flow: Axol polls the upstream on your behalf and POSTs each new item to her own loopback port. No neuromast, no cloud. The credential never leaves your Mac.

the name

In an axolotl: three pairs of feathery external gills fan out on either side of the head — the single most recognizable thing about the animal. They wave in slow, steady arcs, pulling fresh water past the fronds to sample oxygen and chemical signals from the surrounding column.

In Axol: one bash script per service you want to pull from, running on its own launchd job. Each sample is one full cycle — grab the token, ask the upstream what's new since last time, hand any fresh items to the bubble. Same rhythmic reach, same pickup role.

A neuromast is a sensor that waits for pressure to come to it. A gill is a sensor that actively goes and gets the signal. Pairs cleanly with the existing neuromast / lateral-line vocabulary.

the sample

Every gill follows the same shape, enforced by a handful of helpers in lib.sh. A single sample does six things, any one of which can bail the whole pass cleanly without burning credentials or the upstream's rate limit:

  1. Claim the lock File-level flock (with a noclobber-symlink fallback for macOS's flock-less default). Overlapping samples exit silently — whoever got the lock runs alone.
  2. Probe Axol Bare TCP connect to 127.0.0.1:47329. If she's closed, bail before calling the upstream so you don't spend rate-limit budget just to throw the result away.
  3. Read the secret security find-generic-password -s <service> -w. Missing item → log once and exit 0; no retry storm, no stack trace in the launchd log.
  4. Read the cursor Poll-since value from ~/Library/Application Support/Axol/gills/<name>.cursor. First-run default is the gill's call — github-notifications starts an hour ago so you don't backfill stale unread threads.
  5. Fetch & render Hit the upstream with the token + cursor. For each new item, build an Axol envelope and POST it to the loopback receiver. Bubbles render through the same adapter pipeline as webhook-originated alerts.
  6. Advance the cursor Atomic .tmp + mv, so a crash mid-write leaves either the old or the new value — never a truncated string. If any POST failed, leave the cursor alone and retry next sample.

install

Two commands, assuming you already have Axol running. First time only — stash the credential in macOS Keychain where the gill can find it:

security add-generic-password -a "$USER" -s axol-github-pat -w ghp_xxxxxxxxxxxxxxxxxxxxxxxx

Then install the launchd job:

cd lateral-line/gills
./install.sh github-notifications

That writes ~/Library/LaunchAgents/com.axol.gill.github-notifications.plist pointing at the script, bootstraps it into your user session, and runs a first sample immediately. Default interval is 30 seconds — same cadence lateral-line uses.

Tail the log to watch the cycle:

tail -f ~/Library/Logs/Axol/gill-github-notifications.log

Each line is one sample — delivered=N/M cursor=… on the happy path, a one-word reason on the sad path. Nothing noisy between.

Uninstall: ./install.sh github-notifications --uninstall bootouts the launchd job and removes the plist. Your cursor and keychain item stay put — rotation is separate from uninstall.

bundled gills

Shipping with Axol today. Each one lives as a self-contained bash script in lateral-line/gills/.

GillUpstreamKeychain serviceScope required
github-notifications GitHub /notifications axol-github-pat Fine-grained PAT, Notifications: read

github-notifications maps each thread's reason field to an Axol priority — review_requested, mention, team_mention, assign, and security_alert render as urgent (pinned bubble, stays until clicked); everything else (comments, subscribed threads, CI activity) rides the normal auto-dismiss lane. API URLs are rewritten to their human-facing github.com equivalents on the way out, so clicking the bubble opens the PR / issue in the browser, not a raw JSON blob.

write your own

A gill is any bash script that sources lib.sh, calls gill_init first, and uses the six helpers the lib exposes. No registry file, no manifest — install.sh <name> just looks for <name>.sh next to it.

Minimum viable gill, end-to-end:

#!/usr/bin/env bash
set -euo pipefail
. "$(cd "$(dirname "$0")" && pwd)/lib.sh"

gill_init "example"
gill_axol_up || exit 0
token=$(gill_keychain_secret "axol-example-token") || exit 0
since=$(gill_cursor_read)

resp=$(curl -fsS -H "Authorization: Bearer $token" \
            "https://api.example.com/items?since=$since")

echo "$resp" | jq -c '.items[]' | while read -r item; do
    title=$(jq -r '.title' <<<"$item")
    env=$(jq -n --arg t "$title" '{title:$t, body:"new item", icon:"bell"}')
    gill_post_envelope "$env" || exit 0
done

gill_cursor_write "$(date -u +%FT%TZ)"

That's it. ./install.sh example writes a launchd plist for it automatically. The helpers you get:

HelperDoes
gill_init <name>Claims a per-gill file lock; sets up cursor + log context.
gill_axol_upTCP probe of 127.0.0.1:47329; returns non-zero if Axol's closed.
gill_keychain_secret <svc>Prints the password for Keychain service <svc>, or returns non-zero + logs when missing.
gill_cursor_read / gill_cursor_write <val>Per-gill cursor I/O, atomic on write.
gill_post_envelope <json>POST a pre-rendered envelope to Axol's loopback.
gill_log <msg>Timestamped line to the gill's launchd log.
The envelope spec is the same as adapters. Anything Axol's adapter pipeline accepts works here — priority, icon, actions, source. The generic adapter (zz-generic.json) fires for anything with a title, so a minimal gill doesn't need its own matching JSON file. Write a dedicated adapter only if you want a different rendering per subtype inside one source.

security

Gills are intentionally local. That shapes the security posture: