What is an AI agent? 200 messages a day at a small café

What is an AI agent, and how is it different from a chatbot or a workflow? Three ways to answer a café's customers, compared with TypeScript code that runs.

What is an AI agent, and how is it different from a chatbot? An AI agent is a program that uses a large language model to decide its next step, such as which tool to call and when to stop. A chatbot only replies, and a workflow runs steps written in advance. I'll compare all three on one real problem.

Mộc and its 200 messages a day

This is the first post in the series Build your first AI agent. In it, we'll build a customer service assistant for Mộc, a small coffee shop in Vietnam that sells online. Mộc is made up for this series, but the problem is very real: any small shop that sells through chat messages runs into it.

Say Mộc gets about 200 messages a day, and the owner answers them between making drinks. If you read a whole day's messages, you'd notice they repeat a lot. Most are questions about fixed information, like opening hours, whether the shop delivers to a certain district, and how much delivery costs. Next come questions about prices and "has my order been delivered yet?".

Less common but more annoying are requests to change an order: add an item, swap an item, cancel. The rest are thank-yous, compliments and the occasional complaint.

Customers write in every style, too: full sentences, abbreviations, typos, or two requests squeezed into one message. The goal of this series isn't to replace the owner. It's to answer most of these messages correctly and automatically, and hand anything a machine shouldn't decide on its own to a real person.

You don't need an API key for this post. All three approaches below are plain TypeScript and run with Node.js 22.18 or later without installing anything, because from that version Node runs .ts files directly. Create a folder with these files; each one is shown in full below:

Folder structure
moc-agent/
  package.json           # just one line: "type": "module"
  src/
    messages.ts          # 8 sample messages with the correct intent, for scoring
    shop.ts              # the shop's data and functions to look up orders, add items, check prices
    1-rule-bot.ts        # approach 1: keyword matching
    1b-more-rules.ts     # approach 1, after "patching" in more rules
    2-workflow.ts        # approach 2: workflow
    3-agent.ts           # approach 3: agent
package.json
{
  "name": "moc-agent",
  "private": true,
  "type": "module"
}

The "type": "module" line tells Node that the files use import syntax. After that, you run each approach with node src/<file-name>.ts.

To have something to score against, I prepared 8 sample messages. Each one comes with the customer's correct intent, meaning what the customer is really asking about. There are five intents: opening hours, shipping, price, order status, and "other" for thanks and compliments. An approach gets a message right when it works out the correct intent:

src/messages.ts
export type Intent = "opening-hours" | "shipping" | "price" | "order-status" | "other";

export const messages: { text: string; intent: Intent }[] = [
  { text: "What time do you open?", intent: "opening-hours" },
  { text: "do u ship to district 7", intent: "shipping" },
  { text: "How much is a salt coffee?", intent: "price" },
  { text: "price of bac xiu pls", intent: "price" },
  { text: "Has my order 1041 been delivered yet?", intent: "order-status" },
  { text: "What time will order 1042 arrive?", intent: "order-status" },
  { text: "I bought something this morning and nobody has called me, my number is 0901000001", intent: "order-status" },
  { text: "Thanks, the coffee was great!", intent: "other" },
];

The three order messages are written in three different ways, one with an order number, one with a time, and one with only a phone number, because real customers ask in all of those ways. (District 7 is a district of Ho Chi Minh City, and bac xiu is a Vietnamese milky coffee.)

The second file is "Mộc in miniature". It holds the menu with prices in Vietnamese đồng, two ready-made answers about opening hours and delivery, two sample orders, and four functions shared by the approaches below.

findOrderByPhone finds an order by phone number. viewOrder returns a sentence describing an order. addItem adds an item to an order, but only while the order is still being made. itemPrice returns the price of one item:

src/shop.ts
export type OrderStatus = "brewing" | "out-for-delivery" | "delivered";
export type Order = {
  id: number;
  phone: string;
  items: string[];
  status: OrderStatus;
  createdAt: string;
};

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

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

const orders: Order[] = [
  { id: 1041, phone: "0901000001", items: ["milk coffee", "egg banh mi"], status: "brewing", createdAt: "today 8:12" },
  { id: 1042, phone: "0901000002", items: ["bac xiu"], status: "out-for-delivery", createdAt: "today 9:40" },
];

const STATUS_TEXT: Record<OrderStatus, string> = {
  brewing: "being made",
  "out-for-delivery": "out for delivery",
  delivered: "delivered",
};

export function findOrderByPhone(phone: string): Order | undefined {
  return orders.find((order) => order.phone === phone);
}

export function viewOrder(id: number): string {
  const order = orders.find((item) => item.id === id);
  if (!order) return `No order ${id}.`;
  return `Order ${order.id} (${order.items.join(", ")}) placed ${order.createdAt}: ${STATUS_TEXT[order.status]}.`;
}

export function addItem(id: number, item: string): string {
  const order = orders.find((entry) => entry.id === id);
  if (!order) return `No order ${id}.`;
  if (order.status !== "brewing") return `Order ${id} is already ${STATUS_TEXT[order.status]}, items can't be added.`;
  if (!(item in MENU)) return `"${item}" is not on the menu.`;
  order.items.push(item);
  return `Added ${item} to order ${id}.`;
}

export function itemPrice(item: string): string {
  const price = MENU[item];
  return price ? `${item}: ${price.toLocaleString("en-US")} đồng` : `"${item}" is not on the menu.`;
}

Approach one: a keyword-matching chatbot

This is the approach everyone thinks of first, and plenty of small business pages still use it. When the bot sees "time" in a message, it sends the opening hours. When it sees "ship", it sends the delivery fee.

In code, each rule is a pair of a regular expression and an intent. ruleBot goes through the rules from the top and takes the first one that matches. If none match, it returns "other". The end of the file runs the bot on the 8 sample messages, prints ✓ or ✗ for each, and counts how many it got right:

src/1-rule-bot.ts
import { messages, type Intent } from "./messages.ts";

const rules: { pattern: RegExp; intent: Intent }[] = [
  { pattern: /time|open/i, intent: "opening-hours" },
  { pattern: /ship|deliver/i, intent: "shipping" },
  { pattern: /price|how much/i, intent: "price" },
  { pattern: /order/i, intent: "order-status" },
];

export function ruleBot(text: string): Intent {
  return rules.find((rule) => rule.pattern.test(text))?.intent ?? "other";
}

let correct = 0;
for (const message of messages) {
  const guess = ruleBot(message.text);
  const ok = guess === message.intent;
  if (ok) correct += 1;
  console.log(`${ok ? "✓" : "✗"} ${message.text}\n    → read as "${guess}"${ok ? "" : `, should be "${message.intent}"`}`);
}
console.log(`\n${correct}/${messages.length} messages right.`);

Here's the result on the 8 sample messages. Each message takes two lines: the message itself, then the intent the bot read, plus the correct intent when it got it wrong:

node src/1-rule-bot.ts
✓ What time do you open?
    → read as "opening-hours"
✓ do u ship to district 7
    → read as "shipping"
✓ How much is a salt coffee?
    → read as "price"
✓ price of bac xiu pls
    → read as "price"
✗ Has my order 1041 been delivered yet?
    → read as "shipping", should be "order-status"
✗ What time will order 1042 arrive?
    → read as "opening-hours", should be "order-status"
✗ I bought something this morning and nobody has called me, my number is 0901000001
    → read as "other", should be "order-status"
✓ Thanks, the coffee was great!
    → read as "other"

5/8 messages right.

The bot gets 5 out of 8 right. What's more worrying than the score is that all three misses are about orders, the kind of question customers are most impatient about. "Has my order been delivered" contains "deliver", so the customer gets the delivery fee table. "What time will my order arrive" contains "time", so the customer gets the opening hours. And "I bought something this morning and nobody has called me" matches no keyword at all, so the bot has no answer for it.

A developer's natural reflex is to patch the rules: move the order rule to the top and add the word "bought". The file below is the patch. It runs the new rules on the 8 old messages plus 2 new ones, and this time it only prints the messages it gets wrong:

src/1b-more-rules.ts
import { messages, type Intent } from "./messages.ts";

const patchedRules: { pattern: RegExp; intent: Intent }[] = [
  { pattern: /order|bought/i, intent: "order-status" }, // patch: order questions first
  { pattern: /time|open/i, intent: "opening-hours" },
  { pattern: /ship|deliver/i, intent: "shipping" },
  { pattern: /price|how much/i, intent: "price" },
];

const bot = (text: string): Intent => patchedRules.find((rule) => rule.pattern.test(text))?.intent ?? "other";

const newMessages: { text: string; intent: Intent }[] = [
  { text: "Do you deliver if I order just one cup?", intent: "shipping" },
  { text: "Can I order egg banh mi ahead, and what does it cost?", intent: "price" },
];

let correct = 0;
const all = [...messages, ...newMessages];
for (const message of all) {
  const guess = bot(message.text);
  if (guess === message.intent) correct += 1;
  else console.log(`✗ ${message.text}\n    → read as "${guess}", should be "${message.intent}"`);
}
console.log(`\n${correct}/${all.length} messages right (8 old + 2 new).`);

After the patch, all 8 old messages are answered correctly, so none of them are printed. But both new messages are wrong:

node src/1b-more-rules.ts
✗ Do you deliver if I order just one cup?
    → read as "order-status", should be "shipping"
✗ Can I order egg banh mi ahead, and what does it cost?
    → read as "order-status", should be "price"

8/10 messages right (8 old + 2 new).

"If I order just one cup" isn't asking about an existing order, and "can I order ahead" is a question about price and how to order, not an order lookup. The patch fixes three old messages and breaks two new ones. Keep patching and other messages will break, and the loop never ends, because people have endless ways to say the same thing, and many words carry two or three meanings.

The root cause is that a keyword-matching bot doesn't understand sentences. It only sees words. To a regular expression, "order" in "my order 1041" and "order" in "can I order ahead" are exactly the same.

What do LLMs change, and what don't they?

Large language models (LLMs) such as Claude, GPT or Gemini understand what a sentence means, even with abbreviations and typos. I'm confident that if you gave the 10 messages above to an LLM to classify, it would do far better than the rules, and later posts in this series run that test for real so you don't have to take my word for it.

On its own, though, an LLM can only produce text. It doesn't know where order 1041 is, because that information lives in the shop's system, not in the model. Ask it "has order 1041 been delivered?" and it will either say it doesn't know or, worse, make up an answer that sounds perfectly plausible.

Anthropic has an article on this topic that is well worth reading, Building effective agents. It calls the building block of these systems the "augmented LLM": an LLM given extra abilities such as retrieval, tools and memory. It then splits these systems into two kinds, workflows and agents, which are exactly approaches two and three below.

Approach two: a workflow with a path written in advance

A workflow is a system where LLMs and tools are connected along a path that a developer has written in code. For Mộc, the most natural path is to classify the message first, then run the branch for that kind of message.

In code, the workflow function takes a message and its intent and uses a switch to pick a branch. Each branch does one fixed thing: return a ready-made answer, find an item name in the message to quote its price, or find an order number (four digits) or a phone number (ten digits starting with 0) to look up the order. The end of the file runs the workflow on the 8 sample messages, then tries one harder message:

src/2-workflow.ts
import { messages, type Intent } from "./messages.ts";
import { FAQ, findOrderByPhone, itemPrice, MENU, viewOrder } from "./shop.ts";

export function workflow(text: string, intent: Intent): string {
  switch (intent) {
    case "opening-hours":
      return FAQ.openingHours;
    case "shipping":
      return FAQ.shipping;
    case "price": {
      const item = Object.keys(MENU).find((name) => text.toLowerCase().includes(name));
      return item ? itemPrice(item) : "Which item would you like the price of?";
    }
    case "order-status": {
      const id = text.match(/\b(\d{4})\b/)?.[1];
      if (id) return viewOrder(Number(id));
      const phone = text.match(/\b0\d{9}\b/)?.[0];
      const order = phone ? findOrderByPhone(phone) : undefined;
      return order ? viewOrder(order.id) : "Could you send Mộc your order number or phone number?";
    }
    default:
      return "Thank you from Mộc! Message us any time.";
  }
}

for (const message of messages) {
  console.log(`Customer: ${message.text}\nMộc:      ${workflow(message.text, message.intent)}\n`);
}

// A message that doesn't fit any branch written in advance:
const hard = "Has my order for 0901000001 been delivered? If not, please add 1 egg banh mi";
console.log(`Customer: ${hard}\nMộc:      ${workflow(hard, "order-status")}`);
console.log("          (only the first half is answered — the 'order-status' branch can't add items)");

Notice that the loop passes in message.intent, the correct answer stored in the sample data. In other words, I assume the classification step is always right, so that only the handling is being scored. In practice, classification is usually handed to an LLM. With that assumption, the result is:

node src/2-workflow.ts
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:      salt coffee: 35,000 đồng

Customer: price of bac xiu pls
Mộc:      bac xiu: 32,000 đồng

Customer: Has my order 1041 been delivered yet?
Mộc:      Order 1041 (milk coffee, egg banh mi) placed today 8:12: being made.

Customer: What time will order 1042 arrive?
Mộc:      Order 1042 (bac xiu) placed today 9:40: out for delivery.

Customer: I bought something this morning and nobody has called me, my number is 0901000001
Mộc:      Order 1041 (milk coffee, egg banh mi) placed today 8:12: being made.

Customer: Thanks, the coffee was great!
Mộc:      Thank you from Mộc! Message us any time.

Customer: Has my order for 0901000001 been delivered? If not, please add 1 egg banh mi
Mộc:      Order 1041 (milk coffee, egg banh mi) placed today 8:12: being made.
          (only the first half is answered — the 'order-status' branch can't add items)

The workflow gets all 8 sample messages right. It's fast and cheap, and most importantly it's predictable: the same question always takes the same path, so when something breaks you know right away which branch to look at. Honestly, for most of Mộc's messages, you could stop here.

The trouble is the last message, the one with two requests in it. The workflow only answers the first half, and the request to add an item is simply ignored.

That request has a condition attached. The system has to find the order by phone number, check its status, add the item if it hasn't gone out yet, and then tell the customer what happened.

You could write one more branch, "look up the order, then add an item". But tomorrow a customer will want "look up the order, then swap an item", the day after "cancel if it hasn't gone out in 30 minutes", then "add an item to my sister's order". The number of combinations grows faster than you can write branches, so sooner or later the workflow can't keep up.

Approach three: an agent that chooses its next step

With an agent, the LLM itself is in control. It decides which tool to call, in what order, and when to stop. The developer no longer writes the path. Instead, they write two things: the tools and a loop.

Every agent's loop has the same shape. First, you send the model the customer's message, the list of tools, and everything that has happened so far. The model returns one of two things: either a request to call a tool with some arguments, or the final answer.

If the model asks for a tool, your code runs that tool, adds the result to the history, and asks the model again. If the model returns an answer, you send it to the customer and you're done.

The file below has three parts, from top to bottom. First comes tools, the list of tools, where each tool is just a function that calls back into shop.ts. In the middle is mockModel, a mock model playing the role of the LLM: it looks at the history and returns either a tool call or the final answer. Last comes runAgent, the loop, which is only about a dozen lines long:

src/3-agent.ts
import { addItem, findOrderByPhone, viewOrder } from "./shop.ts";

type ToolCall = { type: "tool"; name: string; input: Record<string, string | number> };
type FinalAnswer = { type: "text"; text: string };
type Step = ToolCall | FinalAnswer;
type HistoryEntry = { role: "customer" | "model" | "tool"; content: string };

// The tools the agent is allowed to use.
const tools: Record<string, (input: Record<string, string | number>) => string> = {
  find_order_by_phone: ({ phone }) => {
    const order = findOrderByPhone(String(phone));
    return order ? `Found order ${order.id}.` : "No order found for this number.";
  },
  view_order: ({ id }) => viewOrder(Number(id)),
  add_item: ({ id, item }) => addItem(Number(id), String(item)),
};

// Mock model: looks at the history and decides the next step, scripted for exactly one message.
function mockModel(history: HistoryEntry[]): Step {
  const last = history.at(-1)!;
  const toolResults = history.filter((entry) => entry.role === "tool").length;
  if (toolResults === 0) return { type: "tool", name: "find_order_by_phone", input: { phone: "0901000001" } };
  if (toolResults === 1) {
    const id = last.content.match(/\d{4}/)?.[0] ?? "";
    return { type: "tool", name: "view_order", input: { id } };
  }
  if (toolResults === 2 && last.content.includes("being made")) {
    return { type: "tool", name: "add_item", input: { id: 1041, item: "egg banh mi" } };
  }
  return {
    type: "text",
    text: "Your order 1041 is still being made, so it hasn't gone out yet. Mộc has added 1 egg banh mi to it, and it will arrive with the rest!",
  };
}

// The loop: ask the model, run the tool it asks for, add the result to the history, ask again.
export function runAgent(message: string, maxSteps = 5): string {
  const history: HistoryEntry[] = [{ role: "customer", content: message }];
  for (let step = 1; step <= maxSteps; step += 1) {
    const decision = mockModel(history);
    if (decision.type === "text") {
      console.log(`[step ${step}] reply to the customer`);
      return decision.text;
    }
    const result = tools[decision.name](decision.input);
    console.log(`[step ${step}] call ${decision.name}(${JSON.stringify(decision.input)}) → ${result}`);
    history.push({ role: "model", content: `${decision.name} ${JSON.stringify(decision.input)}` });
    history.push({ role: "tool", content: result });
  }
  return "Sorry, a member of staff needs to help with this request."; // step limit
}

const message = "Has my order for 0901000001 been delivered? If not, please add 1 egg banh mi";
console.log(`Customer: ${message}\n`);
const answer = runAgent(message);
console.log(`\nMộc: ${answer}`);

Now I give the agent exactly the message the workflow couldn't handle. Each [step N] line is one turn of the loop: which tool the model asked for, with which arguments, and what the tool returned:

node src/3-agent.ts
Customer: Has my order for 0901000001 been delivered? If not, please add 1 egg banh mi

[step 1] call find_order_by_phone({"phone":"0901000001"}) → Found order 1041.
[step 2] call view_order({"id":"1041"}) → Order 1041 (milk coffee, egg banh mi) placed today 8:12: being made.
[step 3] call add_item({"id":1041,"item":"egg banh mi"}) → Added egg banh mi to order 1041.
[step 4] reply to the customer

Mộc: Your order 1041 is still being made, so it hasn't gone out yet. Mộc has added 1 egg banh mi to it, and it will arrive with the rest!

Two things in this loop are worth pointing out. The first is that step 3 only happens because step 2 returned the status "being made". If the order had already gone out, the agent wouldn't add the item. It would tell the customer instead.

Notice that runAgent contains no if that says so. In this post, the decision lives in the mock model (the last.content.includes("being made") line). Once a real LLM takes its place, the LLM reads the result of step 2 and decides for itself. The loop doesn't change by a single line.

The second is the maxSteps = 5 limit. The agent isn't allowed to loop forever; if it hasn't finished after 5 steps, the request goes to a real person. The limit sounds minor, but without it a confused agent can call tools hundreds of times, and you pay for every single call.

How do the three approaches compare side by side?

The easiest way I know to remember the difference is to ask: who decides the next step?

With a keyword-matching chatbot, hand-written rules decide the next step. It's cheap and runs anywhere, but it doesn't understand sentences, and the more you patch it, the messier the rules get. It fits places where questions are asked in a very fixed way, such as a menu with ready-made buttons.

With a workflow, the developer decides the path, and the LLM only does small jobs along the way, such as classifying or writing the reply. It's predictable, easy to test and cheap, but it can only follow paths written in advance. It fits when the kinds of request are known up front and each kind has a clear way to handle it.

With an agent, the LLM decides each step in the loop. It can handle multi-step, conditional requests, including kinds of request it has never seen before. In exchange, an agent is slower, costs more because every step is a model call, is less predictable, and needs its permissions limited carefully. It fits when the number and order of steps depend on the data, so they can't be written in advance.

When shouldn't you use an agent?

My favourite piece of advice in Anthropic's article is to find the simplest solution possible and only add complexity when it's truly needed. They also say plainly that for many applications, a single LLM call with the right information is enough.

Applied to Mộc, the picture is clear. For questions about opening hours, delivery or prices, a workflow is enough, and an agent would only make customers wait longer and cost the shop more. For checking the status of one order, a workflow is still enough. Only multi-step requests with conditions, such as look up the order and add an item, swap an item or cancel, are where an agent really earns its keep.

So a sensible design usually combines the two. A workflow sits at the front door and handles the simple questions quickly, and only the complex ones are passed to the agent. Anything involving money, such as refunds or discounts, the agent doesn't do itself; it hands it to a real person. Later posts in the series show why I'm so firm on this, when a customer tries to talk the agent into promising a refund.

If you're using AI to write code rather than planning to build an agent, the posts in the AI for developers topic share the same spirit: use AI where it helps, and keep the important decisions for yourself.

What does it take to go from this demo to a real agent?

Today's demo is missing quite a lot, and what's missing is the roadmap of this series. First, we need to call a real LLM from code. Next, we teach the LLM to return tool calls in a precise format instead of free text, a technique called tool calling.

After that, the agent needs a real loop and memory so it knows what the customer just said. Then it needs to read the shop's policies and menu using a technique called RAG. The last part of the series covers what turns a demo into something you'd trust with real customers: resisting manipulation, measuring quality, controlling cost, handing off to a person and going to production.

If you want to check that this post has sunk in, try answering "how is an agent different from a workflow?" in the interview question bank before you look at the answer.

What happens when you plug in a real LLM?

All three approaches in this post run without any real model. The quickest way to see their real limits is to plug in a real LLM and watch what happens.

The next post in the series writes the first TypeScript code that calls an LLM through its API and has it answer Mộc's frequently asked questions. Asked about opening hours, the LLM answers fluently. But asked "has order 1041 been delivered?", it confidently makes up an answer. You can follow the whole series at Build your first AI agent.

Get the next post by email.

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