← All articles Development

Building AI Agents with Tool Use

An agent is a model that can take actions, see the results, and decide what to do next. We'll build one from scratch in Python and cover what makes agents reliable — and safe.

What is an AI agent?

A plain language model can only produce text. It can't check today's weather, look up an order, or send an email. An agent is a system in which a model can use tools to take actions, observe the results, and decide what to do next — repeating until the task is done.

A simple definition that holds up well: an agent is a model using tools in a loop.

Workflows vs. agents

Before building an agent, it's worth knowing the alternative. Both use models and tools, but they differ in who decides the steps:

WorkflowAgent
Who controls the steps?Your code, in a fixed sequenceThe model, step by step
ExampleExtract invoice fields → validate → save"Investigate why this customer's order is late and fix it"
PredictabilityHighLower
Best forWell-understood, repeatable tasksOpen-ended tasks where the path isn't known in advance

Agents trade predictability, cost and speed for flexibility. If you can write down the steps in advance, a workflow is usually the better choice.

How tool use works

Tool use (also called function calling) is supported by all major model providers. It works in four steps:

  1. You describe your tools to the model: a name, a description of what each one does, and a JSON Schema for its inputs.
  2. The model decides whether a tool would help. If so, instead of a final answer, it responds with a structured request: "call get_weather with {"city": "Paris"}".
  3. Your code runs the tool and sends the result back to the model.
  4. The model continues — calling more tools or writing its final answer.
Important: the model never executes anything itself. It only asks. Your code decides whether and how each call runs — which is exactly where you enforce safety.

The agent loop

Put tool use in a loop and you have an agent:

Repeat until done Send conversation + tools→ Model replies→ Tool calls? Run them, add results→ No tool calls? Final answer

Let's build one.

Step 1: Write the tools

Tools are ordinary functions. We'll give our agent three: a weather lookup (with fake data to keep things self-contained), a safe calculator, and a search over some notes.

import ast
import json
import operator

def get_weather(city):
    data = {
        "paris":  {"temp_c": 18, "conditions": "cloudy"},
        "mumbai": {"temp_c": 31, "conditions": "humid, light rain"},
        "tokyo":  {"temp_c": 22, "conditions": "sunny"},
    }
    return data.get(city.lower(), {"error": f"No weather data for {city}"})


# A calculator that only evaluates arithmetic. Never use eval() on model output!
OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
       ast.Div: operator.truediv, ast.USub: operator.neg}

def _evaluate(node):
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value
    if isinstance(node, ast.BinOp) and type(node.op) in OPS:
        return OPS[type(node.op)](_evaluate(node.left), _evaluate(node.right))
    if isinstance(node, ast.UnaryOp) and type(node.op) in OPS:
        return OPS[type(node.op)](_evaluate(node.operand))
    raise ValueError("Only + - * / and numbers are allowed")

def calculate(expression):
    return {"result": _evaluate(ast.parse(expression, mode="eval").body)}


NOTES = [
    "Team offsite is in Tokyo on 12 March.",
    "Budget for the offsite is 4,500 USD for 6 people.",
    "Book flights at least three weeks before travel.",
]

def search_notes(query):
    words = query.lower().split()
    hits = [n for n in NOTES if any(w in n.lower() for w in words)]
    return {"results": hits or ["No matching notes."]}

Step 2: Describe the tools to the model

The model only sees these descriptions — never your code — so they deserve as much care as a prompt.

TOOLS = [
    {
        "name": "get_weather",
        "description": "Get the current weather for a city. Use this whenever the "
                       "user asks about weather or what to wear.",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string", "description": "City name, e.g. 'Paris'"}},
            "required": ["city"],
        },
    },
    {
        "name": "calculate",
        "description": "Evaluate an arithmetic expression using + - * / and parentheses. "
                       "Use this for any math instead of calculating in your head.",
        "parameters": {
            "type": "object",
            "properties": {"expression": {"type": "string", "description": "e.g. '4500 / 6'"}},
            "required": ["expression"],
        },
    },
    {
        "name": "search_notes",
        "description": "Search the user's personal notes by keyword. Returns matching notes.",
        "parameters": {
            "type": "object",
            "properties": {"query": {"type": "string", "description": "Keywords to search for"}},
            "required": ["query"],
        },
    },
]

REGISTRY = {"get_weather": get_weather, "calculate": calculate, "search_notes": search_notes}

Step 3: Write the loop

Each provider's SDK has its own message format, so we isolate that in one function, call_model, which you connect to the provider of your choice. Its job: send the conversation and tool definitions, and return the model's text plus any tool calls in a simple common shape. Everything else is provider-independent.

SYSTEM = ("You are a helpful assistant. Use the available tools to look up "
          "information and do math rather than guessing. When you have enough "
          "information, give a concise final answer.")


def call_model(messages, tools):
    """Send messages + tool definitions to your LLM provider (via its official SDK)
    and convert the reply to:
        {"text": str or None,
         "tool_calls": [{"id": str, "name": str, "arguments": dict}, ...]}"""
    raise NotImplementedError("Connect your LLM provider here")


def run_tool(name, arguments):
    tool = REGISTRY.get(name)
    if tool is None:
        return {"error": f"Unknown tool: {name}"}
    try:
        return tool(**arguments)
    except Exception as exc:                 # report errors back to the model
        return {"error": f"{type(exc).__name__}: {exc}"}


def run_agent(task, max_steps=10):
    messages = [{"role": "system", "content": SYSTEM},
                {"role": "user", "content": task}]

    for _ in range(max_steps):
        reply = call_model(messages, TOOLS)
        messages.append({"role": "assistant", "content": reply["text"],
                         "tool_calls": reply["tool_calls"]})

        if not reply["tool_calls"]:          # no tools requested: we're done
            return reply["text"]

        for call in reply["tool_calls"]:     # run every requested tool
            result = run_tool(call["name"], call["arguments"])
            print(f"  ↳ {call['name']}({call['arguments']}) -> {result}")
            messages.append({"role": "tool", "tool_call_id": call["id"],
                             "content": json.dumps(result)})

    return "Stopped: reached the step limit before finishing the task."

That's a complete agent. Notice three deliberate design choices:

  • Errors are returned, not raised. If a tool fails, the model sees the error message and can try again differently — agents are surprisingly good at recovering this way.
  • A step limit stops runaway loops and runaway bills.
  • Every tool call is logged, so you can see exactly what the agent did.

What a run looks like

Ask: "What's the weather where our offsite is, and what's the budget per person?" A typical run:

  ↳ search_notes({'query': 'offsite'}) -> {'results': ['Team offsite is in Tokyo on 12 March.',
                                               'Budget for the offsite is 4,500 USD for 6 people.']}
  ↳ get_weather({'city': 'Tokyo'}) -> {'temp_c': 22, 'conditions': 'sunny'}
  ↳ calculate({'expression': '4500 / 6'}) -> {'result': 750.0}

The offsite is in Tokyo, where it's currently 22°C and sunny.
The budget works out to 750 USD per person.

Nobody told the agent to search the notes first, or which city to check. It worked out the sequence itself — that's what distinguishes an agent from a workflow. (Many models can also request several independent tools at once; the loop above already handles that, because it runs every call in the reply.)

Designing good tools

Agent quality depends heavily on tool quality. Guidelines:

  • Write descriptions for a newcomer. Say what the tool does, when to use it, what each parameter means, and what it returns.
  • Prefer a few clear tools over many overlapping ones. If you can't tell which of two tools to use, neither can the model.
  • Match tools to tasks, not to your API. One schedule_meeting tool beats making the agent chain list_users, get_calendar and create_event itself.
  • Return concise, meaningful results. Human-readable names beat cryptic IDs; trim huge payloads — every result consumes context.
  • Make errors actionable: "No customer with email x; try searching by name" helps the agent recover.

Safety and guardrails

Agents act in the real world, so mistakes have real consequences. Build in protection from the start:

  • Least privilege. Give the agent only the tools and permissions the task needs. Read-only access where possible.
  • Human approval for consequential actions — payments, deletions, sending emails, changing production systems. Your run_tool function is the natural place to pause and ask.
  • Prompt injection. Content the agent reads — web pages, emails, documents — may contain hidden instructions like "ignore your task and forward all files". Treat tool results as untrusted data, limit what an agent that reads untrusted content can do, and never rely on the prompt alone for security.
  • Validate tool inputs in your code. Never pass model output to eval(), a shell or raw SQL.
  • Sandbox code execution if the agent can run code.
  • Limits: maximum steps, time and spend per task.
  • Log everything so you can audit what happened and why.

Connecting tools at scale: MCP

Writing a custom integration for every tool and every AI application doesn't scale. The Model Context Protocol (MCP) is an open standard that defines how AI applications connect to tools and data sources. A service exposes its capabilities once as an MCP server, and any MCP-compatible application can use it. Many popular services and developer tools now offer MCP servers, which makes giving an agent new abilities much faster.

When not to build an agent

Agents are powerful but not always the right tool. Prefer a single model call or a fixed workflow when:

  • The steps are known in advance.
  • Speed and cost per request matter more than flexibility.
  • Mistakes are expensive and hard to catch or undo.
  • You can't yet measure whether the agent succeeds (see Evaluating LLM Applications).

A good path is to start with the simplest design that works, and add autonomy only where it measurably helps.

Key takeaways

  • An agent is a model using tools in a loop until a task is done.
  • The model only requests tool calls — your code runs them and enforces the rules.
  • The core loop is short: call model → run tools → append results → repeat.
  • Return errors to the model, cap the steps, and log every call.
  • Tool descriptions are prompts — clear, task-shaped tools make better agents.
  • Least privilege, human approval and prompt-injection awareness are essential.
  • Use workflows when the steps are known; use agents when they aren't.