Debugging with AI: give it the right context to find the cause
Debugging with AI needs the right context: how to reproduce, actual and expected output, the files involved and what you ruled out. A Telegram bot example.
How do you debug with AI so it finds the real cause? Give it the steps to reproduce, the actual and expected output, the files involved and what you've ruled out, then ask it to point to a file and line before fixing anything. What do you usually paste when asking AI to fix a bug? Let's look.
Which bug is waiting to be fixed?
This series builds an expense book app from start to finish with AI. You send "coffee 35k" and the app records an expense; you send /total and it replies with this month's total. (The app counts in Vietnamese đồng, so "35k" means 35,000 đồng, about $1.40.) In the previous post, I connected the app to Telegram with the grammY 1.46.0 library, and at the end a bug showed up when two people messaged the bot.
This is the script that reproduces it. It needs no token and no network. It creates the bot with ready-made bot information (botInfo) so grammY doesn't have to ask Telegram, intercepts every API call with a transformer to record what the bot would send, then feeds in three fake messages:
// Two different people message the same bot. Same offline approach: fake updates + a transformer.
import type { Update, UserFromGetMe } from "grammy/types";
import { createBot } from "./src/bot.ts";
import type { Entry } from "./src/expense-book.ts";
const botInfo = {
id: 1, is_bot: true, first_name: "Expense book", username: "expense_book_bot",
can_join_groups: false, can_read_all_group_messages: false, supports_inline_queries: false,
can_connect_to_business: false, has_main_web_app: false, has_topics_enabled: false,
allows_users_to_create_topics: false, can_manage_bots: false, supports_join_request_queries: false,
} satisfies UserFromGetMe;
const book: Entry[] = []; // like src/run-bot.ts: the whole bot has exactly one book
const bot = createBot("fake-token", book, { botInfo });
let reply = "";
bot.api.config.use(async (_prev, _method, payload) => {
reply = (payload as { text?: string }).text ?? "";
return { ok: true, result: { message_id: 0, date: 0, chat: { id: 0, type: "private", first_name: "" }, text: "" } } as never;
});
const at = Math.floor(new Date("2026-12-01T06:30+07:00").getTime() / 1000);
let updateId = 0;
function message(id: number, name: string, text: string): Update {
const user = { id, is_bot: false, first_name: name };
return { update_id: ++updateId, message: { message_id: updateId, date: at, chat: { id, type: "private", first_name: name }, from: user, text } };
}
for (const [id, name, text] of [[42, "Me", "coffee 35k"], [77, "My friend", "milk tea 32k"], [42, "Me", "/total"]] as const) {
await bot.handleUpdate(message(id, name, text));
console.log(`${name} (id ${id}) sends "${text}" → ${reply}`);
}Me (id 42) sends "coffee 35k" → Recorded: coffee, 35,000 đồng
My friend (id 77) sends "milk tea 32k" → Recorded: milk tea, 32,000 đồng
Me (id 42) sends "/total" → 12/2026: 2 expenses, total 67,000 đồngThe last line is wrong. User 42 only recorded one coffee for 35,000 đồng, but their /total shows 2 expenses and 67,000 đồng, because user 77's milk tea got counted too. The TZ=UTC at the start of the command runs it on UTC time like a real server, something from earlier posts.
The previous post already guessed the cause, but in this post I want to redo it from the point of view of someone who only has the bug and doesn't know why. That's the situation you face every day at work.
What's missing from "fix the wrong total for me"?
My first reflex when I hit a bug is to open a chat and type something like "the bot's total is wrong, please fix it", along with the file I think has the bug. For this bug, the most suspicious file is expense-book.ts, because it holds the function that adds things up. Here it is, unchanged since the series' third post:
import { parseExpense } from "./parse-expense.ts";
export type Entry = { note: string; amount: number; at: Date };
const HELP = 'Sorry, I didn\'t understand. Send something like "coffee 35k" to record an expense, or /total for this month\'s total.';
// Create it ONCE and reuse it: a new Intl.DateTimeFormat on every call is dozens of times slower (see speed.ts).
// "en-GB" because it gives exactly "12/2026"; "vi-VN" gives "tháng 12, 2026".
const VN_MONTH = new Intl.DateTimeFormat("en-GB", { timeZone: "Asia/Ho_Chi_Minh", month: "2-digit", year: "numeric" });
// Month/year in Vietnam time, even when the server runs on UTC.
function vnMonth(date: Date): string {
const parts = VN_MONTH.formatToParts(date);
const month = parts.find((p) => p.type === "month")?.value;
const year = parts.find((p) => p.type === "year")?.value;
if (!month || !year) throw new Error(`Could not get month/year from ${date.toISOString()}`);
return `${month}/${year}`;
}
const vnd = (n: number) => `${n.toLocaleString("en-US")} đồng`;
export function monthTotal(entries: Entry[], now: Date): string {
const month = vnMonth(now);
const inMonth = entries.filter((entry) => vnMonth(entry.at) === month);
if (inMonth.length === 0) return `No expenses yet in ${month}.`;
const total = inMonth.reduce((sum, entry) => sum + entry.amount, 0);
const count = `${inMonth.length} ${inMonth.length === 1 ? "expense" : "expenses"}`;
return `${month}: ${count}, total ${vnd(total)}`;
}
export function handleMessage(text: string, now: Date, entries: Entry[]): string {
const message = text.trim().normalize("NFC");
// A message starting with "/" is a command and is never an expense (spec v1.2).
if (message.startsWith("/")) {
const command = message.toLowerCase();
if (command === "/total" || command === "/tổng") return monthTotal(entries, now);
return `I don't have a command called ${message.split(/\s/)[0]}. Send /total to see this month's total.`;
}
const expense = parseExpense(message);
if (!expense) return HELP;
entries.push({ ...expense, at: now });
return `Recorded: ${expense.note}, ${vnd(expense.amount)}`;
}parseExpense, which reads the amount in a message, lives in parse-expense.ts; you can get it from the prompts for developers post. It has nothing to do with this bug.
Now put yourself in the AI's place. You get "the total is wrong" and the file above. You don't know how the total is wrong, by how much, with one user or with several. You read monthTotal and see it filters the right month and adds up the right amounts. There really is nothing wrong in this file.
I'm not pasting a particular AI tool's answer here, because every tool, and every attempt, gives a different one. But one thing is certain: the AI can only search what you give it. The line causing the bug isn't in the file you pasted, so the best possible answer is "I don't see a bug in this code". A worse answer is a fix for a bug that doesn't exist, for example changing how time zones are handled, since that's the most complicated-looking part of the file.
AI tools that work directly in your project folder, like Claude Code or Cursor, can read other files on their own. That helps them find run-bot.ts. But they still don't know what "the total is wrong" means unless you tell them the actual and expected output.
What goes into a debugging prompt?
I use an eight-part outline for every bug. Each part answers a question that whoever is debugging, AI or colleague, would otherwise have to guess:
- Context: what the app does, what it runs on, which library versions. Without this, the AI might suggest an API from a different version.
- The bug: one sentence describing the symptom.
- How to reproduce: the command and the script, so the AI or you can run it again.
- Actual output: paste it exactly, don't summarize.
- Expected output: which line should say what. This is the part people skip most, and without it "wrong" means nothing.
- Relevant code: the files the data passes through, not just the file you suspect.
- Ruled out: what you've already confirmed works, with evidence. This keeps the AI from walking the path you've already walked.
- The request: what you want back.
For the shared-book bug, my prompt looks like this. The parts in angle brackets are where file contents get pasted; I'm not repeating them here since you've seen them above and will see the rest below:
Context: a Telegram bot for logging expenses, TypeScript run directly with Node 22 (no build step), grammY 1.46.0.
Spec (SPEC.md v1.2): each user messages expenses to the bot; /total returns this month's total for THAT user.
Bug: when two people message the bot, one person's /total includes the other person's expenses.
To reproduce (no token needed): TZ=UTC node two-users.ts
<paste two-users.ts>
Actual output:
Me (id 42) sends "coffee 35k" → Recorded: coffee, 35,000 đồng
My friend (id 77) sends "milk tea 32k" → Recorded: milk tea, 32,000 đồng
Me (id 42) sends "/total" → 12/2026: 2 expenses, total 67,000 đồng
Expected on the last line: 12/2026: 1 expense, total 35,000 đồng
Relevant code:
<paste src/bot.ts>
<paste src/run-bot.ts>
<paste src/expense-book.ts>
Ruled out:
- parseExpense reads amounts correctly: 16 green tests.
- monthTotal is correct for a single user: 7 green tests in expense-book.test.ts.
Request:
1. Point out the cause, quoting the exact file and line. Don't change any code yet.
2. Suggest 2 ways to fix it. For each: which files change, which tests need updating, and where else
any function whose signature changes is being called.Two lines in the request deserve an explanation. "Don't change any code yet" separates finding the cause from fixing it, so I can check the cause before accepting a fix built on it. It's the same idea as the "explain with a way to verify" template in the prompts for developers post, and as demanding a file and line in the previous post. The line "where else any function whose signature changes is being called" is one I added after what happens at the end of this post.
The hardest part of this prompt is the reproduction script, and that effort pays off even if you never ask an AI. Once you have to write down exactly which line should say what, you'll often spot the bug yourself before you hit send.
Which file holds the cause?
This time I did exactly what the prompt asks: follow the data through each file in "relevant code". The question is where the book, the Entry[] array, is created and who shares it. These are the other two files, as they were at the end of the previous post:
// Run the real bot: BOT_TOKEN=<token from @BotFather> node src/run-bot.ts
import { createBot } from "./bot.ts";
import type { Entry } from "./expense-book.ts";
const token = process.env.BOT_TOKEN;
if (!token) throw new Error("Missing the BOT_TOKEN environment variable (get it from @BotFather on Telegram).");
const book: Entry[] = []; // kept in memory for now: it's gone when the bot stops
const bot = createBot(token, book);
await bot.start({ onStart: (me) => console.log(`Bot @${me.username} is receiving messages. Press Ctrl+C to stop.`) });// Connects the expense book app to Telegram with grammY. All the logic stays in handleMessage.
import { Bot, type BotConfig, type Context } from "grammy";
import { handleMessage, type Entry } from "./expense-book.ts";
// unit only exists to show the time-unit bug in this post; real code always uses "seconds".
export function createBot(token: string, book: Entry[], config?: BotConfig<Context>, unit: "seconds" | "milliseconds" = "seconds"): Bot {
const bot = new Bot(token, config);
// "message:text" only matches NEW messages with text: not photos, stickers or edited messages.
bot.on("message:text", async (ctx) => {
// Telegram sends the time in SECONDS (Unix time), while JavaScript's Date needs milliseconds.
const sentAt = new Date(unit === "seconds" ? ctx.message.date * 1000 : ctx.message.date);
await ctx.reply(handleMessage(ctx.message.text, sentAt, book));
});
// catch is a FUNCTION that takes an error handler. Without it, an error in a handler stops the bot from receiving messages.
bot.catch((err) => console.error(`Error while handling update ${err.ctx.update.update_id}:`, err.error));
return bot;
}The cause is two lines in two files. const book: Entry[] = [] in run-bot.ts creates exactly one array when the bot starts. handleMessage(ctx.message.text, sentAt, book) in bot.ts passes that same array for every message, no matter who sent it. The handler isn't wrong, and the library isn't wrong. The bug is in how they're wired together, which no single file shows on its own.
That's why "relevant code" has to follow the data, not your hunch. If you only paste expense-book.ts, neither you nor the AI has any chance of seeing these two lines.
Two ways to fix it, which one?
The first way is to make each expense know whose it is. Entry gets a userId field, handleMessage takes the sender's id, and monthTotal filters by id before adding up. That changes three places in expense-book.ts, changes the signature of handleMessage, and means updating all 7 tests that call it.
The second way is to give each person their own book. handleMessage stays as it is, still working with one book, and bot.ts picks the right book for the sender.
I chose the second, because the spec has described one book per person from the start, and handleMessage is the best-tested part of the app. Fixing it in the Telegram layer means the tested part doesn't have to change at all.
// Connects the expense book app to Telegram with grammY. All the logic stays in handleMessage.
import { Bot, type BotConfig, type Context } from "grammy";
import { handleMessage, type Entry } from "./expense-book.ts";
// unit only exists to show the time-unit bug from the previous post; real code always uses "seconds".
export function createBot(token: string, config?: BotConfig<Context>, unit: "seconds" | "milliseconds" = "seconds"): Bot {
const bot = new Bot(token, config);
// One book per Telegram user, keyed by their id. Before, the whole bot shared one array.
const booksByUser = new Map<number, Entry[]>();
// "message:text" only matches NEW messages with text: not photos, stickers or edited messages.
bot.on("message:text", async (ctx) => {
const userId = ctx.from.id;
let book = booksByUser.get(userId);
if (!book) {
book = [];
booksByUser.set(userId, book);
}
// Telegram sends the time in SECONDS (Unix time), while JavaScript's Date needs milliseconds.
const sentAt = new Date(unit === "seconds" ? ctx.message.date * 1000 : ctx.message.date);
await ctx.reply(handleMessage(ctx.message.text, sentAt, book));
});
// catch is a FUNCTION that takes an error handler. Without it, an error in a handler stops the bot from receiving messages.
bot.catch((err) => console.error(`Error while handling update ${err.ctx.update.update_id}:`, err.error));
return bot;
}I use ctx.from.id, the sender's id, rather than the chat's id. In a private chat they're the same. If someone adds the bot to a group, keying by chat id would give the whole group one book, right back to the bug I just fixed.
The book parameter is gone from createBot, because the bot now manages the books itself. I updated the two callers I had open. In run-bot.ts, the line that creates the bot becomes const bot = createBot(token);. In two-users.ts, I removed the line creating the book array and the Entry import, and the bot line becomes const bot = createBot("fake-token", { botInfo });.
Me (id 42) sends "coffee 35k" → Recorded: coffee, 35,000 đồng
My friend (id 77) sends "milk tea 32k" → Recorded: milk tea, 32,000 đồng
Me (id 42) sends "/total" → 12/2026: 1 expense, total 35,000 đồngThe last line now matches the "expected output" in the prompt. I ran both test suites again to make sure nothing else was affected:
# pass 7
# fail 0
# pass 16
# fail 0All 23 tests are green. I was going to stop here.
23 green tests and a broken file
Before committing, I reran try-offline.ts, the offline bot test from the previous post. I ran it out of habit, not suspicion:
--- time read as SECONDS (right) ---
C:\...\tg-debug\node_modules\grammy\out\bot.js:224
throw new Error("Bot not initialized! Either call `await bot.init()`, \
^
Error: Bot not initialized! Either call `await bot.init()`, or directly set the `botInfo` option in the `Bot` constructor to specify a known bot info object.
at Bot.handleUpdate (C:\...\tg-debug\node_modules\grammy\out\bot.js:224:19)
at run (file:///C:/.../tg-debug/try-offline.ts:56:15)I shortened the folder paths to C:\... for readability; the rest is the exact output. This script still calls the function with the old signature, createBot("fake-token", book, { botInfo }, unit). With the new signature, the book array lands in the config slot, and { botInfo } lands in the unit slot. The bot is created without botInfo, so grammY throws on the first update it receives. It's the very error the previous post ran into and read grammY's source to understand.
Why did nothing warn me? Node.js runs .ts files directly by stripping out the type annotations, and Node's TypeScript docs say plainly that it doesn't type-check. A call with the wrong number of arguments still runs until it breaks somewhere far away. Type-checking with TypeScript catches it immediately. I ran tsc --noEmit on src/bot.ts and try-offline.ts:
try-offline.ts(45,58): error TS2554: Expected 1-3 arguments, but got 4.Changing that line to createBot("fake-token", { botInfo }, unit) and removing the book array line above it fixes it. But it made me rethink how I use AI. My prompt asked for the right cause and got the right fix. What broke was a file outside the "relevant code", and nothing protected it, because the 23 tests only check handleMessage and parseExpense. That's why the last line of the request in my prompt now asks "where else any function whose signature changes is being called".
How do you keep the next fix from breaking this one?
This post fixed the shared-book bug by giving the AI, and myself, exactly what it takes to see the cause. But it also exposed a bigger gap in the project. The part that connects the app to Telegram, bot.ts, has no tests at all. The shared-book bug lived there, and the try-offline.ts breakage came from changing a signature there too. Every time AI or I touch this layer, the only way to know whether something broke is to rerun each script by hand and read the output.
I want tests for this layer, and since I'm working with AI, I'll ask AI to write them. But a test written by AI needs checking too, because a test that's always green protects nothing. That's the job of the next posts.
You can follow the whole series at Building a side project with AI. If you're preparing for interviews, the questions on using AI while coding are in the interview question bank.