Build an Agent from Scratch

Step by step: a real command-line agent with file, calculator and notes tools, a guarded loop, error handling, logging and human approval.

What is it?

In this lesson you build a complete, working agent twice: first as an offline JavaScript version you can run right here in the browser (with a scripted fake model, so you can watch every step), then as a real Python command-line agent using the Anthropic SDK. Finally you see the same agent rebuilt with the SDK's tool runner, which writes the loop for you. No framework is needed - an agent is about 150 lines of ordinary code.

What we are building. mini_agent.py answers questions about the files in a workspace/ folder. It has five tools: list_files, read_file (restricted to the workspace), calculator (safe arithmetic), search_notes (keyword search across workspace text files) and save_note (writes to notes.txt, so it needs human approval). Example task: 'Add up the amounts in budget.txt and save the total as a note.'

Before you start: chatbot vs workflow vs agent. If every request needed exactly 'read budget.txt, then sum it', a two-step workflow would be cheaper and more predictable. We build an agent because the questions are open-ended: the model must decide which files to look at, whether it needs arithmetic, and when it knows enough. Always start simple and only add the loop when the task needs it.

Step 1 - Set up. pip install anthropic, set the ANTHROPIC_API_KEY environment variable, create a workspace/ folder with a few text files. Keep the model id and limits as constants at the top of the file (MODEL, MAX_STEPS).

Step 2 - Write the system prompt. The system prompt tells the model its role, its environment and its working rules: 'Use tools instead of guessing. Always use the calculator for arithmetic. If a tool returns an error, read it and adjust. Say which files you used.' Short, concrete rules beat long essays.

Step 3 - Define the tools. Each tool gets a name, a description that says what it does, when to use it and what it returns, and a JSON Schema for its input. Descriptions are where most of the agent's quality comes from.

Step 4 - Implement the tools safely. This is ordinary code, and it is your security boundary:

  • read_file resolves the requested path and refuses anything outside the workspace, so ../../.ssh/id_rsa or an absolute path fails. It also caps how much text it returns.
  • calculator must never call eval() on model text - that would run arbitrary code. Instead it parses the expression into a syntax tree (Python's ast module) and evaluates only numbers and + - * / ** %, with a cap on exponent size so 9**9**9 cannot freeze your machine.
  • search_notes does a case-insensitive keyword search and returns file:line: text hits, capped at 20.
  • save_note appends to a file - a side effect - so it is marked risky.

Step 5 - Write the dispatcher. run_tool(name, input) maps tool names to functions. It raises an exception on bad input or unknown tools; the loop catches exceptions and turns them into is_error tool results so the model can recover.

Step 6 - Add the human-approval gate. Before running a tool in RISKY_TOOLS, print what the agent wants to do and ask Allow? [y/N]. If the human says no, return an is_error result saying the user denied it, so the model continues without it instead of retrying.

Step 7 - Write the loop. Call the model with system prompt, tools and history; log stop_reason and token usage; append the assistant content unchanged; on end_turn return the text; on max_tokens or refusal stop with a clear message; on tool_use run every tool_use block and append all results in one user message (this is how parallel tool calls are handled). Repeat up to MAX_STEPS, then stop with an explanation.

Step 8 - Log every step. One line per model call (step, stop_reason, tokens) and one per tool call (name, input, first 200 characters of output or the error). When an agent misbehaves, this log is how you find out why.

Step 9 - Run it and read the trace. Try tasks that need several tools, a task that triggers an error (ask for a file that does not exist), a task that tries to escape the workspace, and one that asks to save a note (answer 'n' and watch it adapt).

Step 10 - Simplify with the tool runner. Once you understand the loop, the SDK's beta tool runner can run it for you: decorate plain Python functions with @beta_tool (their type hints and docstrings become the schema and description) and iterate over client.beta.messages.tool_runner(...). You keep the same tools and the same safety code; you just stop hand-writing the loop.

Explain like I'm 10

Building an agent is like hiring a new intern and setting up their desk. The system prompt is the onboarding note, the tools are the keys you hand them (the filing room key, a calculator, but not the company credit card), the approval gate is 'ask me before you send anything outside', the max-steps guard is 'if you are not done by 5pm, come and tell me where you got stuck', and the log is the notebook where they write down everything they did.

Examples

Complete offline agent: watch every step (runnable)

// ---------- 1. The environment: a tiny fake workspace ----------
const workspace = {
  "todo.txt": "buy milk\nship release 1.2\ncall the bank",
  "budget.txt": "rent 1200\nfood 450\ntransport 130",
};
const notes = ["Release 1.2 is due on Friday.", "The bank closes at 5pm."];

// ---------- 2. Safe calculator: a tiny parser, NOT eval() ----------
function calculate(expr) {
  for (const ch of expr) if (!"0123456789.+-*/() ".includes(ch)) throw new Error("calculator only accepts numbers and + - * / ( )");
  const s = expr.split(" ").join("");
  let pos = 0;
  function number() {
    const start = pos;
    while (pos < s.length && "0123456789.".includes(s[pos])) pos++;
    if (start === pos) throw new Error("expected a number at position " + pos);
    return parseFloat(s.slice(start, pos));
  }
  function factor() {
    if (s[pos] === "(") { pos++; const v = sum(); if (s[pos] !== ")") throw new Error("missing )"); pos++; return v; }
    if (s[pos] === "-") { pos++; return -factor(); }
    return number();
  }
  function product() {
    let v = factor();
    while (s[pos] === "*" || s[pos] === "/") { const op = s[pos++]; const r = factor(); v = op === "*" ? v * r : v / r; }
    return v;
  }
  function sum() {
    let v = product();
    while (s[pos] === "+" || s[pos] === "-") { const op = s[pos++]; const r = product(); v = op === "+" ? v + r : v - r; }
    return v;
  }
  const result = sum();
  if (pos !== s.length) throw new Error("unexpected character at position " + pos);
  return result;
}

// ---------- 3. Tools registry: name -> description + implementation ----------
const tools = {
  list_files: { description: "List files in the workspace.", run: () => Object.keys(workspace).join(", ") },
  read_file: {
    description: "Read a text file from the workspace. Path must be relative.",
    run: ({ path }) => {
      if (path.startsWith("/") || path.includes("..")) throw new Error("path escapes the workspace: " + path);
      if (!(path in workspace)) throw new Error("no such file: " + path + ". Available: " + Object.keys(workspace).join(", "));
      return workspace[path];
    },
  },
  calculator: { description: "Evaluate arithmetic exactly.", run: ({ expression }) => String(calculate(expression)) },
  search_notes: {
    description: "Keyword search over saved notes.",
    run: ({ query }) => {
      const hits = notes.filter((n) => n.toLowerCase().includes(query.toLowerCase()));
      return hits.length ? hits.join(" | ") : "no matches for " + query;
    },
  },
};

// ---------- 4. Fake LLM: returns API-shaped responses from a script ----------
// It "decides" based on how many rounds of tool results it has seen.
function llm(messages) {
  const rounds = messages.filter((m) => m.role === "user" && Array.isArray(m.content)).length;
  const lastResults = rounds ? messages[messages.length - 1].content : [];
  if (rounds === 0) return { stop_reason: "tool_use", content: [
    { type: "text", text: "Let me see what files exist." },
    { type: "tool_use", id: "t1", name: "list_files", input: {} } ] };
  if (rounds === 1) return { stop_reason: "tool_use", content: [   // two calls at once
    { type: "text", text: "I'll read the budget and check notes about the release." },
    { type: "tool_use", id: "t2", name: "read_file", input: { path: "budget.txt" } },
    { type: "tool_use", id: "t3", name: "search_notes", input: { query: "release" } } ] };
  if (rounds === 2) return { stop_reason: "tool_use", content: [
    { type: "text", text: "Maybe there are more costs in a parent folder." },
    { type: "tool_use", id: "t4", name: "read_file", input: { path: "../secrets.env" } } ] };
  if (rounds === 3) return { stop_reason: "tool_use", content: [
    { type: "text", text: "That was not allowed; I'll total what I have." },
    { type: "tool_use", id: "t5", name: "calculator", input: { expression: "1200 + 450 + 130" } } ] };
  const total = lastResults[0].content;
  return { stop_reason: "end_turn", content: [{ type: "text",
    text: "Your monthly costs total " + total + " (rent 1200 + food 450 + transport 130), from budget.txt. Note: Release 1.2 is due on Friday." }] };
}

// ---------- 5. The agent loop with max steps, errors and a step log ----------
function runAgent(task, maxSteps) {
  const messages = [{ role: "user", content: task }];
  for (let step = 1; step <= maxSteps; step++) {
    const resp = llm(messages);
    messages.push({ role: "assistant", content: resp.content });   // keep tool_use blocks
    for (const b of resp.content) if (b.type === "text") console.log("[step " + step + "] model: " + b.text);
    if (resp.stop_reason !== "tool_use") {
      console.log("[step " + step + "] stop_reason=" + resp.stop_reason + ", " + messages.length + " messages in history");
      return resp.content.filter((b) => b.type === "text").map((b) => b.text).join("");
    }
    const results = [];
    for (const b of resp.content) {
      if (b.type !== "tool_use") continue;
      try {
        const tool = tools[b.name];
        if (!tool) throw new Error("unknown tool " + b.name);
        const output = tool.run(b.input);
        console.log("[step " + step + "]   " + b.name + JSON.stringify(b.input) + " -> " + JSON.stringify(output));
        results.push({ type: "tool_result", tool_use_id: b.id, content: output });
      } catch (err) {
        console.log("[step " + step + "]   " + b.name + JSON.stringify(b.input) + " -> ERROR " + err.message);
        results.push({ type: "tool_result", tool_use_id: b.id, content: "Error: " + err.message, is_error: true });
      }
    }
    messages.push({ role: "user", content: results });               // ALL results, ONE message
  }
  return "Stopped: reached the limit of " + maxSteps + " steps.";
}

console.log("FINAL ANSWER: " + runAgent("What do my monthly costs add up to? Anything about the release?", 8));
console.log("With maxSteps=2: " + runAgent("Same question", 2));

Everything a real agent has is here: tool registry, a safe calculator, a workspace guard that rejects '../secrets.env', parallel tool calls answered in one message, an is_error result the model recovers from, a step log, and a max-steps guard (the second run shows it firing). Swap llm() for a real API call and you have the Python version below.

mini_agent.py - the real command-line agent

"""mini_agent.py - a small command-line agent over a workspace folder.

Setup:  pip install anthropic
        export ANTHROPIC_API_KEY=...
        mkdir workspace && echo "rent 1200" > workspace/budget.txt
Run:    python mini_agent.py "What do the costs in budget.txt add up to? Save the total as a note."
"""
import ast
import json
import logging
import operator
import sys
from pathlib import Path

import anthropic

MODEL = "claude-opus-5-5"
MAX_STEPS = 12
MAX_FILE_CHARS = 20_000
WORKSPACE = Path("workspace").resolve()
NOTES_FILE = WORKSPACE / "notes.txt"
RISKY_TOOLS = {"save_note"}          # tools with side effects need a human "yes"

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger("agent")
client = anthropic.Anthropic()

SYSTEM = """You are a careful assistant working inside a workspace folder of text files.
Rules:
- Use tools to look things up; never guess file contents.
- Always use the calculator for arithmetic, even simple sums.
- If a tool returns an error, read it and change your approach; do not repeat the same call.
- If the user denies an action, continue without it and mention that it was not done.
- Finish with a short answer that names the files you used."""

# ---------------- Step 3: tool definitions ----------------
TOOLS = [
    {"name": "list_files",
     "description": "List all files in the workspace, one relative path per line. "
                    "Use this first when you do not know which files exist.",
     "input_schema": {"type": "object", "properties": {}}},
    {"name": "read_file",
     "description": "Read a UTF-8 text file from the workspace and return its contents "
                    f"(at most {MAX_FILE_CHARS} characters). Paths are relative to the workspace, "
                    "e.g. 'budget.txt' or 'reports/q1.md'. Files outside the workspace cannot be read.",
     "input_schema": {"type": "object",
                      "properties": {"path": {"type": "string", "description": "Workspace-relative path"}},
                      "required": ["path"]}},
    {"name": "calculator",
     "description": "Evaluate an arithmetic expression exactly. Supports numbers, parentheses and "
                    "+ - * / ** %. Example: '(1200 + 450) * 0.2'. Returns the numeric result.",
     "input_schema": {"type": "object",
                      "properties": {"expression": {"type": "string"}},
                      "required": ["expression"]}},
    {"name": "search_notes",
     "description": "Case-insensitive keyword search across all .txt and .md files in the workspace. "
                    "Returns up to 20 matching lines as 'file:line: text'. Use short keywords.",
     "input_schema": {"type": "object",
                      "properties": {"query": {"type": "string", "description": "One or two keywords"}},
                      "required": ["query"]}},
    {"name": "save_note",
     "description": "Append a one-line note to notes.txt in the workspace. Requires user approval. "
                    "Only use when the user asks you to save or remember something.",
     "input_schema": {"type": "object",
                      "properties": {"text": {"type": "string", "maxLength": 500}},
                      "required": ["text"]}},
]

# ---------------- Step 4: safe tool implementations ----------------
def safe_path(relative: str) -> Path:
    path = (WORKSPACE / relative).resolve()          # resolves '..' and symlinks
    if not path.is_relative_to(WORKSPACE):           # Python 3.9+
        raise PermissionError(f"'{relative}' is outside the workspace")
    return path

_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
        ast.Div: operator.truediv, ast.Mod: operator.mod, ast.Pow: operator.pow,
        ast.USub: operator.neg, ast.UAdd: operator.pos}

def _eval(node):
    if isinstance(node, ast.Constant) and type(node.value) in (int, float):
        return node.value
    if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
        left, right = _eval(node.left), _eval(node.right)
        if isinstance(node.op, ast.Pow) and abs(right) > 100:
            raise ValueError("exponent too large")
        return _OPS[type(node.op)](left, right)
    if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
        return _OPS[type(node.op)](_eval(node.operand))
    raise ValueError("only numbers, parentheses and + - * / ** % are allowed")

def calculator(expression: str) -> str:
    if len(expression) > 200:
        raise ValueError("expression too long")
    return str(_eval(ast.parse(expression, mode="eval").body))   # parse, never eval()

def search_notes(query: str) -> str:
    hits = []
    for file in sorted(WORKSPACE.rglob("*")):
        if file.suffix not in {".txt", ".md"} or not file.is_file():
            continue
        lines = file.read_text(encoding="utf-8", errors="replace").splitlines()
        for n, line in enumerate(lines, 1):
            if query.lower() in line.lower():
                hits.append(f"{file.relative_to(WORKSPACE)}:{n}: {line.strip()}")
    return "\n".join(hits[:20]) or f"No matches for '{query}'."

# ---------------- Step 5: dispatcher ----------------
def run_tool(name: str, args: dict) -> str:
    if name == "list_files":
        files = [str(p.relative_to(WORKSPACE)) for p in sorted(WORKSPACE.rglob("*")) if p.is_file()]
        return "\n".join(files) or "(the workspace is empty)"
    if name == "read_file":
        path = safe_path(args["path"])
        if not path.is_file():
            raise FileNotFoundError(f"No file '{args['path']}'. Call list_files to see what exists.")
        text = path.read_text(encoding="utf-8", errors="replace")
        return text if len(text) <= MAX_FILE_CHARS else text[:MAX_FILE_CHARS] + "\n[truncated]"
    if name == "calculator":
        return calculator(args["expression"])
    if name == "search_notes":
        return search_notes(args["query"])
    if name == "save_note":
        with NOTES_FILE.open("a", encoding="utf-8") as f:
            f.write(args["text"].replace("\n", " ").strip() + "\n")
        return "Saved to notes.txt"
    raise ValueError(f"Unknown tool '{name}'")

# ---------------- Step 6: human approval ----------------
def approved(name: str, args: dict) -> bool:
    if name not in RISKY_TOOLS:
        return True
    answer = input(f"\nAgent wants to run {name}({json.dumps(args)}). Allow? [y/N] ")
    return answer.strip().lower() == "y"

# ---------------- Step 7 + 8: the loop, with logging ----------------
def run_agent(task: str) -> str:
    messages = [{"role": "user", "content": task}]
    for step in range(1, MAX_STEPS + 1):
        resp = client.messages.create(model=MODEL, max_tokens=16000, system=SYSTEM,
                                      tools=TOOLS, messages=messages)
        log.info("step %d stop=%s in=%d out=%d", step, resp.stop_reason,
                 resp.usage.input_tokens, resp.usage.output_tokens)
        messages.append({"role": "assistant", "content": resp.content})

        if resp.stop_reason == "end_turn":
            return "".join(b.text for b in resp.content if b.type == "text")
        if resp.stop_reason == "max_tokens":
            return "Stopped: the reply was cut off by max_tokens."
        if resp.stop_reason == "refusal":
            return "The model declined this request."
        if resp.stop_reason != "tool_use":
            return f"Stopped: unexpected stop_reason '{resp.stop_reason}'."

        results = []                                  # one entry per tool_use block
        for block in resp.content:
            if block.type != "tool_use":
                continue
            log.info("  -> %s %s", block.name, json.dumps(block.input))
            if not approved(block.name, block.input):
                results.append({"type": "tool_result", "tool_use_id": block.id, "is_error": True,
                                "content": "The user denied this action. Do not retry it."})
                log.info("  <- denied by user")
                continue
            try:
                output = run_tool(block.name, block.input)
                results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
                log.info("  <- %s", output[:200].replace("\n", " | "))
            except Exception as e:                    # report errors to the model, never crash
                results.append({"type": "tool_result", "tool_use_id": block.id,
                                "content": f"Error: {e}", "is_error": True})
                log.info("  <- ERROR %s", e)
        messages.append({"role": "user", "content": results})   # ALL results in ONE message
    return f"Stopped after {MAX_STEPS} steps without a final answer."

if __name__ == "__main__":
    WORKSPACE.mkdir(exist_ok=True)
    task = " ".join(sys.argv[1:]) or input("Task: ")
    print("\n" + run_agent(task))

About 170 lines, no framework. The security-critical parts are ordinary code you control: safe_path, the AST-based calculator, output caps, and the approval gate. Everything the model produces - tool names, paths, expressions - is treated as untrusted input.

The same agent with the SDK tool runner

# runner_agent.py - reuses the safe helpers from mini_agent.py
import json
import anthropic
from anthropic import beta_tool
from mini_agent import SYSTEM, WORKSPACE, NOTES_FILE, MODEL, safe_path, calculator as calc, search_notes as search

client = anthropic.Anthropic()

@beta_tool
def list_files() -> str:
    """List all files in the workspace, one relative path per line."""
    return "\n".join(str(p.relative_to(WORKSPACE)) for p in sorted(WORKSPACE.rglob("*")) if p.is_file())

@beta_tool
def read_file(path: str) -> str:
    """Read a UTF-8 text file from the workspace.
    Args:
        path: Workspace-relative path, e.g. 'budget.txt'.
    """
    try:
        return safe_path(path).read_text(encoding="utf-8", errors="replace")[:20_000]
    except Exception as e:
        return f"Error: {e}"          # return errors as text so the model can recover

@beta_tool
def calculator(expression: str) -> str:
    """Evaluate an arithmetic expression exactly (numbers, parentheses, + - * / ** %).
    Args:
        expression: For example '(1200 + 450) * 0.2'.
    """
    try:
        return calc(expression)
    except Exception as e:
        return f"Error: {e}"

@beta_tool
def search_notes(query: str) -> str:
    """Keyword search across .txt and .md files in the workspace.
    Args:
        query: One or two keywords.
    """
    return search(query)

@beta_tool
def save_note(text: str) -> str:
    """Append a one-line note to notes.txt. Only use when the user asks to save something.
    Args:
        text: The note to save.
    """
    if input(f"Allow save_note({json.dumps(text)})? [y/N] ").strip().lower() != "y":
        return "The user denied this action. Do not retry it."
    with NOTES_FILE.open("a", encoding="utf-8") as f:
        f.write(text.strip() + "\n")
    return "Saved."

runner = client.beta.messages.tool_runner(
    model=MODEL,
    max_tokens=16000,
    system=SYSTEM,
    tools=[list_files, read_file, calculator, search_notes, save_note],
    messages=[{"role": "user", "content": "Total the costs in budget.txt and save the total as a note."}],
)
for step, message in enumerate(runner, 1):      # one assistant message per loop iteration
    for block in message.content:
        if block.type == "text":
            print(f"[{step}] {block.text}")
        elif block.type == "tool_use":
            print(f"[{step}] tool {block.name} {block.input}")
    if step >= 12:                              # your own max-steps guard
        print("Stopping: step limit reached.")
        break

The decorator builds each tool's JSON Schema from type hints and its description from the docstring; the runner calls the model, runs requested tools, and feeds results back until the model stops. The safety code (workspace guard, safe calculator, approval) does not go away - it moves into the tool functions.

A sample run log (what you should see)

$ python mini_agent.py "Total the costs in budget.txt and save the total as a note."
INFO step 1 stop=tool_use in=1450 out=60
INFO   -> read_file {"path": "budget.txt"}
INFO   <- rent 1200 | food 450 | transport 130
INFO step 2 stop=tool_use in=1540 out=55
INFO   -> calculator {"expression": "1200 + 450 + 130"}
INFO   <- 1780
INFO step 3 stop=tool_use in=1610 out=70
INFO   -> save_note {"text": "Monthly costs total 1780 (from budget.txt)"}

Agent wants to run save_note({"text": "Monthly costs total 1780 (from budget.txt)"}). Allow? [y/N] y
INFO   <- Saved to notes.txt
INFO step 4 stop=end_turn in=1700 out=40

Your monthly costs total 1780 (rent 1200 + food 450 + transport 130), from budget.txt.
I saved this total to notes.txt.

Illustrative output - token counts and wording will differ on every run. Notice input tokens grow each step, because the whole history is re-sent.

How it works

The Python agent and the JavaScript demo have the same skeleton: messages (the growing history), TOOLS (what the model may request), run_tool (what actually happens), and the loop (call, append, run, append, repeat). The model never runs code; it only emits tool_use blocks, and the dispatcher decides what to do with them.

Parallel tool calls need no extra code: if the model returns three tool_use blocks in one turn, the inner for block in resp.content loop runs all three and the results travel together in one user message. To speed this up you could run independent tools concurrently with concurrent.futures.ThreadPoolExecutor, as long as each result keeps its tool_use_id.

The approval gate sits between the model's request and execution. Because a denial is reported as an is_error result, the conversation stays well-formed and the model can explain what it could not do. In a web app the same idea becomes a 'pending approval' state that a user resolves with a button.

The tool runner automates the loop and schema generation, but you still own limits, safety, and logging. Write the manual loop first so you know exactly what the runner is doing for you.

  user task
     |
     v
  +---------------- run_agent ----------------+
  |  messages ----> client.messages.create    |
  |     ^             |  log stop/tokens      |
  |     |        end_turn? --yes--> answer    |
  |     |             | tool_use              |
  |     |      for each tool_use block:       |
  |     |        risky? -> ask human (y/N)    |
  |     |        run_tool -> output | error   |
  |     +---- ONE user msg of tool_results    |
  |           step > MAX_STEPS -> stop        |
  +-------------------------------------------+
  tools: list_files read_file calculator
         search_notes save_note(risky)

Why does it exist?

Frameworks hide the loop behind abstractions, and when something goes wrong you cannot see why. Building an agent from scratch shows that there is no magic: a model, a list of tools, a dispatcher and a loop. With that understanding you can debug any framework, decide whether you need one at all, and put security controls where they belong - in your code, not in the prompt.

When to use it

Build your own loop when you need full control over safety, logging and cost; when your tools are few and well-defined; when you are learning; or when a framework would add more complexity than it removes. Use the SDK tool runner once the manual loop is clear and you want less boilerplate.

When not to use it

If the task is always the same two or three steps, write a workflow instead of a loop. If you need durable long-running agents (resume after crashes, distributed workers, scheduling), consider an established agent framework or platform rather than growing this script indefinitely. And never ship the approval-free version of a tool that sends, deletes or pays.

Common mistakes

  • Implementing a calculator with eval() or exec() on model output - that is remote code execution.

  • Checking paths with string prefix tests (startswith) without resolving them first, so '..' or symlinks escape the workspace.

  • Forgetting to cap file and search output, so one large file fills the context window.

  • Letting a tool exception crash the agent instead of returning an is_error tool_result.

  • Asking for approval but treating 'no' by silently skipping the tool_result, which breaks the tool_use/tool_result pairing.

  • Writing the loop without MAX_STEPS 'just for testing' and forgetting to add it later.

  • Logging full tool outputs that contain secrets or personal data to shared log files.

  • Using the tool runner before understanding the manual loop, then being unable to debug it.

Practice exercises

  1. Easy:

    Run the offline demo, then change the fake llm() so that at round 2 it asks for 'notes.txt' (which does not exist). Check the error message and confirm the loop continues.

  2. Easy:

    Add a * precedence test to the JS calculator: confirm '2 + 3 * 4' returns 14 and '(2 + 3) * 4' returns 20, and that 'alert(1)' is rejected.

  3. Medium:

    Build mini_agent.py on your machine with three text files in workspace/. Try to make it read ../mini_agent.py and confirm the guard blocks it. Paste the log.

  4. Medium:

    Add a write_file tool that can only create new .md files inside workspace/drafts/, requires approval, and refuses to overwrite existing files.

  5. Hard:

    Add a token budget to mini_agent.py: stop when cumulative input + output tokens exceed a limit and return a summary of the steps taken so far. Then add a JSON-lines trace file of every step.

  6. Hard:

    Port the agent to the tool runner fully, including your write_file tool, and compare the two versions on five tasks. Note any behaviour differences and why.

Interview questions

What are the minimum components of an agent you would build from scratch?

A system prompt, tool definitions with good descriptions and JSON Schemas, a dispatcher that runs tools safely and converts failures into is_error results, a loop that appends assistant content and all tool results each turn, and guards: max steps, budgets, approval for risky tools, and per-step logging.

How do you implement a calculator tool safely?

Never eval model text. Parse the expression into a syntax tree (for example with Python's ast module) and evaluate only whitelisted node types - numeric constants, binary and unary arithmetic operators - with limits on expression length and exponent size. Anything else raises an error that is returned to the model.

How do you restrict a file tool to a folder?

Join the requested path to the workspace, fully resolve it (which collapses '..' and follows symlinks), and check that the resolved path is still inside the resolved workspace directory before reading. Reject absolute paths and anything that resolves outside.

How would you add human-in-the-loop approval?

Classify tools by risk. Before executing a risky tool, show the exact name and arguments to a human and wait for an explicit yes. On denial, still return a tool_result for that id with is_error and a message saying the user denied it, so the model can continue without retrying.

What does the SDK tool runner change and what does it not?

It generates schemas from decorated functions and runs the call-execute-append loop for you. It does not remove the need for safe tool implementations, step limits, approval for side effects, logging and cost control - those remain your responsibility.

How do you test an agent like this without spending API credits?

Replace the model call with a scripted fake that returns API-shaped responses (tool_use blocks, then a final answer), and unit-test tools and the dispatcher directly - path guard, calculator, error paths, approval denial, and the max-steps exit.