Your first agent

Build a complete AgentRoute agent end to end — define it, give it tools, run it with dependencies, and read back the output and usage.


This walkthrough builds one real agent from scratch. By the end you'll have an agent with two tools — one plain function, one that reads request-scoped dependencies — running against a live model, and you'll know how to inspect what it produced and what it cost.

Everything here is runnable. If you've set an API key (see Installation), you can paste each block into a file and run it.

The shape of an agent

An Agent is a configuration object. You give it a name, a model, and instructions; you attach tools; you call run(). AgentRoute drives the agentic loop — each turn it sends the running transcript plus your tool schemas to the model, executes any tool calls the model asks for, feeds the results back, and repeats until the model returns a final answer (or a budget is hit).

Start with the smallest thing that works.

agent.py
from agentroute import Agent
 
agent = Agent(
    name="trip-planner",
    model="claude-sonnet-4",
    instructions="You are a concise travel assistant. Answer in one short paragraph.",
)
 
result = agent.run("Suggest a city for a long weekend in October.")
print(result)
# -> Lisbon is a great pick for October: mild weather, fewer crowds, ...

agent.run() is synchronous — it wraps the async arun() with asyncio.run(), so it's fine to call from a plain script. print(result) works because Result stringifies to its output.

Use one key for every model

The model string is all you need. "claude-sonnet-4", "gpt-4o", and "gemini-2.0-flash" all route through a single OpenRouter key. See Models for the resolution rules.

Adding a plain tool

Tools are how the agent does things beyond generating text. The simplest tool is a plain synchronous function: type-hint its parameters, write a docstring, and decorate it. AgentRoute reads the hints and docstring to build the schema the model sees, so the description matters — write it for the model.

Use the agent's own @agent.tool decorator to register the function directly on the agent.

agent.py
from agentroute import Agent
 
agent = Agent(
    name="trip-planner",
    model="claude-sonnet-4",
    instructions="You are a travel assistant. Use tools for any math.",
)
 
 
@agent.tool
def nights_between(check_in_day: int, check_out_day: int) -> int:
    """Return the number of nights between two day-of-month dates.
 
    Args:
        check_in_day: Day of the month you arrive.
        check_out_day: Day of the month you leave.
    """
    return check_out_day - check_in_day
 
 
result = agent.run("If I arrive on the 12th and leave on the 16th, how many nights is that?")
print(result)
# -> That's 4 nights.

A few things are happening automatically here:

  • The parameter names, types, and docstring become the JSON schema the model uses to call the tool.
  • The function is synchronous, so AgentRoute runs it on a worker thread (asyncio.to_thread) — it won't block the event loop even if it does real I/O.
  • When the model decides it needs the number of nights, it emits a tool call, AgentRoute runs nights_between, feeds 4 back, and the model uses it to write the final answer.

You can register a tool three ways: @agent.tool, the standalone @tool decorator passed via Agent(tools=[...]), or by passing a bare function in that same list. They're equivalent. See Tools for the full picture.

A tool that reads dependencies

Real tools usually need something from the outside world — the current user, an API client, a database handle, a feature flag. You don't want to hard-code those or smuggle them through globals. AgentRoute injects them through Context.

Annotate a tool's first parameter as Context and AgentRoute fills it in for you at call time. Whatever you pass to run(prompt, deps=...) shows up on ctx.deps. The model never sees the Context parameter — it isn't part of the tool's schema, so the model can't (and doesn't need to) supply it.

agent.py
from dataclasses import dataclass
 
from agentroute import Agent, Context
 
 
@dataclass
class TravelDeps:
    home_city: str
    budget_usd: float
 
 
agent = Agent(
    name="trip-planner",
    model="claude-sonnet-4",
    instructions=(
        "You are a travel assistant. Use the traveler's home city and budget "
        "from the available tools when giving advice."
    ),
)
 
 
@agent.tool
def traveler_profile(ctx: Context) -> str:
    """Look up the current traveler's home city and trip budget."""
    deps: TravelDeps = ctx.deps
    return f"Home city: {deps.home_city}. Budget: ${deps.budget_usd:.0f}."
 
 
result = agent.run(
    "Where should I go for a weekend that fits my budget?",
    deps=TravelDeps(home_city="Berlin", budget_usd=400),
)
print(result)
# -> Within a $400 budget from Berlin, Prague is a strong choice: ...

The flow: you pass deps=TravelDeps(...) to run(). AgentRoute builds a fresh Context for the run with that object on ctx.deps. When the model calls traveler_profile, AgentRoute sees the first parameter is annotated Context, injects the context, and the tool reads ctx.deps to return the profile.

Context is never sent to the model

Context is a server-side container — your deps, the cumulative Usage, the running transcript, and loop state. It is never serialized into a prompt or shown to the model. That's why it's safe to put API clients, secrets, or a database connection on deps: the model sees only what your tool chooses to return. Read more in Context and usage.

Setting limits

The agentic loop runs until the model is done. In practice you want guardrails so a confused agent can't loop forever or run up a bill. Two constructor arguments cover this:

  • max_turns — the maximum number of loop iterations. Defaults to 10. If the loop exceeds it, AgentRoute raises ErrorMaxTurns.
  • max_cost — a ceiling on accumulated USD cost. Defaults to None (no limit). If the running cost crosses it, AgentRoute raises ErrorBudget.
agent.py
agent = Agent(
    name="trip-planner",
    model="claude-sonnet-4",
    instructions="You are a travel assistant.",
    max_turns=6,
    max_cost=0.10,
)

Both errors subclass ErrorAgent and carry the relevant numbers (turn/limit and spent/limit), so you can catch and report them precisely. See Errors and retries for the full hierarchy.

Running it and reading the result

Pull the pieces together into one agent, run it, and inspect what came back. run() returns a Result with two things you'll reach for constantly: output (the final answer) and usage (tokens and cost for the whole run).

agent.py
from dataclasses import dataclass
 
from agentroute import Agent, Context
 
 
@dataclass
class TravelDeps:
    home_city: str
    budget_usd: float
 
 
agent = Agent(
    name="trip-planner",
    model="claude-sonnet-4",
    instructions=(
        "You are a concise travel assistant. Use the available tools to read "
        "the traveler's profile and to compute nights. Answer in 2-3 sentences."
    ),
    max_turns=6,
    max_cost=0.10,
)
 
 
@agent.tool
def nights_between(check_in_day: int, check_out_day: int) -> int:
    """Return the number of nights between two day-of-month dates.
 
    Args:
        check_in_day: Day of the month you arrive.
        check_out_day: Day of the month you leave.
    """
    return check_out_day - check_in_day
 
 
@agent.tool
def traveler_profile(ctx: Context) -> str:
    """Look up the current traveler's home city and trip budget."""
    deps: TravelDeps = ctx.deps
    return f"Home city: {deps.home_city}. Budget: ${deps.budget_usd:.0f}."
 
 
result = agent.run(
    "Plan a trip that fits my budget. I'd arrive on the 12th and leave on the 16th.",
    deps=TravelDeps(home_city="Berlin", budget_usd=400),
)
 
print(result.output)
# -> From Berlin on a $400 budget, Prague is ideal for your 4-night stay (the
#    12th to the 16th): cheap, close, and walkable.
 
usage = result.usage
print(f"input tokens:  {usage.input_tokens}")
print(f"output tokens: {usage.output_tokens}")
print(f"model calls:   {usage.model_calls}")
print(f"total cost:    ${usage.total_cost_usd:.4f}")
# input tokens:  1284
# output tokens: 96
# model calls:   2
# total cost:    $0.0061

What's on the result

ParameterTypeDefaultDescription
outputAny

The final answer. Plain text when no output model is set on the agent. print(result) shows this directly.

usageUsage

Cumulative tokens and cost for the run: input_tokens, output_tokens, total_cost_usd, and model_calls (how many times the model was hit — one per turn).

messageslist[dict]

The full transcript of the run — system, user, assistant, and tool messages. Useful for debugging or persisting a conversation.

interruptedboolFalse

Whether the run ended early (for example, on a tool awaiting approval).

Notice model_calls is 2, not 1: the first turn the model decided to call tools, AgentRoute ran them and fed the results back, and the second turn produced the final answer. That round-trip is the agentic loop. The full Result reference covers structured output and the rest.

Async, if you need it

If you're already inside an event loop — a FastAPI handler, a worker, a notebook with await — use arun() instead of run(). Same signature, same return type.

# inside an async function
result = await agent.arun(
    "Plan a trip that fits my budget.",
    deps=TravelDeps(home_city="Berlin", budget_usd=400),
)
print(result.output)
Don't call run() inside a running loop

run() calls asyncio.run() under the hood, which fails if a loop is already running. Inside async code, always await agent.arun(...).

Where to go next