Artificial Intelligence

Build Your First AI Agent in Python: Complete Tutorial (2026)

Abolfazl Seyed Javadein
October 26, 2026
13 min read
6 views
Build Your First AI Agent in Python: Complete Tutorial (2026)

A shop-desk agent: look up an order, read the return policy, check today's date, and draft a customer email. Same loop, two tickets, two different replies.

A chatbot invents an answer from memory. An AI agent is a program that lets a language model use tools, read the results, and keep going until the job is done. The model does not magically query your shop database. Your loop does.

This tutorial builds a complete first agent in Python: a small electronics-shop desk that looks up orders, reads return policy, and drafts a customer email. No LangGraph, no LangChain, no hosted “agent product.” When you finish, you will understand every agent framework you meet in 2026, because they are all the same loop with more polish.

You will build: a CLI agent that handles this email — “Order 1042: can I return my ThinkPad? Draft a reply.” — by calling real tools, then writing drafts/order-1042-return.md.

Customer email flows into an agent loop that looks up orders and policy, then writes a draft
Figure 1. The model chooses the next action. Your Python owns the order book, the policy text, and the drafts folder.

1. Chatbot vs agent vs workflow

Three designs get mixed up. Only one of them is an agent.

Design Who chooses the next step? Example
Chatbot Nobody. One LLM call, one reply. “Explain transformers.”
Workflow You, in code. Fixed order of steps. load order → apply policy → send email
Agent The model, from a tool list, in a loop. “Can order 1042 be returned? Draft a reply.”

A workflow is often the better product when every ticket follows the same path. Use an agent when the path is not known in advance: the model may need the order first, or the policy first, or today’s date, and a late accessory return should not get the same email as a laptop still inside the window.

If you can write the steps as a numbered recipe that never branches, write a workflow. If the model must decide which record to fetch, write an agent.

2. The loop

Every agent is this cycle:

  1. Observe — the customer email, or the output of a tool you just ran.
  2. Reason — call the LLM with the transcript plus a list of tools.
  3. Act — if the model returned tool_calls, run those Python functions. If it returned plain text, stop.
Four steps: observe, reason, act, then either stop or send tool results back as a new observation
Figure 2. The dashed arrow is the agent. Without it, you only have a chatbot that guesses return windows.

Two stop rules you should always have:

  • The model returns a message with no tool calls (final answer to you, the shop owner).
  • You hit a max step count (the model is looping or stuck).

3. Project setup

You need Python 3.10+, an OpenAI API key, and one package. Any tool-calling chat model works; this article uses gpt-4o-mini because it is cheap enough to practice with. Swap the model name later.

python -m venv .venv
.venv\Scripts\activate
pip install openai

Set the key in PowerShell for this session:

$env:OPENAI_API_KEY = "sk-..."

Create agent.py. The agent will create a drafts/ folder the first time it writes a reply.

4. Shop data is ordinary Python

A tool is not an LLM feature. It is a function plus a JSON schema that describes it to the model. The model fills in the arguments. You execute the function against your data.

In production these functions would hit SQL, Stripe, or a shipping API. Here they read two in-memory tables so the example runs with one file. That is still a real agent: the model cannot know order 1042 from training data.

import json
from datetime import date
from pathlib import Path

ORDERS = {
    "1042": {
        "customer": "Sara N.",
        "email": "sara@example.com",
        "item": "ThinkPad E14 Gen 5",
        "delivered": "2026-09-01",
        "status": "delivered",
        "price_usd": 899,
        "opened": True,
    },
    "1108": {
        "customer": "James K.",
        "email": "james@example.com",
        "item": "USB-C hub",
        "delivered": "2026-07-20",
        "status": "delivered",
        "price_usd": 49,
        "opened": True,
    },
}

POLICIES = {
    "returns": (
        "Electronics may be returned within 30 days of delivery. "
        "Opened laptops: accepted, 10 percent restocking fee. "
        "Accessories: full refund within 30 days. "
        "After 30 days: store credit only if the item is defective."
    ),
    "shipping": "Return shipping in the US is free with the prepaid label we email.",
    "warranty": "Hardware warranty is 12 months from delivery.",
}

DRAFTS = Path("drafts")
DRAFTS.mkdir(exist_ok=True)


def get_order(order_id: str) -> str:
    key = order_id.strip().lstrip("#")
    row = ORDERS.get(key)
    if not row:
        return json.dumps({"error": f"order {key} not found"})
    return json.dumps({"order_id": key, **row})


def get_policy(topic: str) -> str:
    key = topic.strip().lower()
    if key in POLICIES:
        return POLICIES[key]
    known = ", ".join(POLICIES)
    return f"unknown topic {topic!r}. try one of: {known}"


def today() -> str:
    return date.today().isoformat()


def write_draft(filename: str, content: str) -> str:
    path = DRAFTS / Path(filename).name  # drop any ../ from the model
    path.write_text(content.strip() + "\n", encoding="utf-8")
    return f"wrote {len(content)} characters to {path}"

Two safety habits already:

  • Path(filename).name strips directory parts, so the model cannot write ../secrets.txt.
  • Unknown order IDs return a JSON error string. The loop keeps running; the model can tell the shop owner the order was not found.

Now describe those functions to the model. This JSON is the contract.

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_order",
            "description": "Look up a shop order by id. Returns customer, item, delivery date, status, price.",
            "parameters": {
                "type": "object",
                "properties": {
                    "order_id": {"type": "string", "description": "Order id such as 1042"}
                },
                "required": ["order_id"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_policy",
            "description": "Read a store policy. topic must be one of: returns, shipping, warranty.",
            "parameters": {
                "type": "object",
                "properties": {
                    "topic": {"type": "string", "enum": ["returns", "shipping", "warranty"]}
                },
                "required": ["topic"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "today",
            "description": "Return today's date as YYYY-MM-DD. Use this to check return windows.",
            "parameters": {"type": "object", "properties": {}},
        },
    },
    {
        "type": "function",
        "function": {
            "name": "write_draft",
            "description": "Save an email draft as a markdown file in drafts/. Use a name like order-1042-return.md.",
            "parameters": {
                "type": "object",
                "properties": {
                    "filename": {"type": "string"},
                    "content": {"type": "string", "description": "Full email including Subject and body"},
                },
                "required": ["filename", "content"],
            },
        },
    },
]

Dispatch is a dictionary from name to function. Unknown names must not crash the loop; they must return an error string the model can read.

DISPATCH = {
    "get_order": lambda a: get_order(a["order_id"]),
    "get_policy": lambda a: get_policy(a["topic"]),
    "today": lambda a: today(),
    "write_draft": lambda a: write_draft(a["filename"], a["content"]),
}

def run_tool(name: str, arguments_json: str) -> str:
    try:
        args = json.loads(arguments_json or "{}")
        fn = DISPATCH.get(name)
        if fn is None:
            return f"unknown tool: {name}"
        return str(fn(args))
    except Exception as exc:
        return f"tool error: {exc}"

5. The agent loop in Python

Memory is the messages list. That is not a metaphor. Every observation, every tool call, and every tool result is appended. The next LLM call sees the whole transcript.

from openai import OpenAI

client = OpenAI()
MODEL = "gpt-4o-mini"
MAX_STEPS = 8

SYSTEM = (
    "You are the shop desk agent for a small electronics store. "
    "Never invent order fields or policy text. Call get_order, get_policy, and today when you need them. "
    "Decide eligibility from delivery date vs the 30-day window. "
    "If the return is allowed, write a warm, specific email draft with write_draft, "
    "including the restocking fee when it applies. "
    "If it is not allowed, draft a polite decline and mention store credit only for defects. "
    "Then tell the shop owner, in a short recap, what you decided and which file you wrote."
)

def run_agent(user_message: str) -> str:
    messages = [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": user_message},
    ]

    for step in range(1, MAX_STEPS + 1):
        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=TOOLS,
            tool_choice="auto",
        )
        msg = response.choices[0].message
        tool_calls = [
            {
                "id": call.id,
                "type": "function",
                "function": {
                    "name": call.function.name,
                    "arguments": call.function.arguments,
                },
            }
            for call in (msg.tool_calls or [])
        ]
        entry = {"role": "assistant", "content": msg.content or ""}
        if tool_calls:
            entry["tool_calls"] = tool_calls
        messages.append(entry)

        if not msg.tool_calls:
            return msg.content or ""

        for call in msg.tool_calls:
            result = run_tool(call.function.name, call.function.arguments)
            print(f"[step {step}] {call.function.name} -> {result[:180]}")
            messages.append(
                {
                    "role": "tool",
                    "tool_call_id": call.id,
                    "content": result,
                }
            )

    return "Stopped: max steps reached. Narrow the ticket or raise MAX_STEPS."


if __name__ == "__main__":
    import sys

    query = " ".join(sys.argv[1:]).strip() or (
        "Customer email: Hi, I bought a ThinkPad on order 1042. "
        "Can I return it? If yes, draft a reply I can send."
    )
    print(run_agent(query))

That is the whole agent. Frameworks add streaming, retries, tracing, and handoffs. They do not replace this loop.

Run it:

python agent.py "Customer email: Hi, I bought a ThinkPad on order 1042. Can I return it? If yes, draft a reply I can send."

Typical console trace (your dates will match today() on your machine). With delivery 2026-09-01 and today inside 30 days, the laptop is eligible with a restocking fee:

[step 1] get_order -> {"order_id": "1042", "customer": "Sara N.", "item": "ThinkPad E14 Gen 5", "delivered": "2026-09-01", ...}
[step 1] get_policy -> Electronics may be returned within 30 days of delivery. Opened laptops: accepted, 10 percent restocking fee. ...
[step 1] today -> 2026-09-20
[step 2] write_draft -> wrote 612 characters to drafts\order-1042-return.md
Approved: Sara is 19 days after delivery, inside the 30-day window. Opened laptop, so 10% restocking ($89.90). Draft is in drafts/order-1042-return.md.

Open the draft. You should see a real email, not a math result:

Subject: Return approved for order 1042 (ThinkPad E14)

Hi Sara,

You can return the ThinkPad E14 Gen 5 from order 1042. It was delivered on 1 Sep 2026, so you are still inside our 30-day window.

Because the laptop was opened, a 10% restocking fee applies ($89.90 on $899). We will email a prepaid US return label.

Thanks,
Shop desk

6. What the messages look like

The API does not “remember” between HTTP calls. You send the growing list each time. After the first round of tools, the transcript looks like this (shortened):

role content / payload
system You are the shop desk agent… never invent order fields…
user Customer email: … order 1042 … draft a reply
assistant tool_calls: get_order("1042"), get_policy("returns"), today()
tool JSON for Sara / ThinkPad / delivered 2026-09-01
tool 30-day window, 10% restocking on opened laptops
tool 2026-09-20
assistant tool_calls: write_draft("order-1042-return.md", "Subject: …")
tool wrote 612 characters to drafts\order-1042-return.md
assistant plain-text recap for you — loop stops

The model never saw your database. It only saw the strings you stuffed into tool messages. If get_order returns the wrong delivery date, the eligibility decision will be wrong. Logging [step n] name -> result is how you debug agents.

7. Why the system prompt matters

Tool calling is probabilistic. Without “Never invent order fields,” a small model may skip get_order and invent a delivery date — or invent a 14-day policy. The system prompt is part of the control system, not decoration.

Useful instructions for a desk agent:

  • When to use each tool (“call today before deciding the window”).
  • When not to use tools (greetings, “what can you do?”).
  • How to stop (“recap for the shop owner after the draft is written”).
  • How to handle missing orders (“if get_order returns error, ask for a correct id; do not write a fake approval”).

8. Guardrails you should copy

An agent that can write files and read customer records is already a small security surface. Keep the first version boring and strict.

Guardrail What it prevents
MAX_STEPS = 8 Infinite tool loops and runaway API spend
Allowlist DISPATCH The model inventing a tool name you did not write
Path(filename).name Path traversal into parent folders
Return errors as JSON strings A crash that kills the process mid-ticket
No shell, no unrestricted HTTP The model deleting files or calling random URLs

Do not give a first agent production credentials. Swap the ORDERS dict for a database only after you have watched the trace on a few tickets.

9. A second ticket to prove it is an agent

Chatbots cannot do this, because the second email needs a different decision from the same tools:

python agent.py "James emailed about order 1108. He wants a full refund on the USB-C hub. Draft a reply."

Order 1108 was delivered on 2026-07-20. That is past 30 days. The agent should load the order, load returns policy, call today, then draft a decline (store credit only if defective). You did not code that if. Same loop, opposite email.

A missing-id ticket:

python agent.py "Someone asked to return order 9999."

Expect get_order to return an error JSON, no draft, and a recap that the order was not found.

10. The 2026 shortcut: an agent SDK

Once the loop is clear, a library is fine. The OpenAI Agents SDK wraps the same idea: decorate functions, run until the model stops.

pip install openai-agents
from agents import Agent, Runner, function_tool

@function_tool
def get_order(order_id: str) -> str:
    return json.dumps(ORDERS.get(order_id.strip(), {"error": "not found"}))

agent = Agent(
    name="ShopDesk",
    instructions=SYSTEM,
    tools=[get_order],
)

result = Runner.run_sync(agent, "Can order 1042 be returned?")
print(result.final_output)

Use the SDK in a product. Use the raw loop when you need to see why a tool fired. If you cannot write the 80-line version, you cannot debug the framework version.

Other 2026 names you will see — LangGraph, Anthropic tool use, Model Context Protocol (MCP) — are still “describe tools, loop, append results.” MCP is a standard way to plug in tools (a real order API, a Gmail draft server) without rewriting JSON schemas by hand. Learn the loop first, then plug MCP in as the tool layer.

11. Common failures

Symptom Usual cause Fix
Draft invents a delivery date System prompt too vague; skipped get_order Say “never invent order fields”
Approves a 60-day-old accessory Skipped today or get_policy Require those tools before writing a draft
tool error: ... in the trace Missing JSON field or wrong type Tighten required and the enum on topic
Hits max steps Model re-reads the same policy, or a tool always errors Print the trace; fix the tool
AuthenticationError OPENAI_API_KEY not set in this terminal Set the env var, then run again

12. What to build next

Keep the same loop. Change only the tool list — that is how real desk agents grow.

  1. Load orders from disk — orders.json instead of the dict, so you can add tickets without editing Python.
  2. A tracking tool — get_shipment(order_id) that returns carrier and ETA. Then try: “Where is order 1042?”
  3. Tests without the LLM — unit-test get_order and run_tool. Then write one integration test that mocks client.chat.completions.create and checks the loop stops after write_draft.

When you want a second agent (one that only drafts, one that only looks up policy), you still start here: two loops, or one loop with two tool sets. Multi-agent is multiple copies of Figure 2, not a new kind of intelligence.

Takeaways

  • An agent is a loop: observe → LLM with tools → run tools → append results → repeat.
  • Tools are your functions over your records. The model only proposes names and arguments.
  • The messages list is memory.
  • Always cap steps, allowlist tools, and never invent business data in the prompt.
  • The test of a first agent is not arithmetic. It is two tickets that need different decisions from the same tools.

Copy agent.py, set the API key, and run the order 1042 prompt. If you see get_order / get_policy / today in the trace and a file at drafts/order-1042-return.md, you have a working AI agent.


Related Articles

Back to Articles