TermAl

Feature Brief: Telegram Relay

TermAl supports an experimental Telegram bot relay. The current implementation has one UI-configured bot with one linked Telegram chat; the target design is a small set of named bot profiles, each with its own token, chat binding, project subscriptions, defaults, and relay runtime. The normal path is UI-configured and runs inside the main backend process.

Parent feature: whatsapp-integration.md.

Current Status

Implemented:

Not implemented yet:

Setup

  1. Create a Telegram bot with @BotFather and copy the token.
  2. Open TermAl Settings -> Telegram.
  3. Paste the token and click Test connection.
  4. Choose subscribed projects and an optional default project/session.
  5. Enable the relay and save.
  6. Open the bot in Telegram and send /start.

The relay is part of the main TermAl backend. Saving enabled settings starts, stops, or restarts the in-process relay from the saved configuration. Telegram permits only one getUpdates poller per bot token, so run one TermAl backend per configured bot token.

Telegram Commands

Free text is sent to the selected session when one is set. Otherwise it goes to the latest promptable root session in the active project. The selected session is also tailed: assistant text produced from prompts typed directly in TermAl is forwarded back to Telegram after the message settles.

Project digests, inline digest controls, and digest action commands are temporarily disabled. Existing digest buttons are acknowledged as disabled and do not dispatch backend work.

Assistant Forwarding Boundary

The relay keeps a conservative boundary when Telegram free text is queued behind an already-active or approval-paused TermAl turn. While that older turn is still open, the relay treats the latest assistant text as a moving baseline and does not forward it to Telegram. On the first settled poll, if the tracked assistant message has already grown, the relay records the grown length as the baseline and waits for later growth or a later assistant message.

That means same-message reply text already present before the first settled poll is intentionally not forwarded for queued Telegram prompts. Forwarding it would risk sending output from the pre-existing local turn. Supporting that case requires a stronger per-turn boundary from the session or agent layer.

Storage

UI configuration is stored in the revisioned app state under preferences.telegram, so settings saves publish ordinary state snapshots and SSE updates. ~/.termal/telegram-bot.json remains the runtime metadata file and contains a mirrored config block for relay interop and legacy migration. The bot token itself is stored in the OS credential store under a TermAl service entry scoped to the TermAl data directory. Existing plaintext config.botToken values from older releases are migrated into the credential store and removed from the JSON file the next time the Telegram settings are read or updated.

The UI config contains:

The runtime state contains fields such as:

The full bot token is never returned through /api/telegram/status or persisted back to telegram-bot.json; status responses expose only a masked suffix.

Multi-Bot Target Spec

The multi-bot feature should treat a bot as a named route profile. Examples: Personal, Work, Client A, or On-call. Each profile owns:

Storage Shape

The current singleton file should migrate forward without losing settings. The target JSON shape is:

{
  "version": 2,
  "bots": [
    {
      "id": "default",
      "name": "Telegram",
      "config": {
        "enabled": true,
        "subscribedProjectIds": ["project-id"],
        "defaultProjectId": "project-id",
        "defaultSessionId": null
      },
      "state": {
        "chatId": 123456789,
        "selectedProjectId": "project-id",
        "selectedSessionId": null,
        "nextUpdateId": 42
      }
    }
  ]
}

Token storage moves from one singleton keyring entry to per-bot entries:

Migration rules:

Runtime Model

The current relay runtime is singleton. Multi-bot support requires a supervised runtime map keyed by bot id:

The existing TelegramBotConfig can stay as the per-runtime value, but it needs an added bot_id and a per-bot state_path or state accessor. The relay should never write another bot’s runtime state during digest/cursor persistence.

HTTP Surface

Keep the current singleton routes temporarily for compatibility, but introduce bot-profile routes as the new contract:

Method Path Purpose
GET /api/telegram/status Return aggregate Telegram status with bots: TelegramBotStatus[]; during migration may also include the singleton-compatible fields for the default bot
POST /api/telegram/bots Create a bot profile with name, optional token, project subscriptions, defaults, and enabled flag
PATCH /api/telegram/bots/{bot_id} Update name, token, enabled flag, subscriptions, defaults, or clear token/defaults using nullable marker fields
DELETE /api/telegram/bots/{bot_id} Disable relay, delete token, and remove the bot profile/runtime state
POST /api/telegram/bots/{bot_id}/test Validate a supplied token or that bot’s saved token with Telegram getMe

Response sketch:

type TelegramStatusResponse = {
  bots: TelegramBotStatus[];
};

type TelegramBotStatus = {
  id: string;
  name: string;
  configured: boolean;
  enabled: boolean;
  running: boolean;
  lifecycle: "inProcess";
  linkedChatId?: number | null;
  botTokenMasked?: string | null;
  subscribedProjectIds: string[];
  defaultProjectId?: string | null;
  defaultSessionId?: string | null;
};

PATCH fields should keep the existing tri-state convention:

List fields should keep the existing convention:

Settings UI

The Settings -> Telegram panel should become a profile list plus detail editor:

Validation And Safety

Platform credential-store coverage is split intentionally:

cargo test --bin termal telegram_bot_token_native_credential_store_round_trips -- --ignored

Linux runs require a usable desktop Secret Service/keyring session.

HTTP Surface

Current routes:

Telegram uses a focused API surface instead of the generic POST /api/settings route because the settings response must include relay lifecycle state and a masked token without ever placing secret token material in the normal StateResponse / SSE snapshot stream. The test route is also intentionally separate because it performs an outbound Telegram getMe check without saving configuration. The remaining config route returns a sanitized TelegramStatusResponse so clients can replace their local Telegram settings view from one response.

Method Path Purpose
GET /api/telegram/status Read configured/enabled/running state, lifecycle, linked chat, masked token, subscribed projects, and defaults
POST /api/telegram/config Update token in the OS credential store, enabled flag, subscriptions, and defaults
POST /api/telegram/test Validate a supplied or saved token with Telegram getMe

These routes currently operate on the singleton bot. Multi-bot work should add the bot-profile routes listed in the target spec before changing the settings UI to create multiple profiles.

The relay itself uses existing TermAl routes:

Method Path Purpose
GET /api/state Read projects and sessions for /projects, /sessions, and selected-session validation
GET /api/sessions/{id} Read settled assistant messages for forwarding
POST /api/sessions/{id}/messages Forward Telegram free text into TermAl

Telegram endpoints return the standard TermAl API error shape, { "error": "..." }, with a human-readable diagnostic. Treat that message as presentation text, not a stable discriminator. In particular, /api/telegram/test can return 422 for both local config validation failures and Telegram getMe validation/auth failures; clients should present the message and branch on request context or status, not parse English text. Config validation also checks that referenced projects/sessions still exist before checking default-project membership, so orphaned defaults can report unknown ... project/session wording instead of an older membership-specific message.

POST /api/telegram/config returns the sanitized current settings after the patch is applied, not an echo of request fields. Omitted or null patch fields leave the matching setting unchanged, but stale persisted project/session references can still be scrubbed from the response when they no longer exist. Clients should replace local Telegram settings state with the response instead of diffing request fields against response fields.

Remaining Work