Telegram Serverless
Telegram Serverless
Telegram Serverless lets you run backend code for your bot and Mini App directly on Telegram's infrastructure - no servers to provision, no containers to keep alive, no scaling to think about. You write plain JavaScript modules, deploy them with a single command, and Telegram runs them in a fast, isolated V8 sandbox that sits right next to the Bot API and a built‑in database. If you have ever wired a bot to a VPS, a cloud function, or a hosting panel just to answer a /start, this is the part you no longer have to do.
How It Works
A Telegram bot is, at heart, a program that reacts to updates. Traditionally you had to host that program somewhere that is always on, reachable, and secure - and then keep it that way. Telegram Serverless removes that layer entirely.
You work in three places, and they map cleanly onto each other:
| Where | What lives there |
|---|---|
| Your project folder | JavaScript modules - schema, shared code, update handlers |
| The cloud | The deployed copy of those modules, plus your bot's database |
The tgcloud CLI |
The bridge - it shows you differences and syncs them |
You never SSH into anything. You edit files locally, run npx tgcloud push, and the platform takes it from there. Your bot's traffic is handled by the deployed modules; your database persists between invocations.
Project Structure
A project has just three kinds of code:
handlers/ # entry points - one file per Telegram update type
lib/ # shared code you import from anywhere
schema.js # your database tables
When an update arrives - a message, a button press, an inline query - Telegram routes it to the matching handler (handlers/message.js, handlers/callback_query.js, …) and calls its default export. That function talks to the Bot API and the database through the SDK, and returns. That is the whole loop. An update with no matching handler is simply ignored, so you add only the handlers you need.
Complete Demo Bot
Here is a complete, working demo bot. It replies to every message and remembers how many it has seen from each chat.
schema.js
import { table, integer } from 'sdk/db';
export const counters = table('counters', {
chatId: integer('chat_id').primaryKey(),
seen: integer('seen').notNull().default(0),
});
handlers/message.js
import { api, db } from 'sdk';
import { counters } from 'schema';
import { sql } from 'sdk/db';
export default async function (message) {
const chatId = message.chat.id;
// Insert the counter, or bump it if this chat already has one - and get the
// resulting row back in the same statement via .returning().
const [row] = await db.insert(counters)
.values({ chatId, seen: 1 })
.onConflictDoUpdate({
target: counters.chatId,
set: { seen: sql`${counters.seen} + 1` },
})
.returning()
.run();
await api.sendMessage({
chat_id: chatId,
text: `Hello! I've seen ${row.seen} message(s) from you.`,
});
}
Deploy it:
npx tgcloud push # upload the modules
npx tgcloud migrate # create the `counters` table
That's a live bot with persistent state and no server. Everything in it - api, db, the table() DSL - is described in the sections below.
Getting Started
Serverless is a general backend for Telegram bots and Mini Apps, not a template for one kind of app. This walkthrough takes you from an empty folder to a live bot that answers messages and stores data. It assumes you have Node.js 18 or newer installed and a bot registered with @BotFather. By the end you will have used every command you need day to day: push, migrate, run, and status.
Enable Serverless
Before anything else, switch Serverless on. In @BotFather, open your bot → Serverless and turn it on. That turns the feature on for this bot and unlocks its CLI access token, handlers, library, and database.
Create a Project
The fastest way to start is the project creator, which scaffolds a project and installs the CLI into it:
npm create @tgcloud/bot example_bot
cd example_bot
The argument is the target folder: pass . to scaffold into the current folder, or any path. It works in an existing folder too and never overwrites files you already have.
This gives you a ready‑to‑edit project:
example_bot/
├─ docs/
│ └─ tgcloud-sdk.md # SDK reference (for you and your AI tools)
├─ handlers/
│ └─ message.js # a starter message handler (echoes text back)
├─ lib/ # your shared modules go here (empty to start)
├─ AGENTS.md # orientation for AI coding assistants
├─ package.json
└─ schema.js # your database tables
The scaffolded files are self‑documenting - each one contains commented examples of what you can do next.
Install the CLI
The CLI installs into the project as a local dev‑dependency, so you run it with npx tgcloud (npx finds the copy in your project's node_modules), or through the npm run shortcuts the scaffold adds to package.json (npm run deploy, npm run status). By default there is no global tgcloud on your PATH.
You can also install it globally - npm install -g @tgcloud/cli - if you'd rather type a bare tgcloud from anywhere. That's handy for running tgcloud init in any empty folder, and it's what shell tab‑completion needs. Either way you get the same project.
Connect to Your Bot
Every project is tied to one bot. Connect them with login, which asks for your CLI access token (@BotFather → your bot → Serverless → CLI Access → Access token - a separate token from your bot's API token) and stores it locally:
npx tgcloud login
The token has the form app:<secret>. The CLI keeps it in .tgcloud/, which is git‑ignored, and never prints the secret part. Login is the only time you are asked for it.
Check Status
Two commands tell you where things stand at any moment, both fully offline:
npx tgcloud status # what has changed locally vs. the deployed copy
npx tgcloud diff # the line‑by‑line changes
Right after init everything is new and nothing is deployed yet. status shows the starter files waiting to go up.
Deploy
Send your modules to the cloud:
npx tgcloud push
push uploads every changed module in one atomic batch and updates your local record of what the cloud now holds. Your bot is live: open it in Telegram and send it a message - the starter handler echoes it back.
Deploying never touches your database. Pushing code and changing your database schema are deliberately separate steps, so a code deploy can never surprise you with a data migration.
Add a Database Table
Let's make the bot remember something. Open schema.js and declare a table:
import { table, integer, text, sql } from 'sdk/db';
export const messages = table('messages', {
id: integer('id').primaryKey({ autoIncrement: true }),
chatId: integer('chat_id').notNull(),
text: text('text'),
created: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
Deploy the schema, then apply it to the database:
npx tgcloud push # uploads the new schema.js
npx tgcloud migrate # creates the `messages` table
push reports that the schema is out of sync and shows you the pending change, but applies nothing. migrate walks you through the change and, on your confirmation, creates the table.
Use the Database
Now use the table from your handler. Edit handlers/message.js:
import { api, db } from 'sdk';
import { messages } from 'schema';
import { eq } from 'sdk/db';
export default async function (message) {
// Save this message.
await db.insert(messages)
.values({ chatId: message.chat.id, text: message.text })
.run();
// Count how many we've stored for this chat.
const count = await db.$count(messages, eq(messages.chatId, message.chat.id));
await api.sendMessage({
chat_id: message.chat.id,
text: `Saved. That's ${count} message(s) from this chat so far.`,
});
}
Deploy the updated handler with npx tgcloud push, then send your bot a few messages and watch the count climb. The database persists between invocations - that's your bot's memory.
Testing Locally
You don't have to deploy to try a change. npx tgcloud run executes a handler on the platform using your local files, without publishing them:
npx tgcloud run handlers/message '{ chat: { id: 1 }, text: "hello" }'
The argument is the payload your handler receives - for handlers/message, a Message - written in JSON5 (so you can skip quoting keys). The command prints anything the handler logged with console.*, the return value, and how long it took. This is the tightest loop for iterating on logic - no deploy, no waiting for a real message.
Staying in Sync
As you work, a handful of commands keep your local project and the cloud aligned:
npx tgcloud status- shows what changednpx tgcloud push- deploysnpx tgcloud pull- brings your local project in line with the cloudnpx tgcloud fetch- refreshes the reference copy without touching your filesnpx tgcloud reset- discards local changes
If two people (or two machines) deploy to the same bot, the platform detects the conflict and push stops to let you pull first - you can never silently overwrite someone else's work.
AI-Assisted Development
Prefer to build with an AI assistant - or is the only coder on your team an AI? You can still ship a bot. We've taken a first step to make an AI agent feel at home in a project: every new one is scaffolded with an AGENTS.md and a docs/tgcloud-sdk.md reference that agentic coding tools read automatically. Together with a small, self‑contained runtime - one SDK, no npm packages to wrangle - that gives the assistant a running start on the conventions generic codegen tends to miss here: import by bare name, no foreign keys, every db call is async, one handler per update type, and the two‑step push/migrate flow.
Try it:
npm create @tgcloud/bot my-bot
cd my-bot
opencode # or Claude Code, Cursor, … - any agent that reads AGENTS.md
Then just ask, in plain language: Write a bot that remembers each person's to‑do list - add an item when they send text, and show the whole list when they send /list.
The assistant edits schema.js and your handlers for you; you review, test a change instantly with npx tgcloud run, then go live with npx tgcloud push and npx tgcloud migrate. AGENTS.md is part of your project - edit it as the bot grows so the guidance stays accurate.
Mobile Development
Down to just your phone? The whole project lives in @BotFather too - open your bot → Serverless and you get everything the CLI manages, on a touchscreen: lib/ modules, schema.js in the same Drizzle‑like syntax, review pending changes, and apply them; Save deploys.
It's one and the same cloud project, so you can start a handler on your phone and npx tgcloud pull it to your laptop later - nothing is tied to a single client. Running a handler even shows its Console output right in the chat, just like npx tgcloud run.
Project Anatomy
A serverless project is an ordinary folder under version control. It holds nothing but JavaScript modules and a little local state - there is no build step, no node_modules at runtime, and no server entry point.
example_bot/
├─ handlers/ # update handlers - flat, one level only
│ ├─ message.js
│ └─ callback_query.js
├─ lib/ # shared modules; subdirectories allowed
│ ├─ reply.js
│ └─ internal/util.js
├─ schema.js # database schema - one file, at the root
└─ .tgcloud/ # CLI state - credentials, snapshot, cache (git‑ignored)
Only schema.js and .js files under lib/ and handlers/ are deployed. Everything else - Markdown, config files, the .tgcloud/ folder - stays on your machine.
schema.js
Your database. It declares tables as named exports using the schema DSL and lives at the project root as a single file. It is deployed like any other module, but deploying it never changes the database - schema changes are applied separately with npx tgcloud migrate.
lib/
Shared code, anything you want to reuse across handlers: pure helpers, database access layers, formatting, integrations with outside services. lib/ is the only directory that may contain subdirectories (lib/internal/util.js, lib/payments/stripe.js), so you can organize a larger codebase however you like. Modules in lib/ are never invoked directly by the platform; they exist to be imported by handlers and by each other.
handlers/
The entry points of your bot. Each file corresponds to one Telegram update type, and the platform routes each incoming update to the matching handler:
| File | Handles |
|---|---|
handlers/message.js |
New incoming messages |
handlers/inline_query.js |
New incoming inline queries |
handlers/callback_query.js |
New incoming callback queries |
| … | any other Bot API update type |
handlers/ is flat - no subdirectories. A handler's export default is the function the platform calls. An update type is handled only if its handler file exists and is non‑empty. If there is no handlers/<type>.js - or the file is empty - updates of that type are ignored, and the platform runs nothing for them. So keep only the handlers you actually need: each one you add is another update type your bot wakes up to process, and leaving out the rest means Telegram doesn't spin up your code for updates you'd only discard anyway.
To scaffold a new handler, run npx tgcloud add handlers/<type>.
.tgcloud/
Machine‑local state managed entirely by the CLI: your saved credentials, a mirror of the deployed code used for offline diffs, and a small cache. It is git‑ignored, and you should never read from or write to it by hand - use the CLI commands instead.
Module Resolution
At runtime, a module can see exactly two things: the platform SDK and the other modules in your project. There are no npm packages, no filesystem, and no network except through the SDK's fetch. Modules are addressed by their name - the path from the project root, without the .js extension - not by their location on disk. Always import by that bare name:
import { users } from 'schema'; // the schema module
import { addItem } from 'lib/cart'; // a lib module
import { format } from 'lib/internal/fmt'; // nested lib module
import { db, api, fetch } from 'sdk'; // the platform SDK
Relative paths and file extensions do not work - the platform resolves names in its module space, not files in a directory.
Comments
No comments yet. Start the discussion.