Use AI to read library code: make it cite the file and line

Use AI to read library code without being misled: have it read the real source, demand a file and line for every answer, then check it with a small script.

How do you use AI to read an unfamiliar library properly? Let it read the real source code in node_modules, require a file name and line number with every answer, then run a small script yourself to check. Have you ever trusted a fluent answer that turned out to be wrong? Let's try it.

The expense app needs a library I've never used

In the previous post, the expense book app got better at reading commands thanks to seven prompt templates. The app takes a message like "coffee 35k" and records an expense, and /total returns the month's total. All of it lives in one function, handleMessage(text, now, entries), which takes the message text, the time it was sent and the expense book, and returns the reply. (The app counts in Vietnamese đồng, so "35k" means 35,000 đồng, about $1.40.)

But the app only runs in tests so far. To send real messages from my phone, I picked Telegram, because creating a bot there is free and quick. The popular library for Telegram bots in TypeScript is grammY, and I'd never used it.

This post doesn't teach grammY. It's about how I used AI to understand an unfamiliar library well enough to write 15 lines of glue code, and to be sure of every one of those lines. All output below is from real runs with grammY 1.46.0, the latest version I could install on 10 November 2026.

What if you just let the AI write it from memory?

The fastest way is to type "write me a Telegram bot with grammY, including an offline test". I tried exactly that with Claude Code, and added that it must not open node_modules yet, only write from what it remembered. This is the draft I got, which I kept in the drafts/ folder:

drafts/from-memory.ts
// FIRST DRAFT: written by AI from memory, before reading grammY's source in node_modules.
// Kept for comparison. Running `npx tsc` shows where it's wrong.
import { Bot } from "grammy";

const bot = new Bot("fake-token", {
  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,
  },
});

bot.on("message:text", (ctx) => ctx.reply(`You said: ${ctx.message.text}`));

// Following the exact hint grammY prints when there's no error handler.
bot.catch = (err) => console.error("Error:", err.error);

The code looks fine. It's short, commented, and the function names sound reasonable. If you've never used grammY, there's no way to tell by looking whether it's right, and that's exactly the problem. I let TypeScript check it:

npx tsc --noEmit drafts/from-memory.ts
drafts/from-memory.ts(6,3): error TS2740: Type '{ id: number; is_bot: true; first_name: string; username: string; can_join_groups: false; can_read_all_group_messages: false; supports_inline_queries: false; }' is missing the following properties from type 'UserFromGetMe': can_connect_to_business, has_main_web_app, has_topics_enabled, allows_users_to_create_topics, and 2 more.
drafts/from-memory.ts(20,50): error TS2339: Property 'error' does not exist on type 'ErrorHandler<Context>'.

Two errors, and both are worth learning from.

The first says the botInfo object is missing six fields. The seven fields in the draft used to be enough in older versions. Telegram adds features over time, the library keeps up, and the model's memory stops at the point it was trained. The AI isn't lying. It's giving the right answer for a version that is no longer the one on your machine.

The second is more interesting. The line bot.catch = ... assigns a function to catch, while catch is a method you're supposed to call, as in bot.catch(...).

The odd thing is that this wrong form appears in the very message the library prints. In node_modules/grammy/out/bot.js, line 95 says Set your own error handler with bot.catch = .... A bit further down, at line 383, the real method is defined as catch(errorHandler) { this.errorHandler = errorHandler; }. If you follow the hint in the log without TypeScript, you overwrite the catch method, the error handler is never registered, and nothing tells you.

I took two things from this. An answer from memory can be wrong in ways you can't spot. And the most reliable source about a library is the source code of the exact version you installed, more reliable even than the hints in its own logs.

How do you get the AI to read the real source?

When you run npm install grammy, the whole library lands in node_modules/grammy/. It contains .d.ts files describing the types along with the author's comments, and .js files with the code that actually runs. AI tools that work inside your project folder (Claude Code, Cursor, Copilot in agent mode) can all read these files; they just usually don't unless you ask.

This is the prompt template I start with:

Template 1: library map
I'm about to use the <name> library for the first time, the version in package.json.
Read the source in node_modules/<name>/ (don't answer from memory). Tell me:
1. Which file is the entry point, and which files hold the 5 things I'll use most.
2. For <what I need to do>, which functions I need.
Give a file path and line number for each point. If you can't find something in the source, say so.

"Don't answer from memory" sounds redundant, but it completely changes how the tool works. Instead of writing right away, it opens files first. And the "file path and line number" requirement lets you check in ten seconds: open that file, go to that line, and read it with your own eyes.

For grammY, the answer pointed me to four places. out/bot.js holds the Bot class, including startup and error handling. out/filter.js defines filter strings like "message:text".

out/context.js is the ctx object every handler receives. The message types live in a separate package, node_modules/@grammyjs/types/message.d.ts. I opened each file to confirm it existed and really contained those things before asking anything else.

What should you ask before writing any code?

The second template is the one I find most valuable, because it makes the AI think about what can go wrong before writing:

Template 2: fragile assumptions
I'm going to write: <a short description of the code>.
List the assumptions this code depends on that are most likely to be wrong. For each one:
- quote the line in the source (file:line) that proves it right or wrong,
- suggest a way for me to check it myself by running code, without a network.
Don't write code yet.

I described the code as "receive every text message, call handleMessage with the message's text and time, then reply". The list came back with four assumptions, and I checked each one in the source.

The first assumption is about time units. handleMessage needs a Date, and a Telegram message has a date field. In @grammyjs/types/message.d.ts, line 18 says Date the message was sent in Unix time. Unix time is in seconds, while JavaScript's new Date(...) takes milliseconds. Writing new Date(ctx.message.date) raises no error; it just gives you a day in January 1970.

The second assumption is about what "message:text" matches. In out/filter.js, lines 403 to 407 define the shortcuts: msg covers message and channel_post, and edit covers edited_message and edited_channel_post. So "message:text" only matches new messages with text. A photo without text doesn't match. An edited message is a different kind of update altogether and doesn't match either.

This matters for my app: if a user edits "coffee 35k" to "coffee 45k", the bot never finds out.

The third assumption is whether the /total command goes through "message:text". It does, because to Telegram a command is just a text message starting with a slash. grammY has bot.command() to filter commands specifically, but my app already handles commands in handleMessage since the last post, so I don't need it.

The fourth assumption is about what happens when a handler throws. In out/bot.js, lines 91 to 100 are the default error handler. It prints the error, and if the bot is running with long polling, it calls this.stop(). That means an error while handling one message stops the bot from receiving every message after it, until someone restarts it. So bot.catch(...) is not something to add later.

For none of these four answers did I have to trust the AI. Each one has a line in the source that I opened and read myself.

How do you test the bot without a token?

Reading the source told me what the code should do. But I still wanted to see it run, without creating a real bot or depending on the network yet. So I asked:

Template 3: run without the real service
In the source of <library>, is there a way to feed in fake data and see what the library would send out,
without making network calls? Point to the function and file:line. Then write the smallest script that uses it.

The answer relied on two things grammY already has. The first is bot.handleUpdate(update) in out/bot.js line 222, the function the library itself calls for each update it receives from Telegram. I can call it myself with an update I build. Lines 223 to 226 also give the condition: the bot must have botInfo, or the function throws Bot not initialized!. Passing botInfo to the constructor means the bot doesn't need to ask Telegram about itself.

The second is a transformer, a function that sits between the bot and Telegram, registered with bot.api.config.use(...). grammY's docs on transformers describe it as the way to intercept every API call. If the transformer doesn't pass the call on but returns a fake result instead, no request ever leaves your machine.

First, the file that connects the app to Telegram. createBot takes a token, the expense book and a config, and returns the bot with its handler attached. The unit parameter only exists so I can show you the time-unit bug below; your real code doesn't need it:

src/bot.ts
// 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;
}

This file imports handleMessage and the Entry type from src/expense-book.ts. I'm not repeating that file, or src/parse-expense.ts which it uses, because they're unchanged line for line from the previous post, where you'll find them in full. All this post needs from them is the signature handleMessage(text: string, now: Date, entries: Entry[]): string.

And here's the test script. It builds five fake updates in exactly the shape Telegram sends, feeds each one to the bot, and prints what the bot would send out. It runs all five twice, once with the right unit and once with the wrong one:

try-offline.ts
// Try the bot without a token or a network: build fake updates, feed them to bot.handleUpdate,
// and intercept every call to Telegram with a transformer to see what the bot would send.
import type { Update, UserFromGetMe } from "grammy/types";

import { createBot } from "./src/bot.ts";
import type { Entry } from "./src/expense-book.ts";

// With botInfo, the bot doesn't need to call getMe on Telegram at startup.
const botInfo: UserFromGetMe = {
  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,
};

const sender = { id: 42, is_bot: false, first_name: "Me" };
const chat = { id: 42, type: "private" as const, first_name: "Me" };
const seconds = (vnIso: string) => Math.floor(new Date(`${vnIso}+07:00`).getTime() / 1000);

let updateId = 0;
const textMessage = (text: string, at: string): Update => ({
  update_id: ++updateId,
  message: { message_id: updateId, date: seconds(at), chat, from: sender, text },
});

const UPDATES: [string, Update][] = [
  ['message "coffee 35k"', textMessage("coffee 35k", "2026-12-01T06:30")],
  ['message "/total"', textMessage("/total", "2026-12-01T06:31")],
  ["a photo, no text", { update_id: ++updateId, message: { message_id: updateId, date: seconds("2026-12-01T06:32"), chat, from: sender, photo: [{ file_id: "f", file_unique_id: "u", width: 90, height: 90 }] } }],
  ['EDITING the old message to "coffee 45k"', { update_id: ++updateId, edited_message: { message_id: 1, date: seconds("2026-12-01T06:30"), edit_date: seconds("2026-12-01T06:33"), chat, from: sender, text: "coffee 45k" } }],
  ['message "/total" again', textMessage("/total", "2026-12-01T06:34")],
];

async function run(unit: "seconds" | "milliseconds") {
  const book: Entry[] = [];
  const bot = createBot("fake-token", book, { botInfo }, unit);
  const sent: string[] = [];
  // A transformer sits between the bot and Telegram: record the call and return a fake result, no network.
  bot.api.config.use(async (_prev, method, payload) => {
    sent.push(`${method}: ${(payload as { text?: string }).text ?? ""}`);
    return { ok: true, result: { message_id: 0, date: 0, chat, text: "" } } as never;
  });

  console.log(`--- time read as ${unit === "seconds" ? "SECONDS (right)" : "MILLISECONDS (wrong)"} ---`);
  for (const [label, update] of UPDATES) {
    sent.length = 0;
    await bot.handleUpdate(update);
    console.log(`${label} → ${sent.length ? sent.join(" | ") : "(no reply)"}`);
  }
  console.log();
}

await run("seconds");
await run("milliseconds");

Notice that botInfo now has all thirteen fields, including the six the from-memory draft was missing. I didn't need to remember them. TypeScript listed each missing field by name, and @grammyjs/types/manage.d.ts has a comment for each.

I run it with TZ=UTC as in the earlier posts, to behave like a server that isn't in Vietnam:

TZ=UTC node try-offline.ts
--- time read as SECONDS (right) ---
message "coffee 35k" → sendMessage: Recorded: coffee, 35,000 đồng
message "/total" → sendMessage: 12/2026: 1 expense, total 35,000 đồng
a photo, no text → (no reply)
EDITING the old message to "coffee 45k" → (no reply)
message "/total" again → sendMessage: 12/2026: 1 expense, total 35,000 đồng

--- time read as MILLISECONDS (wrong) ---
message "coffee 35k" → sendMessage: Recorded: coffee, 35,000 đồng
message "/total" → sendMessage: 01/1970: 1 expense, total 35,000 đồng
a photo, no text → (no reply)
EDITING the old message to "coffee 45k" → (no reply)
message "/total" again → sendMessage: 01/1970: 1 expense, total 35,000 đồng

The top half confirms all four assumptions. Text messages and the /total command both reach handleMessage. The message at 6:30 a.m. on 1 December, Vietnam time, counts toward December even though the machine runs on UTC. The photo and the edited message get no reply, just as filter.js said.

The bottom half is the unit bug. Look at its second line, "01/1970". The first line is identical to the correct version, because the "Recorded" reply doesn't print a date. If I had only tried one "coffee 35k" message and seen the bot reply, I'd have thought everything was fine, and the bug would only show at the end of the month when the total came out as nothing.

The two "no reply" lines are also a product finding. A user edits a message from 35k to 45k, and the book still says 35,000 đồng without a word. I haven't fixed that in this post, but thanks to reading filter.js I know it exists before any user complains. I noted it in the spec under out of scope.

What do you need to run the real bot?

The real run file is just a few lines, since everything is in createBot:

src/run-bot.ts
// 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.`) });

You get the token by sending /newbot to the @BotFather account on Telegram, and it's a secret just like a password. The token lives only in an environment variable; it never goes in the code and never gets pasted into a prompt. The project's package.json has just one library, pinned to an exact version so the next install doesn't drift to another one:

package.json
{
  "name": "expense-book-04",
  "private": true,
  "type": "module",
  "dependencies": {
    "grammy": "1.46.0"
  }
}

To be honest, in this post I only ran the offline part, so run-bot.ts has been type-checked but hasn't received a single real message yet.

Does this work for an unfamiliar codebase too?

It does, and in my view it fits even better. Your company's codebase isn't in any model's training data, so the AI has to read the real code. The three templates above only need a few words changed: "library map" becomes "map of this folder", "fragile assumptions" stays the same, and "run without the real service" becomes "which tests go through this code".

I have a real example from this blog, on 10 November 2026. The Tech Notes sitemap wasn't picking up new posts. The first fix was based on an assumption that sounded very reasonable: move the sitemap from a sitemap.ts file to a route handler at the same /sitemap.xml path, and it would refresh like the RSS feed does. The fix was merged and deployed, and the sitemap stayed frozen.

The second time, instead of guessing again, the question became "in the Next.js source, what decides that a route is a sitemap?". The answer was in node_modules/next/dist/lib/metadata/is-metadata-route.js, where a regular expression treats any path ending in /sitemap.xml as a special route, no matter which kind of file you write it in. Calling isMetadataRoute("/sitemap.xml/route") returns true. The second fix moved the route to a different path, and this time the sitemap updated.

The first fix cost a round of merging and deploying because it trusted an assumption. The second started by reading one file. I mention it because it shows this approach isn't just for beginners.

Trying a second user

The offline script was green on all five lines, and I was about to stop there. But anyone with the link can message a Telegram bot, so before creating the real bot I tried one more scenario: two different people messaging it. two-users.ts reuses the exact approach above, with two different user ids and a book created the way run-bot.ts does it:

two-users.ts
// 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}`);
}
TZ=UTC node two-users.ts
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

Look at the last line. I only had one coffee for 35,000 đồng, yet my monthly total is 67,000 đồng with 2 expenses. The other person's milk tea ended up in my book.

The cause is a line I skimmed past: const book: Entry[] = []. The whole bot has one book, and everyone who messages it writes into the same one. The spec from the very first post said "one user (me)", so handleMessage never needed to know who's sending. That was true until the app got connected to a place where anyone can send messages.

No amount of careful library reading would have caught this, because grammY isn't wrong. The bug is where my code meets the real world. For bugs that only show up when the code runs, the question is no longer "how do I write the code" but "what do I give the AI so it finds the right cause". That's the subject of the next post. You can follow the whole series at Building a side project with AI, and if you're preparing for interviews, the question on using AI while coding is in the interview question bank.

Get the next post by email.

One email a week, only when there is something worth reading. No spam.