Create your own
Lesson illustration

Using grammY Sessions for Multi-Turn Claude Interactions

Welcome back. In our previous lesson, you successfully built a bot that connects to the Claude API, turning simple user prompts into intelligent, AI-generated responses. This was a major step, but our bot currently has a significant limitation: it treats every message as a brand new conversation, lacking any memory of what was said before.

Today, we will address this by giving your bot a memory. You will learn to use grammY's session plugin to store and manage conversation history for each user. By combining grammY's state management with the Claude API's conversational structure, you will transform your single-turn bot into a true conversational agent capable of engaging in coherent, multi-turn dialogue.

The Challenge of State in a Stateless World

By design, Telegram bots are stateless. As the grammY documentation explains, a bot only has access to the information within the current incoming update. It cannot look back at previous messages. Similarly, the Claude Messages API is also stateless; it doesn't automatically remember past interactions.

This presents a clear problem: how do we build a continuous conversation? The solution is to manually maintain the state ourselves. We must:

  1. Store the conversation history after each turn.
  2. Provide this entire history back to the API with every new prompt.

This is where grammY's session middleware becomes invaluable. It provides a simple and elegant mechanism for storing data on a per-chat or per-user basis.

Introducing grammY Sessions

The session plugin works like a "middleware sandwich." When a message comes in, it loads the relevant user's data from storage and makes it available on the context object as ctx.session. Your handler can then read from and write to ctx.session. After your handler finishes, the middleware automatically saves the (potentially modified) data back to storage.

To understand the core concepts and see a practical example, let's turn to the official grammY documentation.

Sessions and Storing Data (built-in) - grammY

This documentation explains the "why" and "how" of sessions in grammY. It's the foundational knowledge for adding state to our bot.

Please read the following sections: Start with Why we need storage to understand the stateless nature of bots. Next, read What Are Sessions? to grasp the middleware concept and the role of ctx.session. Then, review the Example Usage section. This is key. Pay close attention to how a SessionData interface is defined, how the context is "flavored" with SessionFlavor, and how the middleware is installed with bot.use(session({ initial })). Finally, read about Initial Session Data. This explains the critical importance of the initial function for new users and the pitfall of sharing object references.

Implementing Sessions for Conversation History

Now, let's apply these concepts to our Claude bot. Our goal is to store the conversation history in the session. The Claude API expects this history as an array of message objects, each with a role (user or assistant) and content.

This gives us the perfect structure for our session data.

1. Define the Session Data Shape

First, let's define the types for our conversation history in src/bot.ts. This ensures TypeScript can help us maintain the correct data structure.

// Add these types near the top of src/bot.ts
interface Message {
  role: "user" | "assistant";
  content: string;
}

interface SessionData {
  messages: Message[];
}

// And flavor the context type
type MyContext = Context & SessionFlavor<SessionData>;

2. Install the Session Middleware

Next, we'll install the session middleware on our bot instance. We must provide an initial function that returns a fresh session object for new users. For our bot, an initial session will simply contain an empty messages array.

// Replace `const bot = new Bot(...)` with this:
const bot = new Bot<MyContext>(process.env.TELEGRAM_BOT_TOKEN!);

// Install the session middleware
bot.use(session({
  initial: (): SessionData => ({ messages: [] }),
}));

With just these few lines, our bot is now equipped to store data for each chat. By default, this uses an in-memory storage, which is perfect for development but means all history will be lost when the bot restarts. For production, you would connect a persistent storage adapter, as hinted at in the documentation.

Linking Session History to the Claude API

The final and most important step is to modify our message handler to use the session.

The logic will now be:

  1. When a user message arrives, add it to the ctx.session.messages array.
  2. Send the entire ctx.session.messages array to the Claude API.
  3. When Claude's response arrives, add that message to the ctx.session.messages array as well.
  4. Send the response to the user.

The Anthropic API documentation clearly illustrates this pattern of sending multiple conversational turns.

Using the Messages API - Claude API Docs - Claude Console

This document from Anthropic explains how to structure multi-turn conversations.

Focus on the section Multiple conversational turns. Note how the messages array contains an alternating sequence of user and assistant roles. This is exactly what we will build and store in our grammY session.

This sequence of messages, stored in our session, acts as the conversational memory.

Visually, you can think of the data we're storing in `ctx.session.messages` as a log like this, which we send to Claude on every new turn.

Updating the Bot Code

Let's update the bot.on("message:text") handler in src/bot.ts to implement this new, stateful logic.

// src/bot.ts

// (Imports and type definitions as before)
import { Bot, Context, session, SessionFlavor, GrammyError, HttpError } from "grammy";
import { claude } from "./services/claude";

// --- Type Definitions ---
interface Message {
  role: "user" | "assistant";
  content: string;
}

interface SessionData {
  messages: Message[];
}

type MyContext = Context & SessionFlavor<SessionData>;

// --- Bot Initialization ---
const bot = new Bot<MyContext>(process.env.TELEGRAM_BOT_TOKEN!);

// Install session middleware
bot.use(session({
  initial: (): SessionData => ({ messages: [] }),
}));

// --- Bot Handlers ---
bot.command("start", (ctx) => {
  ctx.session.messages = []; // Clear history on /start
  return ctx.reply("Hello! I'm an AI assistant. I can remember our conversation. Send /start to begin a new one.");
});

bot.on("message:text", async (ctx) => {
  try {
    await ctx.replyWithChatAction("typing");

    const userPrompt = ctx.message.text;

    // 1. Add the user's message to the session history
    ctx.session.messages.push({ role: "user", content: userPrompt });

    // 2. Call the Claude API with the entire conversation history
    const response = await claude.messages.create({
      model: "claude-3-5-sonnet-20240620",
      max_tokens: 1024,
      // Pass the whole history
      messages: ctx.session.messages,
    });

    const claudeReply = response.content[0].text;

    // 3. Add Claude's response to the session history
    ctx.session.messages.push({ role: "assistant", content: claudeReply });

    // 4. Send the response back to the user
    await ctx.reply(claudeReply);

  } catch (error) {
    console.error("Error calling Claude API:", error);
    await ctx.reply("I'm sorry, but I encountered an error. Please try again.");
  }
});

// --- Error Handling and Startup ---
bot.catch((err) => {
  // ... (existing error handler)
});

bot.start();
console.log("Bot started with session support...");

Notice the key changes:

  • The session is initialized for each user with an empty messages array.
  • We added a /start command that also clears the message history, allowing users to reset the conversation.
  • The message:text handler now reads from and writes to ctx.session.messages, effectively giving the bot memory. Each call to claude.messages.create is now context-aware.

Run bun start and test your bot. Try asking it a question, and then ask a follow-up question that refers to your previous one. For example:

You: What is the capital of France?

Bot: The capital of France is Paris.

You: What is its population?

Bot: The population of Paris is...

The bot can now answer the second question because, thanks to our session history, it knows "its" refers to Paris.

One final consideration: this conversation history cannot grow indefinitely. Language models have a "context window," or a maximum number of tokens they can process. A simple strategy for a production bot would be to trim the history, for example, by removing the oldest messages from the ctx.session.messages array once it reaches a certain length. We will leave this optimization for later to keep today's lesson focused.

Conclusion

In this lesson, you have fundamentally upgraded your bot's capabilities. By integrating grammY's session plugin, you have given it the memory it was missing, enabling it to hold context-aware, multi-turn conversations.

Key Takeaways:

  • Telegram bots and the Claude Messages API are both stateless by design.
  • grammY's session middleware provides a powerful way to store data on a per-chat basis, solving the state problem.
  • Implementing sessions involves three steps: defining the data structure (interface), flavoring the context (SessionFlavor), and installing the middleware (bot.use(session({ initial }))).
  • To maintain a conversation with Claude, you must store the history of user and assistant messages and send the entire log with each new prompt.
  • The ctx.session object is the bridge that allows us to store the Claude conversation history between updates.

Your bot is now significantly more intelligent and useful. However, there's still a noticeable delay while the user waits for Claude to generate a response. In our next lesson, we will dramatically improve the user experience by implementing streaming responses, allowing the bot's reply to appear progressively, word by word, just like you see in interfaces like ChatGPT or Claude's own web app.

Can't find a good explanation? Sign up and we'll make it for you

Sign up