Building AI Agents from Scratch

Tool Calling from Scratch

Parse tool schemas and arguments, execute your code, and return results to the model.

A tool call is JSON the model invented. Treat it as untrusted input. Parse arguments, run a function you wrote, append a tool message, then think again.

Registry and dispatch

import json
from pathlib import Path

ROOT = Path(__file__).resolve().parent


def list_dir(path: str) -> dict:
    target = (ROOT / path).resolve()
    if ROOT not in target.parents and target != ROOT:
        return {"ok": False, "error": "path escapes the project"}
    if not target.is_dir():
        return {"ok": False, "error": "not a directory"}
    names = sorted(p.name for p in target.iterdir())[:50]
    return {"ok": True, "path": str(target), "names": names}


REGISTRY = {"list_dir": list_dir}


def run_tool(name: str, arguments_json: str) -> str:
    fn = REGISTRY.get(name)
    if fn is None:
        return json.dumps({"ok": False, "error": f"unknown tool {name}"})
    try:
        args = json.loads(arguments_json or "{}")
    except json.JSONDecodeError:
        return json.dumps({"ok": False, "error": "arguments are not JSON"})
    result = fn(**args) if isinstance(args, dict) else fn()
    return json.dumps(result)

Close the loop

After think, if tool_calls is set, append the assistant message with those calls, then one tool message per call. Then loop. Do not drop the assistant tool-call message — the API requires it.

def run(user_text: str, max_steps: int = 8) -> str:
    messages = [
        {"role": "system", "content": "Use list_dir when you need filenames. Then answer briefly."},
        {"role": "user", "content": user_text},
    ]
    for _ in range(max_steps):
        message = think(messages, tools=TOOLS)
        calls = message.tool_calls or []
        if not calls:
            return message.content or ""
        messages.append(message)
        for call in calls:
            output = run_tool(call.function.name, call.function.arguments)
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": output,
            })
    return "stopped: max steps"

think is the same create call with tools=TOOLS. Some clients let you messages.append(message) with the SDK object; others want a dict. If a host rejects the object, convert it to {"role": "assistant", "content": ..., "tool_calls": ...}.

Check it

Ask “What Python files are in this folder?” You should see a JSON list in the second model call’s context, then a sentence that names files. If path is ../, the tool must refuse. Next: add a second tool and watch selection.