telegram-bot-sessions Skill
How to persist user state in a Telegram bot — why it's needed, how grammY solves it, how the toolkit wraps it.
Built for the agntdev pipeline. See agnt-cli-builder for the discovery-and-claim loop. This skill teaches the MemorySessionStorage / SQLite adapter patterns you wire into your claimed task's implementation.
1. Why Sessions Are Needed (Bot API)
Telegram Bot API is stateless. Every Update arrives as a fresh HTTP request. The bot has no built-in memory of what a user did before.
User: "I want to book"
Bot: "What service?"
User: "Haircut"
Bot: "When?"
User: "Tomorrow 2pm"
Bot: "Booked!"
Without state, the bot can't know that "Haircut" answers the "What service?" question vs being a random message. It has no memory of the conversation step.
How Bot API handles it — there is no built-in mechanism
Bot API just delivers Updates. It's up to the bot to:
- Remember who is at what step
- Store partial data between messages
- Clear state when a flow completes
Option: Database. Write user state to SQLite/Redis on every message. Works but adds latency + complexity.
Option: In-memory Map. Fast but lost on restart. Fine for dev, bad for production.
Option: grammY sessions. The framework abstracts this away.
2. grammY Sessions — The Framework Solution
grammY provides a session() plugin that stores per-chat state and makes it available as ctx.session.
Basic setup
import { Bot, session } from "grammy";
interface SessionData {
step: string;
service?: string;
}
const bot = new Bot(token);
bot.use(session({
initial: (): SessionData => ({ step: "idle" }),
// storage: ... // defaults to in-memory Map
}));
bot.command("book", async (ctx) => {
ctx.session.step = "choosing_service";
await ctx.reply("What service?");
});
bot.on("message:text", async (ctx) => {
if (ctx.session.step === "choosing_service") {
ctx.session.service = ctx.message.text;
ctx.session.step = "choosing_time";
await ctx.reply("What time?");
}
});
Session key
grammY keys sessions by chatId_userId ("12345_67890"). This means:
- Private chats: one session per user (chat and user are the same person)
- Group chats: one session shared by all users in the chat
- Session is per-chat, not per-user globally
Session flavor — typing ctx.session
import { session, type SessionFlavor } from "grammy";
type MyContext = Context & SessionFlavor<SessionData>;
const bot = new Bot<MyContext>(token);
bot.use(session({ initial: () => ({ step: "idle" }) }));
// ctx.session is now typed:
bot.command("book", async (ctx) => {
ctx.session.step = "choosing"; // TypeScript knows this field
});
StorageAdapter interface
grammY sessions work with any storage backend that implements:
interface StorageAdapter<T> {
read(key: string): T | undefined;
write(key: string, value: T): void;
delete(key: string): void;
has(key: string): boolean;
readAllKeys(): string[];
}
Built-in options:
- Default — in-memory
Map(fast, lost on restart, fine for dev) - SQLite — durable, survives restarts (add
@grammyjs/storage-sqlite) - Redis — fast + durable (add
@grammyjs/storage-redis) - Firebase, MongoDB, Supabase — community adapters available
3. The toolkit (src/toolkit/) — Session Defaults
MemorySessionStorage
The inlined toolkit (at src/toolkit/ in the bot-starter template)
ships MemorySessionStorage — a grammY-compatible StorageAdapter
backed by Map.
import { MemorySessionStorage } from "../src/toolkit/storage/memory.js";
// Implements StorageAdapter:
const store = new MemorySessionStorage<SessionData>();
store.write("123_456", { step: "idle" });
store.read("123_456"); // { step: "idle" }
store.has("123_456"); // true
store.delete("123_456");
store.readAllKeys(); // []
You rarely instantiate it directly. createBot() uses it by default:
const bot = createBot<Session>(token, {
initial: () => ({ step: "idle" }),
// storage omitted → MemorySessionStorage used automatically
});
Session shape design
Keep sessions flat and serializable — no functions, no class instances, no circular refs:
interface Session {
// Dialog state
step: string;
// Flow data (optional until set)
serviceId?: string;
slotDate?: string;
slotTime?: string;
// Simple counters
bookingsCount?: number;
}
Every session starts from initial():
initial: () => ({
step: "idle",
bookingsCount: 0,
})
Harness isolation
The test harness creates a fresh bot per spec via makeBot(). Each bot gets its own MemorySessionStorage:
Spec 1 ("booking flow"):
makeBot() → fresh MemorySessionStorage → session starts from initial()
Spec 2 ("cancel flow"):
makeBot() → another fresh MemorySessionStorage → session starts from initial()
- No session leaks between specs
- No cleanup needed between runs
- Each spec sees exactly the state
initial()defines
SQLite in production
For production bots, swap to SQLite (same StorageAdapter interface, same bot code):
import { SqliteSessionStorage } from "../src/toolkit/storage/sqlite.js"; // planned
const bot = createBot<Session>(token, {
initial: () => ({ step: "idle" }),
storage: new SqliteSessionStorage("./data/sessions.db"),
});
Until the toolkit ships SQLite adapter, use grammY's @grammyjs/storage-sqlite directly — same interface.
Migration
Adding fields to session:
// V1
interface Session { step: string; }
// V2 — add optional field (safe, no migration needed)
interface Session {
step: string;
theme?: "light" | "dark"; // optional — defaults to undefined
}
// Access with default:
const theme = ctx.session.theme ?? "light";
- MemoryStorage: restarts wipe everything — no migration needed
- SQLite: optional fields safe to add. Required new fields need migration step that fills defaults for existing rows
Quick Reference
| What | grammY | Toolkit |
|---|---|---|
| Activate sessions | bot.use(session({...})) |
Auto via createBot() |
| Type ctx.session | Context & SessionFlavor<S> |
BotContext<S> (pre-built) |
| Storage (dev) | In-memory Map (default) | MemorySessionStorage (default) |
| Storage (prod) | @grammyjs/storage-sqlite |
SqliteSessionStorage (planned) |
| Session key | chatId_userId |
Same |
| Harness isolation | Manual setup | Automatic — fresh per spec |
Common mistakes
- Storing non-serializable data — no functions, no class instances. Plain objects only.
- Not initializing fields in
initial()— missing fields areundefined, not defaults. - Relying on session across restarts (MemoryStorage) — ephemeral. Design flows restart-safe.
- Session is per-chat, not per-user — same user in different chats = different sessions.
- Session key =
chatId_userIdstring — don't confuse withchat.idalone.