SLOPSHOPPER

phone-approve

Approve or deny Claude Code's permission prompts from Telegram, with the terminal prompt as the fallback.

newguardtoaststatusnetwork
v0.1.0MITupdated 2026-10-04ShriD5/claude-mods/phone-approve
A shopper browsing a rack in a slop shop
README

phone-approve

Approve Claude Code's permission prompts from your phone. When Claude Code is about to ask you whether it may run a tool, the request goes to Telegram with ✅ Approve and ❌ Deny buttons, and your tap decides it. Walk away from the desk and keep a long task moving.

  • Only a real prompt goes to the phone. Anything your rules, mode or settings hooks already allow or deny never reaches Telegram.
  • The message says what is asked: the Bash command, the file being written or edited, the URL, or the tool's input for anything else.
  • If the phone doesn't answer within the timeout, the normal terminal prompt decides, and the Telegram message says so.
  • If you answer in the terminal first, the plugin stops waiting and edits the message to "answered in the terminal".
  • While it waits, the status line shows 📱 waiting for phone….

Install

claude --plugin-dir ./phone-approve

or add this repo as a marketplace and install it with /plugin.

Setup

  1. Message @BotFather, send /newbot, and copy the token. Use a dedicated bot. The plugin reads button presses with getUpdates, which Telegram refuses while a bot has a webhook set, and two programs polling one bot steal each other's updates.
  2. Send your new bot any message, then open https://api.telegram.org/bot<token>/getUpdates and copy message.chat.id. That's your chat id.
  3. Save both (the token goes to secure storage, not settings.json):
   echo '{"botToken":"123456:ABC…","chatId":"987654321"}' | claude plugin configure phone-approve --values-stdin

Options

OptionDefaultWhat it does
botToken(none)The bot's token. Marked sensitive, so it's kept in secure storage and never logged.
chatId(none)Where requests go. Presses from any other chat are ignored.
timeoutSeconds120How long to wait for the phone before the terminal prompt decides.

The plugin does nothing until botToken and chatId are set.

How it works

It hooks classic.PermissionRequest, which Claude Code raises when it is about to ask you. It sends the request with $.http.fetch, then long-polls getUpdates for the button press carrying that request's one-time nonce. If nothing comes back in time, it returns next(e) so the terminal prompt decides.

Source 2 files
hooks/register.ts 165 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { describe, parseChoice, telegram, type Bot } from './telegram'
4
5type Choice = 'allow' | 'deny'
6type Outcome =
7  | { kind: 'phone'; choice: Choice; callbackId: string }
8  | { kind: 'terminal' }
9  | { kind: 'timeout' }
10  | { kind: 'failed'; error: unknown }
11
12// getUpdates long-polls this long at most, so an abandoned wait ends quickly.
13const POLL_SECONDS = 10
14const MAX_FAILURES = 3
15
16// Telegram refuses two getUpdates at once, so every waiting request shares one
17// poll and the poll hands each button press to the request whose nonce it carries.
18const waiting = new Map<string, (choice: Choice, callbackId: string) => void>()
19let offset: number | undefined
20let polling: Promise<void> | undefined
21
22// Calls in flight, by tool and input: the permission event carries no
23// tool_use_id, so this is how a request finds its call's dialog and learns
24// that the call went on without the phone (answered in the terminal).
25const callIds = new Map<string, string>()
26const onCallSettled = new Map<string, () => void>()
27
28function stable(value: unknown): string {
29  if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`
30  if (typeof value === 'object' && value !== null) {
31    const entries = Object.entries(value).filter(([, v]) => v !== undefined)
32    return `{${entries.sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(',')}}`
33  }
34  return JSON.stringify(value) ?? 'null'
35}
36
37const keyOf = (tool: string, input: unknown) => `${tool} ${stable(input)}`
38
39function showStatus($: EngineInterface) {
40  const n = waiting.size
41  $.ui.status(n === 0 ? undefined : `📱 waiting for phone…${n > 1 ? ` (${n})` : ''}`)
42}
43
44export const register: Register = (on, options) => {
45  const token = String(options.botToken ?? '').trim()
46  const chatId = String(options.chatId ?? '').trim()
47  const timeoutMs = Math.max(5, Number(options.timeoutSeconds) || 120) * 1000
48  const isConfigured = token !== '' && chatId !== ''
49
50  const poll = (bot: Bot, seconds: number) => {
51    polling ??= bot
52      .updates(offset, seconds)
53      .then(updates => {
54        for (const update of updates) {
55          offset = update.update_id + 1
56          const press = update.callback_query
57          if (press === undefined || String(press.message?.chat.id) !== chatId) continue
58          const parsed = parseChoice(press.data)
59          const waiter = parsed && waiting.get(parsed.nonce)
60          if (parsed && waiter) waiter(parsed.choice, press.id)
61          else void bot.answer(press.id, 'This request is no longer waiting.').catch(() => {})
62        }
63      })
64      .finally(() => {
65        polling = undefined
66      })
67    return polling
68  }
69
70  on('session.start', async ($, e, next) => {
71    if (!isConfigured) {
72      $.ui.log('phone-approve is off: set botToken and chatId (claude plugin configure phone-approve).')
73    }
74    return next(e)
75  })
76
77  on('tool.call', async ($, e, next) => {
78    if (!isConfigured) return next(e)
79    const { tool, tool_use_id, agentId, ...input } = e as Record<string, unknown>
80    const key = keyOf(String(tool), input)
81    callIds.set(key, String(tool_use_id))
82    try {
83      return await next(e)
84    } finally {
85      callIds.delete(key)
86      onCallSettled.get(key)?.()
87    }
88  })
89
90  // Fires when Claude Code is about to ask the person. Rules, modes and the
91  // settings hooks beneath have had their say: only a real "ask" reaches the phone.
92  on('classic.PermissionRequest', async ($, e, next) => {
93    const below = await next(e)
94    if (!isConfigured || below.decision !== undefined || below.block !== undefined) return below
95
96    const bot = telegram((url, init) => $.http.fetch(url, init), token)
97    const nonce = crypto.randomUUID().replace(/-/g, '').slice(0, 16)
98    const key = keyOf(e.tool_name, e.tool_input)
99    const project = e.cwd.split('/').filter(Boolean).at(-1) ?? e.cwd
100    const html = describe(e.tool_name, e.tool_input, project)
101
102    // Listen before sending: the person may answer in the terminal meanwhile.
103    let answer: (outcome: Outcome) => void = () => {}
104    const answered = new Promise<Outcome>(resolve => (answer = resolve))
105    waiting.set(nonce, (choice, callbackId) => answer({ kind: 'phone', choice, callbackId }))
106    onCallSettled.set(key, () => answer({ kind: 'terminal' }))
107    next.signal.addEventListener('abort', () => answer({ kind: 'terminal' }), { once: true })
108
109    const wait = async (): Promise<Outcome> => {
110      const deadline = (await $.clock.now()) + timeoutMs
111      let failures = 0
112      for (;;) {
113        const left = deadline - (await $.clock.now())
114        if (left <= 0) return { kind: 'timeout' }
115        const polled = poll(bot, Math.min(POLL_SECONDS, Math.ceil(left / 1000))).then(
116          () => undefined,
117          (error: unknown): Outcome => ({ kind: 'failed', error }),
118        )
119        const outcome = await Promise.race([polled, answered])
120        if (outcome === undefined) failures = 0
121        else if (outcome.kind !== 'failed') return outcome
122        else if (++failures >= MAX_FAILURES) return outcome
123      }
124    }
125
126    let messageId: number
127    let outcome: Outcome
128    try {
129      try {
130        messageId = (await bot.send(chatId, html, nonce)).message_id
131      } catch (error) {
132        $.ui.toast(`📱 ${error instanceof Error ? error.message : 'Telegram send failed'}`)
133        return below
134      }
135      const callId = callIds.get(key)
136      if (callId !== undefined) $.ui.notice(callId, '📱 also sent to Telegram: answer here or on your phone')
137      showStatus($)
138      outcome = await wait()
139    } finally {
140      waiting.delete(nonce)
141      onCallSettled.delete(key)
142      showStatus($)
143    }
144
145    const footer = {
146      phone: outcome.kind === 'phone' && outcome.choice === 'allow' ? '✅ <b>Approved from phone</b>' : '❌ <b>Denied from phone</b>',
147      terminal: '💻 Answered in the terminal',
148      timeout: `⏱ No answer in ${timeoutMs / 1000} s: asked in the terminal`,
149      failed: '⚠️ Stopped listening: asked in the terminal',
150    }[outcome.kind]
151    await bot.edit(chatId, messageId, `${html}\n\n${footer}`).catch(() => {})
152
153    if (outcome.kind === 'failed') {
154      $.ui.toast(`📱 ${outcome.error instanceof Error ? outcome.error.message : 'Telegram poll failed'}`)
155    }
156    if (outcome.kind !== 'phone') return below
157
158    await bot.answer(outcome.callbackId, outcome.choice === 'allow' ? 'Approved' : 'Denied').catch(() => {})
159    $.ui.toast(outcome.choice === 'allow' ? `📱 Approved ${e.tool_name} from phone` : `📱 Denied ${e.tool_name} from phone`)
160    return outcome.choice === 'allow'
161      ? { ...below, decision: { behavior: 'allow' } }
162      : { ...below, decision: { behavior: 'deny', message: 'The person denied this from their phone.' } }
163  })
164}
165
hooks/telegram.ts 113 lines
1import type { HttpInit, HttpResponse } from 'claude-code'
2
3// A tiny Telegram Bot API client over the host's fetch. The token only ever
4// appears in the request URL, which is never logged or put in an error.
5
6export type Fetch = (url: string, init?: HttpInit) => Promise<HttpResponse>
7
8export type CallbackQuery = {
9  id: string
10  data?: string
11  message?: { message_id: number; chat: { id: number } }
12}
13
14export type Update = { update_id: number; callback_query?: CallbackQuery }
15
16export class TelegramError extends Error {
17  constructor(method: string, status: number, description: string) {
18    super(`Telegram ${method} failed (${status}): ${description}`)
19  }
20}
21
22export function telegram(fetch: Fetch, token: string) {
23  async function call<T>(method: string, body: object): Promise<T> {
24    const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
25      method: 'POST',
26      headers: { 'content-type': 'application/json' },
27      body: JSON.stringify(body),
28    })
29    let reply: { ok?: boolean; result?: T; description?: string } = {}
30    try {
31      reply = JSON.parse(res.text)
32    } catch {
33      // fall through: reported below without the body
34    }
35    if (!reply.ok) throw new TelegramError(method, res.status, reply.description ?? 'no reply')
36    return reply.result as T
37  }
38
39  return {
40    send: (chatId: string, html: string, nonce: string) =>
41      call<{ message_id: number }>('sendMessage', {
42        chat_id: chatId,
43        text: html,
44        parse_mode: 'HTML',
45        reply_markup: {
46          inline_keyboard: [
47            [
48              { text: '✅ Approve', callback_data: `pa:${nonce}:allow` },
49              { text: '❌ Deny', callback_data: `pa:${nonce}:deny` },
50            ],
51          ],
52        },
53      }),
54    // Without reply_markup the buttons are removed.
55    edit: (chatId: string, messageId: number, html: string) =>
56      call('editMessageText', { chat_id: chatId, message_id: messageId, text: html, parse_mode: 'HTML' }),
57    answer: (callbackId: string, text: string) =>
58      call('answerCallbackQuery', { callback_query_id: callbackId, text }),
59    updates: (offset: number | undefined, timeoutSeconds: number) =>
60      call<Update[]>('getUpdates', { offset, timeout: timeoutSeconds, allowed_updates: ['callback_query'] }),
61  }
62}
63
64export type Bot = ReturnType<typeof telegram>
65
66/** `pa:<nonce>:allow|deny` -> its parts, or undefined for anything else. */
67export function parseChoice(data: string | undefined) {
68  const match = /^pa:([a-z0-9]+):(allow|deny)$/.exec(data ?? '')
69  return match ? { nonce: match[1]!, choice: match[2] as 'allow' | 'deny' } : undefined
70}
71
72const escape = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
73
74const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text)
75
76/** The request as a readable Telegram message (HTML), whatever the tool. */
77export function describe(tool: string, input: unknown, project: string): string {
78  const args = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
79  const text = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : '')
80  const pre = (body: string) => `<pre>${escape(clip(body, 1500))}</pre>`
81
82  let body: string
83  switch (tool) {
84    case 'Bash':
85      body = pre(text('command')) + (text('description') ? `\n<i>${escape(text('description'))}</i>` : '')
86      break
87    case 'Write':
88      body = `📝 <code>${escape(text('file_path'))}</code> (${text('content').split('\n').length} lines)`
89      break
90    case 'Edit':
91      body = `✏️ <code>${escape(text('file_path'))}</code>\n${pre(text('new_string'))}`
92      break
93    case 'MultiEdit':
94      body = `✏️ <code>${escape(text('file_path'))}</code> (${Array.isArray(args.edits) ? args.edits.length : 0} edits)`
95      break
96    case 'NotebookEdit':
97      body = `📓 <code>${escape(text('notebook_path'))}</code>\n${pre(text('new_source'))}`
98      break
99    case 'Read':
100      body = `📖 <code>${escape(text('file_path'))}</code>`
101      break
102    case 'WebFetch':
103      body = `🌐 ${escape(text('url'))}`
104      break
105    case 'WebSearch':
106      body = `🔎 ${escape(text('query'))}`
107      break
108    default:
109      body = pre(JSON.stringify(args, null, 1))
110  }
111  return `🔐 <b>Claude Code wants to use ${escape(tool)}</b>\n📁 ${escape(project)}\n\n${body}`
112}
113