<!-- source: openspec/current/telegram.md -->

---
title: Telegram
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: Telegram surfaces operated by the studio — three tiers, two bots, one shared n8n bridge. Per-canvas group is the primary work surface.
---
# telegram

> Telegram surfaces operated by Rapoport Studio. Three tiers, two bots, one shared n8n bridge. Inaugural file applied 2026-05-09 from change `add-tg-canvas-surface` (RAP-121).

## What it is

The studio operates Telegram as a first-class work surface — voice-first, async-friendly, reaching specialists and clients who don't open browsers. Three TG surfaces taxonomy:

1. **Public channel** (`@rapoport_studio`) — broadcast, marketing, network ingestion. NOT in this change. Future: `add-tg-public-channel`.
2. **Discussion group** under public channel — community/network discussion. NOT in this change. Future: `add-tg-discussion-group`.
3. **Per-canvas group** — primary work surface for canvas team (owner + collaborators). Scoped by THIS change.

## Bot identity (D-TCS-12)

Two bots, two routes, two architectural meanings:

| Bot | Endpoint | Role |
|-----|----------|------|
| `@rapoport_studio_bot` | `/api/studio/command` | Digital Pavel — standalone Claude agent. Slash-commands `/note`, `/task`, `/done`, free-text mention. NOT Muse. Scoped to Pavel's personal Linear journal. |
| `@rs_canvas_bot` | `/api/studio/telegram/webhook` | Muse headless per-canvas. Voice + text input, propose-only mutations, owner-gated execution. IS Muse. |

Each bot has its own BotFather token, its own Telegram `secret_token` (set via `setWebhook`, validated via `X-Telegram-Bot-Api-Secret-Token` header), and its own Infisical secret pair.

## Headless Muse contract

`runMuseHeadless` in `packages/muse/src/orchestration/run_headless.ts` wraps `runMuseWithTools` with server-loaded contexts (no browser dependency). Studio canvas turn route (`runMuseWithTools` direct) stays untouched.

Surface flag `proposeMode: boolean` drives tool-registry filtering: when `true`, mutator tools (`MUTATOR_TOOLS` allowlist) write to `canvas_proposals` instead of executing the underlying API call. Owner approves via `/yes`; service role executes the proposal post-approval.

## Voice pipeline (D-TCS-5)

Inbound: TG voice note → R2 (`rapoport-tg-audio` bucket, 30d lifecycle) → Whisper (Cloudflare Workers AI by default; OpenAI Whisper if G4 spike shows WER >25% on RU or RO) → text turn into `runMuseHeadless`.

`transcribeAudio({ ogg })` adapter shim swaps backend without changing call sites.

Outbound is text-only v1.

## Propose-only tools (D-TCS-4 + D-TCS-8)

All mutator tools (Mirror writes, GitHub writes, Architect emit) execute as proposals from TG. `/yes` from canvas **owner** required before applying. Read tools (Linear queries, GitHub reads, web/research, OpenSpec/archive readers) execute directly.

D-TCS-8 phase awareness: architect-emit tools (`update_proposal`, `update_design`, `update_tasks`) and `propose_openspec_edit` require owner role specifically — collaborator-proposed architect changes need owner second-confirm even after `/yes`.

CI lint enforces: any new tool exported from `packages/muse/src/tools/` MUST be in either `MUTATOR_TOOLS` or `READ_ONLY_TOOLS` allowlist.

## Identity binding (D-TCS-6)

Owner generates JWT invite token in canvas UI. Sends to TG user via any channel. TG user starts DM with `@rs_canvas_bot` and pastes JWT. Bot validates HS256 signature (`JWT_INVITE_SIGNING_KEY` from Infisical), looks up `tg_invite_tokens` row by `jti`, inserts `tg_user_bindings`, marks token used.

JWT lifetime 24h, one-shot. Telegram Login Widget reserved for future public flows (Q-TCS-A).

### Five-layer auth chain to `runMuseHeadless` (audited 2026-05-26 for AT-08)

The Telegram webhook at [`apps/studio/src/app/api/studio/telegram/webhook/route.ts`](../../apps/studio/src/app/api/studio/telegram/webhook/route.ts) gates every `runMuseHeadless` invocation behind five layers — verified end-to-end against [`agents-threat-model.md § AT-08`](../_methodology/security/agents-threat-model.md):

1. **Webhook secret** — `x-telegram-bot-api-secret-token` + `x-api-key` checked at handler entry (L108-115); unauthenticated POST → 401, no further processing.
2. **Unbound-chat silent-ignore** — `resolveBoundCanvas` returns `bound: false` for any chat without a `channels` row; handler returns immediately without replying (L266-271). This delivers AT-08's "no enumeration leak" property: a Telegram user adding the bot to a random group gets no signal that the bot is there.
3. **Engagement opt-in** — even bound channels require `projects.tg_surface_enabled = true` (L272-279, D-TCS-11). Unenabled projects get the engagement-disabled reply without proceeding.
4. **Identity-binding gate** — `resolveTelegramIdentity` ([`apps/studio/src/lib/telegram/identity.ts`](../../apps/studio/src/lib/telegram/identity.ts)) requires a `tg_user_bindings` row keyed by Telegram `user_id`. Bindings are only created via the signed-JWT-in-DM consume flow described above; the resolver **fails closed** (returns `null`) on any DB error so the handler falls through to the onboarding-hint reply rather than letting an unbound sender proceed (L282-290).
5. **Wake-model classifier + GDPR Art. 9 gate** — `classifyMessage` (Haiku 4.5, A2 wake-model) inspects message intent and special-category content before any Sonnet/`runMuseHeadless` invocation (L355+). Special-category messages drop silently (`gdprBlockedMessage`); below-threshold messages don't wake the Architect.

Identity binding via single-use signed JWT is a **stronger trust mechanism than a static user_id allowlist** would be — bindings can be revoked, the JWT carries the inviter's identity and intended canvas scope, and the binding row's existence is itself the access grant rather than a string compared to a config list.

Test coverage: [`apps/studio/src/lib/telegram/identity.test.ts`](../../apps/studio/src/lib/telegram/identity.test.ts) (binding + bound-canvas + engagement-flag paths).

### Self-revoke (applied 2026-05-26 from change `add-telegram-binding-revoke-action`)

Binding-holders sever the Telegram→studio link themselves at `/settings § Integrations § Telegram bindings`. The IntegrationsCard ([`apps/studio/src/app/(auth-walled)/(default)/settings/integrations-card.tsx`](../../apps/studio/src/app/(auth-walled)/(default)/settings/integrations-card.tsx)) lists the viewer's `tg_user_bindings` rows (scoped by the existing `tg_user_bindings: self read` RLS policy via [`loadTelegramBindingsForViewer`](../../apps/studio/src/app/(auth-walled)/(default)/settings/loaders.ts)); each row has a "Disconnect" `<form action={…}>` that invokes the server action `revokeTgBindingAction(tgUserId)` ([`actions.ts`](../../apps/studio/src/app/(auth-walled)/(default)/settings/actions.ts)).

The server action follows the same admin-client + manual-ownership-check pattern as `disconnectLinearAction` and `generateTgInviteAction`: it loads the authenticated user, then `admin.from('tg_user_bindings').delete().eq('tg_user_id', X).eq('studio_user_id', user.id)`. The `studio_user_id` clause is the security contract — DELETE matches zero rows when the binding doesn't belong to the caller, returning `{ ok: true }` intentionally so the response shape doesn't leak "binding X belongs to a different user." After the row is gone, the next message from that `tg_user_id` falls through `resolveTelegramIdentity` to `null` and the webhook handler treats the sender as unbound (per the five-layer auth chain above).

Re-binding requires a fresh signed-JWT-in-DM consume flow — the prior token is one-shot, so a deleted-then-re-bound user cannot reuse old material.

**Out of scope (deferred):** canvas-owner-level revoke (`D-TBR-1` — bindings are global per studio user; canvas owners already control engagement via `projects.tg_surface_enabled` + member removal); auto-expire on idle (no scheduled-job infrastructure today); bulk revoke (`Q-TBR-2` — `tg_user_bindings.tg_user_id` is PK so users have at most one binding row in practice). See [`openspec/archive/2026-05-26-add-telegram-binding-revoke-action/`](../archive/2026-05-26-add-telegram-binding-revoke-action/) for the full design + decisions.

Test coverage: [`apps/studio/src/app/(auth-walled)/(default)/settings/__tests__/revoke-tg-binding.test.ts`](../../apps/studio/src/app/(auth-walled)/(default)/settings/__tests__/revoke-tg-binding.test.ts) — unauthorized / empty-input / happy-path / zero-row-non-leak / DB-error cases.

### Anthropic outbound scrub coverage (applied 2026-05-26 from change `wrap-studio-anthropic-with-cli-core-security`)

Both Telegram bots — `@rs_canvas_bot` (canvas-bot via `apps/studio/src/app/api/studio/telegram/webhook/route.ts § handleGroupMessage`) and `@rapoport_studio_bot` (Pavel-bot via `apps/studio/src/app/api/studio/command/route.ts`) — pass every outbound Anthropic call through the shared `createScrubbedAnthropic` factory at [`apps/studio/src/lib/anthropic-client.ts`](../../apps/studio/src/lib/anthropic-client.ts), which wraps the SDK client with `wrapAnthropicWithSecretScrub` from `@rapoport-studio/cli-core/security`. A Telegram user pasting a secret in a group message (or DM to the Pavel-bot) gets that secret redacted before the classifier turn — or any subsequent `runMuseHeadless` turn — hits Anthropic's request logs. Coverage matches the Muse runtime (PR #1810) and Forge (PR #1792).

## Per-canvas lifecycle (D-TCS-1, D-TCS-2)

One canvas = one TG group. `channels.bound_canvas_id` (nullable, partial unique idx WHERE NOT NULL) binds a Telegram chat to a canvas. Studio UI generates "Bind Telegram group" panel: shows QR + invite copy; owner adds bot to existing TG group; bot binds chat on first `/start` from authenticated owner.

Memberships mirror `project_members`: owner + collaborators only. Clients NOT in canvas TG groups in v1 (per `add-client-role`).

## Cost model (D-TCS-7 + D-TCS-13)

`canvas_sessions.daily_cap_cents` (default 300 = $3, owner-overridable). New table `canvas_daily_costs` tracks `(canvas_id, date)` ledger. Lifetime `cost_cents` stays cumulative (existing $5 cap).

Worker-side fail-closed pre-turn check: `cents_spent >= cap_cents` → degraded mode (classify only, no Sonnet, no tool calls); owner DM'd. Cap-approach alert at 2/3.

In-flight turn always finishes; next turn rejected. Atomic dual-write via `record_canvas_cost(canvas_id, delta_cents, usage)` Postgres function.

## Privacy boundaries (D-TCS-11)

Engagement-level opt-in via `projects.tg_surface_enabled` (default `false`). Methodology charter records same value in `rapoport-studio-engagement.md § 8 Appendix B → D11`.

GDPR special-category data (health, religion, ethnicity, biometric, sexual orientation per Art. 9) BANNED on this surface. Worker classification turn includes special-category detection; matched messages are NOT processed and NOT stored as transcript.

## Decisions captured

D-TCS-1..13 — see [proposal.md § Decisions inherited](../archive/2026-05-09-add-tg-canvas-surface/proposal.md) (archived change folder).

## Out of scope v1

- Public channel, discussion group (separate future changes).
- Client-facing TG presence in canvas groups.
- Mirror auto-update from voice (RAP-31 v2).
- WhatsApp parity.
- Forum-mode supergroup topics.
- Voice replies outbound.
- Per-user budgets.
- Audio replay in studio.

## Glossary

- **Canvas-bot** — `@rs_canvas_bot`, the Muse-powered TG bot scoped to per-canvas groups.
- **Pavel-bot** — `@rapoport_studio_bot`, the standalone Digital Pavel agent (NOT Muse).
- **Wake model A2** — per-message Haiku 4.5 classification gate before any Sonnet call. Default v1.
- **Wake model A3** — proactive standup-style nudges. Per-canvas opt-in, P5+.
- **Propose-mode** — Muse runtime flag that wraps mutator tools to write proposals instead of executing. TG default; browser canvas direct-execute.
