The Forty-Line Agent
Here is the whole thing: a system prompt, three tools with schemas, a message list, a loop with a stop-reason branch, and a dispatch table that runs whichever function the model asked for. About forty lines, no library beyond an HTTP client, and it handles Sundry's canonical ticket.
It is an agent all the same — the model, not the code, decides what to look up and in what order. And it is wrong in eleven ways, each of which has a chapter with its name on it. Naming them here is the map of the rest of the book.
The Pieces on the Table
Four things sit in the file. A system prompt that states the agent's role and today's date, with a summary of the return policy pasted into it. Three tool definitions, each a name, a sentence of description and a parameter schema. A dispatch table mapping those names to real functions in Sundry's existing internal API. And the loop.
SYSTEM = """You are Sundry's support agent. Today is 2026-03-14. Resolve the ticket using the tools. Never promise what policy does not allow.""" TOOLS = [ {"name": "search_orders", "description": "Find orders by email, order id or date range.", "input_schema": {"type": "object", "properties": {"email": {"type": "string"}, "order_id": {"type": "string"}}}}, {"name": "get_order", "description": "Full order detail: seller, items, charges, description.", "input_schema": {"type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"]}}, {"name": "search_policy", "description": "Search Sundry's policy library. Returns passages.", "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}, ] DISPATCH = { "search_orders": orders_api.search, "get_order": orders_api.get, "search_policy": policy_index.search, } def handle(ticket): messages = [ {"role": "system", "content": SYSTEM + POLICY_SUMMARY}, {"role": "user", "content": ticket.text}, ] while True: reply = model(messages=messages, tools=TOOLS) messages.append({"role": "assistant", "content": reply.content}) if reply.stop_reason != "tool_use": return reply.text for call in reply.tool_calls: result = DISPATCH[call.name](**call.args) messages.append({"role": "tool", "tool_use_id": call.id, "content": json.dumps(result)})
In words: the message list starts with the system prompt and the customer's text. The loop calls the model with that list and the three tool definitions, appends whatever comes back, and reads the stop reason. Anything other than a request for a tool means the run is finished and the text goes to the customer. Otherwise each requested tool is looked up by name, called with the arguments the model supplied, and its output appended as a message tagged with the identifier of the request it answers.
Notice what is absent. No framework, no agent class, no memory subsystem, no retry decorator, no planner. The only third-party dependency is whatever makes the HTTP call to the model. By the end of this chapter you should be able to retype this from memory, which is the point — everything from Chapter 3 onwards is an addition to a shape you already hold in your head.
Walking One Real Ticket
The ticket is the canonical one: "the shelving unit turned up Tuesday with a cracked side panel, and I've been charged twice for delivery. I don't want a replacement, I just want it gone." On turn one the context holds the system prompt with its pasted policy summary, the three schemas, and that paragraph. There is no order number anywhere in it. The model asks for search_orders with the buyer's email address, which the ticket record supplies.
Turn two carries the request and its result. Two orders come back for that address, one of them delivered on Tuesday and containing a shelving unit. The model picks that one and asks for get_order. Nobody wrote the rule that says "search first, then fetch the detail of the one that matches the described delivery date" — that sequencing decision was made at runtime, from the data, which is the thing the loop is for.
Turn three brings the order record: a marketplace seller rather than Sundry's own warehouse, one shelving unit, and two delivery charges of $9.95 on the same order. The model now has both problems in front of it — the damage the customer described and a billing error the customer mentioned in half a sentence. It asks search_policy for the damage-in-transit procedure for a marketplace order.
Turn four returns two passages: the damage procedure, which offers replacement or refund, and the refund matrix row saying that money for a marketplace order comes out of the seller's balance. The model stops asking and answers. It acknowledges the crack, accepts the customer's refusal of a replacement, says a refund is the right remedy under the damage procedure, treats the duplicate $9.95 as a separate billing correction, and explains what happens next.
Read that reply carefully and one thing stands out: it is a promise. This version has no issue_refund, no start_return, no message_seller. It reads, it reasons, it drafts — and nothing in the world changes. That is a deliberate starting point, because the moment tools that act are added, every item on the list below stops being untidy and starts being an incident with a date.
What Already Works
Three capabilities came free with those forty lines, and all three were genuinely hard to build any other way. It read intent out of rambling prose and found two problems where the customer signposted one. It chose between two tools whose purposes overlap, using search_orders when it had an email and get_order once it had an id. And it combined lookups in an order nobody sequenced in advance, deciding after reading the order record that a policy question had appeared.
A rules engine reaches the first of those after years of work and never quite gets the third. That is the case for the loop, and it is worth writing down before the list of defects, because the defects are all fixable engineering and the capability above is not something you can add later by trying harder.
The Eleven Things Wrong With It
None of these are subtle once named, and every one of them is invisible in a demo. Each points at the chapter that fixes it.
- No turn limit.
while Truemeans an ambiguous ticket runs until the context window ends it. Topic 03 defined the four ways a run is allowed to finish; Chapter 7 puts a guard behind each of them. - No spend ceiling. Nothing counts what this ticket has cost so far, so one bad run can spend forty dollars unnoticed. Topic 03 named spend as one of those four; Chapter 7 turns it into a ceiling somebody owns.
- No idempotency. The dispatcher passes whatever the model sent, so the day
issue_refundjoins the table, a timed-out call retried by the loop pays the buyer twice. Chapter 3. - No retries. One slow response from the carrier API raises straight out of the loop and the ticket dies mid-run. Chapter 8 covers timeouts, backoff and what to do with a half-finished run.
- Unbounded context growth. Every tool result stays in the array for the rest of the run and is re-sent on every turn, with nothing measuring the total. Chapter 5.
- Policy pasted into the prompt.
POLICY_SUMMARYis a fork of a document the business keeps editing, and it cannot hold 2,000 seller supplements. Chapter 6 replaces it with retrieval. - No logging worth reading. The function returns text. What the model asked for, what came back and what it cost are gone the moment it returns. Chapter 13.
- No evaluation. There is no way to tell whether a change helped, so every improvement is a matter of opinion and a demo. Chapter 9.
- No permission model. Any tool in the dispatch table can be called with any arguments the model produces, and nothing checks scope or ceilings. Chapter 12.
- No way to resume. The message list lives in a local variable, so a crash on turn six loses the whole run and the work it already did. Chapter 11.
- No idea what it costs. The usage counts come back on every call and are discarded, so cost per ticket is unknown. Chapter 13 builds the cost model on the arithmetic from Chapter 2.
Sort those eleven into two piles before starting work. Missing limits, missing idempotency and the absent permission model are dangerous: each one is a production incident with a customer or a seller on the other end. Missing logging, evaluation and cost accounting are not dangerous on any single run — they are the reason you will not be able to tell what happened, which is worse over a quarter than any one incident.
The Honest Baseline
Run this against the 120-ticket eval set and it resolves 61% of them, at $0.41 a ticket. That number is the one every later improvement in this book is measured against, and it is roughly the share of the queue that a correct, well-written answer settles on its own — status questions, delivery questions, product questions. It fails almost everything that needs something to actually happen, which is what the next three chapters are about.
Record the conditions alongside the number: which model, which prompt, which tools, which 120 tickets, and who graded them. Chapter 9 exists because the improvements that feel biggest are frequently not the ones that move that figure, and without a recorded baseline there is no way to find that out — only a team that is certain the agent is better and cannot say by how much.
- Shipping this version because it demoed well — it has no limits, no idempotency and no audit trail, and each of those three is a production incident with a name in Chapter 8 and Chapter 12.
- Reaching for a framework at this point to fix the eleven problems — a framework fixes four of them, hides three behind its own abstractions, and you still cannot say which four (Chapter 14).
- Measuring it by reading transcripts — transcripts read well and hide systematic errors, because the runs that look fluent are exactly the ones nobody re-reads. The eval set exists for this reason (Chapter 9).
- Growing this file to five hundred lines before separating the tools, the prompts and the loop — the loop should stay the small readable thing it is now, because it is what you will debug for years.
- Keep this forty-line version in the repository as a reference implementation, and hand it to every new engineer before they touch the production one.
- Record the 61% baseline and the exact conditions it was measured under before changing a single line.
- Fix the limits first — turns, spend, idempotency — before fixing the quality, because they bound the damage while everything else is still moving.
- Read ten full transcripts by hand before writing the first improvement, then stop trusting transcripts and build the eval set (Chapter 9).
Knowledge Check
Which part of the forty lines is genuinely load-bearing — remove it and the program stops being an agent?
- The branch on the stop reason that sends the run around again when a tool was requested
- The dispatch dictionary, which is what allows a tool name to be resolved to a real function
- The system prompt, which is what tells the model to behave as a support agent at all
- The JSON Schema on each tool, without which the model cannot express a request at all
Why record the 61% baseline before making any improvement?
- Without it there is no way to tell an improvement that worked from one that only felt convincing
- A recorded baseline makes the later runs more consistent, because the eval set is then held fixed for all of them
- Cost per ticket cannot be calculated at all until a resolution rate has first been established on the eval set
- Stakeholders need a number to approve the project before any more engineering time can be spent on it
Of the eleven problems, which grouping is dangerous rather than merely untidy?
- The missing turn and spend limits, the missing idempotency, and the absent permission model
- The missing logging, the missing evaluation, and the missing cost accounting per ticket
- The unbounded context growth, and the policy summary pasted into the system prompt
- The absence of retries, and the fact that a crashed run cannot be resumed from where it stopped
Vera's colleague suggests adopting an agent framework right now to clear the list. What is the honest assessment?
- It would solve several items and hide others behind abstractions nobody on the team can yet inspect
- It would solve all eleven, since limits, retries, evaluation and permissions are standard features
- It would solve none of them at all, because every framework is only a thin wrapper over exactly the same loop
- It would solve most of them, at a latency cost that would make the support widget unusable for customers
The team reads ten transcripts, finds them excellent, and concludes the agent is ready. What is wrong with that conclusion?
- Transcripts read well even when the reasoning was wrong, so systematic errors survive exactly this kind of review
- Ten is too small a sample, and the same review over a hundred transcripts would have been sound
- The transcripts were read by the team that built the agent rather than by independent graders
- The same ten tickets would produce different transcripts on a second run, so nothing was reproducible
You got correct