An educational template for Telegram Serverless — Telegram's own platform that runs your bot's backend directly on their infrastructure. You write plain JavaScript modules; the platform runs them in a V8 sandbox next to the Bot API, with a built-in SQLite database. No servers, no containers, no webhook plumbing.
The template is a small but complete todo bot: send it text to add a task, /list to see your tasks as tappable buttons, tap to toggle or delete. Small on purpose — every file exists to demonstrate one part of the platform, and each folder has its own README explaining its role:
- handlers/README.md — how updates enter your bot
- lib/README.md — shared modules and the import rules
- docs/README.md — the full SDK reference lives here
There is no main(), no process, no event loop of yours. Telegram delivers each update (a message, a button tap, a member change) to the file in handlers/ named after the update's type — handlers/message.js for messages, handlers/callback_query.js for button taps. Each file default-exports one async function; when it resolves, the invocation is over.
The webhook is managed for you and derived from your code: the platform builds allowed_updates from which handler files exist. Handling a new update type is creating a file, not configuring anything.
flowchart LR
U([User]) -->|message, button tap, …| TG[Telegram]
TG -->|Update| R{{"platform routes by update type"}}
R -->|update.message| M["handlers/message.js"]
R -->|update.callback_query| C["handlers/callback_query.js"]
M --> DB[("built-in SQLite")]
C --> DB
M -.->|"api.sendMessage(…)"| TG
C -.->|"api.editMessageText(…)"| TG
Your code runs in a V8 isolate with exactly three kinds of imports, all by bare name — never relative paths, never .js extensions:
import { db, api, fetch } from 'sdk'; // the platform: database, Bot API, HTTP
import { todos } from 'schema'; // your schema (root file)
import { addTodo } from 'lib/todos'; // your shared modulesThere are no npm packages at runtime, no filesystem, and no network except fetch from the SDK. This sounds restrictive; it's what makes deploys instant, cold starts negligible, and every module trivially auditable. (The @tgcloud/cli in package.json is a local dev tool — it never ships.)
Handlers are stateless between invocations — a global variable won't survive. Persistent state goes in the SQLite database that ships with every bot, declared in schema.js as tables and queried with a Drizzle-style builder:
await db.insert(todos).values({ chatId, text }).returning().run();
await db.select().from(todos).where(eq(todos.chatId, chatId)).all();Two rules that bite: every DB call must be awaited (a forgotten await returns the query builder, not rows), and there are no foreign keys — .references() throws at deploy time, so integrity is your application code's job (see how lib/todos.js scopes every query by chatId).
npx tgcloud push deploys your modules atomically — and never touches the database. Schema changes are a separate, explicit step:
npx tgcloud push # deploy code; reports pending DB changes, applies none
npx tgcloud migrate # apply schema.js changes to the databaseflowchart LR
subgraph local["your folder (working copy)"]
H["handlers/ + lib/"]
S["schema.js"]
end
subgraph cloud["your bot's cloud environment"]
D["deployed modules"]
P["pending schema changes"]
DB[("database")]
end
H -->|"push (atomic)"| D
S -->|push| P
P -->|"migrate (explicit)"| DB
Destructive changes are deliberately hard: dropping a column or table happens only by marking it .deprecated('reason') in schema.js — deleting the declaration drops nothing. This asymmetry (code moves fast, data moves carefully) is the same discipline production teams enforce by convention; here the platform enforces it.
The deployed bot is the source of truth; your folder is a working copy. push is rejected if someone else deployed since you last synced (like a git push to a moved branch) — status, diff, fetch, and pull reconcile, --force overwrites deliberately. The .tgcloud/ folder is the CLI's private state for all this: gitignored, never edited by hand.
-
Create a bot with @BotFather (or pick an existing one) and enable Serverless for it.
-
Link and deploy:
npm install npx tgcloud login # links this folder to your bot npx tgcloud push # deploy the modules npx tgcloud migrate # create the tables
-
Message your bot: any text adds a todo,
/listshows the list, buttons toggle/delete.
- schema.js — two tables, an index, timestamp defaults, and why there's no foreign key.
- handlers/message.js — command routing, including the
/cmd@botnameform used in groups. - lib/todos.js — the queries, and
renderList(): one function that both handlers use to draw the list, embeddingtoggle:<id>/delete:<id>into the buttons'callback_data. - handlers/callback_query.js — the other side of that contract: answer the callback, mutate, re-render the message in place, and swallow exactly one expected Bot API error.
- lib/users.js — an idempotent upsert (
onConflictDoUpdate).
Then skim docs/tgcloud-sdk.md for the full API surface, and AGENTS.md for the condensed rules (that file is auto-loaded by AI coding tools working in this repo).
npx tgcloud status # what changed locally vs the cloud
npx tgcloud diff # line-by-line diff of changed modules
npx tgcloud push # deploy (rejects if the cloud moved; --force overrides)
npx tgcloud migrate # apply schema changes (--dry-run to preview)
npx tgcloud run handlers/message '{ chat: { id: 1 }, text: "hi" }'
# execute a handler server-side with a fake payload
npx tgcloud add handlers/inline_query # scaffold a new handler
npx tgcloud webhook # inspect webhook state; `webhook sync` repairs driftnpm shortcuts: npm run deploy, npm run status, npm run migrate.
- Bot API calls via
api.<method>()return the unwrapped result and throwBotApiErroron failure (.code,.description,.parameters— e.g.retry_afteron a 429). - File bytes can't be downloaded or uploaded from a handler yet — pass
file_ids around instead. fetchresponses are textual only, capped at 32 MB total.fetchnever throws on HTTP error statuses — a 404 resolves normally withres.ok === false; only real network failures (bad host, invalid URL) reject. Redirects are followed automatically (res.urlis the final URL), and a body can be read once — a second.json()/.text()throws.- A plain insert/update/delete resolves to
[]— there's no insert id or affected-row count. Add.returning()when you need the rows back (as lib/todos.js does). - Raw SQL (
db.run/db.all/db.get) skips column mode conversion: booleans arrive as0/1, timestamps as unix seconds, json as strings. Only the table-bound query builder converts. - Deployed surface is exactly:
schema.js,.jsfiles inlib/andhandlers/. Markdown (including every README here), dotfiles, and.tgcloud/stay local.
See CONTRIBUTING.md. Bugs and ideas go in issues. Security reports are private: see SECURITY.md.
MIT. See LICENSE.