A Fork-and-Go Telegram Bot
An open-source starter that drops a tool-using LangGraph agent into Telegram — Fastify for lifecycle, Postgres for memory, MCP for reach. Clone it, point it at a token, and you have a real assistant.

I keep coming back to the same idea: a chat interface is the lowest-friction way to talk to an LLM agent. You already have Telegram on your phone. You already know how to send a message. So why build a whole web app when you could just… text your bot?
This is that bot, cleaned up into something you can actually fork. It’s a Telegram bot backed by a LangGraph agent that can call tools, remember your conversation, and run on whatever model you point it at — Gemini out of the box, or a local model on your own machine if you’d rather not send anything to the cloud.
I want to be clear about what this is: it’s a starting point, not a product. It’s the boring, load-bearing 80% — the message plumbing, the agent wiring, the memory layer, the tool loading — so you can spend your time on the 20% that’s actually yours. Fork it, swap the prompt, plug in your tools, ship your own thing.
Repo: github.com/CuriouslyCory/fastify-telegram-bot (ISC licensed — do what you want with it).
What you get out of the box
None of this is glamorous, and that’s the point — it’s the wiring you’d otherwise rebuild from scratch every time.
LangGraph ReAct agent
A prebuilt reason-and-act loop that decides when to answer directly and when to reach for a tool.
Swappable models
Gemini by default, but the model is one line to change — including a fully local Ollama setup.
MCP + local tools
Register in-process TypeScript tools and connect any MCP server, side by side, through one interface.
Persistent memory
A Postgres checkpointer keeps conversation state per thread, so context survives restarts.
Resilient delivery
Telegraf handles long-polling, with automatic message chunking and retry on flaky sends.
How it works
Fastify is here as the process host, not as a web server for Telegram. There are no webhook endpoints. The Telegram path does not go through HTTP routes at all. Fastify handles app lifecycle — plugins, startup, graceful shutdown — and gives me a clean onReady hook to launch the bot. The bot itself talks to Telegram over long-polling.
Here’s the path a message takes:
- 01
Telegram → long-poll
Telegraf pulls updates via long-polling. No webhook, no public URL to expose.
- 02
TelegramService
A singleton service receives the update. Fastify is only the lifecycle host here — it isn’t in the message path.
- 03
handleTelegramMessage
The handler wraps your text with injected context and resolves the per-thread state before invoking the agent.
- 04
LangGraph ReAct agent
createReactAgent runs the reason/act loop with its tools and a Postgres checkpointer for memory.
- 05
Chunked, retried reply
The response is HTML-formatted, split to fit Telegram’s 4096-char limit, and sent with retry on transient failures.
One small nicety along the way: the bot replies “Processing your request…” the instant a message lands so you know it’s alive — and if the agent’s entire reply is just "okay", the bot stays quiet instead of spamming you. Documents get politely rejected for now: send a PDF and you’ll get “Documents not supported at this time.”
The agent core
The heart of the project is one call to LangGraph’s createReactAgent. This is a genuine agent runtime — it manages the reason-act-observe loop, tool invocation, and state — so I’m not hand-rolling any of that.
// src/agents/telegram-agent.ts (trimmed)
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { SystemMessage } from "@langchain/core/messages";
import { MultiServerMCPClient } from "@langchain/mcp-adapters";
// Load MCP tools from config at startup
const client = MultiServerMCPClient.fromConfigFile("./src/constants/mcp.json");
await client.initializeConnections();
const mcpTools = client.getTools();
const agent = createReactAgent({
llm: models.geminiToolsModel, // gemini-2.0-flash-exp, temperature 0
tools: [...mathTools, stringLengthTool, ...mcpTools],
stateModifier: new SystemMessage(systemPrompt),
checkpointSaver: pgCheckpointSaver, // Postgres-backed memory
});The model defaults to geminiToolsModel — ChatGoogleGenerativeAI running gemini-2.0-flash-exp at temperature 0. Swapping it means pointing at a different entry in the models map (more on that in “Make it yours”).
The system prompt comes from a JSON file, so you can rewrite your bot’s personality without touching code.
The context injection is the part I’m quietest-proud of. Before each message reaches the agent, I wrap it so the model always knows when it’s operating and who it’s talking to:
<Context>
- The current date and time is July 23, 2026, 2:02:11 PM
- Chat ID: 123456789
- User ID: 987654321
</Context>
<UserMessage>what's the weather in Denver?</UserMessage>That framing keeps the model from hallucinating the date and gives tools the IDs they need to act.
Tools: local + MCP
There are two ways to give the agent capabilities, and the split matters.
Local tools live in src/tools/ and are just TypeScript functions with a Zod schema. Here’s the addition tool, in full — this is the whole pattern:
// src/tools/math.ts
import { tool } from "@langchain/core/tools";
import { z } from "zod";
export const additionTool = tool(
async ({ numbers }) => numbers.reduce((a, b) => a + b, 0),
{
name: "addition_tool",
description: "Add two or more numbers",
schema: z.object({
numbers: z
.array(z.number())
.describe("Array of numbers to add together"),
}),
}
);Write the function, describe it well (the description is what the model reads to decide when to call it), export it, add it to the tools array. That’s a new capability.
MCP tools come from external Model Context Protocol servers, loaded at startup from a config file. No code required — just an entry:
// src/constants/mcp.json
{
"mcpServers": {
"weather": {
"command": "npx",
"args": ["-y", "@smithery/cli@latest", "run", "@turkyden/weather", "--config", "{}"]
},
"duckduckgo-mcp-server": {
"command": "npx",
"args": [
"-y", "@smithery/cli@latest", "run",
"@nickclyde/duckduckgo-mcp-server",
"--key", "${SMITHERY_API_KEY}"
]
}
}
}At boot, MultiServerMCPClient.fromConfigFile(...) reads this file, spins up each server, and merges their tools into the agent alongside the local ones. Adding a search engine, a database, a calendar — it’s a config entry and a restart. That’s the whole point of the two-tier design: trivial stuff is a local function, big integrations are somebody else’s MCP server.
Memory
Conversation memory is handled by LangGraph’s Postgres checkpointer, not by an ORM and not by anything I hand-wrote.
// src/agents/checkpointer.ts (trimmed)
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const checkpointer = new PostgresSaver(pool);
await checkpointer.setup(); // creates its own tables on first runHere’s the model to hold in your head: LangGraph persists the full agent state — message history, intermediate steps — against a thread_id. Every time the agent runs with the same thread_id, it resumes that thread’s state from Postgres. Different thread, clean slate. Because it’s backed by Postgres and not process memory, restarting the bot doesn’t wipe anything.
(There’s a wrinkle in exactly how the thread_id is composed — I’ll be straight about it in the rough-edges section below.)
Set up your own
Roughly ten minutes, most of it spent collecting API keys.
1. Create your Telegram bot. Message @BotFather, send /newbot, follow the prompts, and copy the token it hands back.
2. Clone and install.
git clone https://github.com/CuriouslyCory/fastify-telegram-bot.git
cd fastify-telegram-bot
pnpm install3. Fill in your environment.
cp .env.example .envYou need four things:
TELEGRAM_BOT_TOKEN— from BotFather above.GEMINI_API_KEY— free from Google AI Studio.DATABASE_URL— any Postgres. Neon or Supabase free tiers work great.PORT— anything free, e.g.3000.
Optional: LangSmith tracing vars, and a SMITHERY_API_KEY from smithery.ai if you want the search / sequential-thinking MCP servers.
Heads up on the Smithery key. There’s a known bug:
${SMITHERY_API_KEY}doesn’t currently get injected into the MCP server config from your.env. Until that’s fixed, paste the key directly intomcp.jsonwhere the--keyargument is. If your search tool silently isn’t working, this is why.
4. Set up the database.
pnpm db:generate
pnpm db:pushThe LangGraph checkpointer also creates its own tables on first run via setup(), so you don’t need to do anything extra for chat memory.
5. Run it.
pnpm devThat runs tsx watch src/index.ts. Open Telegram, message your bot, and you should get a reply. Other scripts worth knowing: pnpm start (production), pnpm db:studio (browse the DB), pnpm test (vitest), pnpm lint, pnpm format.
Make it yours
This is where forking pays off. Three levers, from easiest to most involved.
Change its personality. Open src/constants/agent-prompts.json and edit telegram.system_prompt — it’s a string array joined with newlines. The default is a plain, helpful assistant capped at 4000 characters. Rewrite it into a terse ops bot, a cheerful concierge, whatever fits.
// src/constants/agent-prompts.json
{
"telegram": {
"system_prompt": [
"You are a friendly weather assistant.",
"Always use the weather tool when asked about conditions.",
"Format temperature in both Celsius and Fahrenheit.",
"Limit your message responses to 4000 characters."
]
}
}Add capabilities. New MCP server? Add an entry to mcp.json and restart. Something bespoke? Write a local tool() in src/tools/ (see the addition example above) and add it to the agent’s tools array.
Swap the model. The models object in src/utils/ai-models.ts already defines a Gemini reasoning model and two local Ollama models (deepseek-r1:14b, openthinker) alongside the default. Point the agent at a different one to change engines. If you want to run fully local — no API keys, nothing leaving your machine — switch to an Ollama model and pull it locally. Same agent, same tools, private inference.
Fork it and go
That’s the whole thing: Fastify hosting the process, Telegraf handling the chat, a LangGraph ReAct agent doing the thinking, Postgres remembering, and a tool layer you can extend without touching the core.
I built it to be forked. If you’ve been meaning to make a little agent you can text — a research assistant, a home-automation controller, a bot that watches something and pings you — this gives you every unglamorous part already wired up. Clone it, swap the prompt, plug in your tools, and make it yours.
The whole thing is open source. Clone it, drop in a bot token and a Postgres URL, and you’re talking to your own agent in a few minutes. PRs welcome — especially on those rough edges.
View on GitHub