Architecture
flowchart LR
PHONE[Bob's Telegram]
API[(api.telegram.org)]
subgraph GCP[gcp-mcp-standalone VM]
subgraph SVC[systemd: claude-telegram · user claudebot · MemoryMax 768M]
TMUX[tmux tgchan] --> CLAUDE[claude --channels<br/>plugin:telegram]
CLAUDE -->|spawns MCP server| BUN[bun server.ts<br/>grammy long-poll]
end
FW[[iptables: claudebot<br/>blocked from metadata server]]
end
PHONE <--> API
BUN <-->|long-poll getUpdates / sendMessage| API
BUN <-->|stdio MCP: reply tool + injected msgs| CLAUDEMessage flow
bun server.ts(the plugin's MCP server, using the grammy Bot API library) holds a long-poll to Telegram — idle = a network wait, no model, no tokens.- A DM arrives. The server checks the sender against the allowlist (
access.json); an unknown sender inpairingpolicy gets a 6-char pairing code instead of service. - An allowlisted message is injected into the Claude Code session over stdio.
- Claude acts — only commands on its allow-list run without a human to approve (there is
none); anything else is refused — and calls the plugin's
replytool. - The server sends the reply back via
sendMessage. Tokens are consumed only for this turn.
Components
- Isolated user
claudebot— own home, no sudo;iptablesOUTPUT rule (persisted byclaudebot-metadata-block.service) blocks it from169.254.169.254, so it cannot use the VM's service account to reach GCP-Secret-Manager or any GCP API. claude-telegram.service— oneshot systemd unit launchingclaude --channels plugin:telegram@claude-plugins-officialinside tmux;MemoryMax=768M(2 G swap on the box absorbs spikes).- telegram channel plugin
0.0.6— installed and enabled (it installs disabled by default — the channel banner shows but the MCP server never starts until enabled); itsserver.tsruns on bun, deps pre-installed so the server spawns within Claude's MCP startup timeout. - Auth —
TELEGRAM_BOT_TOKEN(BotFather) +CLAUDE_CODE_OAUTH_TOKEN(claude setup-token, a Claude subscription) +NOTION_BROKER_KEY(broker API key) in~/.config/telegram.env(mode 600). notion-brokerMCP server — user-scope in~/.claude.json, HTTP tohttp://127.0.0.1:8002/proxy/notion_api/(localhost, on-box) with header-auth (X-Broker-Key+X-App-Id: my_company:app1). Lets the bot read Notion via the broker without ever seeing the raw Notion token. Note the trailing slash — the bare path 307-redirects and stalls the MCP client at startup.
Security
- Read-only allow-list (
~/.claude/settings.json):Read/LS/Grep/Glob+ a fixed set of harmless shell commands (df free uptime date whoami hostname uname cal echo) + the telegramreplytool + read-only Notion tools via the broker (search,fetch,query_data_source,get_block_children,get_comments,get_page_property,get_users— no create/update/archive). Default permission mode, no bypass — anything off-list is refused, because a headless channel has no human to approve it. This is the core boundary: the bot can read Notion and answer questions, but cannot modify it. - Sender allowlist — pairing captures the numeric user ID; policy then switched to
allowlistso strangers can't even get pairing replies. Only Bob's ID is served. - Blast radius —
claudebotis unprivileged, metadata-blocked (no GCP creds), memory capped, and cannot read the broker's.env/secrets (not on its allow-list, and it runs as a different user from the broker container's files). Worst realistic case: it answers read-only status questions to the one allowlisted account. - Token exposure note — if a bot token is ever pasted in plaintext, rotate it via BotFather → Revoke.
Cost
Negligible: runs in the existing e2-small (already ~$17/mo, see gcp-mcp-standalone/HLD); Telegram Bot API is free; Claude usage draws on the existing subscription and only when a message is actually handled (idle = 0 tokens).
Related concepts
Compiled from
wiki/projects/telegram-mcp-connector/HLD.md · git is the source of truth