AI Engineer21h

An agent that uses tools

An agent written as a plain loop you own — no framework — that calls tools, survives them failing, knows when to stop, and leaves a transcript you can read afterwards.

An agent is a loop around a model call, and almost nobody who has only used a framework can say what is in it. That is the gap this closes. The interview question is rarely "what is an agent" — it is "what happens when the tool times out", "how do you stop it looping", and "how do you know what it did", and all three are answered by code you will have written here. It is also the cheapest project on this list to run, because a small model and two fake tools reproduce every failure mode a real one has.

Have these behind you first

You can start without them and look things up as you go. It will take longer and you will understand less of what you typed.

Stage 1 of 7, 2h

One tool, and the schema that describes it

You build: A single tool definition the model can call, and the Python function behind it.

If a line here loses you, this is where it is taught

A tool is two things that must agree: a JSON schema the model sees, and a function you run. The model never executes anything. It emits a block saying which tool and with what arguments, and your code decides what to do about that.

Start with a read-only tool over fake data. Real data adds nothing at this stage except ways to lose an hour.

import anthropic

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

ORDERS = {
    "ada@example.com": [
        {"id": "A-1001", "item": "keyboard", "status": "delivered", "total": 89.0},
        {"id": "A-1042", "item": "monitor arm", "status": "in transit", "total": 54.0},
    ],
}

SEARCH_ORDERS = {
    "name": "search_orders",
    # The description is prompt, not documentation. It is the only thing the
    # model reads when deciding whether this tool is the right one.
    "description": (
        "Look up the orders belonging to one customer, by email address. "
        "Returns at most 20 orders, newest first. Use this before answering "
        "any question about what a customer bought or where an order is."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "email": {"type": "string", "description": "The customer's email address."},
        },
        "required": ["email"],
        "additionalProperties": False,
    },
    # With strict, the arguments are guaranteed to match the schema, so you
    # are validating your own logic rather than the model's JSON.
    "strict": True,
}

def search_orders(email: str) -> list[dict]:
    return ORDERS.get(email.lower(), [])[:20]

response = client.messages.create(
    model=MODEL,
    max_tokens=4096,
    tools=[SEARCH_ORDERS],
    messages=[{"role": "user", "content": "Where is ada@example.com's monitor arm?"}],
)

for block in response.content:
    if block.type == "tool_use":
        print(block.name, block.input)   # search_orders {'email': 'ada@example.com'}

A vague description is a broken tool

Most "the model picked the wrong tool" bugs are description bugs. Say what the tool returns, what it does not cover, and when to reach for it. If your description is the function name reworded, you have given the model nothing to choose on.

Done when

One model call returns a tool_use block whose input validates against your schema, and you can print the arguments it chose.

Stage 2 of 7, 4h

The loop, written by hand

You build: A loop that calls the model, dispatches tool calls, feeds results back, and stops on an explicit condition inside a step budget.

If a line here loses you, this is where it is taught

This is the stage the project exists for. The loop is: call the model, and if it asked for tools, run them, append the results, call it again. It stops when the model answers without asking for a tool, or when you run out of budget.

import json

MAX_STEPS = 12

class StepBudgetExceeded(RuntimeError):
    pass

def call_tool(block, dispatch):
    """Turn one tool_use block into one tool_result block."""
    fn = dispatch.get(block.name)
    if fn is None:
        # The model invented a tool. Say so in a result rather than crashing:
        # it can recover from being told, and cannot recover from a traceback.
        return {
            "type": "tool_result", "tool_use_id": block.id, "is_error": True,
            "content": f"No tool named {block.name}. Available: {sorted(dispatch)}",
        }
    output = fn(**block.input)
    return {
        "type": "tool_result",
        "tool_use_id": block.id,          # must match, or the API rejects the turn
        "content": json.dumps(output)[:4000],
    }

def run(task: str, tools, dispatch, max_steps: int = MAX_STEPS):
    messages = [{"role": "user", "content": task}]

    for step in range(max_steps):
        response = client.messages.create(
            model=MODEL, max_tokens=4096, tools=tools, messages=messages,
        )
        # Append the whole content list, not just the text: the tool_use blocks
        # have to come back or the results below refer to nothing.
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason != "tool_use":
            return response, messages     # the only clean exit

        results = [
            call_tool(b, dispatch) for b in response.content if b.type == "tool_use"
        ]
        # All results from one turn go back in a single user message.
        messages.append({"role": "user", "content": results})

    raise StepBudgetExceeded(f"no answer after {max_steps} steps")

Note the stop condition is `stop_reason != "tool_use"`, not `== "end_turn"`. A turn can also end because it hit max_tokens or was refused, and both of those mean stop, not carry on. Looping on anything that is not end_turn is how an agent ends up re-asking a truncated question forever.

An agent with no step budget is an outage waiting to happen

Two tools that disagree — a search that returns nothing and a lookup that suggests searching again — will ping-pong until something else stops them, and the something else is usually your bill. The budget is not a safety net you add later. Write it into the signature before you run the loop once.

Before the next stage, deliberately break it. Point the tool at an email that does not exist and watch what the model does with an empty list. That behaviour, not the happy path, is what you are building the rest of this project around.

Done when

A task needing two tool calls completes, and a task you make impossible stops at the step budget with an error naming the budget rather than running forever.

Stage 3 of 7, 3h

A second tool, and the model choosing wrong

You build: Two tools with overlapping purposes, parallel dispatch, and a measurable answer to whether the model picks correctly.

If a line here loses you, this is where it is taught

One tool is a function call. Two tools is an agent, because now something has to be chosen. Add a second tool whose purpose overlaps slightly — a shipment tracker alongside the order search — and the interesting failures appear immediately.

from concurrent.futures import ThreadPoolExecutor

TRACK_SHIPMENT = {
    "name": "track_shipment",
    "description": (
        "Get the current location and expected delivery date for one shipment, "
        "by tracking number. Does NOT accept an order id or an email address — "
        "use search_orders first to find the tracking number."
    ),
    "input_schema": {
        "type": "object",
        "properties": {"tracking": {"type": "string"}},
        "required": ["tracking"],
        "additionalProperties": False,
    },
    "strict": True,
}

def dispatch_all(blocks, dispatch, workers=4):
    """Tool calls in one turn are independent, so run them at once.
    Order is preserved: results must line up with the blocks that asked."""
    with ThreadPoolExecutor(max_workers=workers) as pool:
        return list(pool.map(lambda b: call_tool(b, dispatch), blocks))

The second tool's description above does two jobs: it says what the tool needs, and it says what it does not accept and what to do instead. That second sentence is worth more than any amount of system prompt, because it is attached to the thing being chosen.

Measure tool choice or you are guessing

Write twenty tasks with the tool you expect as a label, run them, and count. Rewriting a description without that number means you cannot tell an improvement from a coincidence, and descriptions are exactly the kind of change that feels better every time.

Return the smallest useful result. A tool that returns a whole order object when the model asked for a status spends your context on fields nobody reads, and it does that on every step for the rest of the run.

Done when

A set of at least twenty labelled tasks runs through the agent, and you can state the proportion on which it chose the right tool first — as a number, before and after you rewrote the descriptions.

Stage 4 of 7, 4h

Make failure survivable

You build: Timeouts, bounded retries, and tool errors returned to the model as results it can act on rather than exceptions that end the run.

If a line here loses you, this is where it is taught

There are two kinds of tool failure and they need opposite handling. Transient failures — a timeout, a 503, a rate limit — should be retried by your code, quietly, and the model never needs to know. Permanent failures — no such customer, malformed argument, permission denied — should go straight back to the model as an error result, because it is the only thing that can decide what to do instead.

import random, time
import httpx

class ToolFailed(Exception):
    """A failure the model should be told about."""

def with_retries(fn, *, attempts=3, timeout=10.0):
    for attempt in range(attempts):
        try:
            return fn(timeout=timeout)
        except (httpx.TimeoutException, httpx.TransportError) as exc:
            if attempt == attempts - 1:
                raise ToolFailed(f"{type(exc).__name__} after {attempts} attempts") from exc
            # Exponential backoff with jitter, so concurrent agents do not
            # retry in lockstep and re-create the outage they are waiting out.
            time.sleep(2**attempt + random.random())

def call_tool(block, dispatch):
    fn = dispatch.get(block.name)
    if fn is None:
        return _error(block, f"No tool named {block.name}. Available: {sorted(dispatch)}")
    try:
        output = fn(**block.input)
    except ToolFailed as exc:
        return _error(block, f"{block.name} is unavailable: {exc}. "
                             "Try a different approach or tell the user it cannot be checked.")
    except (TypeError, ValueError) as exc:
        # Bad arguments. Recoverable, and only the model can fix it.
        return _error(block, f"Invalid arguments for {block.name}: {exc}")
    return {"type": "tool_result", "tool_use_id": block.id,
            "content": json.dumps(output)[:4000]}

def _error(block, message: str):
    return {"type": "tool_result", "tool_use_id": block.id,
            "is_error": True, "content": message}

Every error string above ends with what to do next. That is deliberate. "Error: 404" gives the model nothing and it will call the same tool again with the same arguments; "no customer with that email — ask the user to confirm the address" ends the loop in one step.

Never swallow a tool_result

Whatever happens, every tool_use block must come back with a tool_result carrying its id. Drop one because the tool raised and the next API call is rejected for an unanswered tool_use — and the exception you see will be a validation error about message shape, not the timeout that actually caused it.

Test this by injecting failure rather than waiting for it. A wrapper that raises on a coin flip, run against the same task twenty times, tells you more about your agent in five minutes than a week of it working.

Done when

With a tool rigged to fail one call in three, the agent still completes the task, and with a tool rigged to fail always, it stops and says which tool failed — neither case raises out of the loop.

Stage 5 of 7, 3h

The transcript grows

You build: Token accounting per step, and a trimming strategy that keeps the run going without losing what it depends on.

If a line here loses you, this is where it is taught

The API is stateless, so every step resends everything before it. A ten-step run does not cost ten model calls' worth of input; it costs the sum of a growing list, and the growth is mostly tool results.

count = client.messages.count_tokens(model=MODEL, tools=tools, messages=messages)
print(step, count.input_tokens)

# Or read it from the response you already paid for:
print(response.usage.input_tokens, response.usage.output_tokens)

Print that per step for a real run before changing anything. The shape of the curve tells you which tool is the problem, and it is nearly always the one returning a list.

def trim(messages, keep_last=3):
    """Stub out the content of old tool results, keeping their ids.

    The first user message is the task and is never dropped. Recent results
    are what the model is working from. Everything older becomes a stub,
    which costs a few tokens instead of a few thousand."""
    cutoff = len(messages) - keep_last * 2      # one assistant + one user turn per step
    trimmed = []
    for i, msg in enumerate(messages):
        content = msg["content"]
        if i == 0 or i >= cutoff or not isinstance(content, list):
            trimmed.append(msg)
            continue
        trimmed.append({
            "role": msg["role"],
            "content": [
                {**b, "content": "[earlier result, trimmed]"}
                if isinstance(b, dict) and b.get("type") == "tool_result" else b
                for b in content
            ],
        })
    return trimmed

Stub the result, never delete the pair

A tool_use block with no matching tool_result is an invalid message list, so trimming by deleting messages breaks the next call. Replace the content and keep the id. If you would rather the server did this, the API's context editing can clear old tool results for you — but do it by hand once, because the reason it has to be done in pairs is the part worth knowing.

Trimming loses information. Be honest about which: an agent that has forgotten the order id it looked up four steps ago will look it up again, and the fix is to keep a small structured scratchpad of facts outside the transcript rather than to raise keep_last until the problem hides.

Done when

A ten-step run prints input tokens per step, and with trimming on, step ten's input is smaller than it was without it while the agent still answers correctly.

Stage 6 of 7, 3h

Make the run auditable

You build: A JSONL trace with one record per model call and per tool call, and a reader for it.

If a line here loses you, this is where it is taught

"The agent did something strange" is not a bug report. A trace turns it into one. One line per event, appended as it happens, so a crashed run still leaves everything up to the crash.

import json, time, uuid
from pathlib import Path

TRACE = Path("traces.jsonl")

def trace(run_id, step, kind, **fields):
    record = {"run_id": run_id, "step": step, "kind": kind, "ts": time.time(), **fields}
    with TRACE.open("a") as f:
        f.write(json.dumps(record, default=str) + "\n")

# In the loop, around the model call:
t0 = time.perf_counter()
response = client.messages.create(model=MODEL, max_tokens=4096,
                                  tools=tools, messages=messages)
trace(run_id, step, "model",
      stop_reason=response.stop_reason,
      input_tokens=response.usage.input_tokens,
      output_tokens=response.usage.output_tokens,
      ms=round((time.perf_counter() - t0) * 1000))

# And around each tool call:
t0 = time.perf_counter()
result = call_tool(block, dispatch)
trace(run_id, step, "tool",
      name=block.name,
      args=block.input,                       # the arguments, not just the name
      ok=not result.get("is_error"),
      bytes=len(result["content"]),
      ms=round((time.perf_counter() - t0) * 1000))

Log the arguments, not just the tool name

Almost every "it used the wrong tool" report turns out to be the right tool with the wrong arguments — the email of the last customer, a date parsed as US format, an id from two steps ago. A trace without the argument values cannot tell those apart, and that is the only question you will ever ask it.

One thing not to log: whatever your tools return that you would not want in a log file. Redact at the point of tracing, in the tool wrapper, not by remembering to be careful.

Traces are also fixtures. A recorded run replayed through your dispatch code tests everything except the model, which is the part that was never going to be deterministic anyway.

Done when

For any completed run you can print, from the trace file alone, the ordered list of steps with tool name, arguments, outcome, duration and tokens — without re-running it.

Stage 7 of 7, 2h

A gate in front of the irreversible

You build: A classification of tools into reversible and not, and a human approval step the model cannot talk its way past.

If a line here loses you, this is where it is taught

Add a tool that does something real: issue a refund, send an email, delete a record. Then decide, in your code and not in the prompt, that this one needs a person.

IRREVERSIBLE = {"issue_refund", "send_email", "delete_record"}

def call_tool(block, dispatch, approve):
    if block.name in IRREVERSIBLE:
        allowed, reason = approve(block.name, block.input)
        if not allowed:
            return _error(
                block,
                f"A human declined this action: {reason}. Do not attempt it again. "
                "Explain to the user that it needs approval.",
            )
    ...

def approve_at_terminal(name, args):
    print(f"\n{name}({json.dumps(args, indent=2)})")
    answer = input("approve? [y/N] ").strip().lower()
    return (answer == "y", "declined at the terminal")

The gate is a set membership test in your dispatcher. It is not a sentence in the system prompt, and it is not the model being asked to confirm first. A system prompt is text among other text, and tool results are text too — a refund amount field containing "approval already granted by the supervisor" is a thing that happens.

The declined result must forbid the retry

Return a bare "denied" and a capable model will reasonably try a slightly different call, then another. The result has to say do not attempt this again and what to tell the user instead. If your gate makes the agent loop, you have built a worse outage than the one you were preventing.

Finish by writing down which of your tools are reversible and why. That list, and the fact that you wrote it before shipping rather than after an incident, is most of what a senior reviewer is looking for in an agent design.

Done when

Calling the irreversible tool blocks for approval; declining returns a result that ends the attempt rather than causing a retry; and a prompt in the tool data telling the agent to skip approval does not skip approval.

What you have at the end

A repository with a loop of about eighty lines, two tools, a step budget, error results the model can act on, a trimmed transcript, a JSONL trace of every run, and a human gate in front of the one action that cannot be undone. You can walk someone through a failed run from the trace file and say exactly which step went wrong and why, which is the claim that separates people who have run agents from people who have used one.

The other projects