09Build Your First Agent
A complete, runnable Python agent on a local model: real tools, a system prompt, step limits and a human-approval gate. Understand every line.
What you'll learn
- Write tools as plain Python functions
- Implement the agent loop yourself
- Add guardrails: step limits and human approval
- Extend the agent with your own business tools
What we're building
A back-office agent for a freelancer. It can look up invoices in a CSV, calculate overdue amounts, and draft reminder emails — but it must ask you before “sending” anything. Everything runs locally with Ollama, so it's free and private.
Prerequisites
- Python 3.10+ (download)
- Ollama installed and running (Lesson 16)
- A model that handles tool calling well:
qwen3:8b(16 GB RAM) orqwen3:4b(8 GB RAM)
ollama pull qwen3:8b
mkdir invoice-agent && cd invoice-agent
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install ollama
Step 1 — Sample data
Create invoices.csv in the same folder:
id,client,email,amount_php,due_date,status
1041,Kapé Iloilo,[email protected],12500,2026-08-30,paid
1042,Maria Santos,[email protected],18500,2026-09-10,unpaid
1043,Island Supply Co.,[email protected],7200,2026-09-18,unpaid
1044,Dr. Reyes Dental,[email protected],25000,2026-10-15,unpaid
Step 2 — The complete agent
import csv, datetime, json
import ollama
MODEL = "qwen3:8b"
TODAY = datetime.date(2026, 9, 27) # fixed for the demo; use date.today() in real life
# ---------------- TOOLS: plain Python functions -----------------
# The docstring and type hints become the tool description the model reads.
def list_invoices(status: str = "unpaid") -> str:
"""List invoices filtered by status ('paid', 'unpaid' or 'all').
Returns id, client, email, amount in PHP, due date and days overdue."""
rows = []
with open("invoices.csv", newline="") as f:
for r in csv.DictReader(f):
if status != "all" and r["status"] != status:
continue
due = datetime.date.fromisoformat(r["due_date"])
r["days_overdue"] = max(0, (TODAY - due).days)
rows.append(r)
return json.dumps(rows)
def draft_email(to: str, subject: str, body: str) -> str:
"""Save a draft email for the owner to review. Use for every reminder.
Does NOT send anything."""
DRAFTS.append({"to": to, "subject": subject, "body": body})
return f"Draft #{len(DRAFTS)} saved for {to}."
def send_email(draft_number: int) -> str:
"""Send a previously saved draft by its number. Requires human approval."""
d = DRAFTS[draft_number - 1]
print("\n" + "=" * 60)
print(f"TO: {d['to']}\nSUBJECT: {d['subject']}\n\n{d['body']}")
print("=" * 60)
if input("Approve sending this email? [y/N] ").strip().lower() != "y":
return "Owner declined. Do not send; ask what to change."
with open("outbox.log", "a") as log: # stand-in for a real email API
log.write(json.dumps(d) + "\n")
return f"Draft #{draft_number} sent to {d['to']}."
TOOLS = {f.__name__: f for f in [list_invoices, draft_email, send_email]}
DRAFTS = []
SYSTEM = f"""You are the back-office assistant for a freelance web designer.
Today is {TODAY.isoformat()}.
- Always use tools to get facts. Never invent amounts, dates or emails.
- For overdue invoices, draft a short, friendly, professional reminder
(under 120 words) that states the invoice number, amount and due date.
- Only call send_email after drafting. If the owner declines, stop and ask.
- When finished, give a one-paragraph summary of what you did."""
# ---------------- THE AGENT LOOP -----------------
def run(goal: str, max_steps: int = 15):
messages = [{"role": "system", "content": SYSTEM},
{"role": "user", "content": goal}]
for step in range(1, max_steps + 1):
response = ollama.chat(model=MODEL, messages=messages,
tools=list(TOOLS.values()))
msg = response.message
messages.append(msg)
if not msg.tool_calls: # no tool requested → final answer
print("\n🤖", msg.content)
return
for call in msg.tool_calls:
name, args = call.function.name, call.function.arguments
print(f"🔧 step {step}: {name}({args})")
try:
result = TOOLS[name](**args)
except Exception as e: # helpful errors let it recover
result = f"error: {e}"
messages.append({"role": "tool", "content": str(result), "tool_name": name})
print("⚠️ Stopped: step limit reached.") # guardrail
if __name__ == "__main__":
run(input("What should I do? "))
Step 3 — Run it
python agent.py
What should I do? Find overdue invoices and chase them politely.
You'll see the agent call list_invoices, notice that 1042 and 1043 are overdue (1044 isn't due yet, 1041 is paid), draft two emails, then ask your approval before each send.
Understand every part
| Piece | Why it's there |
|---|---|
| Docstrings + type hints | The Ollama Python library turns them into tool schemas automatically. Clear docstrings = correct tool use. |
TOOLS dict | Maps the model's requested name to the real function. Unknown names can't run anything. |
| System prompt | Role, date, rules, and when to stop. Try removing a rule and watch behavior change. |
max_steps | Prevents infinite loops and runaway costs — every real agent needs one. |
try/except | Tool errors become messages the model can read and react to instead of crashing the run. |
input() in send_email | The human-approval gate. The irreversible action cannot happen without you. |
| Draft vs. send split | Separating preparation from action is the core safety pattern for agents. |
Extend it
Swap the CSV for Google Sheets
Use the Google Sheets API (or gspread) inside
list_invoices. The agent code doesn't change — only the tool.Send real email
Replace the log write with Gmail's API or SMTP. Keep the approval gate.
Add a
mark_remindedtoolWrite back to the sheet so it doesn't chase the same client twice in a week.
Schedule it
Run the script every Monday with cron (Mac/Linux) or Task Scheduler (Windows) — or move it into n8n (Lesson 14) or Hermes (Lesson 12).
Try a cloud model
Swap Ollama for Claude or another API when you need stronger reasoning. The loop is the same idea in every SDK.
Key takeaways
- An agent is ~40 lines of real logic: tools, a system prompt and a loop.
- Docstrings are tool descriptions — write them carefully.
- Step limits, error handling and approval gates are non-negotiable guardrails.
- Separate draft from send. Humans approve irreversible actions.
Knowledge check
0 / 3Q1How does the loop know the agent is finished?
No tool calls means the model is giving its final answer.
Q2What is max_steps for?
It's a guardrail that forces the loop to end.
Q3Why catch exceptions and return them as tool results?
Readable errors let the agent adjust arguments and retry.