> ## Documentation Index
> Fetch the complete documentation index at: https://docs.venice.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Building a Tool-Using Agent with Function Calling

> Give a model three read-only tools and let it explore a database it has never seen.

A single function call is easy. The interesting part is the loop around it, because a model rarely gets what it needs on the first call. It looks something up, sees the result, and decides what to ask for next.

This tutorial builds a command line agent that answers questions about a SQLite database it has never seen. It has no schema in its prompt. It gets three read-only tools and works the rest out itself:

```bash theme={"system"}
python agent.py "Which product brought in the most revenue overall, and which customer spent the most on it?"
```

Along the way we will:

1. Give the model a database and three tools that read it
2. Describe those tools so the model knows when to reach for each one
3. Run the loop that turns tool calls into tool results
4. Watch it request several tools at once
5. Hand errors back to the model instead of raising them
6. Draw the line between what the model will not do and what it cannot do

The [Function Calling](/guides/features/function-calling) guide covers the request shape on its own. This page is about what happens after the first response comes back.

## Setup

You need Python 3.9 or newer, the `requests` package, and a Venice API key. See [Generating an API Key](/guides/getting-started/generating-api-key) if you do not have one. Everything else is in the standard library.

```bash theme={"system"}
pip install requests
export VENICE_API_KEY="your-api-key-here"
```

Create `agent.py` with the imports and the header block every call reuses:

```python theme={"system"}
from __future__ import annotations

import json
import os
import sqlite3
import sys

import requests

BASE_URL = "https://api.venice.ai/api/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['VENICE_API_KEY']}",
    "Content-Type": "application/json",
}
DB_PATH = "shop.db"
```

Not every model can call tools, and the model IDs change, so ask the API which one to use rather than pinning a name that will age:

```python theme={"system"}
def default_tool_model() -> str:
    response = requests.get(f"{BASE_URL}/models/traits", headers=HEADERS, timeout=30)
    response.raise_for_status()
    return response.json()["data"]["function_calling_default"]
```

```python theme={"system"}
print(default_tool_model())
```

```
zai-org-glm-5-2
```

<Note>
  `GET /models/traits` maps stable trait names onto whatever model currently fills that role. Reading `function_calling_default` at startup means your agent keeps working when the underlying model is replaced. See [Models](/api-reference/api-spec) for the full trait list.
</Note>

## 1. A database worth asking about

Any SQLite file will do. This one is a small shop with customers, products, and the orders that join them, which is enough that a real question needs a join and an aggregate:

```python theme={"system"}
SEED = """
CREATE TABLE customers (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    country TEXT NOT NULL,
    signed_up TEXT NOT NULL
);
CREATE TABLE products (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    category TEXT NOT NULL,
    unit_price REAL NOT NULL
);
CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    customer_id INTEGER NOT NULL REFERENCES customers(id),
    product_id INTEGER NOT NULL REFERENCES products(id),
    quantity INTEGER NOT NULL,
    ordered_on TEXT NOT NULL
);
INSERT INTO customers VALUES
    (1,'Aria Bekele','ET','2025-11-02'), (2,'Tomas Vidal','ES','2026-01-14'),
    (3,'Mei Lin','SG','2026-02-03'),     (4,'Jonas Weber','DE','2026-02-27'),
    (5,'Priya Nair','IN','2026-03-19');
INSERT INTO products VALUES
    (1,'Field Notebook','stationery',12.5), (2,'Fountain Pen','stationery',48.0),
    (3,'Desk Lamp','lighting',89.0),        (4,'Cable Organiser','desk',9.75),
    (5,'Monitor Arm','desk',156.0);
INSERT INTO orders VALUES
    (1,1,3,2,'2026-04-04'),  (2,2,5,1,'2026-04-11'), (3,3,2,4,'2026-04-19'),
    (4,1,5,2,'2026-05-02'),  (5,4,1,10,'2026-05-08'), (6,5,3,1,'2026-05-21'),
    (7,2,5,3,'2026-06-01'),  (8,3,4,12,'2026-06-09'), (9,5,2,2,'2026-06-15'),
    (10,4,5,1,'2026-06-28');
"""


def build_db() -> None:
    if os.path.exists(DB_PATH):
        return
    db = sqlite3.connect(DB_PATH)
    db.executescript(SEED)
    db.commit()
    db.close()
```

## 2. Three tools the model can reach for

The tools mirror how a person meets an unfamiliar database: find out what is in it, look at one table closely, then query it.

```python theme={"system"}
def list_tables() -> str:
    db = sqlite3.connect(DB_PATH)
    rows = db.execute(
        "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
    ).fetchall()
    db.close()
    return json.dumps([row[0] for row in rows])


def describe_table(table: str) -> str:
    db = sqlite3.connect(DB_PATH)
    rows = db.execute(f"PRAGMA table_info({table})").fetchall()
    db.close()
    if not rows:
        return json.dumps({"error": f"no table named {table}"})
    return json.dumps([{"name": row[1], "type": row[2]} for row in rows])


def run_query(sql: str) -> str:
    if not sql.strip().lower().startswith("select"):
        return json.dumps({"error": "only SELECT statements are allowed"})

    db = sqlite3.connect(DB_PATH)
    try:
        cursor = db.execute(sql)
        columns = [d[0] for d in cursor.description]
        rows = [dict(zip(columns, row)) for row in cursor.fetchmany(50)]
        return json.dumps(rows)
    except (sqlite3.Error, sqlite3.Warning) as error:
        return json.dumps({"error": f"{type(error).__name__}: {error}"})
    finally:
        db.close()
```

Every one of them returns a JSON string, including the failures. That is deliberate, and section 5 is about why.

Now describe them for the model. The `description` is not a comment. It is the only thing the model reads when deciding which tool to call and what to put in it:

```python theme={"system"}
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "list_tables",
            "description": "List every table in the shop database.",
            "parameters": {"type": "object", "properties": {}},
        },
    },
    {
        "type": "function",
        "function": {
            "name": "describe_table",
            "description": "Return the column names and types for one table.",
            "parameters": {
                "type": "object",
                "properties": {
                    "table": {"type": "string", "description": "Exact table name."}
                },
                "required": ["table"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "run_query",
            "description": "Run a read-only SQL SELECT against the shop database.",
            "parameters": {
                "type": "object",
                "properties": {
                    "sql": {"type": "string", "description": "A single SELECT statement."}
                },
                "required": ["sql"],
            },
        },
    },
]

TOOL_IMPLS = {
    "list_tables": lambda: list_tables(),
    "describe_table": lambda table: describe_table(table),
    "run_query": lambda sql: run_query(sql),
}
```

## 3. The loop

Function calling is a conversation, not a request. The model answers with tool calls, you run them, you append the results, and you ask again. It ends when the model replies with content instead of calls.

```python theme={"system"}
SYSTEM = (
    "You answer questions about a shop database. "
    "Inspect the schema with the tools before writing a query. "
    "Answer with the figures you retrieved, not from memory."
)


def ask(question: str, model: str, max_rounds: int = 8) -> str:
    messages = [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": question},
    ]

    for _ in range(max_rounds):
        response = requests.post(
            f"{BASE_URL}/chat/completions",
            headers=HEADERS,
            json={
                "model": model,
                "messages": messages,
                "tools": TOOLS,
                "tool_choice": "auto",
                "temperature": 0,
                "venice_parameters": {"include_venice_system_prompt": False},
            },
            timeout=180,
        )
        response.raise_for_status()
        message = response.json()["choices"][0]["message"]

        calls = message.get("tool_calls") or []
        if not calls:
            return message["content"]

        messages.append(message)
        for call in calls:
            name = call["function"]["name"]
            arguments = json.loads(call["function"]["arguments"] or "{}")
            print(f"  {name}({arguments})", file=sys.stderr)
            messages.append(
                {
                    "role": "tool",
                    "tool_call_id": call["id"],
                    "content": TOOL_IMPLS[name](**arguments),
                }
            )

    raise RuntimeError(f"No answer after {max_rounds} rounds.")
```

Three details in that loop matter more than they look.

The unmodified assistant message goes back into `messages` before the results do. It carries the `tool_calls` the results are answering, and on a reasoning model it also carries a `reasoning_content` field. Rebuilding the message by hand and dropping fields you did not expect is the most common way to break the second round.

Each result is matched to its call by `tool_call_id`. Nothing else identifies it.

`max_rounds` is a real limit, not a formality. A model that keeps querying without concluding will otherwise loop until you run out of patience or credit.

<Warning>
  Tool calls also carry an `index` field, and it is tempting to use it to line results up with calls. Do not. When the model requests three tools at once, all three can arrive with the same `index`, because it numbers the assistant turn rather than the call within it. Only `id` is unique.
</Warning>

## 4. What it actually does

Wire up a main block and run it:

```python theme={"system"}
if __name__ == "__main__":
    build_db()
    question = " ".join(sys.argv[1:]) or "What are the three biggest orders?"
    print(ask(question, default_tool_model()))
```

```bash theme={"system"}
python agent.py "Which product brought in the most revenue overall, and which customer spent the most on it?"
```

The tool calls print to `stderr` as they happen, so you can watch it work:

```
  list_tables({})
  describe_table({'table': 'customers'})
  describe_table({'table': 'orders'})
  describe_table({'table': 'products'})
  run_query({'sql': 'SELECT p.id, p.name, SUM(o.quantity * p.unit_price) AS total_revenue FROM orders o JOIN products p ON o.product_id = p.id GROUP BY p.id, p.name ORDER BY total_revenue DESC LIMIT 1;'})
  run_query({'sql': 'SELECT c.id, c.name, SUM(o.quantity * p.unit_price) AS amount_spent FROM orders o JOIN products p ON o.product_id = p.id JOIN customers c ON o.customer_id = c.id WHERE o.product_id = 5 GROUP BY c.id, c.name ORDER BY amount_spent DESC LIMIT 1;'})
```

```markdown theme={"system"}
- **Top product by revenue:** **Monitor Arm** (product ID 5) brought in the most
  revenue overall, totaling **$1,092.00**.
- **Top customer for that product:** **Tomas Vidal** (customer ID 2) spent the most
  on the Monitor Arm, contributing **$624.00**, more than half of the product's
  total revenue.
```

That took five rounds. The shape of them is worth reading closely, because it is the whole argument for the loop:

| Round | What the model did                                              |
| ----- | --------------------------------------------------------------- |
| 1     | Called `list_tables`, having been given no schema               |
| 2     | Called `describe_table` three times in one response             |
| 3     | Wrote the revenue query, now knowing the column names           |
| 4     | Used product 5 from the previous result to write a second query |
| 5     | Answered, with no tool calls                                    |

Round 4 is the part a single function call cannot do. The model could not write that query until it had seen the answer to the one before it.

Your run will not match this one call for call. The model sometimes describes all three tables at once and sometimes one at a time, and it occasionally skips `list_tables` and guesses a name. The figures are stable because they come from the database; the route to them is not.

<Note>
  Round 2 returned three tool calls in one response, and the loop above runs them one after another. They are independent, so a `ThreadPoolExecutor` here is worth having as soon as your tools do real I/O. Keep the `tool` messages in the same order as the calls that produced them.
</Note>

Each round resends the whole conversation, so the prompt grows as the agent works. Venice caches the stable prefix automatically, and the `usage` block shows it paying off:

```json theme={"system"}
{"prompt_tokens": 1020, "completion_tokens": 103, "prompt_tokens_details": {"cached_tokens": 960}}
```

By the last round, 960 of 1020 prompt tokens were served from cache. [Prompt Caching](/guides/features/prompt-caching) covers how to keep that prefix stable.

## 5. Let errors reach the model

The instinct is to raise on a bad query. Resist it. An error is information, and the model can act on it.

Ask for a table that does not exist:

```bash theme={"system"}
python agent.py "How many rows are in the 'purchases' table?"
```

```
  run_query({'sql': 'SELECT COUNT(*) AS row_count FROM purchases'})
  list_tables({})
```

```
There is no `purchases` table in this database. The available tables are
customers, orders, and products. It's possible that the orders table is what
you're looking for. Would you like me to check the row count there instead?
```

The first query failed. Because `run_query` returned `{"error": "OperationalError: no such table: purchases"}` as an ordinary tool result rather than raising, the model read it, called `list_tables` to find out what did exist, and corrected itself. Had the exception propagated, the script would have died on a typo.

This is why every tool returns JSON on the failure path too. The rule is simple: if a human debugging your tool would want to see the message, so does the model.

## 6. What it will not do, and what it cannot do

Ask the agent to destroy something:

```bash theme={"system"}
python agent.py "Delete every order placed by customers in Spain."
```

Run that twice and you may get two different behaviors. Once, it declined before touching a tool:

```
I'm unable to help with that. The tools I have access to are read-only, so I can
only run SELECT queries. I cannot perform DELETE, UPDATE, or INSERT operations.
```

Another time it went looking first, ran a `SELECT` for Spanish customers, found none because the column stores `ES` rather than `Spain`, and reported that instead:

```
It turns out there are no orders placed by customers in Spain, so there would be
nothing to delete. If you need to perform deletions, you'll need a tool with
write access.
```

Both are reasonable. Neither is a security control. The model read the word "read-only" in a tool description and chose to respect it, and a different model, a longer conversation, or a more insistent user can produce a different choice.

The guard inside `run_query` is the part that does not depend on a choice:

```python theme={"system"}
print(run_query("DELETE FROM orders WHERE customer_id = 2"))
print(run_query("SELECT 1; DROP TABLE orders"))
print(run_query("SELECT COUNT(*) AS n FROM orders"))
```

```json theme={"system"}
{"error": "only SELECT statements are allowed"}
{"error": "Warning: You can only execute one statement at a time."}
[{"n": 10}]
```

Write the description so the model rarely tries. Write the guard so it does not matter when it does.

<Note>
  That second line is why `run_query` catches `sqlite3.Warning` alongside `sqlite3.Error`. Python's driver refuses stacked statements, but it raises `Warning` for them, and `Warning` is not a subclass of `Error`. Catching only `sqlite3.Error` lets a stacked statement escape the handler and kill the loop instead of returning a message the model can read.
</Note>

<Warning>
  A prefix check stops writes, but it says nothing about reads. Any `SELECT` the model writes can reach every table in the file, including ones you never meant to expose. Two changes are worth making before this touches real data: open the database read-only with `sqlite3.connect("file:shop.db?mode=ro", uri=True)`, which fails writes with `attempt to write a readonly database` no matter what the string check misses, and point the agent at a database or a set of views containing only the columns it is allowed to see.
</Warning>

## Controlling when tools get used

`tool_choice` decides how much say the model has:

| Value                                                     | Behavior                                                               |
| --------------------------------------------------------- | ---------------------------------------------------------------------- |
| `"auto"`                                                  | The model decides. The right default                                   |
| `"required"`                                              | The model must call something before it can answer                     |
| `"none"`                                                  | Tools are visible but unavailable, useful for a final summarizing turn |
| `{"type": "function", "function": {"name": "run_query"}}` | Forces one specific tool                                               |

`"required"` is blunter than it looks. Asking this agent `What is 2 + 2?` with `tool_choice` set to `"required"` makes it call `list_tables`, look at a database it has no use for, and then answer `4` on the next round. With `"auto"` it answers `4` immediately and calls nothing. Reach for `"required"` when a tool genuinely must run, such as logging a request, and leave it alone otherwise.

## Tuning the agent

| Goal                    | What to change                                                                             |
| ----------------------- | ------------------------------------------------------------------------------------------ |
| Fewer rounds            | Put the schema in the system prompt so the model can skip discovery                        |
| Lower cost              | Drop `fetchmany(50)`, since wide result sets dominate the prompt as rounds accumulate      |
| More reliable arguments | Add `"strict": true` to the function definition to constrain arguments to the schema       |
| Faster wide steps       | Run parallel tool calls concurrently, or set `parallel_tool_calls` to `false` to stop them |
| Less wandering          | Lower `max_rounds` and say in the system prompt how many queries are reasonable            |

## Next steps

The loop you now have is the same one behind most agents. Only the tools change.

* Swap the SQL tools for HTTP calls and it becomes an API agent.
* Add [Web Search and Scraping](/guides/tools/web-retrieval) as a tool and it can check the live web mid-answer.
* Ask for a typed result instead of prose with [Structured Responses](/guides/features/structured-responses).
* See a larger version of this pattern in the [Private Research Agent](/learn/private-research-agent).

<CardGroup cols={2}>
  <Card title="Function Calling" icon="code" href="/guides/features/function-calling">
    Reference for the tools array and tool\_choice.
  </Card>

  <Card title="Structured Responses" icon="braces" href="/guides/features/structured-responses">
    Constrain the final answer to a JSON schema.
  </Card>

  <Card title="Prompt Caching" icon="database" href="/guides/features/prompt-caching">
    Keep the growing conversation cheap.
  </Card>

  <Card title="Private Research Agent" icon="robot" href="/learn/private-research-agent">
    The same loop with web tools and a planner.
  </Card>
</CardGroup>
