# Getting Started with AI Agents: Build Your First One from Scratch
If you’ve been following the developments in artificial intelligence, you’ve likely heard the term **AI agent** thrown around quite frequently. But what exactly does it mean, and how do you go from using one to actually building one? This guide walks you through the entire process step by step.
## What Is an AI Agent, Really?
At its core, an AI agent is a software system powered by a Large Language Model (LLM) that operates in a loop. In each cycle, the model evaluates a situation, decides whether it needs to use a tool, calls that tool, receives the result, and then uses that information to progress toward a goal.
> **An AI agent is a software system that uses an LLM to call one or more tools in a repeated cycle until it achieves a defined goal.**
The goal can be remarkably simple — answering a factual question — or extraordinarily complex, such as navigating a browser, booking travel arrangements, or writing and debugging software.
The key insight is that the model itself doesn’t perform the work. It acts as a planner and coordinator, while your code handles the actual execution.
## Why Build a Simple Agent First?
Before jumping into complex multi-tool systems, it helps to understand the fundamental mechanics with a minimal example. A simple agent with a single tool keeps the architecture transparent. You can see every message exchanged between the model and your code, making it far easier to understand what’s happening under the hood.
This article will guide you through building a local support assistant for a fictional online shop. It answers questions about customer orders by querying a small SQLite database. The entire setup runs on your own machine — no cloud account, no API key, no external dependencies beyond a local model and a Python library.
## Your Setup Checklist
Before you start coding, make sure you have the following in place:
– **Operating System:** Windows 10 or 11 (the guide also works on macOS and Linux, though installation commands differ)
– **Package Manager:** `uv` — a fast Python package and environment manager
– **Local Model Runtime:** Ollama — a tool that lets you download and run LLMs directly on your hardware
– **Disk Space:** At least 4 GB free for the model file
– **Shell:** PowerShell on Windows
To install Ollama, download the Windows installer from the official website, run it, then open a new terminal and pull a model:
“`
ollama pull qwen3.5:4b
“`
The download is roughly 3.4 GB. Once the download completes successfully, you’re ready to create your project.
## Setting Up Your Project
Open a terminal in your preferred directory and run the following commands:
“`
uv init shop-agent
cd shop-agent
uv python pin 3.14
uv add ollama
“`
The `uv` tool handles everything — it downloads Python 3.14 if needed, creates a virtual environment, and installs the Ollama Python library. You don’t need to manually activate the environment; `uv run` takes care of that automatically.
## Building the Test Database
Before involving the language model, create a reliable SQLite database so you have something concrete to test against. Create a file named `build_shop_database.py` with the following code:
“`python
import sqlite3
from contextlib import closing
from pathlib import Path
DATABASE_PATH = Path(__file__).with_name(“shop_agent.db”)
DATABASE_PATH.unlink(missing_ok=True)
with closing(sqlite3.connect(DATABASE_PATH)) as connection:
connection.executescript(
“””
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_name TEXT NOT NULL,
order_date TEXT NOT NULL
);
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders(order_id),
product_name TEXT NOT NULL,
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL
);
“””
)
connection.execute(
“INSERT INTO orders VALUES (?, ?, ?)”,
(1001, “Sarah Jones”, “2026-08-03”),
)
connection.executemany(
“INSERT INTO order_items VALUES (?, ?, ?, ?)”,
[
(1001, “Mechanical keyboard”, 1, 79.95),
(1001, “USB-C cable”, 2, 8.50),
],
)
connection.commit()
print(f”Created {DATABASE_PATH}”)
“`
Run it with `uv run python build_shop_database.py`.
The database contains one order for Sarah Jones: a mechanical keyboard priced at £79.95 and two USB-C cables at £8.50 each, totaling £96.95. This deterministic dataset gives you a clear benchmark to verify the agent’s correctness.
Note the use of `closing()` when working with SQLite connections. While sqlite3 supports context managers for transactions, it doesn’t always close the connection cleanly when the block exits — explicitly closing it prevents locked database files on Windows.
## Writing the Agent Code
Now for the main event. Create a file called `single_tool.py` and add the following code:
“`python
import json
import os
import sqlite3
from contextlib import closing
from pathlib import Path
from ollama import chat
MODEL = os.getenv(“OLLAMA_MODEL”, “qwen3.5:4b”)
DATABASE_PATH = Path(__file__).with_name(“shop_agent.db”)
def get_order_total(order_id: int) -> str:
“””Calculate the total value of an order.
Args:
order_id: The numeric order ID.
Returns:
JSON containing the order ID, item count and total in pounds.
“””
uri = f”file:{DATABASE_PATH.as_posix()}?mode=ro”
with closing(sqlite3.connect(uri, uri=True)) as connection:
connection.row_factory = sqlite3.Row
row = connection.execute(
“””
SELECT
o.order_id,
COALESCE(SUM(oi.quantity), 0) AS item_count,
ROUND(
COALESCE(SUM(oi.quantity * oi.unit_price), 0),
2
) AS total_gbp
FROM orders AS o
LEFT JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.order_id = ?
GROUP BY o.order_id
“””,
(order_id,),
).fetchone()
if row is None:
return json.dumps({“error”: f”Order {order_id} wasn’t found”})
return json.dumps(dict(row))
messages = [
{
“role”: “system”,
“content”: (
“Use tools for shop facts. Write currency as GBP, ”
“without a currency symbol.”
),
},
{
“role”: “user”,
“content”: “What is the total value of order 1001?”,
},
]
response = chat(
model=MODEL,
messages=messages,
tools=[get_order_total],
)
messages.append(response.message)
if not response.message.tool_calls:
raise RuntimeError(“The model answered without calling get_order_total”)
for call in response.message.tool_calls:
if call.function.name != “get_order_total”:
raise RuntimeError(
f”The model requested an unknown tool: {call.function.name}”
)
arguments = call.function.arguments
print(f”Model requested: get_order_total({arguments})”)
result = get_order_total(**arguments)
print(f”Python returned: {result}”)
messages.append(
{
“role”: “tool”,
“tool_name”: call.function.name,
“content”: result,
}
)
final_response = chat(
model=MODEL,
messages=messages,
tools=[get_order_total],
)
print(f”Agent: {final_response.message.content}”)
“`
## How the Agent Works Under the Hood
Let’s break down what’s happening in this code.
**The MODEL variable** reads the model name from the `OLLAMA_MODEL` environment variable, falling back to `qwen3.5:4b` if it’s not set. This makes it straightforward to swap models later without editing the script.
**The `get_order_total()` function** is pure application logic. It opens the SQLite database in read-only mode, runs a parameterized SQL query, and returns the result as a JSON string. The model never sees the SQL, and it never connects to the database directly. Your Python code maintains full control.
**The function’s signature matters.** When you pass `tools=[get_order_total]` to the Ollama library, the SDK inspects the function’s name, docstring, and type annotations and converts them into a JSON schema. This schema is what gets sent to the model — not your source code. A well-written docstring significantly improves the model’s ability to use the tool correctly.
**The conversation flow** works in two stages. First, the system message and user question are sent to the model along with the tool schema. The model decides whether the tool is relevant and, if so, returns a tool call request containing the function name and arguments. The script then executes the actual Python function, appends the result to the conversation with a `tool` role, and sends the updated history back to the model for a final response.
This two-step process — request, execute, respond — is the fundamental pattern that all AI agents follow, regardless of complexity.
## Running Your First Agent
Execute the script with:
“`
uv run python single_tool.py
“`
On the first run, Ollama may take a moment while it loads the model into memory. You should see output similar to this:
“`
Model requested: get_order_total({‘order_id’: 1001})
Python returned: {“order_id”: 1001, “item_count”: 3, “total_gbp”: 96.95}
Agent: The total value of order 1001 is 96.95 GBP.
“`
The exact wording of the final sentence may vary slightly between runs since local models generate text rather than selecting a fixed response. However, the numerical value should always be 96.95 because that’s what the Python code calculated.
## Handling Failure Cases
A robust agent needs to handle situations where it can’t find an answer. Try changing the user message to ask about a non-existent order:
“`python
“content”: “What is the total value of order 9999?”
“`
The database function returns structured error information, and a well-behaved model should communicate that the order wasn’t found rather than fabricating a total.
You should also test questions the available tool can’t answer, such as:
“`python
“content”: “When will order 1001 arrive?”
“`
The model may fall back on general knowledge in this case. The system message helps constrain its behavior, but it isn’t a hard security boundary. If your application requires verified data, you’ll need to validate that the correct tool was called and handle cases where it wasn’t.
## Understanding the Tool Schema
A helpful feature of passing Python functions directly is that the SDK inspects them and generates a schema automatically. Here’s what the schema for `get_order_total` looks like:
“`json
{
“type”: “function”,
“function”: {
“name”: “get_order_total”,
“description”: “Calculate the total value of an order.”,
“parameters”: {
“type”: “object”,
“required”: [“order_id”],
“properties”: {
“order_id”: {
“type”: “integer”,
“description”: “The numeric order ID.”
}
}
}
}
}
“`
The model sees this schema alongside the conversation and must decide two things: whether the function is relevant, and if so, whether its generated arguments match the expected types. Schema generation is not the same as validation — the model can still send a string when an integer is expected or request a function that wasn’t advertised. Later articles in this series will cover how to add proper input validation and error handling.
## Switching to a Different Model
The `OLLAMA_MODEL` environment variable makes it simple to experiment with other models. Pull a different model with Ollama, set the variable, and rerun the script:
“`
ollama pull gpt-oss:20b
$env:OLLAMA_MODEL = “gpt-oss:20b”
uv run python single_tool.py
“`
Larger models require more memory and disk space and will typically respond more slowly on the same hardware. Don’t assume that a smooth, natural-sounding final answer means the model used the tool correctly. Test with invalid order IDs, irrelevant questions, and ambiguous phrasing, then compare the requested function and arguments across different models.
## Common Questions About AI Agents
**Q: Do I need an internet connection or cloud API to run an AI agent?**
A: Not necessarily. This example uses Ollama to run a model locally on your machine. As long as you have enough RAM and disk space, everything runs offline. Cloud-based agents require API keys and internet access, but local setups do not.
**Q: What happens if the model hallucinates a tool call?**
A: The code explicitly checks the function name against an allowlist. If the model requests a function that wasn’t provided in the `tools` parameter, the script raises an error. This is a simple but effective safety mechanism.
**Q: Can I add more tools to the agent?**
A: Yes. The `tools` parameter accepts a list of functions. You can add functions for looking up customer details, checking order status, or querying product inventory. Each function needs a clear name, docstring, and type annotations so the SDK can generate an accurate schema.
**Q: Why does the agent use two separate chat calls instead of one?**
A: The first call determines which tool (if any) the model wants to use. The second call gives the model the tool’s result so it can formulate a final answer. This mirrors the natural loop of an agentic system: observe, act, observe, respond.
**Q: Is SQLite safe to use in a production agent?**
A: SQLite is excellent for lightweight, local use cases. For production systems with concurrent access, higher traffic, or sensitive data, you’d want a more robust database solution, proper connection pooling, and additional security measures around input validation.
**Q: What if the model sends arguments in the wrong format?**
A: The current example doesn’t include explicit argument validation. In practice, you should validate that the arguments match the expected types and ranges before passing them to your function. This prevents runtime errors and potential security issues.
## Conclusion
Building your first AI agent doesn’t require a massive infrastructure or a deep learning background. With just Python, Ollama, and SQLite, you can create a functional local agent that queries a database, uses tools safely, and returns accurate answers.
The core pattern is straightforward: a model receives a question and a tool description, decides whether to use the tool, your code executes the tool and returns the result, and the model turns that result into a human-readable response. This request-execute-respond loop forms the foundation of every AI agent, from the simplest single-tool example to the most sophisticated multi-step systems.
The deliberate simplicity of this starting point keeps the mechanics visible and gives you a solid foundation to build on. Once you’re comfortable with this pattern, you can explore agents with multiple tools, longer conversation histories, error recovery, and more complex workflows.
Thank you for reading



