Call an LLM API in TypeScript: a chatbot for a small café

Call an LLM API in TypeScript with Claude's official SDK, write a system prompt for a café chatbot, and see why it confidently invents order statuses.

Is it hard to call an LLM API in TypeScript? You install the SDK, put your API key in an environment variable, and send a message with a system prompt. The hard part is that the model also answers questions it has no data for. We'll build a chatbot for a small café and watch that happen.

Where did the previous post leave Mộc?

In the previous post, I compared three ways to answer the 200 messages a day that Mộc, a small coffee shop in Vietnam, receives: a keyword-matching chatbot, a workflow and an agent. The keyword chatbot got only 5 of 8 sample messages right, because it sees words rather than understanding sentences. All three approaches ran without any real model.

This post replaces the "understanding" part with a real LLM. The goal is small: a chatbot that answers frequently asked questions such as opening hours, delivery and prices. No tools yet, no loop, just one API call per message.

Mộc is still made up for this series, and so is its data. The code really runs. You need Node.js 22.18 or later, because from that version Node runs .ts files directly without a compile step. By the end of the post your folder will have these files:

Folder structure
moc-chatbot/
  package.json
  src/
    shop-info.ts           # opening hours, delivery fee and price list
    prompts.ts             # two versions of the system prompt
    call-llm.ts            # askLLM: sends one question to Claude and returns the answer
    ask-moc.ts             # tries 4 customer questions
    inspect-request.ts     # prints the request that would be sent (no API key needed)
    guard.ts               # blocks answers that guess an order's status
    guard.test.ts          # tests for the file above

Create the folder, create the package.json below, then install Claude's SDK. The "type": "module" line tells Node that the files use import syntax:

package.json
{
  "name": "moc-chatbot",
  "private": true,
  "type": "module"
}
Terminal
npm install @anthropic-ai/sdk

This post uses Claude, but the approach with GPT or Gemini is almost identical.

What do you need to call an LLM?

You need three things: an API key, a library to send requests, and an array of messages.

You create the API key in your provider's dashboard; for Claude that's the Claude Console. The key works like a password: anyone who has it can spend your money. That's why a key must never be written into code or committed to GitHub. Put it in an environment variable instead:

Terminal (macOS, Linux, Git Bash)
export ANTHROPIC_API_KEY="sk-ant-..."

In PowerShell, write $env:ANTHROPIC_API_KEY = "sk-ant-...". The SDK reads this variable when it starts, so no line of code contains the key.

The library I use is the official SDK, @anthropic-ai/sdk. You could call the HTTP API directly with fetch, but the SDK adds the headers for you, retries automatically when the server is busy, and ships TypeScript types for the response.

The messages array has a shape you'll see with every LLM provider: each element has a role of user or assistant, and a content with the text. Instructions for the model, like "you are a customer service assistant for Mộc", go into a separate parameter called system.

What does the first call look like?

Here is the function that sends one question to Claude:

src/call-llm.ts
import Anthropic from "@anthropic-ai/sdk";

export const MODEL = "claude-opus-5-5";

// Send one question with a system prompt; return the answer and a few numbers worth watching.
export async function askLLM(system: string, question: string) {
  const client = new Anthropic(); // reads ANTHROPIC_API_KEY from the environment
  try {
    const response = await client.messages.create({
      model: MODEL,
      max_tokens: 16000,
      output_config: { effort: "low" }, // simple questions, no need to think for long
      system,
      messages: [{ role: "user", content: question }],
    });

    // content is an array of blocks; keep only the text blocks.
    const text = response.content
      .filter((block) => block.type === "text")
      .map((block) => block.text)
      .join("");

    return {
      text,
      stopReason: response.stop_reason,
      inputTokens: response.usage.input_tokens,
      outputTokens: response.usage.output_tokens,
    };
  } catch (error) {
    if (error instanceof Anthropic.AuthenticationError) throw new Error("The API key is wrong or has been revoked.", { cause: error });
    if (error instanceof Anthropic.RateLimitError) throw new Error("Too many requests, try again in a few minutes.", { cause: error });
    throw error;
  }
}

The function takes two strings. system is the instruction for the model, and question is the customer's message. It returns the text of the answer, plus why the model stopped and how many tokens it used. I explain the catch block at the end in the section on errors below.

A few details deserve a closer look, because this is exactly where beginners trip up.

The first is max_tokens. It's the upper limit on the length of the answer, measured in tokens (a token is a chunk of text, usually shorter than a word). Many tutorials set a tiny number like 100 to save money. That's riskier than it looks: when the answer hits the limit, it's cut off mid-sentence and the customer gets half a reply. You only pay for the tokens actually generated, not for the max_tokens number, so setting it generously costs nothing extra.

The second is output_config.effort. Recent Claude models think before they answer, and effort controls how much. Opening hours don't need deep thought, so I set it to low to keep it fast and cheap.

The third is response.content. It isn't a string but an array of blocks, each with a type. This post only needs the text block, but in later posts, when the agent calls tools, this array also contains tool_use blocks. Filtering by type from the start means you won't have to change the code later.

The fourth is stop_reason, the reason the model stopped. The normal value is end_turn, meaning it finished its answer, while max_tokens means the answer was cut off.

Model names and parameters change over time, so check the Messages API documentation if you're reading this later. The model I use is claude-opus-5-5. For something cheaper on simple questions, switch to claude-haiku-5-5; later posts in the series go into choosing a model by cost.

What happens when the system prompt only says "who you are"?

For the first try, I wrote the system prompt the way you often see online, only saying who the model is and how to answer:

System prompt, version 1
export const SYSTEM_V1 = "You are a customer service assistant for Mộc, a coffee shop. Keep answers short and friendly.";

Then I asked the shop's four usual questions. The ask-moc.ts file that runs them comes later, once both prompt versions exist. This is the kind of answer you might get:

node src/ask-moc.ts v1 (sample output, not a real model)
Customer: What time do you open?
Mộc:      We're open from 7:00 a.m. to 9:00 p.m.!

Customer: do u ship to district 7
Mộc:      Yes, we deliver! The fee depends on the distance.

Customer: How much is a salt coffee?
Mộc:      A salt coffee is about 30,000 đồng.

Customer: Has my order 1041 been delivered yet?
Mộc:      Yes, order 1041 has been delivered. Please check your door!

Notice that every answer is fluent, polite, and sounds just like shop staff. And every answer is wrong. Mộc opens from 6:30 a.m. to 10 p.m., a salt coffee costs 35,000 đồng (a little over one US dollar), and order 1041 is still being made, not delivered.

I put the sample output above together to illustrate a behaviour every LLM provider warns about, called hallucination: the model produces information that sounds plausible but isn't true. Anthropic's Reduce hallucinations guide says plainly that even the most advanced models sometimes generate text that is factually incorrect or inconsistent with the given context. With a real model, the answer might be a guess or a polite refusal. You can't predict which, and that's exactly the problem.

The first three answers are wrong because the model has never heard of Mộc. The fourth is far more dangerous. The customer reads "delivered", goes to the door, finds nothing, and writes back annoyed, and now the owner has to deal with a false promise made by the shop's own chatbot.

Put the shop's information into the prompt

The first fix is fairly obvious: put the shop's information into the system prompt. I keep the data in its own file, so when the shop changes a price there's only one place to edit:

src/shop-info.ts
export const MENU: Record<string, number> = {
  "black coffee": 25000,
  "milk coffee": 29000,
  "bac xiu": 32000,
  "salt coffee": 35000,
  "egg banh mi": 25000,
};

export const OPENING_HOURS = "Mộc is open from 6:30 a.m. to 10 p.m. every day.";
export const SHIPPING = "Mộc delivers within 5 km for 15,000 đồng; orders of 150,000 đồng or more ship free.";

// Turns the menu into lines like "- salt coffee: 35,000 đồng" for the prompt.
export function priceList(): string {
  return Object.entries(MENU)
    .map(([item, price]) => `- ${item}: ${price.toLocaleString("en-US")} đồng`)
    .join("\n");
}

Then prompts.ts holds both prompt versions. Version 2 inserts the shop's information with a template string:

src/prompts.ts
import { OPENING_HOURS, SHIPPING, priceList } from "./shop-info.ts";

// Version 1: only says who the model is.
export const SYSTEM_V1 = "You are a customer service assistant for Mộc, a coffee shop. Keep answers short and friendly.";

// Version 2: has the shop's information, and is allowed to say "I can't check that".
export const SYSTEM_V2 = `You are a customer service assistant for Mộc, a coffee shop. Keep answers short and friendly, in English.

Only use the information in the <shop_info> tags to answer.
<shop_info>
${OPENING_HOURS}
${SHIPPING}
Prices:
${priceList()}
</shop_info>

You CANNOT see customers' orders. If a customer asks about an order (delivered yet, when it arrives, changing items, cancelling),
do not guess its status. Say that you can't look up the order here and that a member of staff will check and message them back.
If the question isn't covered by the information above, say that you don't have that information.`;

This prompt does three things, each for its own reason.

The first is putting the information inside <shop_info> tags. XML-style tags help the model keep instructions and data apart.

The second is the sentence "only use the information in the tags". Without it, the model may mix the shop's information with general knowledge about coffee shops, and you're back to answers like "the fee depends on the distance".

The third is the one I see skipped most often: allowing the model to say "I don't know". In the guide above, it's the first technique Anthropic lists for reducing hallucinations, with the note that explicitly allowing the model to admit uncertainty can drastically reduce false information. The reason lies in how we write prompts. When all you ask for is "friendly", "your order has been delivered!" looks far friendlier than "I don't know". You have to say clearly that not knowing is fine, and give the replacement answer too.

With both prompts in place, the test file just asks the four questions in turn and prints the answers. The v1 or v2 argument on the command line picks the prompt. With version 2, the answer also passes through blockStatusGuess, a check I write later in the post:

src/ask-moc.ts
import { askLLM, MODEL } from "./call-llm.ts";
import { blockStatusGuess } from "./guard.ts";
import { SYSTEM_V1, SYSTEM_V2 } from "./prompts.ts";

const QUESTIONS = [
  "What time do you open?",
  "do u ship to district 7",
  "How much is a salt coffee?",
  "Has my order 1041 been delivered yet?",
];

// node src/ask-moc.ts v1  or  node src/ask-moc.ts v2
const version = process.argv[2] === "v1" ? "v1" : "v2";
const system = version === "v1" ? SYSTEM_V1 : SYSTEM_V2;
console.log(`Calling ${MODEL}, system prompt ${version}\n`);

for (const question of QUESTIONS) {
  const result = await askLLM(system, question);
  const final = version === "v2" ? blockStatusGuess(result.text) : { text: result.text, blocked: false };
  console.log(`Customer: ${question}`);
  console.log(`Mộc:      ${final.text}${final.blocked ? "   [blocked a guess about the order status]" : ""}`);
  console.log(`          (stop_reason: ${result.stopReason}, tokens in/out: ${result.inputTokens}/${result.outputTokens})\n`);
}

With version 2, the answers look like this. I left out the stop_reason and token lines to keep it readable, since sample output has no real numbers:

node src/ask-moc.ts v2 (sample output, not a real model)
Customer: What time do you open?
Mộc:      Mộc is open from 6:30 a.m. to 10 p.m. every day.

Customer: do u ship to district 7
Mộc:      Mộc delivers within 5 km for 15,000 đồng; orders of 150,000 đồng or more ship free.

Customer: How much is a salt coffee?
Mộc:      A salt coffee is 35,000 đồng.

Customer: Has my order 1041 been delivered yet?
Mộc:      Sorry, I can't look up your order here. A member of staff will check and message you back shortly!

The first three answers now match the shop's information. The fourth no longer invents anything: the chatbot admits it can't look the order up and promises only what the shop can actually do. And "do u ship to district 7", abbreviations and all, is understood fine, which is something the keyword rules in the previous post needed endless patching to manage.

What does the model actually see in the request?

I'd like you to pause here for a moment, because this explains why the fourth question can never be answered correctly, however cleverly the prompt is written.

An API call is one package of data. The model only knows what's in that package, plus the general knowledge it learned in training. To see this for yourself, I wrote a small file that builds exactly the request askLLM would send when a customer asks about order 1041, but doesn't send it anywhere, so it runs without a key. It prints three things: what the request is made of, whether the system prompt contains any order number, and how many times 1041 appears:

src/inspect-request.ts
import { MODEL } from "./call-llm.ts";
import { SYSTEM_V2 } from "./prompts.ts";

const request = {
  model: MODEL,
  max_tokens: 16000,
  system: SYSTEM_V2,
  messages: [{ role: "user", content: "Has my order 1041 been delivered yet?" }],
};

const json = JSON.stringify(request);
console.log(`The request is ${json.length} characters long and contains: ${Object.keys(request).join(", ")}.`);
const orderIds = SYSTEM_V2.match(/\b\d{4}\b/g) ?? [];
console.log(`Does the system prompt contain any order number (4 digits)? ${orderIds.length ? orderIds.join(", ") : "no"}`);
console.log(`Information about order 1041 appears in: ${json.split("1041").length - 1} place, the customer's question.`);
node src/inspect-request.ts
The request is 974 characters long and contains: model, max_tokens, system, messages.
Does the system prompt contain any order number (4 digits)? no
Information about order 1041 appears in: 1 place, the customer's question.

The whole request is just 974 characters, and the number 1041 appears exactly once, in the customer's question. There's no order status, no order time, no items. So when the model answers "order 1041 has been delivered", that sentence can't come from data. It can only be a guess.

In my view, this is the most important lesson of the whole post. The model isn't connected to the shop's system. It doesn't go and look at the database, and it doesn't open orders.json. Every piece of information about an order has to be fetched by your code and put into the request. Once that clicks, you also see why "write a better prompt" is never the fix for questions about data that keeps changing, like an order's status.

Should you rely on the prompt's instructions alone?

The instruction "don't guess the status" removes a lot of made-up answers, but Anthropic's guide also notes that these techniques significantly reduce hallucinations rather than eliminate them completely. For an answer that could send a customer to wait at the door, I want a second layer checked by code.

That layer rests on a simple fact: the chatbot in this post can't see orders. So any answer that states an order's status is a guess, and a regular expression can catch it. blockStatusGuess takes the model's answer. If it says an order "has been delivered", is "out for delivery", "on its way" or "being made", the function replaces it with a safe reply; otherwise it keeps the answer as it is:

src/guard.ts
const CLAIMS_STATUS = /(has been|have been|was|already) delivered|out for delivery|on (its|the) way|being (made|prepared)/i;

export const SAFE_REPLY = "Sorry, Mộc can't look up your order here. A member of staff will check and message you back!";

export function blockStatusGuess(reply: string): { text: string; blocked: boolean } {
  if (CLAIMS_STATUS.test(reply)) return { text: SAFE_REPLY, blocked: true };
  return { text: reply, blocked: false };
}

I wrote four tests for it. The first two pass in answers that guess an order's status and expect them to be blocked. The last two pass in correct answers about opening hours and the delivery fee and expect them to be kept:

src/guard.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";

import { blockStatusGuess, SAFE_REPLY } from "./guard.ts";

test("blocks a reply claiming the order was delivered", () => {
  assert.equal(blockStatusGuess("Your order 1041 has been delivered!").text, SAFE_REPLY);
});

test("blocks a reply saying the order is out for delivery", () => {
  assert.equal(blockStatusGuess("Your order is out for delivery, about 10 minutes away.").blocked, true);
});

test("keeps an answer about opening hours", () => {
  const reply = "Mộc is open from 6:30 a.m. to 10 p.m. every day.";
  assert.deepEqual(blockStatusGuess(reply), { text: reply, blocked: false });
});

test("keeps an answer about the delivery fee", () => {
  assert.equal(blockStatusGuess("Mộc delivers within 5 km for 15,000 đồng.").blocked, false);
});
node src/guard.test.ts
ok 1 - blocks a reply claiming the order was delivered
ok 2 - blocks a reply saying the order is out for delivery
ok 3 - keeps an answer about opening hours
ok 4 - keeps an answer about the delivery fee
# tests 4
# pass 4
# fail 0

The last two tests matter as much as the first two. The delivery answer contains the word "delivers" ("Mộc delivers within 5 km"), and if the regex were written carelessly as /deliver/, the chatbot would block a correct answer too. Remember how the word "deliver" made the keyword chatbot in the previous post answer wrongly? This time it nearly broke the safety net.

I'll admit this check is crude. It matches words rather than understanding sentences, so something like "your order will be there any minute" still slips through. But a crude net with tests beats no net at all.

How much does each call cost, and how slow is it?

Every answer comes back with usage, the number of input and output tokens. ask-moc.ts prints both numbers under each answer when you run it with a real key. It's worth getting into the habit of looking at them now, because API pricing is per token.

For this chatbot, most of the input tokens are the system prompt, and it's repeated word for word with every message. 200 messages a day means sending the same price list 200 times. That sounds wasteful, and it is, but it's how every LLM API works: each request is independent, and the model remembers nothing between two calls. Techniques such as prompt caching cut the cost of the repeated part, and later posts in the series measure it properly before optimizing.

As for speed, I'm not giving a number because I haven't measured it with a real key, and it changes with the model, the time of day and the network. Once you have a key, wrap the call in console.time and measure it yourself.

How do you handle errors when calling the API?

Calling an API over the network will fail sooner or later. The SDK has its own error classes for each case, so you don't have to parse error messages to guess what happened. That's the job of the catch block at the end of askLLM in call-llm.ts above. It catches the two most common errors separately, AuthenticationError (wrong key) and RateLimitError (too many requests), and rethrows everything else.

The two need telling apart because they're handled in opposite ways. An authentication error won't go away no matter how often you retry; you have to fix the key. A rate limit error goes away if you wait, and the SDK has already retried a few times before it throws. The cause option keeps the original error, the same technique as in the post on reading a stack trace from the series Taking over a legacy project, so you can still see what happened when you need to debug.

If you run ask-moc.ts without setting ANTHROPIC_API_KEY, the program stops at the very first question, before any request is sent:

node src/ask-moc.ts v2 (no API key set)
Calling claude-opus-5-5, system prompt v2

Error: Could not resolve authentication method. Expected one of apiKey, authToken, credentials, config, or profile to be set. Or for the "Authorization" header to be explicitly omitted

This isn't an AuthenticationError, because the server was never contacted. The SDK notices on its own that there's no way to authenticate, so my catch block lets it through unchanged. Until you have a key, you can still run inspect-request.ts and the test file, because neither calls the network.

The chatbot stopped inventing, but it still can't help

Look again at the answer about order 1041: "a member of staff will check and message you back". It's honest, but the owner still has to open the order book, find the order and reply. Meanwhile, the information about order 1041 sits right there in the shop's system, one function call away from the chatbot.

So how do you let the model say "I need to look at order 1041", so that your code looks up the real order and hands the result back for the model to write its answer? That technique is called tool calling, and it's the step that turns this chatbot into the foundation of an agent. The next post in the series does exactly that. You can follow the whole series at Build your first AI agent, and if you're preparing for interviews, the question about hallucination 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.