Skip to content

nvxdm/letta-telegram

Repository files navigation

🤖 letta-telegram

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.


NestJS TypeScript Telegraf Letta Docker License: MIT


✨ Features

  • 🔗 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 imageslinux/amd64 + linux/arm64, published automatically on release.

📚 Table of Contents

🚀 Quick Start

Local

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:dev

Docker (prebuilt image)

The 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:latest

With 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: 20s
docker 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.

Build from source

If you've cloned the repo, the bundled docker-compose.yml builds the image locally instead of pulling it:

docker compose up --build -d

⚙️ Configuration

All configuration is done through environment variables. Copy .env.example to .env and fill in the blanks.

Letta server

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

Per-bot block

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.

Misc

Var Default Notes
LOG_LEVEL info One of error, warn, info/log, debug, verbose
NODE_ENV production recommended in Docker

🖼️ Media Support

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 ![alt](url) Sent as a photo
Letta → TG [label](url) (with file extension) Sent as a document
Letta → TG Remaining text Sent as a message

🏗️ Architecture

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.

🔄 How a Message Flows

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)
Loading
  1. Telegram → middlewarebot.use(...) fires for the configured bot.
  2. Access controlAccessControlService checks the chat/user allow-list. Non-allowed updates are dropped silently.
  3. Payload buildTgPayloadBuilderService converts the update into Letta content blocks. Media is downloaded; documents/voice/audio are uploaded to a per-bot Letta folder attached to the agent.
  4. Send to agentLettaService.sendMessage(agentId, content) calls client.agents.messages.create(...). Calls to the same agent are serialized in-process to avoid concurrent-message undefined behavior.
  5. Extract reply — the last assistant_message from the response is extracted. Empty text = silent (no reply).
  6. Parse outputAgentOutputParserService parses markdown for photos/documents.
  7. Send backTelegramSenderService sends photos, documents, and text in order via ctx.replyWith*.

🔎 Finding Chat IDs

  • 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.

📝 Notes & Limitations

  • 📡 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 LettaService serializes them per-agent.

🧰 Tech Stack

📄 License

Released under the MIT License. © 2026 Dmytro.


Built with NestJS · Powered by Letta agents

About

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.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors