A tiny, self-hosted notification inbox for developers. Something happened in one of your apps; Boop tells you on your phone.
One Go binary, one SQLite file, one Docker container. Pushes go straight from your server to Apple's APNs. There is no hosted relay, account system, or telemetry.
curl https://boop.example.com/api/v1/events \
-H "Authorization: Bearer $BOOP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title": "Backup complete", "level": "success"}'
Architecture
Your apps POST events with a project API key. The Go server redacts and stores them in SQLite, then pushes straight to Apple's APNs using your .p8 key. The push carries only the title, body and event id; the iOS app fetches the full event from your server with its own device credential. The embedded web UI manages projects and devices and shows the pairing QR the phone scans. An interactive version lives in docs/architecture/index.html (open it locally; the source is boop.architecture.json).
What is in the box
| Part | Where |
|---|---|
| Go server (API, SQLite, APNs, embedded web UI) | server/ |
| Web UI (Svelte, built into the binary) | server/web/ |
| iOS app (SwiftUI, iOS 26, you build and sign it) + notification service extension | ios/ — see ios/README.md |
| Client libraries | separate repos — see Integrations |
| Native desktop client | planned |
Quick start (Docker)
git clone https://github.com/chrisgreg/boop && cd boop
cp .env.example .env # optional: BOOP_BASE_URL and APNS_* values
mkdir -p data && chown 1000:1000 data # Linux hosts only; the container runs as uid 1000
docker compose up -d --build
open http://localhost:8080
The first visit opens a setup wizard: server check, APNs, pairing, first project, test notification. APNs credentials are optional; without them events are stored and shown in the UI but pushes are skipped, and the settings page says so.
Data lives in ./data/boop.db. Back up by copying that file (use sqlite3 data/boop.db ".backup backup.db" for a consistent copy while running). Back up your .p8 key separately.
Quick start (binary)
Every release ships a static, dependency-free binary for Linux, macOS and Windows (amd64 and arm64) with the web UI embedded. Download the archive for your platform, verify it against checksums.txt if you like, and run:
tar xzf boop_*_linux_amd64.tar.gz && cd boop_*_linux_amd64
BOOP_DATABASE_PATH=./boop.db ./boop # listens on :8080
Configuration is the same set of environment variables as Docker (see Configuration). BOOP_DATABASE_PATH defaults to /data/boop.db, so set it to somewhere writable.
Send an event
Create a project in the web UI and copy its API key (shown once). Then:
curl http://localhost:8080/api/v1/events \
-H "Authorization: Bearer boop_proj_..." -H "Content-Type: application/json" \
-d '{"title": "Deploy complete"}'
# rich
curl http://localhost:8080/api/v1/events \
-H "Authorization: Bearer boop_proj_..." -H "Content-Type: application/json" \
-d '{
"title": "KeyError", "body": "key :can_palette? not found",
"level": "error", "source": "error_tracker", "fingerprint": "uini-keyerror",
"data": {
"exception": {"type": "KeyError", "message": "key :can_palette? not found"},
"stacktrace": [{"file": "lib/uini_web/live/widget_settings_live.ex", "line": 49, "function": "handle_event/3", "in_app": true}],
"tags": {"environment": "production"},
"context": {"user_id": "123"}
}
}'
Levels: info, success, warning, error, critical. Anything in data is kept as-is (recognised sections such as exception, stacktrace, tags, context and breadcrumbs get a nicer rendering) after sensitive keys are redacted.
Actions. Up to three buttons that open a URL — on the notification itself (long-press or pull down) and in the event detail:
curl http://localhost:8080/api/v1/events \
-H "Authorization: Bearer boop_proj_..." -H "Content-Type: application/json" \
-d '{"title": "Payment received", "body": "£19.99", "level": "success",
"actions": [{"label": "Open in Stripe", "url": "https://dashboard.stripe.com/payments/pi_1"},
{"label": "Open order", "url": "myshop://orders/42"}]}'
Labels are 40 characters or fewer; URLs must be absolute (https://… or an app scheme). Tapping the notification body still opens the event in Boop.
Grouping. Send the same fingerprint for the same problem and the inbox (web and phone) shows one row per fingerprint — KeyError ×47 · First seen 09:31 · Last seen 10:42 — that opens the individual occurrences. Toggle it with "Group repeats". GET /api/v1/events?grouped=true is the API behind it. Grouping only affects display; every occurrence is stored and pushed (use a silence to stop pushes).
Copy for an agent. On the phone, the share button on an event offers Copy, Copy as Markdown and Share. The Markdown version lays the event out as sections — exception, environment, stack trace, context, breadcrumbs, data, links — ready to paste into Claude, ChatGPT or whatever you use. Share hands it straight to the assistant's app without the clipboard.
From a shell script:
boop() { curl -fsS "$BOOP_URL/api/v1/events" -H "Authorization: Bearer $BOOP_API_KEY" \
-H "Content-Type: application/json" -d "{\"title\": \"$1\", \"body\": \"${2:-}\", \"level\": \"${3:-info}\"}"; }
pg_dump mydb > backup.sql && boop "Backup complete" "" success || boop "Backup failed" "$(tail -1 backup.log)" error
From GitHub Actions:
- name: Boop
if: always()
run: |
curl -fsS "${{ secrets.BOOP_URL }}/api/v1/events" \
-H "Authorization: Bearer ${{ secrets.BOOP_API_KEY }}" \
-H "Content-Type: application/json" \
-d '{"title": "${{ github.workflow }} ${{ job.status }}", "body": "${{ github.repository }}@${{ github.ref_name }}", "level": "${{ job.status == 'success' && 'success' || 'error' }}", "source": "github_actions"}'
Integrations
Clients live in their own repos. They all speak the same one endpoint (POST /api/v1/events), redact sensitive keys before sending, truncate rather than reject, retry only network errors and 5xx, and never crash the host application.
Elixir — boop_ex
{:boop_ex, "~> 1.1"}
config :boop_ex, url: System.fetch_env!("BOOP_URL"), api_key: System.fetch_env!("BOOP_API_KEY"), source: "my_app"
Boop.send("Deploy complete")
Boop.send(title: "Payment received", body: "£19.99", level: :success, data: %{customer_id: id})
Boop.send_async(title: "Cron finished") # :ok immediately, never raises
Boop.send(title: "Deploy failed", level: :error, actions: [{"Open run", run_url}]) # button on the push
Boop.Event.exception(e, __STACKTRACE__, tags: %{env: "prod"}) # rich error data
send/2 returns {:ok, %{id, created_at}} or {:error, %Boop.Error{code: …}}; send_async/2 runs on a supervised task. Ships a usage-rules.md for AI agents.
Elixir + ErrorTracker — boop_error_tracker
{:error_tracker, "~> 0.9"}, {:boop_ex, "~> 1.1"}, {:boop_error_tracker, "~> 1.1"}
config :boop_error_tracker, environment: config_env(), source: "my_app",
error_tracker_url: "https://my-app.com/dev/errors" # "Open in ErrorTracker" button on every push
Attaches to ErrorTracker's telemetry events and pushes new errors and resolved-errors-that-came-back to your phone, with the exception, stacktrace (your frames highlighted), context and breadcrumbs. Optional per-occurrence pushes with per-error throttling; muted errors are never sent. It installs next to ErrorTracker and never touches its database or config.
Node.js — @boop/node
pnpm add @boop/node
import boop, { Boop } from '@boop/node' // default client reads BOOP_URL / BOOP_API_KEY
await boop.send('Deploy complete')
const client = new Boop({ url, apiKey, source: 'my_app' })
const result = await client.send({ title: 'Payment received', level: 'success', data: { customerId } })
if (!result.ok) console.warn(result.error.code)
client.sendAsync({ title: 'Cron finished' }) // fire and forget, never throws
client.exception(err, { tags: { env: 'prod' } }) // rich error data
TypeScript, ESM + CJS, zero runtime dependencies, Node 18+.
Laravel — laravel-boop (community)
composer require solution-forest/laravel-boop
use SolutionForest\Boop\Facades\Boop; // reads BOOP_URL / BOOP_API_KEY from .env
$result = Boop::send('Backup complete');
Boop::send(['title' => 'Payment received', 'body' => '£19.99', 'level' => Level::Success, 'data' => ['customer_id' => 123]]);
Boop::sendAsync('Cron finished'); // runs after the response is sent, never throws
Boop::send(['title' => 'Deploy failed', 'level' => 'error', 'actions' => [['label' => 'Open run', 'url' => $runUrl]]]);
send() never throws; it returns a Result (ok, disabled or failed with a BoopError). PHP 8.1+, Laravel 10–13. Maintained by Solution Forest, not by this repo.
Sentry SDKs — drop-in DSN
Boop speaks the Sentry ingest protocol, so any existing server-side Sentry SDK (Python, Node, Go, Ruby, PHP, …) can report to Boop with no code changes — just point the DSN at your Boop host and use a project API key as the public key:
SENTRY_DSN=https://boop_proj_xxxxxxxx@boop.example.com/1
The part before @ is a Boop project's API key; the host is your Boop server; the trailing project id is required by the DSN format but ignored (the key selects the project). Boop implements the SDK envelope endpoint (/api/{id}/envelope/) that every current Sentry SDK uses, including gzipped bodies.
Keep the DSN server-side. A real Sentry DSN's public key is safe to ship in frontend bundles, but Boop's is a write-capable project API key — treat it like any other secret and don't embed it in browser or other client-side code.
Each Sentry event becomes a Boop event: the exception Type: value (or the message) is the title; the body carries the culprit, top stack frame and env/release; levels map fatal→critical, error, warning, info/debug→info; source is sentry; and Sentry's grouping fingerprint is preserved so silence rules work per error group. Full event context (platform, tags, top frames) is kept in the event's data and redacted like any other event. Transactions, sessions and other non-error items are accepted and ignored.
Anything else
curl is a first-class client (see Send an event), and integration-llms.md is a prompt you can hand to an LLM to generate a client for any other language that behaves like the ones above.
API
All endpoints are under /api/v1 (except the Sentry envelope endpoint). Errors are JSON: {"error": "code", "message": "..."}.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /health |
none | {"status":"ok"} |
| POST | /api/v1/events |
project key | Create event, returns {id, created_at} |
| POST | /api/:id/envelope/ |
Sentry DSN key | Sentry SDK ingest; DSN public key is a project API key |
| GET | /api/v1/events?project=&level=&source=&fingerprint=&since=&until=&silenced=&grouped=&before=&limit= |
device or none | List, newest first; next_cursor feeds before; silenced=true|false filters; grouped=true collapses repeated fingerprints into one row with group: {count, first_seen, last_seen} |
| GET | /api/v1/events/:id |
device or none | Full event |
| GET | /api/v1/events/:id/deliveries |
device or none | Push attempts for an event |
| GET/POST | /api/v1/projects |
admin | List / create (returns api_key once) |
| GET/PATCH/DELETE | /api/v1/projects/:id |
admin | Manage |
| POST | /api/v1/projects/:id/rotate-key |
admin | New key, old one stops working |
| POST | /api/v1/pairing |
admin | One-time pairing token + QR payload (10 min, single use) |
| DELETE | /api/v1/pairing/:id |
admin | Revoke |
| POST | /api/v1/pairing/exchange |
none | {token, name, platform} → {device, credential} |
| POST | /api/v1/devices |
device | Register APNs token {device_token, name, app_bundle_id} |
| PATCH/DELETE | /api/v1/devices/:id |
device (self) or admin | Update / remove |
| GET | /api/v1/devices |
admin | List paired devices |
| GET | /api/v1/status |
admin | Health, APNs state, counts, last push |
| GET/PATCH | /api/v1/settings |
admin | retention_days, redact_keys, setup_completed, mcp_enabled (read-only mcp_token_set says whether BOOP_MCP_TOKEN is configured) |
| GET/POST | /api/v1/silences |
admin | Rules that stop matching events from being pushed: {field: fingerprint|title|source, value, project_id?, note?} |
| GET | /api/v1/silences/:id |
admin | One rule |
| DELETE | /api/v1/silences/:id |
admin | Remove a rule (already-silenced events keep their flag) |
| POST | /api/v1/events/:id/unsilence |
admin | Clear the flag and push the event now |
| POST | /api/v1/test |
admin | Create a test event and push it |
| POST | /mcp |
MCP token, device, or admin | Read-only MCP endpoint (Streamable HTTP); 404 mcp_disabled when switched off in Settings |
Event fields on POST /api/v1/events: title (required), body, level, source, type, external_id, fingerprint, occurred_at, data, actions — see integration-llms.md for limits.
Credentials: project keys (boop_proj_...) can only create events; device credentials (boop_dev_...) can only read events and manage their own device. Only SHA-256 hashes are stored.
Admin auth. Set BOOP_ADMIN_USER and BOOP_ADMIN_PASSWORD and the web UI shows a sign-in screen; admin endpoints then need the session cookie it sets (POST /api/v1/auth/login) or HTTP Basic credentials (curl -u user:pass …). Sessions last 30 days and live in memory, so a restart signs everyone out. Leave both unset and everything is open — only do that behind your own proxy, Tailscale, or VPN. Either way, project and device credentials are refused on admin endpoints, so a leaked client secret never grants admin rights.
MCP (for AI agents)
Boop speaks the Model Context Protocol at /mcp (Streamable HTTP), read-only. Point the agent you already use at it and ask things like "what errors happened overnight?", "show me critical events from Infra", "what started failing after 14:00?" or "get the full context for the latest KeyError". There is no LLM inside Boop; it just serves structured context.
Tools: list_projects, list_events (filters, time window, grouped), search_events, get_event (full payload), get_event_group (every occurrence of a fingerprint).
Set BOOP_MCP_TOKEN (16+ characters) and use it as a bearer token. A device credential works too, and so does the admin login; with admin auth off and no token the endpoint is open, like the rest of the read API. Project keys are refused. Settings → MCP has a switch that turns the endpoint off entirely.
claude mcp add --transport http boop https://boop.example.com/mcp --header "Authorization: Bearer $BOOP_MCP_TOKEN"
Any client that supports Streamable HTTP with a custom header can connect the same way.
Configuration
| Variable | Default | Notes |
|---|---|---|
BOOP_PORT |
8080 |
|
BOOP_BASE_URL |
request origin | Public URL your phone can reach; used in the pairing QR |
BOOP_DATABASE_PATH |
/data/boop.db |
WAL mode, migrations applied on start |
BOOP_RETENTION_DAYS |
90 |
Days of history to keep; 0 = forever. When set it overrides the value saved in the web UI on every start; leave unset to manage it from Settings |
BOOP_LOG_LEVEL |
info |
debug, info, warn, error |
BOOP_ADMIN_USER |
Web UI / admin API username; set together with the password | |
BOOP_ADMIN_PASSWORD |
8+ characters. Unset = no login | |
BOOP_MCP_TOKEN |
Bearer token for the read-only MCP endpoint; 16+ characters | |
APNS_TEAM_ID |
Apple Developer team id | |
APNS_KEY_ID |
Id of the APNs auth key | |
APNS_BUNDLE_ID |
Bundle id of your Boop iOS build | |
APNS_PRIVATE_KEY_PATH |
Path to the mounted .p8 (preferred) |
|
APNS_PRIVATE_KEY |
Alternative: the .p8 contents, as PEM text or base64 (base64 -i key.p8 | tr -d '\n') |
|
APNS_ENVIRONMENT |
production |
sandbox for Xcode debug builds |
Apple setup
- In the Apple Developer portal, create an App identifier for your Boop iOS build and enable Push Notifications.
- Under Keys, create an APNs authentication key. Download the
.p8(only possible once). Note the Key id. - Note your Team id (top right of the portal).
- Put the
.p8at./secrets/apns.p8, uncomment the secrets volume indocker-compose.yml, setAPNS_PRIVATE_KEY_PATH=/run/secrets/apns.p8, and fill the otherAPNS_*values in.env. - Restart:
docker compose up -d. Settings should show APNs as configured. - Build the iOS app (
open ios/Boop.xcodeproj, set your team and the same bundle id — see ios/README.md), install it on your phone, open Devices → Pair iPhone, and scan the QR. - Settings → Send test notification.
Deploying with Dokploy (or any compose host)
Use docker-compose.dokploy.yml, not docker-compose.yml. It swaps the ./data bind mount for a named volume (the bind mount is created root-owned on the host, and the image runs as uid 1000, so SQLite cannot write /data/boop.db), joins the external dokploy-network so Traefik can route to it, and drops the published port so the server is reachable only through your HTTPS proxy.
- New Compose application → your repo, compose path
docker-compose.dokploy.yml. - Environment tab:
BOOP_BASE_URL,BOOP_ADMIN_USER,BOOP_ADMIN_PASSWORD,APNS_TEAM_ID,APNS_KEY_ID,APNS_BUNDLE_ID,APNS_ENVIRONMENT. Dokploy writes these to a.envbeside the compose file, which is whatenv_filepicks up. - The
.p8key, either:APNS_PRIVATE_KEY=base64 -i AuthKey_XXXXXX.p8 | tr -d '\n'(one line, easiest). LeaveAPNS_PRIVATE_KEY_PATHunset — if it has a value it overrides the inline key, and the resulting missing-file error is the usual reason APNs looks unconfigured after a deploy. Copying.env.examplewholesale is how it ends up set.- Or Dokploy Mounts → File mount: paste the
.p8contents, mount path/run/secrets/apns.p8, and setAPNS_PRIVATE_KEY_PATH=/run/secrets/apns.p8.
- Add a domain with HTTPS in Dokploy pointing at port
8080; deploy. Settings in the web UI should show APNs · Configured.
Editing the compose file in Dokploy's UI only works for Raw compose apps; when the source is Git, commit changes and redeploy.
Pairing
The web UI generates a one-time token (10 minutes, single use, revocable) and shows it as a QR code containing:
{"version": 1, "server": "https://boop.example.com", "token": "pair_..."}
The app posts the token to /api/v1/pairing/exchange, stores the returned device credential, registers for APNs, and posts its token to /api/v1/devices. Registering the same APNs token twice updates the existing device instead of creating a duplicate.
Silences
Some events you want stored but not on your phone: a known flaky job, a noisy warning. On any event's page click Silence events like this and pick what to match — its fingerprint, its title, or its source — for that project or every project. Matching events still arrive in the inbox (marked silenced) and in the iOS app, but no push is sent. Filter the inbox to Silenced only to review them; a silenced event's page shows the rule that caught it with Remove rule and Unsilence and push now. Manage all rules under Settings → Silences. Fingerprint and source match exactly; title ignores case.
Grouping and actions
Events that share a fingerprint within a project are shown as one row (KeyError ×47 · First seen 09:31 · Last seen 10:42) in the web inbox and on the phone; open it for the individual occurrences, or untick Group repeats to see every row. Events can also carry up to three actions ({label, url}) that appear as buttons on the push notification and in the event detail — open the deploy, the Stripe payment, the error in your tracker. See Send an event.
Redaction
Values under these keys are replaced with [REDACTED] anywhere in data before storage: password, password_confirmation, secret, token, access_token, refresh_token, api_key, authorization, cookie, set-cookie, private_key. Matching is case-insensitive and treats - and _ alike. Add your own keys in Settings.
Development
cd server && BOOP_DATABASE_PATH=./data/boop.db go run ./cmd/boop # API on :8080
cd server/web && npm install && npm run dev # UI on :5173, proxies /api
make test # Go + web tests
make build # bin/boop with the UI embedded
Requires Go 1.27 and Node 24 (see .tool-versions). The SQLite driver is pure Go, so CGO_ENABLED=0 builds work everywhere.
Comments