GuidesBuild an agent

Build an agent with a tool loop

An agent is a model that works towards a goal over several steps: it decides what to do next, calls a tool, reads the result, and repeats until the goal is met or it should stop. The loop mechanics are covered in Write a tool loop. This guide is about everything around the loop that turns it into an agent you can trust with real work: a clear goal, a step and spending budget, approval for actions with consequences, recovery from tool failures, and a trace you can read afterwards.

The example is an operations agent for a fictional bike-rental company, Wheelhaus. Given a complaint (“bike 1182 had a flat tyre and I lost an hour”), it checks the rental record, looks up the refund policy, drafts a refund, and asks a person to approve it before anything is paid.

You will need: an API key in SYNTIC_API_KEY, Python 3.9 or later, and pip install openai.

Is an agent the right shape?

Use a single requestUse a fixed chainUse an agent
One input, one output: classify, summarise, translate.Known steps in a known order: extract, then check, then write.The steps depend on what earlier steps found, and their number varies.

Agents are the most flexible and the hardest to test. Choose the simplest shape that does the job; Chain prompts covers the middle column.

Define tools by risk

Sort every tool into one of three groups before writing code:

GroupWheelhaus examplesPolicy
Readget_rental, get_policyRun automatically.
Reversible writeadd_note to the customer recordRun automatically, log it.
Consequentialissue_refund, send_emailNever run directly. Queue for a person, return “pending approval” to the model.
TOOLS = [
    {"type": "function", "function": {
        "name": "get_rental",
        "description": "Rental record for a bike id or rental id: customer, start and end times, price paid, reported issues.",
        "parameters": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}}},
    {"type": "function", "function": {
        "name": "get_policy",
        "description": "Text of a Wheelhaus policy. Topics: refunds, damage, late_return.",
        "parameters": {"type": "object", "properties": {"topic": {"type": "string", "enum": ["refunds", "damage", "late_return"]}}, "required": ["topic"]}}},
    {"type": "function", "function": {
        "name": "add_note",
        "description": "Add an internal note to the customer's record.",
        "parameters": {"type": "object", "properties": {"customer_id": {"type": "string"}, "note": {"type": "string"}}, "required": ["customer_id", "note"]}}},
    {"type": "function", "function": {
        "name": "issue_refund",
        "description": "Request a refund. A staff member must approve it before money moves; the result says whether it is pending.",
        "parameters": {"type": "object", "properties": {
            "rental_id": {"type": "string"},
            "amount_eur": {"type": "number", "minimum": 0},
            "reason": {"type": "string"}}, "required": ["rental_id", "amount_eur", "reason"]}}},
]

The issue_refund description tells the model the truth about approval. Models plan better when tool descriptions say what will actually happen.

The agent

import json
import os
import time
 
from openai import OpenAI
 
client = OpenAI(base_url="https://app.syntic.ai/v1", api_key=os.environ["SYNTIC_API_KEY"])
 
SYSTEM = """
You handle customer complaints for Wheelhaus bike rental.
Goal: resolve the complaint according to policy, using the tools.
Work method:
1. Look up the rental before deciding anything.
2. Read the relevant policy; quote the rule you apply.
3. If a refund is due, call issue_refund with the amount the policy allows, never more.
4. Add a short internal note describing what you did.
5. Finish with a reply to the customer in plain language (under 100 words).
If the information is contradictory or the policy does not cover the case, stop and explain what a person should check.
""".strip()
 
AUTO = {"get_rental", "get_policy", "add_note"}
 
 
class Budget:
    def __init__(self, max_steps=12, max_tokens=60_000, max_seconds=120):
        self.max_steps, self.max_tokens, self.max_seconds = max_steps, max_tokens, max_seconds
        self.steps, self.tokens, self.started = 0, 0, time.monotonic()
 
    def charge(self, usage):
        self.steps += 1
        self.tokens += usage.total_tokens
 
    def exceeded(self):
        if self.steps >= self.max_steps:
            return "step limit"
        if self.tokens >= self.max_tokens:
            return "token limit"
        if time.monotonic() - self.started > self.max_seconds:
            return "time limit"
        return None
 
 
def run_agent(complaint: str, tools_impl: dict, approvals, trace: list) -> str:
    messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": complaint}]
    budget = Budget()
    while True:
        reason = budget.exceeded()
        if reason:
            trace.append({"stop": reason})
            return f"Stopped ({reason}). Handing over to staff."
 
        reply = client.chat.completions.create(
            model="Amara S1.2", stream=False, max_tokens=1200, tools=TOOLS, messages=messages,
        )
        budget.charge(reply.usage)
        msg = reply.choices[0].message
        messages.append(msg.model_dump(exclude_none=True))
 
        if not msg.tool_calls:
            trace.append({"final": msg.content})
            return msg.content or ""
 
        for call in msg.tool_calls:
            name = call.function.name
            try:
                args = json.loads(call.function.arguments or "{}")
                if name in AUTO:
                    result = tools_impl[name](**args)
                elif name == "issue_refund":
                    ticket = approvals.submit(name, args)
                    result = {"status": "pending_approval", "approval_id": ticket}
                else:
                    result = {"error": f"unknown tool {name}"}
            except Exception as err:          # bad JSON, wrong arguments, tool failure
                result = {"error": f"{type(err).__name__}: {err}"}
            trace.append({"tool": name, "args": call.function.arguments, "result": result})
            messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result, default=str)})

The parts that make it trustworthy

A written method

The numbered work method in the system message gives the agent a default plan. It still adapts (it skips the refund if none is due), but it no longer improvises the order of steps, which makes runs easier to compare and debug.

Budgets on three axes

Steps, tokens and wall-clock time each catch a different failure: a model searching in circles, a tool returning huge results, a slow dependency. When any budget runs out the agent stops and hands over; it does not try to squeeze in a final answer.

Approval as a tool result

The model is told that the refund is pending, and it writes its customer reply accordingly (“a refund of 12 euros has been requested and will be confirmed by our team”). The approval queue itself lives in your system, with the amount, the reason, the policy quote and the trace attached for the reviewer.

Errors become information

Every exception, including invalid JSON in arguments, becomes an error result. Models recover well from a clear error message (“KeyError: rental R-77 not found”) and badly from a crashed run.

A trace for every run

The trace list records each tool call, its arguments and its result, plus the stop reason. Store it with the request ids. When a reviewer asks “why did it offer 12 euros?”, the answer is in the trace.

Test the agent

Agents need scenario tests, not just input/output pairs. For each scenario, fake the tools and assert on behaviour:

def test_flat_tyre_refund():
    fake = {
        "get_rental": lambda id: {"rental_id": "R-5521", "bike": "1182", "paid_eur": 24, "hours": 4, "issues": ["flat tyre reported 10:40"]},
        "get_policy": lambda topic: "Mechanical failure: refund the hours lost, pro rata, up to the full price.",
        "add_note": lambda customer_id, note: {"ok": True},
    }
    approvals, trace = FakeApprovals(), []
    reply = run_agent("Bike 1182 had a flat and I lost an hour of my 4-hour rental.", fake, approvals, trace)
 
    assert approvals.submitted[0]["amount_eur"] == 6        # 24 / 4 hours
    assert any(step.get("tool") == "add_note" for step in trace)
    assert "refund" in reply.lower()

Write scenarios for the paths that matter: refund due, no refund due, contradictory records, a tool that fails, and a customer asking for more than the policy allows. Run them after every prompt, tool or model change. Because output is not exactly repeatable, run each scenario several times and require it to pass every time.

Costs

Each step re-sends the whole conversation, so cost grows with the number of steps. A typical Wheelhaus run takes five requests and about 15,000 tokens in total; most of that is the repeated system message and tool list, which the prompt cache serves at the cached-input rate. Track cost per run from usage (see Count cost from usage) and alert when a run is far above the median; that is usually a loop.

Hosted agents

Running the loop, tool sandboxes and session storage yourself gives full control. Syntic is also building hosted agents that keep sessions, memory and credentials on the platform; that design is on Agents and is in preview.

Was this page helpful?