Your phone buzzes when the coding agent needs you, and you can answer from the phone.
agent-pager is a Claude Code mod (a plugin of function hooks, Claude Code 2.1.287 or later). It sends a page through ntfy or your own Telegram bot when:
- a turn finishes after running longer than a threshold (60 seconds by default). The page carries the project folder name, the duration and the first 200 characters of the reply.
- the agent waits on you: an
AskUserQuestiondialog that stays unanswered for a few seconds, a permission prompt you have not reacted to (the engine'spermission_promptnotification, which fires after a short idle delay, so it stays quiet while you are typing), or an MCP input form. - a turn ends on an API error or a refusal (optional, on by default).
Messages you send back become prompts in the one session you picked with /pager listen. They are queued and run when that session is idle. A few words are commands instead:
| From the phone | Does |
|---|---|
/status |
Replies with the session state: working or idle, model, turn count, whether paging is paused |
/stop |
Pauses automatic pages (same as /pager pause) |
/resume |
Turns automatic pages back on |
/help |
Lists these |
| anything else | Queued as a prompt; when the turn that prompt started ends, its reply is sent back to you (other turns in between are not) |
All network traffic goes through the engine's $.http.fetch. The mod runs no processes and needs no server of its own.
- Install the ntfy app (Android, iOS) or open https://ntfy.sh/app in a browser.
- Pick a long random topic name, for example
agent-pager-followed by 16 random characters (openssl rand -hex 8). Anyone who knows the name can read the topic, so do not use a guessable one. - Subscribe to that topic in the app.
- Set
backendtontfyandntfy_topicto the name (see Download and install). Run/pager testin Claude Code. The test page should arrive within a second or two.
A self-hosted ntfy server works too: set ntfy_server to its URL and, for a protected topic, ntfy_token to an access token.
Replies over ntfy are off by default. ntfy has no notion of who sent a message, so ntfy_inbound only takes effect together with ntfy_token: use a protected topic (a self-hosted server, or a reserved topic with access control) where only that token can write. Without a token, /pager status says replies are off and why. With both set, run /pager listen in the session that should receive replies. Telegram is the simpler choice for replies.
- In Telegram, open a chat with @BotFather, send
/newbotand follow the questions. Copy the token it gives you (123456789:AA...). - Open a chat with your new bot and send it any message.
- Find your chat id: open
https://api.telegram.org/bot<TOKEN>/getUpdatesin a browser and readmessage.chat.idfrom the answer. - Set
backendtotelegram,telegram_tokento the token andtelegram_chat_idto the id. Run/pager test. - Run
/pager listenin the session that should receive your replies. About ten seconds later it starts reading them.
The bot must not have a webhook set, since the mod reads messages with getUpdates. Only a private chat with you is read: a message counts only when the chat is private and both the chat id and the sender id equal telegram_chat_id (in a private chat they are your user id). Groups are not supported, since anyone in a group could prompt your agent. Everything else is ignored without a reply.
From the marketplace, at the prompt of a terminal session:
/plugin marketplace add RadTech-Solutions/agent-pager
/plugin install agent-pager@agent-pager
or in one line:
/plugin install agent-pager --marketplace RadTech-Solutions/agent-pager
Answer y to add the marketplace and pick a scope. The install shows a screen that sets the options; the tokens are masked and stored in the system keychain. Non-secret options also appear as rows in /config.
From a clone, for one session:
git clone https://github.com/RadTech-Solutions/agent-pager
claude --plugin-dir ./agent-pager
Options for a --plugin-dir load are read from pluginConfigs in your user settings (~/.claude/settings.json), keyed by the plugin name:
{
"pluginConfigs": {
"agent-pager": {
"options": { "backend": "ntfy", "ntfy_topic": "agent-pager-3f9c0e1d2b7a4c55" }
}
}
}For a one-off headless run, the same object can be passed with --settings.
Careful: values in pluginConfigs are stored as plain text. If you put telegram_token or ntfy_token there, the token sits unencrypted in settings.json. The marketplace install keeps them in the system keychain instead.
| Option | Default | Meaning |
|---|---|---|
backend |
ntfy |
ntfy, telegram or off |
ntfy_topic |
empty | Topic to publish to |
ntfy_server |
https://ntfy.sh |
ntfy server |
ntfy_token |
empty | Access token (sensitive) |
ntfy_inbound |
false |
Treat messages on the topic as prompts (needs ntfy_token) |
telegram_token |
empty | Bot token (sensitive) |
telegram_chat_id |
empty | The only chat that is read and written |
inbound |
true |
Read messages from the phone, in the session where you run /pager listen |
poll_seconds |
5 |
Poll interval |
min_turn_seconds |
60 |
Shortest turn that pages when it finishes; 0 pages every turn |
ask_delay_seconds |
10 |
How long a question waits before it pages |
min_gap_seconds |
30 |
Automatic pages closer together than this are dropped |
quiet_hours |
empty | Local time range with no automatic pages, like 22-07 or 22:30-06:45 |
private_mode |
false |
Pages, including replies to your phone prompts, say only "Your agent needs you." |
notify_errors |
true |
Page when a turn ends on an API error or a refusal |
In Claude Code:
| Command | Does |
|---|---|
/pager or /pager status |
Where pages go (topic masked), whether paging is paused, thresholds, quiet hours (flagged when invalid), last page, last poll or send error |
/pager test |
Sends a test page now, ignoring pause, quiet hours and the gap |
/pager pause |
Stops automatic pages, in every session, until resumed |
/pager listen |
Makes this session the one that reads messages from the phone |
/pager unlisten |
Stops reading them; no session does until one runs /pager listen |
/pager resume |
Turns them back on |
Pause and quiet hours apply to automatic pages. The minimum gap applies to routine pages (finished turns, errors); question and permission pages are never dropped for it. Answers to the phone (the reply to a prompt you sent, /status, /stop) always go out, since you just asked for them.
Pages go out from every session, but only one session reads messages from the phone: the one where you ran /pager listen. Until you run it somewhere, replies are not read at all. /pager status says which session is the receiver and when it last polled.
Why it works this way: the mod can only keep shared state in $.store, which has get, set and delete but no atomic compare-and-set, so two sessions cannot safely race each other for the role. Instead a claim is write, wait, verify. /pager listen writes the session's id under one key, waits at least two poll intervals (11 seconds with the default 5 second interval), reads the key again and starts reading only if it still names that session. When two sessions claim at once, the later write wins and the other stands down. The receiver checks that it still holds the role before each poll, after each fetch returns and before each message, so after a handoff the old receiver stops at its next check and drops anything it fetched after losing the role. Each message id is recorded as handled in the store before it is submitted, and the Telegram offset is acknowledged right after, so a message that both sessions saw during a handoff runs once.
What to expect from that:
- A handoff takes about 11 seconds (two poll intervals plus a second). Messages sent in that window are read by the new receiver once it starts.
- Ending the receiver session gives the role up. A session that crashes keeps it on record until another session runs
/pager listen; nothing takes over on its own. - The handled check and the role check are separate store reads and writes, not one atomic step. The tests drive two overlapping pollers over one shared store and show one submit per message, and a run of two real sessions showed that a store write from one is visible to the other within a fraction of a second. A duplicate would need a session to stall for more than the whole grace period between its role check and its handled check.
On Telegram and on ntfy alike, messages sent before the receiving session started are skipped, so a prompt from yesterday does not run in today's session. Headless claude -p runs send pages but never read replies.
A failed poll is shown in /pager status and retried with exponential backoff, up to five minutes, or after the wait the server asks for (Telegram's retry_after). Error texts never include the bot token.
- No remote approvals. agent-pager never approves or denies a tool call. A permission prompt is announced on the phone and must be answered in the terminal. This is a deliberate choice for v1: a leaked bot token or topic name should not be able to run commands without your consent at the keyboard.
- Phone prompts are your own words. A message from the configured Telegram chat is submitted as if you typed it, so it runs under the session's normal permission rules. Anyone who controls that chat can prompt your agent. Keep the bot token secret.
- ntfy topics are public by name. Pages on ntfy.sh can be read by anyone who guesses the topic. Use a long random name, a self-hosted server with an access token, or
private_mode./pager statusshows only the first four characters of the topic. - ntfy replies need a token.
ntfy_inbounddoes nothing withoutntfy_token, since an open topic would let anyone prompt your agent. - Telegram groups are ignored. Only a private chat whose chat id and sender id both match
telegram_chat_idis read. - Private mode replaces every automatic page, and the reply sent back for a prompt from the phone, with "Your agent needs you." It drops the project name, the reply text and the question.
/pager testand/statusreplies still name the project. - What leaves the machine. Without private mode: the project folder name, the first 200 characters of a reply or question, and the permission prompt's message. Nothing else from the conversation is sent.
- Secrets.
telegram_tokenandntfy_tokenare marked sensitive, so the install screen masks them and Claude Code stores them in the system keychain rather thansettings.json. Setting them by hand underpluginConfigsstores them in plain text.
claude plugin validate .
claude plugin test .
claude plugin test runs hooks/register.test.tsx against the engine with a mocked $.http.fetch, clock and store, and hooks/receiver.test.tsx, which drives the exported poller of two sessions at once over one shared store and a fake Telegram server: no receiver means no submits, only the listening session submits, simultaneous /pager listen settles on one receiver with one submit per update, and a handoff during an in-flight fetch or right after a submit does not duplicate. It covers the outbound page format, the Telegram private chat and sender filter, inbound messages reaching $.prompt.submit, the reply going back for the phone's own turn and not for a keyboard turn in between, /status and /stop from the phone, subagent turns, rate limits and backoff, listen, unlisten and takeover, quiet hours (valid and invalid), pause and resume, the minimum gap and its urgent exemption, private mode, the question delay, permission and error pages, and ntfy inbound with its stale message and token checks.
Type checking keeps the tsconfig.json outside the repository; the recipe is in the header of types/index.d.ts.
RadTech builds apps, web products and applied AI, and advises founders. We do AI advisory, building and consulting.