Skip to main content
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:
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 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 if you do not have one. Everything else is in the standard library.
Create agent.py with the imports and the header block every call reuses:
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:
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 for the full trait list.

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:

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.
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:

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.
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.
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.

4. What it actually does

Wire up a main block and run it:
The tool calls print to stderr as they happen, so you can watch it work:
That took five rounds. The shape of them is worth reading closely, because it is the whole argument for the loop: 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.
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.
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:
By the last round, 960 of 1020 prompt tokens were served from cache. 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:
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:
Run that twice and you may get two different behaviors. Once, it declined before touching a tool:
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:
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:
Write the description so the model rarely tries. Write the guard so it does not matter when it does.
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.
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.

Controlling when tools get used

tool_choice decides how much say the model has: "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

Next steps

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

Function Calling

Reference for the tools array and tool_choice.

Structured Responses

Constrain the final answer to a JSON schema.

Prompt Caching

Keep the growing conversation cheap.

Private Research Agent

The same loop with web tools and a planner.