# @mono-agent/telegram-adapter

Connect an agent host to Telegram with explicit chat authorization, attachment
handling, command controls, and Telegram-safe final or streamed delivery.

## Category

<!-- package-metadata:start -->
<!-- Generated by scripts/generate-package-docs.mjs. Do not edit by hand. -->

Category: `communication`
Tier: `core`
Catalog responsibility: Adapts Telegram updates to structural agent requests and streamed replies.

<!-- package-metadata:end -->

## Responsibility

Telegram communication adapter for agent hosts. It provides a Bot API client, long poller, update handler, streamed message edits, cancellation, allowlist enforcement, and Telegram-owned settings helpers.

## Install / Usage

### Config-first host

`@mono-agent/agent-app` already includes this adapter. Put the bot token in the
agent folder's `.env`, authorize one or more chats, and enable Telegram:

```dotenv
MONO_AGENT_TELEGRAM_BOT_TOKEN=123456789:replace-me
```

```json
{
  "telegram": {
    "enabled": true,
    "allowedChatIds": ["123456789"],
    "allowAllChats": false
  }
}
```

```bash
mono-agent validate
mono-agent start --foreground
```

The adapter is opt-in: `telegram.enabled` / `MONO_AGENT_TELEGRAM_ENABLED`
defaults to `false`. While disabled, the loader skips token validation and the
channel reports `disabled`. Once enabled, a missing token or missing
`allowedChatIds`/`allowAllChats` authorization becomes a real
`waiting_for_config` reason.

For an allowlisted group where the bot should stay quiet unless addressed, set
`telegram.groupMode` to `"mention"`. Native @mentions of the bot and replies to
its messages start turns; ordinary group conversation is ignored before agent
admission. Direct chats and slash commands are unchanged. The bot must receive
group updates (for example, by being a group administrator); the adapter owns
the local trigger filter. `telegram.stripMentionText` defaults to `true`.

### Programmatic use

Install the package directly only when composing a custom host:

```bash
pnpm add @mono-agent/telegram-adapter
```

<!-- doc-test:typescript -->
```ts
import {
  startTelegramAdapter,
  type AgentResponder,
} from "@mono-agent/telegram-adapter";

const botToken = process.env.MONO_AGENT_TELEGRAM_BOT_TOKEN;
if (botToken === undefined) {
  throw new Error("Telegram bot token is required.");
}

const responder: AgentResponder = {
  async respond(request) {
    return { text: `Telegram request received: ${request.text}` };
  },
};

const telegram = await startTelegramAdapter({
  botToken,
  allowedChatIds: ["123456789"],
  groupMode: "mention",
  responder,
});

process.once("SIGINT", () => void telegram.stop());
```

`startTelegramAdapter` is the recommended standalone entrypoint. Advanced hosts
can use `createTelegramBot` to own start/stop timing or compose
`createGrammyTelegramApi` / `createTelegramMessageSender` at the Bot API boundary.

### Attachments

Inbound documents, photos, audio, video, round video (`video_note`), and voice
messages are downloaded after chat authorization and delivered as shared
`AgentAttachment` values (`mimeType`, base64 `data`, optional `name`). Captions
remain the request text; media-only messages receive a concise text summary.
Original Telegram ids, sizes, and kinds remain under
`metadata.telegram.attachments`. Downloads that fail, exceed the size cap, or
miss the MIME allowlist are skipped without failing the turn. The host runtime,
not this adapter, decides whether a model can understand the resulting content.

The canonical shared constants are `DEFAULT_AGENT_ATTACHMENT_MAX_BYTES` and
`DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST`. The exported
`DEFAULT_ATTACHMENT_MAX_BYTES` and `DEFAULT_ATTACHMENT_MIME_ALLOWLIST` names are
Telegram compatibility aliases with the same values.

### Voice transcription (optional)

Set `telegram.transcription` to transcribe inbound voice, audio, and round-video
attachments through an OpenAI-compatible transcription route. The transcript
is added to the current turn; a failure falls back to the saved-file note and
does not fail the run.

| Field | Environment variable | Required | Description |
| --- | --- | --- | --- |
| `telegram.transcription.endpoint` | `MONO_AGENT_TELEGRAM_TRANSCRIPTION_ENDPOINT` | when used | Full HTTP(S) `POST /v1/audio/transcriptions` URL. |
| `telegram.transcription.model` | `MONO_AGENT_TELEGRAM_TRANSCRIPTION_MODEL` | with endpoint | Multipart `model` value. |
| `telegram.transcription.language` | `MONO_AGENT_TELEGRAM_TRANSCRIPTION_LANGUAGE` | no | Optional ISO-639 language hint. |
| `telegram.transcription.timeoutMs` | `MONO_AGENT_TELEGRAM_TRANSCRIPTION_TIMEOUT_MS` | no | Per-call timeout; default `120000` ms. |

The request uses native `fetch`, `FormData`, and `Blob` with `file`, `model`, and
optional `language` parts. Durable history keeps the attachment's file reference,
not the transcript, after provider context is lost.

### Final answer and transient tool activity

Inbound turns do not stream answer tokens by default. Telegram first shows the
`typing…` action; if tools run, one cumulative, secret-safe activity message is
edited in place. Once the response is ready, Telegram posts it as a new message
and deletes the activity message. Adjacent duplicates collapse as `(×N)`.
A subagent stays expanded while it runs; at its first terminal event, Telegram
removes all of that subagent's child tool lines while retaining its total call
count and duration. The compact row may include one secret-redacted `Result` or
`Reason` line capped at 120 Unicode code points. Concurrent subagents collapse
independently, and late events cannot re-expand a completed group.
Proactive deliveries suppress this message, and an
acknowledged `/cancel` best-effort deletes it while retaining one `Cancelled.`
acknowledgement. Programmatic drivers can set `stream.showHints: false` to keep
only the ordinary working indicator or `stream.finalOnly: false` for live answer
edits.

`ReadSkill` renders the selected skill as `📚 Reading "<skill>"` without exposing
its path. Memory recall remains preview-free as `🧠 Recalling memory`. Memory
writes remain `🧠 Updating memory`, and ordinary file reads remain `📖 Reading`.
Long file paths keep both their leading location and trailing filename; long
commands keep a balanced prefix and suffix. Every preview remains capped at 40
Unicode code points after secret redaction.

Applied live guidance adds a completed `↪️ Steered: “<safe preview>”` entry. If
a confirmed ledger already exists, Telegram best-effort deletes and reposts the
same cumulative ledger so it becomes the newest bot message after the human
follow-up. A failed delete edits the existing ledger in place; neither path can
block or replace the final answer.

### Host-owned process-job lifecycle

The config-first host uses `updateProcessJob` on the bot controller/start result
for a background Exec/Bash job's lifecycle card. This path never invokes the
responder. It binds the card to the exact chat origin, serializes updates per
job, edits the same message when possible, and ignores any nonterminal update
that arrives after a terminal state. The adapter retains up to the shared
10,096 outstanding lifecycle identities, refuses overflow rather than evicting
live state, and reclaims only terminal identities whose wake has already
settled. When a terminal update has no editable reference, the adapter makes at
most one self-contained terminal fallback post in that same chat. This identity
state is instance-local, not durable across an adapter/process restart; the
existing post-restart fallback contract is unchanged. Ordinary proactive
`notify` behavior is unchanged.

The app-owned completion wake uses `notify` with a stable delivery key and
`steerActive: true`. It reserves the chat's normal queue position first,
targets only the exact active run, and reports `steered` after provider
acknowledgement. Every explicit non-applied settlement runs the reserved normal
turn with visible thinking and tool activity; an unknown outcome is not
silently reported as success.

### Live follow-up steering

When the responder exposes live input, another plain-text message in the same
Telegram chat while a turn is running is offered to that active provider run.
With reactions enabled it moves from the configured working reaction to the
done reaction after provider acknowledgement. Commands, pending `AskUser`
replies, and messages with attachments keep their existing behavior. The
adapter reserves the normal per-chat queue position first, so an unsupported
provider, failed delivery, or end-of-turn race runs the exact message next as a
normal turn. Applied guidance does not create a second assistant response;
provider acknowledgement adds the completed `↪️ Steered` activity described
above.

### Per-chat runtime controls

The mono-agent app supplies a display-ready `runtimeControls` catalog to the
adapter, which adds built-in `/model` and `/effort` menus. Direct programmatic
callers can opt into the same behavior through `createTelegramBot` or
`startTelegramAdapter`.

Only the host-supplied configured primary and fallback models are selectable;
the adapter performs no model discovery and accepts no arbitrary model
references. A choice is held in memory per chat until `/model default`,
`/effort default`, or process restart. Changing model clears an explicit effort
only when the new model does not support it. Interactive messages, configured
command prompts, and synthetic button-answer turns carry the selection under
`metadata.telegram`; public proactive `notify` calls intentionally retain the
configured runtime defaults.

The inline menus include **Cancel**, which deletes the menu without changing the
selection. Choosing a model or effort edits that menu into its confirmation
instead of posting another message.

When the host supplies `startNewSession`, `/new` cancels current work and starts
a fresh session for only that Telegram conversation. In the mono-agent app this
retires the warm provider session, clears that conversation's canonical history,
and reloads skills/startup context on the next message; durable memory and the
chat's model/effort selection remain intact.

When a structured `AskUser` interaction is pending, Telegram renders each
question with native option buttons plus **Other**; multi-select questions add a
**Done** button. The next authorized plain-text message is accepted as a custom
answer to that same tool call, while slash commands remain commands. Separately,
non-blocking `TelegramSendMessage.reply_options` buttons start a new user turn
when tapped.

After an answer is recorded, Telegram removes the buttons and edits the question
message with the original labels for resolved selections. One answer is shown
inline; multiple answers are attributed by question header in recorded order.
Unknown question and option IDs are omitted. Multi-answer custom-only entries use
the placeholder `custom answer`, while Telegram never echoes the custom reply
text; a single custom-only or otherwise unresolved answer keeps the generic
`Answer recorded.` confirmation.
### Generated reply files

When the host returns an authorized reply-file part, the adapter reads its
integrity-checked private stream and sends a native Bot API `sendDocument` to
the exact chat and reply target. Proactive quiet-hour delivery preserves
`disable_notification`. A part leaves textual fallback only after Telegram
confirms the send; failure or a custom sender without `sendDocument` keeps a
concise warning without exposing a local path or private URL. Confirmed sends
are deduplicated by file integrity plus chat/reply target.

This path is separate from model-invoked `TelegramSendFile`: reply files use the
shared response-part contract and host authorization. See
[Reply files and MCP Apps](https://mono-agent-docs.vercel.app/tools/rich-replies/).

## Architecture

### Data flow

The request lifecycle is:

1. `config.ts` layers JSON and environment values, validates credentials and
   allowlists, and resolves optional commands, reactions, uploads, transport,
   quiet hours, and transcription settings.
2. `start.ts` converts the high-level options into a bot controller and starts
   it. `bot.ts` owns grammY polling, command/callback dispatch, per-chat queues,
   live-input fallback reservation, cancellation, degradation recovery, and
   proactive notification.
3. `adapter.ts` normalizes Telegram updates and downloaded media into the shared
   responder request. `transcription.ts` optionally enriches supported audio.
4. The host responder returns normal stream events. `message-stream.ts` renders
   Telegram-safe final-only delivery, retries, activity, and cleanup; the
   Markdown translator runs only at this transport boundary.
5. Host-owned process-job projections bypass the responder and use the
   adapter-local monotonic lifecycle-message path.
6. `stop()` ends polling and waits for the runner to settle.

### Package structure

| Source module | Responsibility |
| --- | --- |
| [`config.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/telegram-adapter/src/config.ts) | Config/env loading, validation, redaction, quiet hours, and feature settings. |
| [`start.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/telegram-adapter/src/start.ts) | Recommended programmatic composition root. |
| [`bot.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/telegram-adapter/src/bot.ts) | Polling lifecycle, authorization, commands, callbacks, queues, and notification. |
| [`adapter.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/telegram-adapter/src/adapter.ts) | Request normalization, attachment metadata, download limits, and responder types. |
| [`grammy-client.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/telegram-adapter/src/grammy-client.ts) | grammY-backed Bot API and file-transfer boundary. |
| [`message-stream.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/telegram-adapter/src/message-stream.ts) | Telegram delivery, retry classification, transient activity, and finalization. |
| [`transcription.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/telegram-adapter/src/transcription.ts) | Optional OpenAI-compatible audio transcription. |
| [`ask-user.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/telegram-adapter/src/ask-user.ts) | Bounded callback encoding for structured `AskUser` buttons. |
| [`reply-options.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/telegram-adapter/src/reply-options.ts) | Callback protocol for non-blocking `TelegramSendMessage.reply_options`. |
| [`types.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/telegram-adapter/src/types.ts) | Minimal Telegram wire and Bot API contracts. |

## Public API

### Start here

| API | Use it for |
| --- | --- |
| `loadTelegramAdapterConfig` | Load, validate, and redact the config/env surface. |
| `startTelegramAdapter` | Start the complete bot/poller lifecycle and obtain notify/post helpers. |
| `createTelegramBot` | Control the bot controller lifecycle directly. |
| `TelegramBotController.updateProcessJob` | Post or monotonically update one exact-origin host lifecycle card without a model turn. |
| `createGrammyTelegramApi` / `createTelegramMessageSender` | Compose the Bot API boundary separately. |
| `downloadTelegramAttachments` | Normalize authorized Telegram media into shared attachments. |
| `createOpenAiTranscriber` | Add the optional transcription boundary. |
| `TelegramMessageStream` | Deliver one responder stream to Telegram. |
| `renderTelegramMarkdown` | Render standard Markdown for Telegram. |

<!-- public-api-inventory:start -->
<!-- Generated by scripts/generate-public-api-docs.mjs. Do not edit by hand. -->

Every symbol exported by each public code entrypoint is listed below.

**`@mono-agent/telegram-adapter`**

```text
AgentMessageStream
AgentRequest
AgentResponder
AgentResponse
CreateTelegramBotOptions
DEFAULT_AGENT_ATTACHMENT_MAX_BYTES
DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST
DEFAULT_ATTACHMENT_MAX_BYTES
DEFAULT_ATTACHMENT_MIME_ALLOWLIST
DownloadTelegramAttachmentsOptions
LoadTelegramAdapterConfigInput
RedactedTelegramAdapterConfig
TELEGRAM_CONFIG_FIELDS
TELEGRAM_REPLY_CALLBACK_PREFIX
TELEGRAM_REPLY_MAX_OPTIONS
TELEGRAM_TRANSCRIPTION_UNAVAILABLE_NOTE
TelegramAdapterConfig
TelegramAdapterConfigError
TelegramAdapterConfigErrorCode
TelegramAdapterConfigErrorDetails
TelegramAdapterErrorText
TelegramAdapterErrorTextInput
TelegramAdapterLogger
TelegramAdapterMessages
TelegramAdapterStartOptions
TelegramAdapterStartResult
TelegramAdapterStreamOptions
TelegramAgentMessageInput
TelegramApiError
TelegramApiErrorDetails
TelegramApiErrorKind
TelegramAskUserAction
TelegramAskUserCallback
TelegramAttachment
TelegramAttachmentBase
TelegramAttachmentKind
TelegramAttachmentsConfig
TelegramAudio
TelegramAudioAttachment
TelegramBotApi
TelegramBotController
TelegramChat
TelegramChatId
TelegramCommandConfig
TelegramDeleteMessageParams
TelegramDeleteWebhookParams
TelegramDeliveryError
TelegramDocument
TelegramDocumentAttachment
TelegramEditMessageTextParams
TelegramFileDownloader
TelegramFileReference
TelegramGetUpdatesParams
TelegramGroupTriggerMode
TelegramMessage
TelegramMessageEntity
TelegramMessageSender
TelegramMessageStream
TelegramMessageStreamLogger
TelegramMessageStreamOptions
TelegramPhotoAttachment
TelegramPhotoAttachmentSize
TelegramPhotoSize
TelegramQuietHours
TelegramReactionsConfig
TelegramRequestMetadata
TelegramRequestOptions
TelegramRuntimeControls
TelegramRuntimeEffortOption
TelegramRuntimeModelOption
TelegramSendDocumentParams
TelegramSendMessageParams
TelegramSendOutcome
TelegramSendPhotoParams
TelegramSendToolsConfig
TelegramSentMessage
TelegramTranscriber
TelegramTranscriptionConfig
TelegramUpdate
TelegramUser
TelegramVideo
TelegramVideoAttachment
TelegramVideoNote
TelegramVideoNoteAttachment
TelegramVoice
TelegramVoiceAttachment
agentAttachmentKindFromMimeType
classifyTelegramError
createGrammyTelegramApi
createOpenAiTranscriber
createTelegramBot
createTelegramMessageSender
decodeAgentAttachmentText
downloadTelegramAttachments
isTelegramReplyCallbackData
isWithinQuietHours
loadTelegramAdapterConfig
parseTelegramAskUserCallbackData
redactTelegramAdapterConfig
renderTelegramMarkdown
startTelegramAdapter
telegramAskUserCallbackData
telegramReplyCallbackData
```

<!-- public-api-inventory:end -->

## Dependency Boundary

This adapter depends on shared `@mono-agent/agent-contracts`, `grammy`,
`@grammyjs/runner`, and `telegramify-markdown`. It does not depend on the
harness, core config, memory, runtime package, or other adapters. Hosts compose
those pieces outside the adapter.

## What This Package Does Not Own

It does not build prompts, run models, store memory, serve UI, manage provider credentials, or decide core runtime settings.

## Related Documentation

- [Telegram channel guide](https://mono-agent-docs.vercel.app/channels/telegram/)
- [Telegram personal-assistant playbook](https://mono-agent-docs.vercel.app/playbooks/telegram-personal-assistant-bujo/)
- [Delivery and send tools](https://mono-agent-docs.vercel.app/channels/delivery-and-send-tools/)
- [Reply files and MCP Apps](https://mono-agent-docs.vercel.app/tools/rich-replies/)
- [Custom channel adapters](https://mono-agent-docs.vercel.app/programmatic/custom-channels/)

## Verification

Run the package-local build, typecheck, and behavior tests:

```bash
pnpm --filter @mono-agent/telegram-adapter run build
pnpm --filter @mono-agent/telegram-adapter run typecheck
pnpm --filter @mono-agent/telegram-adapter run test
```
