Recommended resources
The freely available resources this lesson will direct you to.
Hello again! In our last lesson, you successfully gave your bot a memory using grammY's session plugin, enabling it to hold coherent, multi-turn conversations. While the bot is now much more intelligent, you've likely noticed a delay between sending a prompt and receiving the full response. For longer answers, this waiting period can make the bot feel slow and unresponsive.
Today, we will directly address this by implementing one of the most impactful user experience enhancements for AI bots: streaming. You will learn how to modify your bot to receive Claude's response as a stream of text chunks and progressively display it to the user in real-time. This technique makes the bot feel significantly faster and more interactive, mimicking the experience of modern AI chat interfaces.
The Power of Streaming
Currently, our bot works like this:
- User sends a message.
- We send the entire conversation history to the Claude API.
- We wait for the Claude API to generate the entire response.
- We receive the full block of text and send it as a single Telegram message.
The bottleneck is step 3. Instead of waiting for the whole response, we can ask the API to send us the answer piece by piece as it's being generated. This is called streaming.
The Anthropic TypeScript SDK provides excellent support for this. Let's start by looking at the official documentation to understand the mechanism.
TypeScript SDK - Claude API Docs
This documentation shows how to enable and handle streaming responses from the Claude API.
Please review the following sections: First, look at Streaming responses. Notice the key change: adding stream: true to the messages.create call. This changes the return value from a single Message object to an async iterable stream. Next, read the section on Streaming helpers. This introduces the client.messages.stream(...) method, which is a more convenient way to work with streams, offering event handlers like .on("text", ...). We will use this helper method. Finally, quickly read the section on Long requests. It explains why streaming is recommended: it avoids connection timeouts on requests that take a long time to process.
As the documentation shows, getting a stream from the API is straightforward. The real challenge is presenting this stream of text chunks elegantly within a Telegram chat.
The "Placeholder and Edit" Pattern
We can't just send a new Telegram message for every text chunk we receive—that would be chaotic and spam the user with notifications. Instead, we'll use a common and effective pattern:
- Immediately after the user sends a message, our bot replies with a temporary placeholder message (e.g., "...").
- As we receive text chunks from the Claude API stream, we accumulate them in a buffer.
- Periodically, we edit the placeholder message to display the buffered text.
- Once the stream ends, we perform a final edit to ensure the complete message is displayed.
This creates the illusion of the bot "typing" its response in real-time, directly inside a single message bubble. The key API method we'll need for this is editMessageText, which is available in grammY as ctx.api.editMessageText().
A blog post by René Zander provides a fantastic, concise implementation of this exact pattern. It also introduces a critical real-world consideration: API rate limits.
Telegram Bot with Claude API: 30-Line Bash Daemon - René Zander
This article demonstrates a robust, real-world implementation of streaming Claude responses into a Telegram bot using grammY.
Scroll down to the "TypeScript version" section. You can skip the setup code as we've already done that. Focus on the code block inside the bot.on("message:text", ...) handler. Pay close attention to this logic: the placeholder and edit loop. Notice how it: Sends a placeholder message. Initializes a buffer for text and a lastEdit timestamp. In the for await loop, it appends text to the buffer. Crucially, it only calls editMessageText if some time has passed (Date.now() - lastEdit > 800). After reading the code, read the author's explanation right below it, which starts with this paragraph. This explains why the throttling is necessary—Telegram rate-limits editMessageText calls to about one per second. Updating too frequently would cause API errors.
This pattern of sending a placeholder, buffering text, and using a throttled edit loop is the professional way to handle streaming in a Telegram bot. Let's integrate this into our project.
For a visual demonstration of the SDK's streaming capabilities in action, the following video is a helpful reference.
Build with Claude as a JavaScript developer - Anthropic API
This video from the Supabase channel demonstrates using the Anthropic SDK. While it's in a slightly different context (Deno Edge Functions), it clearly shows the streaming API being used.
Watch the segment from streaming messages. This part visually walks through changing a messages.create call to messages.stream and handling the resulting stream events, which is exactly what we're about to do in our code.
Updating the Bot for Streaming
Now, let's update our bot.on("message:text") handler in src/bot.ts. We'll replace the single call to claude.messages.create with the new streaming logic, while keeping the session management from our previous lesson.
Here is the updated code for src/bot.ts:
// src/bot.ts
import { Bot, Context, session, SessionFlavor } 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!);
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 a streaming AI assistant. I can remember our conversation. Send /start to begin a new one.");
});
bot.on("message:text", async (ctx) => {
try {
const userPrompt = ctx.message.text;
ctx.session.messages.push({ role: "user", content: userPrompt });
// 1. Send a placeholder message and start the "typing" action
const placeholder = await ctx.reply("...");
await ctx.replyWithChatAction("typing");
let responseText = "";
let lastEdit = Date.now();
const EDIT_INTERVAL = 800; // milliseconds
// 2. Call the Claude API with streaming enabled
const stream = claude.messages.stream({
model: "claude-3-5-sonnet-20240620",
max_tokens: 1024,
messages: ctx.session.messages,
});
// 3. Process the stream of text chunks
for await (const chunk of stream) {
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
responseText += chunk.delta.text;
// 4. Throttle edits to avoid hitting Telegram's rate limits
if (Date.now() - lastEdit > EDIT_INTERVAL && responseText.length > 0) {
await ctx.api.editMessageText(
ctx.chat.id,
placeholder.message_id,
responseText
);
lastEdit = Date.now();
}
}
}
// 5. Perform a final edit to ensure the full message is sent
await ctx.api.editMessageText(
ctx.chat.id,
placeholder.message_id,
responseText
);
// 6. Save the final assistant response to the session history
ctx.session.messages.push({ role: "assistant", content: responseText });
} catch (error) {
console.error("Error in streaming response:", error);
await ctx.reply("I'm sorry, but I encountered an error while generating a response. Please try again.");
}
});
// --- Error Handling and Startup ---
bot.catch((err) => {
// ... (existing error handler)
});
bot.start();
console.log("Streaming bot started...");
Let's break down the changes in the message:text handler:
- Placeholder: We immediately send
ctx.reply("...")to get amessage_idwe can edit later. - Streaming Call: We now use
claude.messages.stream()which returns an async iterable. The conversation history fromctx.session.messagesis still passed in, maintaining the bot's memory. - Stream Processing: The
for await...ofloop iterates through events from the stream. We check forcontent_block_deltaevents, which contain the text chunks (chunk.delta.text). - Throttled Edits: Text chunks are appended to the
responseTextbuffer. Theif (Date.now() - lastEdit > EDIT_INTERVAL)condition ensures we only callctx.api.editMessageTextat most once every 800ms, respecting Telegram's rate limits. - Final Edit: After the loop finishes, there might be remaining text in the buffer that hasn't been sent. A final call to
editMessageTextflushes the buffer and displays the complete message. - Session Update: Only after the full response is constructed do we save it to the session history.
Now, run your bot with bun start. Send it a prompt that requires a longer answer, like "Explain the difference between TCP and UDP". You should see the ... appear almost instantly, then be replaced by the response as it's generated, word by word. The user experience is dramatically improved.
Conclusion
In this lesson, you've transformed your bot from a simple request-response machine into a dynamic, real-time conversationalist. By implementing streaming, you have significantly enhanced the perceived performance and interactivity of your AI assistant.
Key Takeaways:
- Streaming responses from an LLM API is crucial for a good user experience, making the bot feel much faster.
- The Anthropic SDK makes this easy with the
client.messages.stream()method. - In Telegram, the correct way to display a stream is the "placeholder and edit" pattern using
ctx.reply()followed byctx.api.editMessageText(). - It is essential to throttle calls to
editMessageTextto avoid hitting Telegram's API rate limits. - The complete assistant message should be saved to the session history after the stream has concluded to maintain conversational context for the next turn.
Your bot is now not only context-aware but also highly responsive. However, our error handling is still quite basic. What happens if the Claude API returns an error mid-stream, or if a user's prompt is blocked by a content filter? In our next lesson, we will dive into robust error handling for the Claude API, learning how to manage rate limits, token limits, and other API-specific issues gracefully.