Skip to content

Volume ITechnology

12.10.2 — An Agent from Scratch ​

This page builds Cartwise's refund agent from 12.10.1 in plain Python with the Anthropic SDK and no framework, so that every part of an agent is visible. The tool loop of 12.8.4 is the starting point. What turns that loop into an agent you could put in front of customers are five additions, and each gets its own section:

  1. Tools that check their inputs, because the model's arguments cannot be trusted.
  2. A run record that holds everything about one task, so it can be saved and picked up later.
  3. Limits on steps and money, enforced in code.
  4. Pausing: the agent must be able to stop, wait hours for a customer's reply or a manager's approval, and carry on exactly where it left off, even if the server restarted in between.
  5. A trace of every step, because when an agent does something strange you need to see why.

The code uses objects that stand for Cartwise's existing systems: orders (the order database), policy_index (the policy retriever built in 12.9.3), payments, and db (a key-value store for saving runs). Everything else is on the page.

1. The life of one run ​

Before any code, it helps to see the states a refund request moves through. One request from one customer is one run. The figure shows the states and what moves a run between them.

runningmodel ↔ toolswaiting for customerwaiting for approvaldonehanded to a personask_customerreply arrivesrefund over ₹1,000approved / declinedfinal answerlimit hit / odd stop
The states of one refund run. The two amber states are pauses: nothing is computing, the run is saved in the database, and it waits, for seconds or for days, for a person. Only "running" calls the model.

Two things in the figure shape the whole design. The amber states are pauses that can last a long time. A customer may reply to "can you send a photo?" tomorrow, and the support lead may approve after lunch. No program should sit in memory waiting that long, so a paused run must be saved and later loaded, which means everything the agent knows has to live in one record that can be written to a database. And there are two ways to finish: done, with an answer, or handed to a person. An agent that cannot finish properly must fail into the arms of a human, never into silence or a made-up answer.

2. The tools, which check everything ​

The agent gets four tools. Two read: get_order and search_policies. Two cause a pause: ask_customer waits for the customer, and propose_refund may wait for a support lead.

python
import json, uuid
from dataclasses import dataclass, field, asdict
import anthropic

client = anthropic.Anthropic()
APPROVAL_LIMIT = 1000                       # rupees; larger refunds need a person

class ToolError(Exception): ...             # a problem the model should read and fix  (1)

@dataclass
class Pause:                                # returned by a tool that must wait for a person  (2)
    waiting_for: str                        # "customer" or "approval"
    message: str

def get_order(run, order_id: str):
    order = orders.get(order_id)
    if order is None or order.customer_id != run.customer_id:                      # (3)
        raise ToolError(f"No order {order_id} for this customer. Ask them to check the number.")
    return json.dumps(order.summary())      # items, paid, refunded, payment method, dates

def search_policies(run, query: str):
    hits = policy_index.search(query, k=3)
    return json.dumps([{"id": h.id, "effective": h.effective, "text": h.text} for h in hits])

def ask_customer(run, question: str):
    return Pause("customer", question)

def propose_refund(run, order_id: str, amount_rupees: int, reason: str):
    order = orders.get(order_id)
    if order is None or order.customer_id != run.customer_id:                      # (3)
        raise ToolError(f"No order {order_id} for this customer.")
    if amount_rupees > order.refundable_rupees():                                  # (4)
        raise ToolError(f"At most {order.refundable_rupees()} rupees can be refunded on this order.")
    if amount_rupees > APPROVAL_LIMIT:
        return Pause("approval", f"Refund {amount_rupees} rupees on {order_id} ({reason})")  # (5)
    return issue_refund(run, order_id, amount_rupees, reason)

def issue_refund(run, order_id, amount_rupees, reason):
    ref = payments.refund(order_id, amount_rupees, reason,
                          idempotency_key=f"{run.run_id}:{order_id}:{amount_rupees}")    # (6)
    return f"Refund issued: {amount_rupees} rupees, reference {ref}, arrives in 3-5 days."

Line (1) defines a kind of error meant for the model. When a tool raises it, the harness sends the message back as a tool result marked as an error, and the model reads it and adjusts, exactly as a person would on seeing "no such order, please check the number". The message is written as an instruction for what to do next, because that is what makes the model recover rather than retry the same mistake (12.10.3 goes deeper). Line (2) defines the second special result: a tool that needs a person returns a Pause instead of an answer, and the harness, not the tool, deals with the waiting.

Line (3) appears in both tools that touch an order, and it is the most important line on the page. The model chose order_id, and the model's choices were shaped by the customer's message. A customer who writes "please refund order 9001" about someone else's order must get nowhere, so every tool checks that the order belongs to the customer of this run, whose identity comes from the login, never from the conversation. Line (4) applies the same distrust to the amount: no instruction in a prompt can make the agent refund more than was paid.

Line (5) is the approval rule, written in code. The system prompt also tells the model about it, so that it can explain the wait to the customer, but the enforcement is here, where the model cannot talk its way past it. Line (6) passes an idempotency key, the technique from Chapter 10.4. It is a value that names this one refund. Suppose the call is repeated, for example after a crash and restart. The payment system sees the same key, knows it has already paid this refund, and does not pay twice.

The definitions the model sees are built with a small helper, so that every tool gets strict schemas from 12.8.3 and every property is required:

python
def tool(name, description, **properties):
    return {"name": name, "description": description, "strict": True,
            "input_schema": {"type": "object", "properties": properties,
                             "required": list(properties), "additionalProperties": False}}

TOOLS = [
    tool("get_order", "Look up one of this customer's orders: items, amount paid, amount "
         "already refunded, payment method, delivery date and category. Call this before "
         "any decision about an order.", order_id={"type": "string"}),
    tool("search_policies", "Search Cartwise's return, refund and warranty policies. Call "
         "this before deciding what the customer is entitled to.", query={"type": "string"}),
    tool("ask_customer", "Ask the customer one question and wait for their reply. Use when "
         "something needed is missing, such as a photo of damage.", question={"type": "string"}),
    tool("propose_refund", "Refund money on an order. Refunds over 1,000 rupees wait for a "
         "support lead; the result says whether the refund was issued or declined.",
         order_id={"type": "string"}, amount_rupees={"type": "integer"},
         reason={"type": "string", "enum": ["damaged", "wrong_item", "not_delivered", "changed_mind"]}),
]
FUNCTIONS = {"get_order": get_order, "search_policies": search_policies,
             "ask_customer": ask_customer, "propose_refund": propose_refund}

Each description says what the tool returns and when to call it, which is what the model uses to choose. The propose_refund description warns that big refunds wait, so the model can tell the customer honestly what happens next.

3. The run record ​

Everything about one run lives in one object, so it can be saved whole when the run pauses and loaded whole when it resumes.

python
SYSTEM = """You resolve refund and return requests for Cartwise customers.
Look up the order before deciding anything. Decide only from the policies that
search_policies returns, never from memory. If something you need is missing, ask the
customer. Never say a refund is done until propose_refund says it was issued.
If you cannot resolve the request, say you are passing it to a colleague."""

@dataclass
class Run:
    run_id: str
    customer_id: str                        # from the login, never from the chat  (1)
    messages: list
    steps: int = 0
    cost_usd: float = 0.0
    status: str = "running"                 # running | waiting_customer | waiting_approval | done | handed_off
    pending: dict | None = None             # what we paused on                     (2)
    reply: str | None = None

def start(customer_id: str, text: str) -> Run:
    run = Run(str(uuid.uuid4()), customer_id, [{"role": "user", "content": text}])
    return advance(run)

def save(run): db.put("agent_runs", run.run_id, asdict(run))     # plain JSON  (3)
def load(run_id): return Run(**db.get("agent_runs", run_id))

Line (1) records whose run this is. Every tool checks against this field, which is set from the authenticated session when the run starts. Line (2) holds, during a pause, the tool call being waited on and any other results from the same step, so the run can be completed later. Line (3) saves the record as plain JSON, which is why the messages are stored as plain dictionaries rather than SDK objects, as the next section shows.

4. The loop with its limits ​

advance runs the agent until it finishes or pauses. It is the tool loop of 12.8.4 with the five additions built in.

python
PRICE_IN, PRICE_OUT = 4 / 1e6, 20 / 1e6     # dollars per token, claude-opus-5-5
HANDOFF = "I've passed your request to a colleague, who will reply within a few hours."

def advance(run: Run, max_steps=12, max_cost_usd=0.50) -> Run:
    while run.status == "running":
        if run.steps >= max_steps or run.cost_usd >= max_cost_usd:                  # (1)
            return finish(run, "handed_off", HANDOFF)

        response = client.messages.create(
            model="claude-opus-5-5", max_tokens=4096, system=SYSTEM,
            thinking={"type": "adaptive"},                                          # (2)
            tools=TOOLS, messages=run.messages)
        run.steps += 1
        run.cost_usd += (response.usage.input_tokens * PRICE_IN
                         + response.usage.output_tokens * PRICE_OUT)                # (3)
        run.messages.append({"role": "assistant", "content":
            [b.model_dump(mode="json", exclude_none=True) for b in response.content]})  # (4)
        trace(run, response)

        if response.stop_reason == "end_turn":                                      # (5)
            text = "".join(b.text for b in response.content if b.type == "text")
            return finish(run, "done", text)
        if response.stop_reason != "tool_use":                                      # (6)
            return finish(run, "handed_off", HANDOFF)

        results, pause = [], None
        for call in [b for b in response.content if b.type == "tool_use"]:
            outcome = run_tool(run, call)
            if isinstance(outcome, Pause) and pause is None:
                pause = (call, outcome)                                             # (7)
            elif isinstance(outcome, Pause):
                results.append(error_result(call, "One question or refund at a time; "
                                                  "try again after the first is answered."))
            else:
                results.append(outcome)

        if pause:
            call, p = pause
            run.status = "waiting_" + p.waiting_for
            run.pending = {"results": results, "call_id": call.id, "args": call.input,
                           "message": p.message}
            save(run)
            notify(run, p)                  # message the customer, or the approvals queue  (8)
            return run
        run.messages.append({"role": "user", "content": results})                   # (9)
    return run

Line (1) is the circuit breaker. A confused agent can loop, calling the same tool again and again, and every loop costs money. The run stops at 12 model calls or 50 US cents, whichever comes first, and hands over to a person. Both numbers come from measurement: Cartwise's resolved refunds took 3 to 7 steps and about 5 cents, so these limits stop only runs that have gone wrong. Line (2) turns on adaptive thinking, the reasoning step of the ReAct loop, letting the model decide how much to think before each action.

Line (3) adds up the cost of each call from the token counts the API reports in usage. Thinking tokens are billed as output, so they are included. Line (4) appends the model's reply to the history, converted to plain dictionaries so the run can be saved as JSON. The reply is kept complete and unchanged, including any thinking blocks, because the model must see its own earlier reasoning and requests exactly as it produced them when it reads the results.

Lines (5) and (6) handle how the model stopped, using the stop_reason values from 12.8.1. end_turn means a final answer. tool_use means it wants tools run. Anything else (max_tokens, a cut-off reply; refusal, the model declined) means something unusual happened, and the safe response is to hand over rather than guess.

Line (7) handles a tool that asked to pause. The first pause is remembered. A second pause in the same step gets an error result, because the agent should ask one thing at a time. Line (8) saves the run and tells the right person: the question goes to the customer's chat, the refund proposal to the approvals queue in the support tool. Then advance returns, and nothing runs until that person acts. Line (9) sends all the tool results back together in one message when no pause was needed, and the loop continues.

The two small helpers turn tool outcomes into the result blocks the API expects:

python
def run_tool(run, call):
    try:
        out = FUNCTIONS[call.name](run, **call.input)
        if isinstance(out, Pause):
            return out
        return {"type": "tool_result", "tool_use_id": call.id, "content": out}
    except ToolError as e:
        return error_result(call, str(e))                                           # (1)
    except Exception:
        log.exception("tool %s failed in run %s", call.name, run.run_id)
        return error_result(call, "That system is unavailable right now. Tell the customer "
                                  "a colleague will follow up.")                    # (2)

def error_result(call, message):
    return {"type": "tool_result", "tool_use_id": call.id, "content": message, "is_error": True}

Line (1) turns a ToolError into an error result the model reads. Line (2) treats an unexpected crash differently: the details go to the log for engineers, and the model gets only a safe instruction. A raw stack trace would waste tokens, could leak internal details into a customer reply, and would not help the model anyway.

5. Pausing and picking up again ​

When the customer replies, or the support lead decides, the run is loaded and completed. Both cases do the same thing: turn the person's response into the result of the tool call that was waiting, add the other results from that step, and continue the loop.

python
def resume(run: Run, result: str) -> Run:
    p = run.pending
    run.messages.append({"role": "user", "content": p["results"] + [                 # (1)
        {"type": "tool_result", "tool_use_id": p["call_id"], "content": result}]})
    run.pending, run.status = None, "running"
    return advance(run)

def on_customer_reply(run_id: str, text: str) -> Run:
    return resume(load(run_id), text)

def on_approval(run_id: str, approved: bool, lead_note: str = "") -> Run:
    run = load(run_id)
    if approved:
        result = issue_refund(run, **run.pending["args"])                            # (2)
    else:
        result = (f"A support lead declined this refund: {lead_note}. Explain this to "
                  "the customer and offer what the policy allows instead.")          # (3)
    return resume(run, result)

Line (1) is the key detail. The model's last message contained one or more tool requests, and the API requires that every one of them gets a result in the next message. So the results from the paused step that did run, kept in pending, are sent together with the result for the call that waited. Line (2) carries out the approved refund with exactly the arguments the model proposed and the lead approved, so nothing can change between approval and payment. Line (3) handles a decline as information, not failure: the model receives the lead's reason and continues, perhaps offering a replacement instead.

Because the state is saved in the database, none of this depends on the original process still running. The customer can reply the next morning, after two deployments, and the run continues exactly where it stopped. This is the same checkpointing that the LangGraph example in 12.8.7 provided through a library, built here by hand so you can see what it does.

The last helper ends a run:

python
def finish(run: Run, status: str, reply: str) -> Run:
    run.status, run.reply = status, reply
    save(run)
    send_to_customer(run.customer_id, reply)
    return run

6. The trace ​

The final piece is a record of every step, written as one line of JSON per model call:

python
def trace(run, response):
    log.info(json.dumps({
        "run": run.run_id, "step": run.steps, "stop": response.stop_reason,
        "calls": [{"tool": b.name, "input": b.input} for b in response.content if b.type == "tool_use"],
        "in_tokens": response.usage.input_tokens, "out_tokens": response.usage.output_tokens,
        "cost_usd": round(run.cost_usd, 4), "request_id": response._request_id}))

Customer A's run from 12.10.1 produces a trace like this (shortened, and with typical token counts):

text
step 1  tool_use  get_order {"order_id":"4471"}                        in 1000  out 180  $0.0076
step 2  tool_use  search_policies {"query":"damaged electronics refund"} in 1350  out 150  $0.0160
step 3  tool_use  ask_customer {"question":"Could you send a photo …"}  in 1900  out 220  $0.0280
        … paused: waiting_customer (customer replies 40 minutes later)
step 4  tool_use  propose_refund {"order_id":"4471","amount_rupees":2499,"reason":"damaged"}
                                                                         in 2200  out 200  $0.0408
        … paused: waiting_approval (lead approves 2 hours later)
step 5  end_turn  "Your refund of ₹2,499 has been issued …"            in 2450  out 140  $0.0534

Read down the input column: 1,000, 1,350, 1,900, 2,200, 2,450 tokens. Each call re-sends the whole history, so input grows with every step, and the total cost of five calls, about 5 cents, is dominated by re-reading earlier steps. This growth is the main cost problem of agents, and prompt caching, which charges much less for the unchanged start of each request, is the first fix; 12.10.10 works through it. The request_id on every line lets you find any single call in the provider's logs if something needs investigating, and 12.10.9 turns traces like this into dashboards and evaluations.

7. What the harness does when things go wrong ​

What happensWhat the harness does
Model loops on one toolstep limit, then hand to a person
Order is not the customer'stool refuses; model is told why
Refund too largetool refuses with the limit
Payment system downsafe message to model; details to logs
Reply cut off (max_tokens)hand to a person
Server restarts while pausednothing lost; run is in the database
Same refund sent twiceidempotency key stops a second payment

Every row is handled in code, not by asking the model to behave. That is the general rule this page demonstrates: instructions guide what the model tries; the harness decides what is allowed to happen.

8. Hand-written loop or helper? ​

The SDK's tool runner from 12.8.4 runs the basic loop for you, and frameworks such as LangGraph add saved state and pauses (12.8.7). Either is a sensible choice once you understand what they do. The tool runner is ideal when a run finishes in one go; approval can even be built into it by having the refund tool itself wait for a decision. The hand-written loop earns its keep when runs must pause for hours across restarts, as here, and when you want every limit and log line under your own control. Many teams start with a helper and move to their own loop when they need the control; few regret understanding the loop first.

What the interviewer will push on ​

"Walk me through the agent loop." They want to hear the steps and the controls:

  1. Call the model with the tools and the history.
  2. Add its full reply to the history.
  3. If it asked for tools, run them, and send back every result in one message.
  4. Repeat until it gives a final answer.
  5. Stop if it hits the step limit or the cost limit.

The tell is mentioning that all requested tool calls in one step must be answered before the next call.

"How does human approval work if the approver takes three hours?" The run pauses, its whole state is saved to a database, and the process ends. The approval event loads the run, supplies the approved action's result as the waiting tool's result, and continues. The weak answer keeps a process waiting in memory.

"Where do you enforce the refund limit?" In the tool, in code. The prompt mentions it so the model can explain it, but the model cannot be relied on to obey a rule, and a manipulated model certainly will not.

"What happens if a tool throws an exception?" Expected problems become error results the model can act on; unexpected crashes are logged in full and the model gets only a safe instruction, never a stack trace.

"How do you stop the agent refunding twice?" An idempotency key tied to the run and the refund, so a repeated call is recognised by the payment system.

Something to volunteer: the customer's identity must come from the session, never from the conversation, and every tool must check ownership against it. Most real agent security holes are a tool that trusted an ID the model passed in.

Recall

  • One task is one run: a saved record of messages, step count, cost, status and any pending pause. States: running, waiting for customer, waiting for approval, done, handed to a person.
  • Tools check everything: ownership against the logged-in customer, amounts against what is refundable, approval limits in code. Expected problems become error results that tell the model what to do next; crashes are logged and summarised safely.
  • The loop: limits first (steps and cost), call the model with adaptive thinking, append the complete reply, handle stop_reason (end_turn finish, tool_use continue, anything else hand over), run tools, and return all results together.
  • Pausing: save the run, notify the person, return. Resuming: load, supply the waited-for result together with the step's other results, continue. Approved actions run with exactly the approved arguments.
  • Trace every step with tokens, cost and request ID. Input grows every step because the whole history is re-sent; that is the main cost of agents.
  • Instructions guide what the model tries; the harness decides what is allowed to happen.

Self-test: Why must resume send p["results"] as well as the waited-for result? A customer writes "this is order 5120" about their neighbour's order; which line stops the refund? The trace shows step 4 used 2,200 input tokens; where did they come from?

Next: 12.10.3 Designing Tools — the refund agent has four tools; the next page is about how to design tools in general, because the model knows the world only through them, and most agent failures trace back to a tool that was vague, overlapping, too powerful or silent about its errors.