Recommended resources
The freely available resources this lesson will direct you to.
Welcome back. In our previous lesson, you equipped your bot with a memory using the session plugin. This was a critical step, allowing you to store user-specific data across multiple interactions. We are now ready to leverage that stateful foundation to build something much more dynamic: guided, multi-step conversations.
This lesson will focus on implementing form-like data collection flows. Imagine registering a user, booking an appointment, or configuring a service—all of these require asking a sequence of questions and capturing the answers. You'll learn how to achieve this cleanly using grammY's powerful conversations plugin, which allows you to write complex, stateful interactions with the simple, linear logic of async/await. This moves us closer to your goal of building sophisticated "tech UIs" within the Telegram chat interface.
The Challenge of Multi-Step Interactions
With only the session plugin, managing a multi-step flow can become cumbersome. You would typically need to store a "state" or "step" variable in the user's session (e.g., ctx.session.step = 'awaiting_name'). Your main message handler would then become a large switch statement, routing the logic based on the current step.
// The "old" way - gets complicated fast!
bot.on("message:text", async (ctx) => {
switch (ctx.session.currentStep) {
case "awaiting_name":
// process name, then...
ctx.session.currentStep = "awaiting_email";
await ctx.reply("Thanks! Now, what's your email?");
break;
case "awaiting_email":
// process email, then...
ctx.session.currentStep = "done";
await ctx.reply("Registration complete!");
break;
// ... and so on
}
});
This approach is fragile and difficult to read or maintain. As a seasoned developer, you know that state management is a primary source of complexity. The conversations plugin provides a much more elegant solution, transforming this state machine into what looks like a simple synchronous script.
Introducing the Conversations Plugin
The conversations plugin allows you to define a conversation as a single async function. Inside this function, you can await user input, turning the asynchronous back-and-forth of a chat into a linear, readable flow.

To begin, let's explore the core idea behind the plugin.
Conversations (conversations) | grammY
This official grammY documentation introduces the plugin and provides a quickstart example that demonstrates its fundamental usage.
First, read the brief introduction to understand the plugin's purpose. Then, review the quickstart code example to see how a conversation is defined, registered, and entered.
As you saw in the example, the key is the conversation object, which is passed as the first argument to your conversation function. It provides methods like conversation.waitFor() that pause the function's execution until the user sends another message.
Setting Up Your First Conversation
Let's integrate this plugin into our project. The setup involves four main steps.
First, add the dependency using Bun:
bun add @grammyjs/conversations
Next, let's update our types and bot initialization.
-
Update
src/types.ts: We need to addConversationFlavorto our context type. We also will define the conversation function itself in a separate file, so we need a type for it.src/types.tsimport type { Context, SessionFlavor } from "grammy"; import type { Conversation, ConversationFlavor, } from "@grammyjs/conversations"; export interface SessionData { // You can keep your previous session data here. // Conversations will store their own state separately. } // Flavor the context type to include sessions AND conversations. export type MyContext = Context & SessionFlavor<SessionData> & ConversationFlavor; // Define a type for our conversation functions. export type MyConversation = Conversation<MyContext>; -
Create a Conversation File: Let's create a
greetingconversation in a new file. This modular approach keeps your code organized.src/conversations/greeting.tsimport type { MyConversation, MyContext } from "../types"; /** Defines the conversation */ export async function greeting(conversation: MyConversation, ctx: MyContext) { await ctx.reply("Hi there! What is your name?"); // Wait for the user to send a text message. const nameCtx = await conversation.waitFor("message:text"); const name = nameCtx.message.text; await ctx.reply(`Welcome to the chat, ${name}!`); } -
Install Middleware in
src/bot.ts: In your main bot file, you must install theconversationsmiddleware and then register your new conversation function usingcreateConversation.src/bot.tsimport { Bot, session } from "grammy"; import { conversations, createConversation } from "@grammyjs/conversations"; import type { MyContext, SessionData } from "./types"; import { greeting } from "./conversations/greeting"; // Import the conversation // ... (your initialSession function and bot setup) const bot = new Bot<MyContext>(Bun.env.BOT_TOKEN || ""); // Install session middleware bot.use(session({ initial: () => ({}) })); // Install conversations middleware bot.use(conversations()); // Register the conversation. The first argument is the function, the second is its unique identifier. bot.use(createConversation(greeting, "greeting")); // ... (your other middleware and handlers) -
Create a Command to Start the Conversation: Finally, create a command handler that uses
ctx.conversation.enter()to begin the flow. This can be in your commands module or directly inbot.ts.src/bot.ts(continued)// ... bot.command("start_greeting", async (ctx) => { // Enter the "greeting" conversation. await ctx.conversation.enter("greeting"); }); bot.start(); console.log("Bot with conversations started!");
Now run your bot and send it the /start_greeting command. You'll see how the bot asks a question and waits for your response before continuing. This linear control flow is made possible by the plugin's internal "replay engine," which re-runs your function on each new update, cleverly skipping over parts it has already executed. This is an implementation detail you rarely need to worry about, unless you are performing side-effects like database calls, which must be wrapped in conversation.external().
Building a Form with conversation.form
While conversation.waitFor() is powerful, the plugin provides an even more convenient API for the specific task of building forms: conversation.form. This utility combines waiting for input with validation and data extraction, simplifying your code significantly.
Let's build a practical example: a bot to create a meeting invitation.
Conversations (conversations) | grammY
This section of the guide introduces the high-level conversation.form utility.
Read the section Conversational Forms. Pay close attention to the example showing how .photo() and .int() are used to request specific types of data. Also note the otherwise option, which is key for providing user feedback on invalid input.
Let's create a new conversation, createMeeting, using conversation.form.
src/conversations/meeting.ts
import type { MyConversation, MyContext } from "../types";
export async function createMeeting(conversation: MyConversation, ctx: MyContext) {
// Ask for the meeting title
await ctx.reply("What is the title of the meeting?");
const title = await conversation.form.text();
// Ask for the number of participants, with validation
await ctx.reply("How many participants will there be?");
const participants = await conversation.form.int({
otherwise: (ctx) => ctx.reply("Please enter a valid number!"),
});
// Ask for the date
await ctx.reply("On what date will the meeting take place? (e.g., YYYY-MM-DD)");
const date = await conversation.form.text({
// A more advanced use case could involve regex validation here
otherwise: (ctx) => ctx.reply("Please enter a valid date format."),
});
// Summarize and finish
await ctx.reply(
`Great! Meeting "${title}" with ${participants} participants is scheduled for ${date}.`
);
}
Now, register and trigger this new conversation in src/bot.ts just as you did for greeting.
src/bot.ts (updated)
// ... imports
import { createMeeting } from "./conversations/meeting"; // Import the new conversation
// ...
bot.use(createConversation(greeting, "greeting"));
bot.use(createConversation(createMeeting, "create-meeting")); // Register it
// ...
bot.command("start_greeting", async (ctx) => {
await ctx.conversation.enter("greeting");
});
bot.command("create_meeting", async (ctx) => {
await ctx.conversation.enter("create-meeting");
});
// ...
When you run this and send /create_meeting, the bot will guide you through the form. If you try to enter text when it expects a number for participants, the otherwise function will be triggered, re-prompting you for the correct input. conversation.form has helpers for various data types, such as .text(), .int(), .number(), and .url(), which you can explore in the API reference.
Conclusion
You have now mastered a fundamental pattern for creating interactive Telegram bots. By moving beyond simple command-and-response, you can now build guided experiences that collect structured data from users in a natural, conversational way. This skill is the cornerstone for building bots that handle registrations, surveys, support tickets, and more.
Key Takeaways:
- The
conversationsplugin solves the problem of managing multi-step flows by allowing you to write them in a linearasync/awaitstyle. - A conversation is an
asyncfunction that uses aconversationobject toawaituser input. - Setup involves installing the
conversationsmiddleware and registering each conversation function withcreateConversation. - Conversations are initiated from a regular handler via
ctx.conversation.enter("your-conversation-id"). - The
conversation.formobject provides high-level helpers like.text()and.int()to simplify data collection and validation. - The
otherwiseoption in form helpers is used to provide feedback to the user when their input is invalid.
In our next lesson, we'll make our conversations even more robust. We will explore how to add more sophisticated validation and sanitization for user input, handle branching logic within a conversation, and allow users to cancel a flow midway.