Blog · 2026-07-09

One backend, two front doors: Telegram bot plus MCP

Serve humans through a Telegram webhook and agents through MCP tools from one always-on backend sharing a single durable store.

You do not need two backends to serve humans and agents. One DenkOps project can expose a Telegram webhook route for people and MCP tools for agents, with both doors reading and writing the same durable store. Humans chat with the bot, agents call the tools, and everybody sees the same data.

Why one backend instead of two?

Because the state is the product. Say your team drops notes, leads or expense lines into a Telegram chat all day. If the bot stores them in one service and your agent's tools live in another, you have just invented a sync problem. Put both on one project and there is nothing to sync: the webhook writes to the project's durable /persist disk, the MCP tool reads from it, done.

The always-on slot makes this practical. Telegram expects a fast answer when it POSTs an update, and MCP clients expect the server to be there for the next call. One warm process serves both without cold starts, and the project's URL gives each door a stable address: your webhook route on the project's HTTPS URL, and the MCP endpoint at /mcp, the standard path every DenkOps app gets.

The human door: a Telegram webhook

The bot side is the same grammY setup as our Bun and Hono Telegram bot guide, extended to checkpoint every message to the durable disk:

import { Hono } from "hono";
import { Bot, webhookCallback } from "grammy";

const STATE = "/persist/notes.json";
const bot = new Bot(Bun.env.BOT_TOKEN!);

bot.on("message:text", async (ctx) => {
  const notes: string[] = (await Bun.file(STATE).exists()) ? await Bun.file(STATE).json() : [];
  notes.push(ctx.message.text);
  await Bun.write(STATE, JSON.stringify(notes));
  await ctx.reply(`Saved. ${notes.length} notes on file.`);
});

const app = new Hono();
app.post("/webhook", webhookCallback(bot, "hono"));

export default { port: 3000, fetch: app.fetch };

Register the webhook once with Telegram's setWebhook. Your project URL stays the same across deploys, so you never register it again. The BOT_TOKEN lives in an encrypted environment variable, write-only after you save it.

The agent door: an MCP tool over the same data

The agent side is a small MCP definition in the same project, reading the same file:

import { defineMcp } from "@denkopsai/sdk/mcp";
import { z } from "zod";

const STATE = "/persist/notes.json";

export default defineMcp({
  name: "team-notes",
  version: "1.0.0",
  tools: {
    list_notes: {
      description: "Return the most recent notes captured by the Telegram bot",
      input: z.object({ limit: z.number().int().positive().default(20) }),
      handler: async ({ limit }) => {
        const notes: string[] = (await Bun.file(STATE).exists()) ? await Bun.file(STATE).json() : [];
        return notes.slice(-limit);
      },
    },
  },
});

Any MCP client that supports remote servers can now use it. Ask your assistant "what did the team log today?" and it calls list_notes against /mcp, getting exactly what the bot saved seconds earlier. The pattern is the same one we used for webhooks your agent can query: one side writes, the other side reads, the durable disk is the meeting point.

What does the shared store buy you?

Three things you would otherwise build:

  • No sync layer. There is one copy of the data, on a disk that survives restarts and redeploys.
  • No second deployment. One project, one deploy history, one set of logs covering both doors.
  • A clean upgrade path. Start with a JSON file as shown, or use the SDK's built-in key-value store on the same disk when you want get, set and TTL semantics without managing files.

Both doors also stay behind the platform's defaults: routes require the project's API key unless you make a path public, so the webhook route is the only thing you deliberately open.

If you have not deployed an MCP server before, start with the basic starter, then bolt the bot on. It is one file each way.

FAQ

Can one DenkOps project serve both a Telegram bot and MCP tools?

Yes. A project is an always-on process with a stable HTTPS URL. The Telegram webhook is a normal HTTP route on that URL, and the project's MCP endpoint lives at /mcp. Both run in the same slot and share the same durable /persist disk.

How do the bot and the MCP tools share data?

Through the project's durable store. In the example the webhook appends messages to a file on /persist and the MCP tool reads the same file. The disk survives restarts and redeploys, so neither door ever sees an empty store after a ship.

Does the Telegram webhook need a separate URL after each deploy?

No. Your project keeps the same HTTPS URL across deploys, so you call Telegram's setWebhook once. The always-on slot answers webhook POSTs without cold starts, which keeps replies inside Telegram's expected response window.

Ship it yourself: bunx denkops deploy or say "deploy on DenkOps" from your coding agent.

Start on DenkOps →

← Deploy history and one-click rollback · A scraper your agent can drive →