Bridge any number of Telegram bots to Letta agents — with one tiny NestJS process.
A lightweight, production-ready proxy that relays Telegram messages (text, photos, documents, voice) to stateful Letta agents and streams their replies back — including agent-generated images and files.
- 🔗 1:1 bot ↔ agent binding — each bot maps to a single Letta agent, with shared agent state across every chat it serves.
- 🚀 Multi-bot, single process — host N bots concurrently; each is configured purely through env vars, no code changes.
- 👥 Group-chat aware — every group message is relayed to the agent, and the agent decides whether to reply.
- 🔒 Explicit allow-lists — whitelist Telegram user IDs (DMs) and chat IDs (groups). Everything else is dropped silently.
- 🖼️ Bidirectional media — photos, documents, voice & audio flow both ways (see Media support).
- 🧩 Zero hard-coded handlers — a bot-agnostic middleware is bound to every Telegraf instance at startup, so the bot list grows straight from config.
- 🛡️ Type-safe config — environment variables validated at boot with Zod; fail fast, not at runtime.
- 🐳 Multi-arch Docker images —
linux/amd64+linux/arm64, published automatically on release.
- Quick Start
- Configuration
- Media Support
- Architecture
- How a Message Flows
- Finding Chat IDs
- Notes & Limitations
- Tech Stack
- License
cp .env.example .env
# edit .env: set LETTA_TOKEN, BOT_1_TOKEN, BOT_1_AGENT_ID, BOT_1_ALLOWED_USER_IDS, ...
yarn install
yarn build && yarn start
# …or for live reload during development:
yarn start:devThe fastest path — no Node toolchain, no build. Just create your env file and run the published multi-arch image from Docker Hub.
cp .env.example .env
# edit .env: LETTA_TOKEN, BOT_1_TOKEN, BOT_1_AGENT_ID, BOT_1_ALLOWED_USER_IDS, …With the Docker CLI:
docker run -d \
--name letta-telegram \
--restart unless-stopped \
--env-file .env \
--init \
nvxdm/letta-telegram:latest
docker logs -f letta-telegram # follow logs
docker stop letta-telegram && docker rm letta-telegram # stop & remove
# update: pull the new image, drop the old container, then re-run the command above
docker pull nvxdm/letta-telegram:latest
docker rm -f letta-telegram…or skip the .env file entirely and pass the variables inline with -e:
docker run -d \
--name letta-telegram \
--restart unless-stopped \
--init \
-e LETTA_BASE_URL=https://api.letta.com \
-e LETTA_TOKEN=your-letta-api-key \
-e BOT_1_NAME=primary \
-e BOT_1_TOKEN=123456:your-telegram-bot-token \
-e BOT_1_AGENT_ID=agent-b4c582f4-… \
-e BOT_1_ALLOWED_USER_IDS=11111111,22222222 \
-e BOT_1_ALLOWED_CHAT_IDS=-1001234567890 \
nvxdm/letta-telegram:latestWith Docker Compose — drop this docker-compose.yml next to your .env:
services:
letta-telegram:
image: nvxdm/letta-telegram:latest
container_name: letta-telegram
restart: unless-stopped
env_file: .env
init: true
stop_grace_period: 20sdocker compose up -d # start in the background
docker compose logs -f # follow logs
docker compose pull # fetch a newer image…
docker compose up -d # …then recreate to apply it
docker compose down # stop & remove…or inline the variables with an environment: block instead of env_file:
services:
letta-telegram:
image: nvxdm/letta-telegram:latest
container_name: letta-telegram
restart: unless-stopped
init: true
stop_grace_period: 20s
environment:
LETTA_BASE_URL: https://api.letta.com
LETTA_TOKEN: your-letta-api-key
BOT_1_NAME: primary
BOT_1_TOKEN: "123456:your-telegram-bot-token"
BOT_1_AGENT_ID: agent-b4c582f4-…
BOT_1_ALLOWED_USER_IDS: "11111111,22222222"
BOT_1_ALLOWED_CHAT_IDS: "-1001234567890"Tip
The image is multi-arch (linux/amd64 + linux/arm64), so the same tag runs on x86 servers and Apple-silicon / ARM hosts. Pin a specific release (e.g. nvxdm/letta-telegram:0.1.0) instead of :latest for reproducible deploys.
If you've cloned the repo, the bundled docker-compose.yml builds the image locally instead of pulling it:
docker compose up --build -dAll configuration is done through environment variables. Copy .env.example to .env and fill in the blanks.
| Var | Required | Default | Notes |
|---|---|---|---|
LETTA_BASE_URL |
✅ | — | Letta Cloud https://api.letta.com, or your self-hosted instance URL |
LETTA_TOKEN |
✅ | — | Bearer token — a Letta Cloud API key, or a self-hosted server password |
LETTA_TIMEOUT_MS |
— | 120000 |
Per-request timeout in ms |
Repeat the block for each bot — BOT_1_*, BOT_2_*, BOT_3_*, …
| Var | Required | Notes |
|---|---|---|
BOT_<N>_NAME |
✅ | Human-readable name, must be unique across bots |
BOT_<N>_TOKEN |
✅ | Telegram bot token from @BotFather |
BOT_<N>_AGENT_ID |
✅ | Letta agent id (e.g. agent-b4c5…) |
BOT_<N>_ALLOWED_USER_IDS |
✅ | Comma-separated Telegram numeric user IDs · empty = deny all DMs |
BOT_<N>_ALLOWED_CHAT_IDS |
✅ | Comma-separated group/supergroup IDs (negative numbers) · empty = deny all groups |
Important
Bots are discovered by scanning for BOT_<N>_TOKEN with any positive integer N. They load in ascending index order and gaps are fine (e.g. BOT_1_* then BOT_3_* with no BOT_2_* works), and the indices need not start at 1. Each BOT_<N>_NAME must be unique.
| Var | Default | Notes |
|---|---|---|
LOG_LEVEL |
info |
One of error, warn, info/log, debug, verbose |
NODE_ENV |
— | production recommended in Docker |
| Direction | Type | Handling |
|---|---|---|
| TG → Letta | Photos | Sent inline as base64 |
| TG → Letta | Documents | Uploaded to a per-bot Letta files folder attached to the agent |
| TG → Letta | Voice / Audio | Uploaded as files |
| Letta → TG |  |
Sent as a photo |
| Letta → TG | [label](url) (with file extension) |
Sent as a document |
| Letta → TG | Remaining text | Sent as a message |
src/
├── config/ # Env parsing + Zod validation (bots, letta)
├── letta/ # @letta-ai/letta-client wrapper + per-agent serialization
├── media/ # Telegram file download, TG→Letta payload, agent output parser
├── bots/ # Bot launcher (Telegraf), access control, update handler, sender
├── app.module.ts
└── main.ts # Bootstrap, log-level setup, signal/error handling
The handler middleware (telegram-update.handler.ts) is bot-agnostic and is bound at startup to every configured Telegraf instance via bot.use(...). This avoids hard-coded @Update() classes and lets the bot list grow purely from env config.
sequenceDiagram
participant TG as Telegram
participant MW as bot.use() middleware
participant AC as AccessControlService
participant PB as TgPayloadBuilderService
participant LS as LettaService
participant OP as AgentOutputParserService
participant SD as TelegramSenderService
TG->>MW: incoming update
MW->>AC: check chat / user allow-list
AC--xMW: not allowed → dropped silently
AC->>PB: allowed
PB->>PB: download media, build content blocks
PB->>LS: sendMessage(agentId, content)
LS->>LS: serialize per-agent · client.agents.messages.create(...)
LS->>OP: last assistant_message (empty = silent)
OP->>SD: parse markdown → photos / documents / text
SD->>TG: replyWithPhoto / Document / message (in order)
- Telegram → middleware —
bot.use(...)fires for the configured bot. - Access control —
AccessControlServicechecks the chat/user allow-list. Non-allowed updates are dropped silently. - Payload build —
TgPayloadBuilderServiceconverts the update into Lettacontentblocks. Media is downloaded; documents/voice/audio are uploaded to a per-bot Letta folder attached to the agent. - Send to agent —
LettaService.sendMessage(agentId, content)callsclient.agents.messages.create(...). Calls to the same agent are serialized in-process to avoid concurrent-message undefined behavior. - Extract reply — the last
assistant_messagefrom the response is extracted. Empty text = silent (no reply). - Parse output —
AgentOutputParserServiceparses markdown for photos/documents. - Send back —
TelegramSenderServicesends photos, documents, and text in order viactx.replyWith*.
- DMs — each user has a numeric ID. Easiest: ask the user to message @userinfobot; it replies with their ID.
- Groups — add the bot to a group, then have any user send a message. The first dropped update logs
chat <ID> not in BOT_*_ALLOWED_CHAT_IDS— that's the ID to add.
- 📡 Long-polling only — no webhook server is exposed.
- 🎙️ No voice transcription in this service — the agent (or its tools) is responsible.
- 🚫 Stickers and videos are not currently relayed.
- 🔁 Per-agent serialization — the Letta server warns against concurrent messages to the same agent, so
LettaServiceserializes them per-agent.
- NestJS 10 — application framework
- Telegraf — Telegram bot framework (
nestjs-telegrafinstalled for future decorator extensions) @letta-ai/letta-client— official Letta SDK- Zod — runtime env validation
- TypeScript · Jest · Docker (multi-arch)
Released under the MIT License. © 2026 Dmytro.
Built with NestJS · Powered by Letta agents