Barefoot AI HubTHE BAREFOOT FREELANCER
Start learning
⬢ AI Agents · Lesson 4 of 7

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.

⏱ 35 min📶 Intermediate🧪 3-question check

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.

YOU“Chase my overdue invoices”
AGENTlist_invoices()reads invoices.csv
AGENTdraft_email() ×None per client
GATEYou approvey / n per email
DONEsend_email()logged to outbox

Prerequisites

  • Python 3.10+ (download)
  • Ollama installed and running (Lesson 16)
  • A model that handles tool calling well: qwen3:8b (16 GB RAM) or qwen3:4b (8 GB RAM)
terminalbash
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:

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

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

terminalbash
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

PieceWhy it's there
Docstrings + type hintsThe Ollama Python library turns them into tool schemas automatically. Clear docstrings = correct tool use.
TOOLS dictMaps the model's requested name to the real function. Unknown names can't run anything.
System promptRole, date, rules, and when to stop. Try removing a rule and watch behavior change.
max_stepsPrevents infinite loops and runaway costs — every real agent needs one.
try/exceptTool errors become messages the model can read and react to instead of crashing the run.
input() in send_emailThe human-approval gate. The irreversible action cannot happen without you.
Draft vs. send splitSeparating preparation from action is the core safety pattern for agents.

Extend it

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

  2. Send real email

    Replace the log write with Gmail's API or SMTP. Keep the approval gate.

  3. Add a mark_reminded tool

    Write back to the sheet so it doesn't chase the same client twice in a week.

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

  5. 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 / 3

Q1How does the loop know the agent is finished?

Q2What is max_steps for?

Q3Why catch exceptions and return them as tool results?

Finished this lesson?Your progress is saved in this browser.