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

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.
📱 waiting for phone….claude --plugin-dir ./phone-approve
or add this repo as a marketplace and install it with /plugin.
/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.https://api.telegram.org/bot<token>/getUpdates and copy message.chat.id. That's your chat id. echo '{"botToken":"123456:ABC…","chatId":"987654321"}' | claude plugin configure phone-approve --values-stdin
| Option | Default | What 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. |
timeoutSeconds | 120 | How long to wait for the phone before the terminal prompt decides. |
The plugin does nothing until botToken and chatId are set.
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.
hooks/register.ts 165 lines1import 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}
165hooks/telegram.ts 113 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>')
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