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 in services/oms/alerts.py to 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:

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.