Integrating Palmtop
Who this is for: an engineer wiring their own systems — a trading stack, a service, a pipeline, a build — into Palmtop, so that the people who need to know something is wrong find out on their phones.
Your code already knows what is wrong. It should not also have to know who is on call, whether it is 3am, whether this is the fortieth time this minute, or which phone to ring. You raise it; Palmtop decides and delivers.
your code ──▶ palmtop-link ──▶ relay ──▶ the phone
(this machine) (blind) (board, and a ring if you want one)
Three things are true of that path, and they are why it is short: the machine that sends is one that stays awake, nothing has to be reachable from the internet, and the relay in the middle cannot read a single alert.
1. Five minutes
On the machine that will raise alerts — a production server, a cron box, a CI runner, a VM, a container, your own laptop:
curl -fsSL https://priorstates.com/palmtop/link/install.sh | sh
One static binary, about 6 MB. No runtime, no daemon, no service, no inbound
port, nothing that starts at boot. (Windows: download
palmtop-link-windows-amd64.exe from the same place. Or build it: palmtop/link.)
On the phone: Palmtop → Alerts → Turn on → Add a server. You get a
code like PT-GESN-NXPB. It works once, and for half an hour.
Back on the machine:
palmtop-link enroll PT-GESN-NXPB --name prod-ny-3
palmtop-link test
The test alert appears on the phone. You are integrated; everything below is detail.
2. Raising an alert
palmtop-link send --sev1 --service md-gateway \
"Market data gap on CME" "No ticks for 47s; backup feed 3 ticks behind"
Exit status is 0 if at least one phone took it, non-zero otherwise, so a shell
script can react. Add --json for a machine-readable result.
From a language, without a library. Every runtime can start a process, and a process is all this is:
import subprocess
subprocess.run(["palmtop-link", "send", "--sev1", "--service", "oms",
"Order gateway disconnected", detail])
require('child_process').execFile('palmtop-link',
['send', '--sev2', '--service', 'oms', 'Acks are slow', 'p99 1.8s']);
exec.Command("palmtop-link", "send", "--sev2", "--service", "risk", title).Run()
From something that already emits JSON — a webhook receiver, a log shipper, Alertmanager, a Lambda:
echo '{"sev":"sev1","service":"oms","title":"Acks stopped","detail":"0 in 60s"}' \
| palmtop-link send --stdin
Accepted fields: sev (or severity), service, title (or message),
detail, key (or dedupe_key), topic, link, source. Anything else is
ignored, so you can pipe a fuller object in and let it pick what it knows.
From Prometheus Alertmanager, whose webhook posts a batch:
# alertmanager.yml: webhook_configs: [{ url: 'http://127.0.0.1:9099/' }]
# a receiver in ten lines of shell, or the same thing in your language of choice:
jq -c '.alerts[] | {sev: (if .labels.severity=="critical" then "sev1" else "sev2" end),
service: .labels.job, title: .annotations.summary,
detail: .annotations.description, key: .fingerprint,
link: .generatorURL}' \
| while read -r a; do echo "$a" | palmtop-link send --stdin; done
From CI, so a broken main branch reaches someone:
- if: failure()
run: palmtop-link send --sev2 --service ci "main is red"
"${{ github.workflow }} failed on ${{ github.sha }}"
--link "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
From systemd, for a unit that should not have stopped:
[Unit]
OnFailure=palmtop-alert@%n.service
# palmtop-alert@.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/palmtop-link send --sev2 --service systemd "%i stopped"
Severity, and what it costs the person
| means | what happens | |
|---|---|---|
--sev1 |
someone has to act now | rings the phone; the notification stays until it is dismissed; top of the board until acknowledged |
--sev2 |
someone should look soon | a quiet notification; sits above sev3 on the board |
--sev3 |
worth recording, not worth waking anyone | the board only |
Sev1 is a promise you make to a person asleep. Spend it accordingly: a good
alert names the thing that broke, what it means, and where to look
(--link https://your-dashboard/...).
Repeats
The same alert twice is one line on the phone with a count, not two lines. What
counts as "the same" is --key, which defaults to service|title:
palmtop-link send --sev2 --service oms --key oms-gw-down "Gateway disconnected"
Use a stable key for a flapping condition (Alertmanager's fingerprint is
ideal). Repeats still cost quota, so if your monitor fires every ten seconds,
collapse it on your side. The alerting policy helper
does that, and holds the routing rules in one file.
3. Being woken
The board fills whether or not the phone is looked at. Ringing it is a separate switch: Alerts → Ring this phone (or Settings → Alerts from your servers).
When it is on, the relay sends a Web Push carrying no content at all — the
phone wakes, fetches the sealed alert and decrypts it itself, then shows you
SEV1 md-gateway — Market data gap on CME. A sev1 stays on the screen until
dismissed; quieter things arrive quietly.
Where it works: Android (Chrome, Firefox), any desktop browser, and on an iPhone only when Palmtop has been added to the Home Screen — Safari's Share button → Add to Home Screen, then turn ringing on from there. Apple gives Web Push to installed web apps and to nothing else. The App Store and Play builds cannot be woken yet; that needs APNs and FCM directly (on the roadmap).
It is best-effort. A phone that is off, out of signal, or has had the tab evicted gets the alert when it comes back, and the board is always the record. If a person must be reached — a real 3am page — put a phone call beside it:
// ~/.config/palmtop/alerts.json, on this same machine
{ "routes": { "sev1": ["phone", "call", "sms"], "sev2": ["phone"], "sev3": ["phone"] },
"who": "+447700900123", "dedupe_seconds": 300 }
and raise through the alerting policy helper, which follows that policy and places the call through your own Twilio account. Push tells people; a call wakes them. Do not promise a page you have not wired.
3a. Letting an assistant do the setup
Nothing above needs a person at a terminal. The only thing that ever has to travel is the enrolment code, and an assistant can spend it.
Your own computer, one tap. Register one action on it:
"actions": {
"setup-link": {"run": "python3 ~/bin/setup_link.py",
"description": "set this computer up to send me alerts", "enabled": true}
}
(setup_link.py — the assistant that builds your apps can fetch and register it for you.) After that, Alerts → Set up my computer on the phone mints a code, hands it straight to the computer, and the computer installs Palmtop Link — checking the download against the published checksums — and enrols itself. Nobody types anything, and the result appears in your server list like any other machine.
A production machine, through the assistant already sitting on it. If you have Claude Code or similar in your editor with a terminal on that box, give it the code and let it do the rest:
Set this machine up to send alerts to my phone with Palmtop Link. The enrolment code is PT-GESN-NXPB. Install it, enrol it as
prod-ny-3, send a test, and then wire our error handler inservices/oms/alerts.pyto raise a sev1 when the gateway disconnects.
That last clause is the half a person cannot do quickly: the assistant already
knows your code, so it can put the palmtop-link send calls where they belong —
the exception handler, the health check, the CI step — rather than you grafting
them on afterwards. The guide's §2 is written to be readable by an assistant
doing exactly this; point it here.
What an assistant cannot do is mint the code, because that comes off your phone. Which is the point: nothing can enrol itself without a person deciding, once, that it should.
4. More than one phone, more than one server
A rotation. Enrol the same machine with each phone — every enroll adds one,
and send goes to all of them:
palmtop-link enroll PT-AAAA-BBBB --name prod-ny-3 # on-call primary
palmtop-link enroll PT-CCCC-DDDD --name prod-ny-3 # secondary
palmtop-link status
palmtop-link send --sev1 --to "prod-ny-3" ... # or just one of them
Rotating on-call is then enrolling and forgetting, with no change to any code that raises alerts.
A fleet. Enrol each machine separately, with a name you will recognise on
the phone. Every machine gets a credential of its own, so one can be taken away
without touching the rest — which is the whole reason it is not one shared
secret. Config lives at ~/.config/palmtop-link/config.json, or
/etc/palmtop-link/config.json when running as root; put it in your
configuration management as a file, or bake palmtop-link enroll into the
machine's first-boot script.
Taking one away. On the phone: Alerts → that server → Revoke. Its next
alert is refused, and it is told plainly to enrol again. On the machine,
palmtop-link forget <name> cleans up the local side.
Does any of this expire?
No. The enrolment code is the only thing with a clock on it — half an hour, and once — because it is a pairing secret read off a screen. What the machine holds afterwards has no expiry and is never rotated for you: it works until someone presses Revoke on the phone, or turns alerts off, or makes a new channel. Enrol a fleet today and it is still sending in a year.
Three things do end a relationship, all of them deliberate or visible:
- Revoke on the phone, which is immediate and tells the machine why.
- Turn off on the phone, which deletes the board and every enrolled machine with it.
- Losing the phone's half. The key that reads the board lives only on that device. If it is wiped, or its browser storage is evicted, the servers keep sending — the relay takes their alerts and answers 200 — and nobody is reading. Palmtop asks the browser to keep its storage, and Settings → Back up everything carries the channel (and your apps' inboxes) so a new phone picks the relationship up where the old one left off. Take one.
A fourth case is ours, not yours: if the relay lost its database, the channels and every enrolled machine would go with it, and both sides would have to be set up again. It is one SQLite file on one host; run your own relay if that is not a risk you want to carry (§7).
Because the phone-side case is silent from the sending side, the relay tells senders when the phone last collected anything, and Palmtop Link says so plainly:
$ palmtop-link status
prod-ny-3 last sent 2m ago · not collected since 9d ago
$ palmtop-link send --sev1 --service oms "Gateway down"
sent to 1 phone(s)
warning: prod-ny-3: taken, but that phone last collected anything 9d ago — check it is still set up
An alert that is accepted and never read is the worst failure this system can
have, so it is worth a check in whatever already watches your services: status --json carries phone_last_read per phone.
5. What each piece can do
This is the part to read if you are the one who has to sign off on it.
| who holds it | can | cannot |
|---|---|---|
| Palmtop Link on a server | add an alert to the boards it enrolled with | read any board, reach your computer, enrol anything else, see other servers |
| an enrolment code | be spent once, within 30 minutes, for one credential | do anything after that, or be replayed |
| the relay | see an inbox id, a size and a timestamp; wake a phone | read an alert — ever |
| the push service (Google, Apple, Mozilla) | deliver a knock with no content | learn what the alert said |
| the phone | read everything, revoke any server, turn it all off | be reached by anything it did not enrol |
Every alert is sealed on the sending machine, with a fresh key each time, to a key that only that phone holds, and opened only on the phone. Nothing in the middle holds anything that could open it, including us. When your phone is woken, the wake-up carries no content at all: the phone fetches the alert and decrypts it on the device.
What turning ringing on costs you in privacy: the relay then stores an opaque push endpoint for that device, which is the one thing it holds that points at a phone. Turning ringing off deletes it.
Treat the enrolled credential as a machine secret. It is worth exactly one thing — writing alerts to that phone — but a leaked one means alerts you did not send, so keep the config file at mode 600 (the binary writes it that way) and out of your repository. If one leaks, revoke that server on the phone and enrol again; nothing else is affected.
6. Limits, and what happens at them
| limit | what happens | |
|---|---|---|
| one alert | 64 KB | refused, with a message saying to send a summary and a --link |
| per phone | 200 alerts an hour | refused until the hour rolls; collapse repeats with --key |
| ringing | one wake-up per 8 seconds | a burst is one knock; every alert is still on the board |
| the board | kept 90 days on the relay, 500 rows on the phone | oldest fall off |
| servers per phone | 200 | enrol fails until you revoke some |
A send that fails does not stop your program: it exits non-zero and prints
why. Alerting should never be the thing that takes a service down, so raise it
after you have written to your own log, not instead.
7. Running your own relay
Nothing here is tied to our infrastructure. Teams that cannot use a shared
service — compliance, policy, or simple preference — can run this whole path on
their own: palmtop-link enroll --relay … (or PALMTOP_RELAY) points a machine
at yours, and the phone is set to match. It holds no accounts, no readable
content and no key that can open anything, which is why running it yourself
changes what you operate rather than what you are trusted with.
Ask us and we will set it up with you.
8. When it does not work
| what you see | what it means |
|---|---|
no phone enrolled yet |
this machine has never been enrolled — palmtop-link enroll |
that code is wrong, already used, or expired |
codes last 30 minutes and work once; make another on the phone |
this machine was revoked from that phone |
someone pressed Revoke; enrol it again |
that phone has taken 200 alerts in the last hour |
a flapping check — give it a stable --key, or collapse on your side |
could not reach the relay |
this machine's egress; the relay is ordinary HTTPS on 443 |
| alerts on the board but no ring | ringing is off, or this is an iPhone that has not been added to the Home Screen |
| nothing at all on the phone | palmtop-link status — it records the last problem per phone |
palmtop-link status is the first thing to run, on the machine, always.
9. Where the rest of Palmtop fits
Mini-apps on the phone read the same board: forge.alerts.list() for an
on-call view of your own design, forge.alerts.raise() to put something on it
from inside an app. The Alerts template is the board plus this setup; the
Incidents template is the same stream with runbook actions attached.
Your editor's assistant is where MCP belongs — "raise this", "what is open", "acknowledge that" from Claude Code in VS Code, over stdio, to an assistant that is already running. That is a different feature from this one, and deliberately so: production code should not be speaking an agent protocol to send an alert.
Your computer, when it is awake, can do more than the phone can: the hub runs actions you register, which is how a mini-app reaches a database, a report or a printer, by name and with your permission. Alerts do not need it, and that is the point of this page.
Reference
palmtop-link enroll CODE [--name NAME] [--relay URL]
palmtop-link send [--sev1|--sev2|--sev3] [--service S] [--key K] [--topic T]
[--link URL] [--source S] [--to NAME] [--json] "title" ["detail"]
palmtop-link send --stdin one alert as JSON on stdin (2s and it gives up)
palmtop-link test a sev3, to watch one arrive
palmtop-link status [--json] phones this machine can reach, and the last result
palmtop-link forget NAME stop sending to one phone
Config: ~/.config/palmtop-link/config.json, or /etc/palmtop-link/config.json
as root, or $PALMTOP_LINK_CONFIG. Mode 600. Exit 0 when at least one phone took
the alert, 1 when none did, 2 for a usage mistake.