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.