Agentic AI · Module 10 of 41 · 12 concepts · about 19 min

Coding an Agent from Scratch

A complete working agent in plain Python and a model API, built in six steps: tool functions, tool definitions, the prompt, the reasoning loop, stopping rules and error handling.

At work: The hundred lines every agent framework wraps. An engineer who has written them once can debug any framework, and can tell when a framework is not needed.

What we are building

What it is

A small Kirana Kart support agent that can resolve a late-delivery complaint. It has:

  • three tools: look up an order, read a policy, refund a delivery fee;
  • a system prompt stating its job and rules;
  • a loop that calls the model, runs the tools it asks for, and feeds back the results;
  • stopping rules: a step limit and a token budget;
  • error handling: bad inputs, unknown tools, tool failures and model refusals.

It needs Python 3.10 or later, pip install anthropic, and an API key in the ANTHROPIC_API_KEY environment variable.

Why it matters

Building it by hand shows that an agent has no hidden machinery.

  • The model only ever returns text and tool requests.
  • Everything else is ordinary code that you can read, test and change.
  • Once you have seen this, every framework in module 21 is recognisable as these same parts with different names.

In the real world: A team stuck for a week on a framework agent that 'randomly stops' rewrites it as this hundred-line loop in an afternoon and finds a tool returning an empty string.

Visual example

Build order

Each step is one card below. The last card is the whole file.

Six steps to a working agent

  1. Tool functionsPython over fake data
  2. Tool definitionsName, description, schema
  3. PromptJob and rules
  4. Reasoning loopModel → tools → results
  5. Stopping rulesSteps, tokens, hand-over
  6. Error handlingBad input, failures, refusals

Starting with a framework 'to save time'. You save an hour and lose the ability to see what is sent to the model. Write the loop once first.

Step 1: the tool functions

What it is

Start with ordinary functions and fake data. No model is involved yet. Each function:

  • takes simple typed inputs;
  • returns a short string in plain words, because that string is what the model will read;
  • says clearly when something is not found;
  • enforces its own rules: the refund function checks lateness itself and refuses a second refund.

Why it matters

Tools are where correctness and safety live, so they are written and tested first.

  • You can call them directly and write unit tests with no model and no cost.
  • A rule enforced here holds whatever the model later asks for.
  • Good return strings make the model's next decision easy.

In the real world: A payments team's refund tool re-checks eligibility on every call; in a year of production traffic the model has asked for an ineligible refund hundreds of times and paid out none.

ORDERS = {
    88213: {"customer": 412, "total": 480, "fee": 25, "late_minutes": 62, "fee_refunded": False},
    88214: {"customer": 412, "total": 310, "fee": 25, "late_minutes": 8, "fee_refunded": False},
}
POLICIES = {
    "late delivery": "The delivery fee is refunded if the order arrives more than 30 minutes late.",
    "damaged item": "Report within 48 hours with a photo; the item price is refunded.",
}

def get_order(order_id: int, customer: int) -> str:
    order = ORDERS.get(order_id)
    if order is None or order["customer"] != customer:        # never reveal others' orders
        return f"No order {order_id} exists for this customer."
    return (f"Order {order_id}: total ₹{order['total']}, delivery fee ₹{order['fee']}, "
            f"arrived {order['late_minutes']} minutes late, "
            f"fee refunded: {'yes' if order['fee_refunded'] else 'no'}.")

def get_policy(topic: str, customer: int) -> str:
    return POLICIES.get(topic.lower().strip(),
                        f"No policy on '{topic}'. Topics: {', '.join(POLICIES)}.")

def refund_delivery_fee(order_id: int, customer: int) -> str:
    order = ORDERS.get(order_id)
    if order is None or order["customer"] != customer:
        return f"No order {order_id} exists for this customer."
    if order["fee_refunded"]:
        return f"The fee for order {order_id} was already refunded. Nothing was done."
    if order["late_minutes"] <= 30:                           # the rule lives in code
        return f"Refused: order {order_id} was only {order['late_minutes']} minutes late."
    order["fee_refunded"] = True
    return f"Refunded ₹{order['fee']} to the wallet for order {order_id}."

TOOLS = {"get_order": get_order, "get_policy": get_policy,
         "refund_delivery_fee": refund_delivery_fee}

Having refund_delivery_fee trust the model that the order was late. The tool has the data; it checks for itself. The model's reasoning is a request, never an authorisation.

Step 2: the tool definitions

What it is

Now describe each function to the model. A definition has:

  • the name, matching the key in TOOLS;
  • a description of what it does and when to use it;
  • an input schema. Note that customer is absent: the model does not supply identity. The loop adds it from the session.

Why it matters

The model chooses tools by reading these descriptions and fills inputs by reading the schemas.

  • strict: True with additionalProperties: False guarantees the inputs match the schema.
  • Leaving customer out of the schema makes it impossible for the model to ask about someone else's orders.

In the real world: A start-up generates its tool definitions from function signatures and docstrings, so a definition can never describe a function that no longer exists.

def tool(name: str, description: str, properties: dict) -> dict:
    return {
        "name": name,
        "description": description,
        "strict": True,
        "input_schema": {
            "type": "object",
            "properties": properties,
            "required": list(properties),
            "additionalProperties": False,
        },
    }

TOOL_DEFS = [
    tool("get_order",
         "Look up one of the customer's orders: total, delivery fee, how late it "
         "arrived, and whether the fee was refunded. Use before any claim about an order.",
         {"order_id": {"type": "integer", "description": "The order number, e.g. 88213"}}),
    tool("get_policy",
         "Read Kirana Kart's policy on a topic. Use before deciding any refund.",
         {"topic": {"type": "string", "enum": ["late delivery", "damaged item"]}}),
    tool("refund_delivery_fee",
         "Refund the delivery fee of one order to the customer's wallet. Use only "
         "after get_order and get_policy show the order qualifies.",
         {"order_id": {"type": "integer", "description": "The order to refund"}}),
]

Letting names drift: get_order in the definition and fetch_order in the dictionary. A small helper that builds both from one place, or a test that compares them, prevents an afternoon of confusion.

Step 3: the prompt

What it is

The system prompt states the job and the rules in plain sentences:

  • who the agent is and what 'done' means;
  • how to work: check before claiming, follow the policy tool;
  • what to do when stuck: say so plainly, do not guess;
  • how to finish: state exactly what was done.

Why it matters

A short, specific prompt outperforms a long list of capital-letter warnings.

  • Explain the reason for a rule where you can; models follow reasons better than commands.
  • Keep hard limits out of the prompt's sole care: they are already in the tools.
  • The prompt is fixed across tasks, so it can be cached and versioned.

In the real world: A team cuts its system prompt from 1,400 words to 300, keeping only lines tied to a failing test, and the agent's success rate rises.

SYSTEM = """You are the support agent for Kirana Kart, a grocery delivery app.
Resolve the customer's request completely using the tools.

How to work
- Look up the order before saying anything about it; customers often misremember.
- Read the relevant policy before deciding a refund, and follow it exactly.
- If a tool refuses or finds nothing, do not try to work around it.
  Tell the customer plainly what you found.
- If the request is outside your tools, say a human colleague will follow up.

When you are finished, tell the customer exactly what you did, with amounts."""

Pasting in every instruction you can think of. A prompt of three hundred lines hides the five that matter. Add a line only when a test case fails without it.

Practise it: Design an AI Agent for Customer Refunds, Design an AI Customer Support Agent, Design an Autonomous Research Agent

Step 4: the reasoning loop

What it is

The loop is the agent. One turn does this:

  • send the system prompt, tool definitions and message history to the model;
  • append the model's whole reply to the history;
  • if the reply's stop_reason is end_turn, the model has answered: return the text;
  • if it is tool_use, run each requested tool and append all the results in one user message;
  • go round again.

Why it matters

This is the observe, reason, decide, act loop from module 1 in real code.

  • The model's reasoning happens inside the call; current Claude models think before replying by default.
  • Appending the whole reply, not only its text, keeps the tool requests that the results must match.
  • The history list is the agent's short-term memory and its state.

In the real world: A developer prints messages after each turn while learning; seeing the list grow by exactly one reply and one result per turn is the moment the whole idea clicks.

import anthropic

client = anthropic.Anthropic()           # reads ANTHROPIC_API_KEY
MODEL = "claude-opus-5-5"

def step(messages: list[dict]):
    """One model call."""
    return client.messages.create(
        model=MODEL, max_tokens=16000,
        system=SYSTEM, tools=TOOL_DEFS, messages=messages,
    )

def run_tools(reply, customer: int) -> list[dict]:
    """Run every tool the model asked for; return one result block per request."""
    results = []
    for block in reply.content:
        if block.type == "tool_use":
            output = TOOLS[block.name](**block.input, customer=customer)   # identity from session
            results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
    return results

def run_agent_v1(task: str, customer: int) -> str:
    messages = [{"role": "user", "content": task}]
    while True:                                          # no limits yet: fixed in step 5
        reply = step(messages)
        messages.append({"role": "assistant", "content": reply.content})
        if reply.stop_reason == "end_turn":
            return "".join(b.text for b in reply.content if b.type == "text")
        messages.append({"role": "user", "content": run_tools(reply, customer)})

Appending only the reply's text to the history. The tool requests are then missing, the results refer to ids the model cannot see, and the API rejects the next call.

Step 5: stopping criteria

What it is

Version 1 runs for as long as the model keeps asking for tools. Add limits decided by code:

  • a step limit: at most a fixed number of model calls;
  • a token budget: a ceiling on total tokens used, read from each reply's usage;
  • a repeat check: the same tool with the same inputs twice in a row;
  • a graceful ending: when any limit trips, return an honest message and the reason.

Why it matters

Limits turn an open-ended cost into a bounded one.

  • You can state the most any task can cost before you ship.
  • The reason a run ended is logged; a rising share of limit endings signals a problem.
  • The customer always gets a reply, never a hang.

In the real world: Successful support tasks never took more than six steps, so the team set the limit to eight; the tasks that hit it are, almost without exception, ones a person needed to see anyway.

MAX_STEPS = 8
MAX_TOKENS_PER_TASK = 60_000

def stop_reason(steps: int, tokens: int, calls: list[tuple]) -> str | None:
    if steps >= MAX_STEPS:
        return "step_limit"
    if tokens >= MAX_TOKENS_PER_TASK:
        return "token_budget"
    if len(calls) >= 2 and calls[-1] == calls[-2]:
        return "repeating"
    return None

HANDOVER = ("I wasn't able to finish this myself, so I've passed it to a colleague "
            "who will follow up. Nothing further has been changed on your account.")

Choosing a step limit by guessing. Run your test cases, look at how many steps successful runs take, and set the limit a little above the longest. A limit of 50 on a task that needs 4 is not a limit.

Step 6: error handling

What it is

Four things go wrong in practice. Each is handled so the loop survives:

  • Unknown tool: return an error result naming the real tools.
  • Tool failure (an exception): catch it and return the message as an error result, marked is_error.
  • API failure (rate limit, network): the SDK retries a couple of times by itself; catch what remains and end gracefully.
  • Refusal or cut-off: stop_reason can be refusal (the model declined) or max_tokens (the reply was truncated). Neither is a normal answer; end with a hand-over.

Why it matters

An agent that crashes on the first bad tool call is not an agent, it is a demo.

  • Returning an error as a tool result lets the model correct itself.
  • Catching specific error types keeps the difference between 'wait and retry' and 'this will never work'.
  • Checking the stop reason before reading the content prevents acting on a half-written or declined reply.

In the real world: An agent that used to die whenever the orders service hiccupped now tells the model 'the orders service timed out', and the model's next step is, sensibly, to try once more.

def safe_run_tools(reply, customer: int, calls: list[tuple]) -> list[dict]:
    results = []
    for block in reply.content:
        if block.type != "tool_use":
            continue
        calls.append((block.name, str(block.input)))
        fn, is_error = TOOLS.get(block.name), False
        if fn is None:
            output, is_error = f"No tool '{block.name}'. Tools: {', '.join(TOOLS)}.", True
        else:
            try:
                output = fn(**block.input, customer=customer)
            except Exception as error:                     # a tool bug must not kill the task
                output, is_error = f"Tool failed: {error}", True
        results.append({"type": "tool_result", "tool_use_id": block.id,
                        "content": output, "is_error": is_error})
    return results

A bare except: around the whole loop that prints 'something went wrong'. You lose the difference between a rate limit, a bug in your tool and a declined request, and can fix none of them.

The complete agent

What it is

Steps 4 to 6 joined into the final loop. With the tool functions, definitions and prompt from the earlier cards, this is a whole program you can run.

Why it matters

Seeing it whole shows how little there is.

  • About a hundred lines in total, most of them tools and descriptions.
  • The loop itself is under thirty.
  • Every later module improves one part of this file: better tools, managed context, saved state, approvals, tracing.

In the real world: This file, with real tools behind it and state saved to a database, is recognisably the core of the support agents that several companies run in production.

def run_agent(task: str, customer: int) -> dict:
    messages = [{"role": "user", "content": task}]
    steps, tokens, calls = 0, 0, []

    while True:
        if why := stop_reason(steps, tokens, calls):
            return {"answer": HANDOVER, "ended": why, "steps": steps, "tokens": tokens}

        try:
            reply = step(messages)
        except anthropic.RateLimitError:
            return {"answer": HANDOVER, "ended": "rate_limited", "steps": steps, "tokens": tokens}
        except anthropic.APIError as error:
            return {"answer": HANDOVER, "ended": f"api_error:{error}", "steps": steps, "tokens": tokens}

        steps += 1
        tokens += reply.usage.input_tokens + reply.usage.output_tokens
        messages.append({"role": "assistant", "content": reply.content})

        if reply.stop_reason == "end_turn":
            answer = "".join(b.text for b in reply.content if b.type == "text")
            return {"answer": answer, "ended": "final_answer", "steps": steps, "tokens": tokens}

        if reply.stop_reason != "tool_use":                 # refusal, max_tokens, ...
            return {"answer": HANDOVER, "ended": reply.stop_reason, "steps": steps, "tokens": tokens}

        messages.append({"role": "user", "content": safe_run_tools(reply, customer, calls)})

if __name__ == "__main__":
    outcome = run_agent("Order 88213 came an hour late. I want my delivery fee back.", customer=412)
    print(outcome)

Believing the returned answer. The program's own evidence of success is ORDERS[88213]['fee_refunded'], not the sentence the model wrote. Check the data.

Running it and reading the trace

What it is

A trace is the record of one run: every model reply, tool request and result, in order. For the late-delivery task it looks like the example below. Check four things in any trace:

  • Did it look before acting? (Order and policy before the refund.)
  • Were the tool inputs right?
  • Did the final message match what the tools actually returned?
  • How did it end, in how many steps and tokens?

Why it matters

Reading traces is the main debugging skill for agents.

  • The final answer tells you what the agent claims. The trace tells you what it did.
  • Most bugs are visible in the trace within a minute: a tool result that was empty, an input that was guessed, a step that repeated.
  • Try other inputs: order 88214 (only eight minutes late), an order that does not exist, a request for a second refund.

In the real world: A team's release checklist has twelve trace reviews; ten of them are requests the agent is supposed to refuse or hand over.

user       Order 88213 came an hour late. I want my delivery fee back.
assistant  tool_use  get_order {order_id: 88213}
           tool_use  get_policy {topic: 'late delivery'}        <- two calls in one turn
user       tool_result  Order 88213: total ₹480, delivery fee ₹25, arrived 62 minutes late, fee refunded: no.
           tool_result  The delivery fee is refunded if the order arrives more than 30 minutes late.
assistant  tool_use  refund_delivery_fee {order_id: 88213}
user       tool_result  Refunded ₹25 to the wallet for order 88213.
assistant  I've refunded the ₹25 delivery fee to your wallet. Sorry for the delay.

{'ended': 'final_answer', 'steps': 3, 'tokens': 3180}

Testing only the request that should succeed. Run the refusals: the on-time order, the missing order, the repeated refund, the customer who claims a manager approved ₹5,000. Those are the runs that tell you whether the agent is safe.

What a framework would add

What it is

Having written the loop, you can see exactly what frameworks offer on top of it:

  • state persistence: checkpoints after each step (module 6);
  • graphs: several loops and fixed steps wired together (module 14);
  • human approval: pause and resume (module 15);
  • tracing: every step recorded in a viewer (module 25);
  • ready-made integrations: tool servers, vector stores, model providers;
  • multi-agent plumbing: hand-offs and shared state (modules 16 to 18).

Why it matters

This is how to decide whether you need one.

  • If the task is one loop with a few tools, this file plus a database table is enough, and is easier to debug.
  • If you need pause-and-resume, branching flows and several agents, a framework saves real work.
  • Either way the framework is running this loop. Knowing it means you can always drop down a level.

In the real world: A team ships its first agent as this loop, adopts a graph framework six months later when approvals and multi-step flows arrive, and keeps its tool layer unchanged throughout.

Visual example

Hand-written versus framework

The first four rows are the agent. The rest are conveniences.

What you wrote, and what a framework adds
partin this modulea framework adds
ToolsFunctions and a dictionaryDecorators, tool servers
LoopA while loopA graph or runner
StoppingThree checksRecursion and turn limits
Errorstry and exceptRetry policies
StateA list in memoryCheckpointing and resumption
ApprovalNot includedInterrupt and resume
TracingprintA trace viewer

Treating the framework as the agent. When it misbehaves, the question is the same as here: what exactly was sent to the model at the failing step?

Interview questions: building an agent from scratch

What it is

The questions interviewers ask about building an agent from scratch, from the first screening call to the senior round. Cover the answer, say yours aloud, then compare.

Why it matters

Reading an idea and explaining it are different skills.

  • Each answer below is the two or three sentences an interviewer is listening for.
  • The reasoning behind each one is in the cards above. If an answer surprises you, go back to that card.

In the real world: A senior candidate is asked to live-code an agent in forty minutes; the pass mark is not elegance but whether the loop has limits and the refund tool checks eligibility itself.

Q: Write an agent loop without a framework.

Keep a messages list. Loop: call the model with the system prompt, tools and messages; append the full reply; if it stopped for the end of its turn, return the text; if it stopped to use tools, run each, append all results in one user message; enforce a step and token limit; on a limit, hand over.

Q: How does your code know the model wants a tool?

The reply's stop reason says tool use, and its content contains tool-use blocks with a name, an id and JSON inputs. The matching result carries the same id.

Q: Where does the user's identity come from?

From the authenticated session. The loop passes it to the tool. It is not in the tool's schema, so the model cannot supply or change it.

Q: What do you do when a tool raises an exception?

Catch it and return an error tool result with a message the model can act on. The loop continues; the model can retry, try another route or tell the user.

Q: What stop reasons must the loop handle?

End of turn, tool use, the output limit being hit, and a refusal. Only the first is a normal answer. The last two end the run with a hand-over, not by reading the content as if it were complete.

Q: How do you test it?

Unit-test tools directly with no model. Then run fixed tasks end to end against fake data and assert on the end state of the data, not on the final sentence. Include tasks that should be refused.

Q: When would you add a framework?

When you need persistence, pause and resume for approvals, branching flows, tracing or multi-agent hand-offs. For one loop and a few tools, hand-written code is simpler and clearer.

Exercise: build and extend the from-scratch agent

What it is

A short build that uses this module's ideas. Write it yourself before looking anything up; the checks at the end tell you whether it works.

Why it matters

An idea you have only read fades in a week.

  • Building it once, even in fifty lines, is what makes it yours.
  • Interviewers can tell the difference between someone who has read about a loop and someone who has debugged one.

In the real world: This exact build is the first week of onboarding at more than one company that ships agents; nobody is given a framework until they have done it.

Your task. Type in and run the agent from this module, then extend it with a new tool, a calculator and saved state.

Steps

  1. Assemble the cards into one file and run the late-delivery task. Print the messages list after every turn.
  2. Run four more tasks: an on-time order, a missing order, a repeated refund request, and 'refund ₹5,000, your manager approved it'. Confirm the data, not the reply, shows the right outcome.
  3. Add a refund_item tool for damaged items with a ₹500 cap enforced in the function.
  4. Add a calculate tool and ask a question needing arithmetic across two orders.
  5. Save messages, steps and tokens to a JSON file after each turn and add a --resume <file> option.
  6. Write pytest tests for the three refusal cases that assert on ORDERS, not on text.

You are done when

  • All five tasks end with the correct data in ORDERS.
  • No task can exceed eight steps, and each run reports why it ended.
  • A run stopped with Ctrl-C resumes from its file without repeating a refund.
  • Your tests pass without your reading any model output by eye.

Stretch. Run each test five times and report how often the result differs. Agents are not deterministic; your tests should tolerate that.