Building a tool-using agent

A hands-on walkthrough of giving an agent several tools — sync, async, and dependency-reading — and watching the loop pick the right one each turn.


An agent is only as useful as the things it can do. On its own, a model can reason and write text; tools let it check a database, call an API, or do math it would otherwise hallucinate. In AgentRoute you attach tools as plain Python functions, and the agent loop decides which to call, in what order, until it has enough to answer.

This guide builds one agent with three tools — a synchronous lookup, an asynchronous fetch, and one that reads request-scoped dependencies through Context. Along the way you'll see how docstrings become the tool schema and how needs_approval and timeout guard the tools you care about.

For the conceptual model behind any of this, see Tools and Context and usage. For the full signatures, see the tools reference.

What you'll build

A support-desk assistant that can convert currency, look up an order's live status over HTTP, and read the current user's account tier from injected dependencies. The model never sees your Python — it sees a JSON schema derived from each function's signature and docstring, and asks to call whichever tool fits the question.

Defining tools

You register a tool with the @agent.tool decorator. The function's name becomes the tool name, its parameter type hints become the input schema, and its docstring becomes the description the model reads when deciding whether to call it. Treat the docstring as a prompt: it is the only context the model has for what the tool does.

Docstrings are the API the model sees

Write the first line as a crisp, imperative summary ("Convert an amount from one currency to another."). The model uses it to choose tools, so vague docstrings lead to wrong or skipped calls.

A synchronous tool

Start with the simplest case: a pure function. Sync tools are offloaded to a worker thread (asyncio.to_thread) so a slow one never blocks the event loop, so you can write ordinary blocking code.

from agentroute import Agent
 
agent = Agent(
    name="support-desk",
    model="claude-sonnet-4",
    instructions="You help customers with orders and billing. Use tools for live data.",
)
 
RATES = {("USD", "EUR"): 0.92, ("EUR", "USD"): 1.09}
 
 
@agent.tool
def convert_currency(amount: float, from_currency: str, to_currency: str) -> float:
    """Convert an amount between two ISO currency codes (e.g. USD, EUR).
 
    Args:
        amount: The amount to convert.
        from_currency: Source currency code.
        to_currency: Target currency code.
    """
    rate = RATES.get((from_currency, to_currency))
    if rate is None:
        raise ValueError(f"No rate for {from_currency}->{to_currency}")
    return round(amount * rate, 2)

The schema for this tool — three named parameters with types and descriptions — is derived automatically. You never hand-write JSON.

An asynchronous tool

When a tool does I/O, make it async def and the loop will await it directly. This one fetches order status over HTTP with httpx.

import httpx
 
 
@agent.tool(timeout=10.0)
async def get_order_status(order_id: str) -> dict:
    """Look up the live status of an order by its ID.
 
    Args:
        order_id: The order identifier, e.g. "ORD-4821".
    """
    async with httpx.AsyncClient() as client:
        resp = await client.get(f"https://api.example.com/orders/{order_id}")
        resp.raise_for_status()
        return resp.json()

Notice @agent.tool(timeout=10.0). The decorator is dual-call: @agent.tool registers with defaults, and @agent.tool(...) lets you pass options. A timeout (in seconds) caps how long the tool may run before it is aborted — useful for any network call that might hang.

A tool that reads dependencies

Tools often need request-scoped data that should never reach the model: the calling user, a database handle, an API client. Annotate a parameter as ctx: Context and AgentRoute injects the live Context at call time. That parameter is hidden from the model's schema — it only sees the rest.

from dataclasses import dataclass
from agentroute import Context
 
 
@dataclass
class Deps:
    user_id: str
    tier: str  # "free" | "pro" | "enterprise"
 
 
@agent.tool
def get_account_tier(ctx: Context) -> str:
    """Return the current customer's subscription tier."""
    return ctx.deps.tier

Whatever you pass as deps= to agent.run(...) lands on ctx.deps. The same ctx also carries the running usage totals and an optional memory handle, so tools can record token cost or call ctx.memory.remember(...). See Context and usage for the full surface.

Note

The injected parameter must be annotated exactly as ctx: Context (the first non-variadic parameter). AgentRoute detects it by type, then strips it from the schema and supplies the real object when the model calls the tool.

Running it

Call agent.run(prompt, deps=...) for a synchronous run, or await agent.arun(...) inside async code. The result's string form is the final answer text; result.usage holds cumulative token and cost totals.

deps = Deps(user_id="u_123", tier="pro")
 
result = agent.run(
    "I'm on which plan, and what's 250 USD in EUR? Also check order ORD-4821.",
    deps=deps,
)
 
print(result.output)        # the model's final answer
print(result.usage)         # Usage(input_tokens=..., total_cost_usd=..., model_calls=...)

To run several prompts concurrently, gather over arun:

import asyncio
 
async def main() -> None:
    prompts = ["Convert 100 USD to EUR", "Status of ORD-1000?", "What tier am I?"]
    results = await asyncio.gather(
        *(agent.arun(p, deps=deps) for p in prompts)
    )
    for r in results:
        print(r.output)
 
asyncio.run(main())

How the loop chooses tools

You don't orchestrate tool calls — the loop does. Each turn works like this:

1
The model gets the prompt plus every tool schema

On the first turn the loop sends your prompt, the agent's instructions, and the auto-derived schema for every registered tool. The model decides whether it can answer directly or needs to call one or more tools.

2
Requested tools run, results go back

For each tool the model asks for, AgentRoute invokes your function — awaiting async tools, threading sync ones, injecting ctx where requested — and feeds the return value back into the conversation as a tool result.

3
The model reasons over the results

With fresh data in hand, the model may answer, or it may call more tools. For the example prompt above it would typically call get_account_tier, convert_currency, and get_order_status, then synthesize one reply.

4
The loop stops at an answer or at max_turns

The cycle repeats until the model returns a final text answer. A safety valve, max_turns (default 10), bounds the back-and-forth; exceeding it raises ErrorMaxTurns. If max_cost is set, exceeding it raises ErrorBudget.

Because the model picks tools from their schemas, the quality of your names, type hints, and docstrings directly determines whether it calls the right one. Keep each tool focused on a single job.

Bound your runs

The loop will keep calling tools as long as the model asks. Set max_turns and max_cost on the Agent to cap runaway loops and spend. See Errors and retries for how ErrorMaxTurns and ErrorBudget surface.

Guarding sensitive tools

Two options on the decorator let you control risky tools without changing their logic.

Requiring approval

Pass needs_approval=True for a tool that has real-world side effects — issuing a refund, deleting a record, sending an email. The flag marks the tool as gated so a call can be paused for human confirmation rather than executed blindly.

@agent.tool(needs_approval=True)
def issue_refund(order_id: str, amount: float) -> str:
    """Issue a refund for an order. Requires human approval before running.
 
    Args:
        order_id: The order to refund.
        amount: Amount to refund, in the order's currency.
    """
    # ... call the payments API ...
    return f"Refunded {amount} on {order_id}"

needs_approval also accepts a callable — (ctx: Context, args: dict) -> bool — when approval should depend on the arguments or the caller. For instance, auto-approve small refunds and gate large ones:

@agent.tool(needs_approval=lambda ctx, args: args["amount"] > 100)
def issue_refund(order_id: str, amount: float) -> str:
    """Issue a refund; refunds over 100 require approval."""
    return f"Refunded {amount} on {order_id}"

Capping runtime

timeout (seconds) caps how long a single tool invocation may run before it is aborted, so one stuck network call can't stall the whole agent. Set it on any tool that reaches out over the network, as shown for get_order_status above.

Putting it together

Here is the full agent in one file.

support_desk.py
import asyncio
from dataclasses import dataclass
 
import httpx
 
from agentroute import Agent, Context
 
agent = Agent(
    name="support-desk",
    model="claude-sonnet-4",
    instructions="You help customers with orders and billing. Use tools for live data.",
    max_turns=8,
    max_cost=0.50,
)
 
RATES = {("USD", "EUR"): 0.92, ("EUR", "USD"): 1.09}
 
 
@dataclass
class Deps:
    user_id: str
    tier: str
 
 
@agent.tool
def convert_currency(amount: float, from_currency: str, to_currency: str) -> float:
    """Convert an amount between two ISO currency codes (e.g. USD, EUR).
 
    Args:
        amount: The amount to convert.
        from_currency: Source currency code.
        to_currency: Target currency code.
    """
    rate = RATES.get((from_currency, to_currency))
    if rate is None:
        raise ValueError(f"No rate for {from_currency}->{to_currency}")
    return round(amount * rate, 2)
 
 
@agent.tool(timeout=10.0)
async def get_order_status(order_id: str) -> dict:
    """Look up the live status of an order by its ID.
 
    Args:
        order_id: The order identifier, e.g. "ORD-4821".
    """
    async with httpx.AsyncClient() as client:
        resp = await client.get(f"https://api.example.com/orders/{order_id}")
        resp.raise_for_status()
        return resp.json()
 
 
@agent.tool
def get_account_tier(ctx: Context) -> str:
    """Return the current customer's subscription tier."""
    return ctx.deps.tier
 
 
if __name__ == "__main__":
    deps = Deps(user_id="u_123", tier="pro")
    result = agent.run(
        "What plan am I on, and what is 250 USD in EUR?",
        deps=deps,
    )
    print(result.output)
    print(result.usage)

Run it with your AgentRoute key set:

export AGENTROUTE_API_KEY="sk-or-..."
python support_desk.py

One key works for every model string — see Models for how model="claude-sonnet-4" resolves.

Concepts used

Next, give your agent structured results with structured output, or persistent recall with memory.