Build an AI Agent in Python Step by Step (76 Lines)
An AI agent in 76 lines of Python: loop, tools, step limit, decision log. Real runs and versions using OpenAI Agents SDK and Claude Agent SDK.

👁 121 reads
An AI agent in Python is a loop: the model gets a task and a list of tools, chooses a tool, your code executes it, the result goes back to the model, and the model decides what to do next. We built such an agent in 76 lines, without any framework: it searches files, calculates, reads web pages, has a step limit and logs decisions. Then we rewrote it using OpenAI Agents SDK and Claude Agent SDK. All runs below are real, from October 4, 2026. This is the third step in the AI Agents from Scratch learning path (we tested it in Polish).
Status as of October 4, 2026
- Environment: Windows 11, Python 3.13,
openai3.24.0,openai-agents0.23.1,claude-agent-sdk0.2.163. - Model:
openai/gpt-5.6-lunavia OpenRouter ($0.20 per million input tokens, $1.20 per million output). Claude Agent SDK version: Claude Haiku 4.5. - One solved task cost $0.0008-0.0013 (Luna) and $0.013-0.018 (Claude Agent SDK with Haiku).
- Full code: ai-agents-starter package, files
02_agent_bez_frameworka.pyand03_agent_openai_sdk.py.
How an AI Agent Differs from a Regular AI Script
A regular AI script has predefined steps: “fetch email, summarize, save.” The model only fills in one slot. In an agent, the model chooses the order of steps. It gets a goal (“calculate expenses from invoices”) and tools, and decides on its own to first browse files, then check the euro exchange rate, and finally calculate the sum. We cover what an agent is and where it breaks in more detail in Agent AI: What It Is.
Technically, an agent is function calling wrapped in a loop. If you don’t know how a model requests function calls, read that article first. Here we assume you already understand that.
The Agent Loop in Four Steps
- Model reads the task and the history so far.
- Decision: either requests a tool (with arguments), or decides it’s done and writes an answer.
- Tool: your code executes the function.
- Observation: the function result goes into the history and we return to step 1.
Plus two safety features we never ship an agent without: step limit (so it doesn’t loop forever and burn money) and decision log (so you know after the fact what it did and why).
Step 1: Environment Setup
- Install Python 3.11-3.13 from python.org (check “Add python.exe to PATH”).
- In your project folder, create an environment:
python -m venv .venv, then.venvScriptsactivate. - Install libraries:
pip install openai requests beautifulsoup4 python-dotenv. - Get an OpenRouter key and put it in a
.envfile asOPENROUTER_API_KEY=.... Set a credit limit on the key, e.g. $2. How OpenRouter works and what it costs is covered in OpenRouter. - Put a few text files in the
dane/fakturyfolder. The package includes three fictional invoices: two in Polish currency and one for 120 EUR.
Step 2: Tools - Just Regular Functions
The agent gets three tools. Each is a plain Python function that returns text:
szukaj_plikowshows .txt files from thedane/folder. The agent sees nothing outside this folder;kalkulatorevaluates expressions via theastmodule (without dangerouseval). Models still make arithmetic mistakes, so we make them use a tool to calculate;czytaj_stronefetches a page or API response and truncates it to 4000 characters, because every character goes back to the model as paid tokens.
Step 3: The Complete Agent in 76 Lines
"""02 - AI Agent without framework: loop model -> tool -> observation -> decision.
Run: python 02_agent_bez_frameworka.py "Sum the gross amounts from invoices in the dane/faktury folder."
"""
import ast, json, operator, os, pathlib, sys, time
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
MODEL, MAX_KROKOW = os.getenv("MODEL", "openai/gpt-5.6-luna"), int(os.getenv("MAX_KROKOW", 8))
client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"])
KATALOG = pathlib.Path("dane").resolve() # agent only sees this folder
def szukaj_plikow(fraza: str = "") -> str:
"""List of files in dane/ (with contents), optionally only those containing the phrase."""
wynik = []
for p in sorted(KATALOG.rglob("*.txt")):
tekst = p.read_text(encoding="utf-8")
if fraza.lower() in tekst.lower() or fraza.lower() in p.name.lower():
wynik.append(f"== {p.relative_to(KATALOG)} ==n{tekst[:1500]}")
return "n".join(wynik) or "no files"
OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.USub: operator.neg}
def kalkulator(wyrazenie: str) -> str:
def licz(n):
if isinstance(n, ast.Constant): return n.value
if isinstance(n, ast.BinOp): return OPS[type(n.op)](licz(n.left), licz(n.right))
if isinstance(n, ast.UnaryOp): return OPS[type(n.op)](licz(n.operand))
raise ValueError("only numbers and + - * /")
return str(round(licz(ast.parse(wyrazenie, mode="eval").body), 4))
def czytaj_strone(url: str) -> str:
r = requests.get(url, timeout=20, headers={"User-Agent": "ai-agents-starter/1.0"})
tekst = BeautifulSoup(r.text, "html.parser").get_text(" ", strip=True)
return tekst[:4000] # truncate: long pages eat tokens
NARZEDZIA = {"szukaj_plikow": szukaj_plikow, "kalkulator": kalkulator, "czytaj_strone": czytaj_strone}
def opis(nazwa, tekst, param, typ="string"):
return {"type": "function", "function": {"name": nazwa, "description": tekst,
"parameters": {"type": "object", "properties": {param: {"type": typ}}, "required": [param]}}}
TOOLS = [opis("szukaj_plikow", "Shows .txt files from dane/ folder and their content. Empty phrase = all files.", "fraza"),
opis("kalkulator", "Calculates expression, e.g. 2214+1180.8. Always calculate with this tool.", "wyrazenie"),
opis("czytaj_strone", "Fetches text of a web page or API response from a URL.", "url")]
def agent(zadanie: str) -> str:
messages = [{"role": "system", "content": "You are an agent. Solve the task using tools. Before each "
"tool, write in one sentence why you're using it. Get exchange rates from NBP API: "
"https://api.nbp.pl/api/exchangerates/rates/a/{code}/?format=json. Answer in Polish."},
{"role": "user", "content": zadanie}]
log = open("agent_log.jsonl", "a", encoding="utf-8")
koszt = 0.0
for krok in range(1, MAX_KROKOW + 1):
r = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS)
koszt += getattr(r.usage, "cost", 0) or 0
msg = r.choices[0].message
messages.append(msg.model_dump(exclude_none=True))
if not msg.tool_calls:
print(f"--- finished after {krok} steps, cost {koszt:.5f} USD")
return msg.content
if msg.content: print(f"[step {krok}] THOUGHT: {msg.content.strip()[:300]}")
for call in msg.tool_calls:
args = json.loads(call.function.arguments or "{}")
try:
obs = NARZEDZIA[call.function.name](**args)
except Exception as e:
obs = f"ERROR: {e}"
print(f"[step {krok}] ACTION: {call.function.name}({json.dumps(args, ensure_ascii=False)})")
print(f"[step {krok}] OBSERVATION: {obs[:160]!r}")
log.write(json.dumps({"t": time.strftime("%H:%M:%S"), "krok": krok, "narzedzie": call.function.name,
"args": args, "obserwacja": obs[:500]}, ensure_ascii=False) + "n")
messages.append({"role": "tool", "tool_call_id": call.id, "content": obs})
return f"Interrupted: limit of {MAX_KROKOW} steps (cost {koszt:.5f} USD)."
if __name__ == "__main__":
print("nRESULT:n" + agent(" ".join(sys.argv[1:]) or "Sum the gross amounts from invoices in the dane/faktury folder."))
Key parts of the code:
for krok in range(1, MAX_KROKOW + 1)is the agent loop with a hard limit (default 8);if not msg.tool_calls: the model isn’t requesting tools, so this is the final answer;- a tool error doesn’t break the program, it just goes back to the model as an “ERROR: …” observation. The model usually corrects itself;
- every call goes to
agent_log.jsonl(time, step, tool, arguments, result); - the system prompt tells the model to write in one sentence why it’s reaching for a tool. This is the “thought” visible in the log and very helpful for debugging.
Run 1: Invoices in Two Currencies
> python 02_agent_bez_frameworka.py "Calculate how much I spent in total in złoty on invoices from the dane folder. Convert the euro invoice at today's NBP rate."
[step 1] THOUGHT: First I'll read all invoice files from the `dane` folder and fetch today's EUR rate from the NBP API, so I can sum the amounts in złoty.
[step 1] ACTION: szukaj_plikow({"fraza": ""})
[step 1] OBSERVATION: '== faktury\FV_2026_09_014.txt ==nFAKTURA VAT nr FV/2026/09/014 (sample data)nIssue date: 2026-09-12nSeller: Studio Przykładowe Sp. z o.o.nBuyer'
[step 1] ACTION: czytaj_strone({"url": "https://api.nbp.pl/api/exchangerates/rates/a/eur/?format=json"})
[step 1] OBSERVATION: '{"table":"A","currency":"euro","code":"EUR","rates":[{"no":"192/A/NBP/2026","effectiveDate":"2026-10-02","mid":4.3745}]}'
[step 2] THOUGHT: Now I'll convert the 120 EUR invoice at the rate of 4.3745 PLN/EUR and add it to the two invoices for 2,214.00 zł and 1,180.80 zł.
[step 2] ACTION: kalkulator({"wyrazenie": "2214+1180.8+120*4.3745"})
[step 2] OBSERVATION: '3919.74'
--- finished after 3 steps, cost 0.00104 USD
RESULT:
You spent a total of **3,919.74 zł**.
Calculation:
- Invoice 1: **2,214.00 zł**
- Invoice 2: **1,180.80 zł**
- EUR invoice: 120 × 4.3745 zł = **524.94 zł**
**Total: 2,214.00 + 1,180.80 + 524.94 = 3,919.74 zł**
NBP EUR rate: **4.3745 zł** (from 2026-10-02).
The agent figured out the plan itself: in step 1 it read the files and fetched the euro rate from the NBP API in parallel (table 192/A/NBP/2026 from October 2, because October 4 is a Sunday). In step 2 it calculated everything in one expression in the calculator. In step 3 it wrote the answer. We verified the result 3919.74 zł manually: 2214 + 1180.80 + 120 × 4.3745 = 3919.74. Three queries to the model, $0.001.
Run 2: Web Page Plus Files
> python 02_agent_bez_frameworka.py "Read the page https://pl.wikipedia.org/wiki/Model_Context_Protocol and summarize in 3 points what MCP is for. Then check the dane folder for any note with a deadline, and tell me how many days remain until that deadline from October 4, 2026."
[step 1] THOUGHT: I'll read the indicated Wikipedia page to summarize in 3 points what MCP is for.
[step 1] ACTION: czytaj_strone({"url": "https://pl.wikipedia.org/wiki/Model_Context_Protocol"})
[step 1] OBSERVATION: 'Model Context Protocol - Wikipedia, the free encyclopedia Jump to content Main menu Main menu pin hide Navigation Main page Random article Catego'
[step 2] THOUGHT: I'll search the "dane" folder to find a note containing a deadline.
[step 2] ACTION: szukaj_plikow({"fraza": ""})
[step 2] OBSERVATION: '== faktury\FV_2026_09_014.txt ==nINVOICE VAT no. FV/2026/09/014 (sample data)nIssue date: 2026-09-12nSeller: Studio Przykładowe Sp. z o.o.nBuyer'
[step 3] THOUGHT: I'll calculate the difference between October 4 and 7, 2026, to give the exact number of days.
[step 3] ACTION: kalkulator({"wyrazenie": "7-4"})
[step 3] OBSERVATION: '3'
--- finished after 4 steps, cost 0.00130 USD
RESULT:
**MCP is for:**
1. Standardizing how AI systems, including large language models, connect to external tools, systems, and data sources.
2. Providing models with a universal interface for reading files, executing functions, and using context.
3. Integrating AI with practical applications such as corporate documents, CRM systems, databases, and development environments.
In the file `dane/faktury/notatka.txt` I found a deadline: **send September invoices to the accountant by October 7, 2026**.
From **October 4, 2026** to that deadline there are **3 days** remaining.
Here the agent combined two independent sources. An interesting detail from the log: the beginning of the Wikipedia page is the menu (“Jump to content, Main menu…”), not the article content. With a 4000-character limit, there was enough room for the actual text, but for longer pages it’s worth extracting only the <main> or <article> element. Subtracting 7 - 4 with the calculator looks funny, but that’s how it should be: the model calculates with a tool, not in memory.
Run 3: Agent Hits the Step Limit
We set the limit to 2 steps and added conversion to dollars to the task:
> MAX_KROKOW=2 python 02_agent_bez_frameworka.py "Calculate how much I spent in total in złoty on invoices from the dane folder. Convert the euro invoice at today's NBP rate, then also check the dollar rate and give the sum in USD."
[step 1] THOUGHT: First I'll fetch the contents of all files from the `dane` folder and in parallel today's EUR and USD rates from the NBP API, to identify amounts and perform conversions.
[step 1] ACTION: szukaj_plikow({"fraza": ""})
[step 1] OBSERVATION: '== faktury\FV_2026_09_014.txt ==nINVOICE VAT no. FV/2026/09/014 (sample data)nIssue date: 2026-09-12nSeller: Studio Przykładowe Sp. z o.o.nBuyer'
[step 1] ACTION: czytaj_strone({"url": "https://api.nbp.pl/api/exchangerates/rates/a/eur/?format=json"})
[step 1] OBSERVATION: '{"table":"A","currency":"euro","code":"EUR","rates":[{"no":"192/A/NBP/2026","effectiveDate":"2026-10-02","mid":4.3745}]}'
[step 1] ACTION: czytaj_strone({"url": "https://api.nbp.pl/api/exchangerates/rates/a/usd/?format=json"})
[step 1] OBSERVATION: '{"table":"A","currency":"US dollar","code":"USD","rates":[{"no":"192/A/NBP/2026","effectiveDate":"2026-10-02","mid":3.8881}]}'
[step 2] THOUGHT: Based on the invoices I'm considering three gross amounts: 2,214.00 zł, 1,180.80 zł, and 120 EUR; now I'll calculate the euro invoice value and the total sum in złoty.
[step 2] ACTION: kalkulator({"wyrazenie": "2214+1180.80+120*4.3745"})
[step 2] OBSERVATION: '3919.74'
RESULT:
Interrupted: limit of 2 steps (cost 0.00079 USD).
The agent didn’t finish in time, so the program interrupted the loop and returned a clear message instead of continuing to spin. In a real agent we set the limit with headroom (8-15 steps), and when exceeded we save the entire state to the log so it can be resumed. Here’s what the decision log from this run looks like:
> type agent_log.jsonl (last 3 entries)
{"t": "13:50:52", "krok": 1, "narzedzie": "czytaj_strone", "args": {"url": "https://api.nbp.pl/api/exchangerates/rates/a/eur/?format=json"}, "obserwacja": "{"table":"A","currency":"euro","code":"EUR","rates":[{"no":"192/A/NBP/2026","effectiveDate":"2026-10-02","mid":4.3745}]}"}
{"t": "13:50:52", "krok": 1, "narzedzie": "czytaj_strone", "args": {"url": "https://api.nbp.pl/api/exchangerates/rates/a/usd/?format=json"}, "obserwacja": "{"table":"A","currency":"dolar amerykański","code":"USD","rates":[{"no":"192/A/NBP/2026","effectiveDate":"2026-10-02","mid":3.8881}]}"}
{"t": "13:50:56", "krok": 2, "narzedzie": "kalkulator", "args": {"wyrazenie": "2214+1180.80+120*4.3745"}, "obserwacja": "3919.74"}
What Breaks in Agents (and How to Fix It)
| Problem | How You’ll Notice | What Works |
|---|---|---|
| Infinite loop | same calls in the log several times | step limit, detecting repeated arguments |
| Tool results too long | growing cost of each step | truncation, summarization, returning only needed fields |
| Model calculates in memory | no calculator call, strange decimals | “always calculate with tool” in function description |
| Prompt injection from page content | agent does something you didn’t ask for | read-only tools, human confirmation for writes |
| Wrong data interpretation | correct arithmetic on wrong numbers | test on data you know before giving it real data |
The Same Agent with OpenAI Agents SDK
Once you understand the loop, frameworks stop being magic. OpenAI Agents SDK does exactly what our 76 lines do: calls the model, executes tools, enforces the limit (max_turns), collects the log. It generates function descriptions itself from the signature and docstring (@function_tool decorator). It also works with models outside OpenAI if you provide a client pointing to OpenRouter:
"""03 - Same agent as in 02, but using OpenAI Agents SDK (openai-agents package).
The loop, tool calls, and step limit are handled by Runner.
Run: python 03_agent_openai_sdk.py "Calculate how much I spent in total on invoices from the dane folder."
"""
import asyncio, os, sys
from dotenv import load_dotenv
from openai import AsyncOpenAI
from agents import Agent, OpenAIChatCompletionsModel, Runner, function_tool, set_tracing_disabled
import importlib
narz = importlib.import_module("02_agent_bez_frameworka") # we use the same tool functions
load_dotenv()
set_tracing_disabled(True) # tracing sends data to OpenAI - we disable it with OpenRouter
model = OpenAIChatCompletionsModel(
model=os.getenv("MODEL", "openai/gpt-5.6-luna"),
openai_client=AsyncOpenAI(base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"]))
@function_tool
def szukaj_plikow(fraza: str = "") -> str:
"""Shows .txt files from dane/ folder and their content. Empty phrase = all files."""
return narz.szukaj_plikow(fraza)
@function_tool
def kalkulator(wyrazenie: str) -> str:
"""Calculates arithmetic expression, e.g. 2214+1180.8. Always calculate with this tool."""
return narz.kalkulator(wyrazenie)
@function_tool
def czytaj_strone(url: str) -> str:
"""Fetches text of a web page or API response from a URL."""
return narz.czytaj_strone(url)
agent = Agent(
name="Invoice Assistant",
instructions="Solve the task with tools. Exchange rates: https://api.nbp.pl/api/exchangerates/rates/a/{code}/?format=json. "
"Answer in Polish.",
model=model, tools=[szukaj_plikow, kalkulator, czytaj_strone])
async def main():
zadanie = " ".join(sys.argv[1:]) or "Calculate how much I spent in total in złoty on invoices from the dane folder."
wynik = await Runner.run(agent, zadanie, max_turns=8)
for item in wynik.new_items: # log: what the agent did along the way
if item.type == "tool_call_item":
print("ACTION:", item.raw_item.name, item.raw_item.arguments)
elif item.type == "tool_call_output_item":
print("OBSERVATION:", str(item.output)[:120].replace("n", " "))
u = wynik.context_wrapper.usage
print(f"nRESULT:n{wynik.final_output}nnmodel queries: {u.requests}, tokens: {u.input_tokens} in / {u.output_tokens} out")
asyncio.run(main())
> python 03_agent_openai_sdk.py "Calculate how much I spent in total in złoty on invoices from the dane folder. Convert the euro invoice at today's NBP rate."
ACTION: szukaj_plikow {"fraza":""}
ACTION: czytaj_strone {"url":"https://api.nbp.pl/api/exchangerates/rates/a/EUR/?format=json"}
OBSERVATION: == fakturyFV_2026_09_014.txt == INVOICE VAT no. FV/2026/09/014 (sample data) Issue date: 2026-09-12 Seller
OBSERVATION: {"table":"A","currency":"euro","code":"EUR","rates":[{"no":"192/A/NBP/2026","effectiveDate":"2026-10-02","mid":4.3745}]}
ACTION: kalkulator {"wyrazenie":"2214+1180.8"}
ACTION: kalkulator {"wyrazenie":"120*4.3745"}
ACTION: kalkulator {"wyrazenie":"2214+1180.8+120*4.3745"}
OBSERVATION: 3394.8
OBSERVATION: 524.94
OBSERVATION: 3919.74
RESULT:
You spent a total of **3,919.74 zł**.
Calculations:
- Invoices in złoty: 2,214.00 zł + 1,180.80 zł = **3,394.80 zł**
- Euro invoice: 120 EUR × NBP rate 4.3745 = **524.94 zł**
- Total: **3,394.80 zł + 524.94 zł = 3,919.74 zł**
model queries: 3, tokens: 1970 in / 323 out
Same result, 3 queries to the model, 1970 input tokens and 323 output. Two things worth knowing:
set_tracing_disabled(True): the SDK by default sends run traces to OpenAI. With OpenRouter without an OpenAI key you’d get errors in the console, and with corporate data you may simply not want to send them anywhere;OpenAIChatCompletionsModel: the SDK by default talks to OpenAI’s Responses API. Other providers (including OpenRouter) support Chat Completions, so we wrap the client with this class.
Version with Claude Agent SDK
Claude Agent SDK is the engine of Claude Code exposed as a library. It has built-in tools for files and terminal, and you plug in your own tools as a small in-memory MCP server. The code is in the package in frameworki/e_claude_agent_sdk.py, and a comparison with all frameworks in AI Agent Frameworks. On Windows we hit two traps:
- The SDK launches the
claudeprogram as a subprocess and refuses to run theclaude.cmdfile that npm installs (for security reasons). You need to point toclaude.exewith thecli_pathparameter or install Claude Code natively; - The SDK loads settings from
~/.claude/settings.json. If you have your ownANTHROPIC_BASE_URLthere, queries will go there, not where you point in the code.setting_sources=[]helps.
With Claude Haiku 4.5 via OpenRouter, the invoice task took 15-18 seconds and cost $0.013-0.018 (3 runs). Our frameworkless loop with the same Haiku model: 7.1 s and $0.007. The difference is the extra instructions and reasoning that the Claude Code engine adds. In return you get ready-made tools for files, permissions, sessions, and sub-agents.
When to Write an Agent Yourself vs. Use an SDK
- Yourself: for learning (essential), for simple agents with 2-5 tools, when you want full control over every token.
- OpenAI Agents SDK: when you need task handoffs between agents, guardrails, and run tracing.
- Claude Agent SDK: when the agent needs to work with files and terminal like Claude Code.
- LangGraph: when the flow has branches, checkpoints, and human-in-the-loop approval.
- No-code: n8n, Make, or Gumloop if you prefer clicking to coding.
Frequently Asked Questions
Do You Need to Know How to Code to Build an AI Agent?
To understand how it works, being able to read simple code is enough. The code in this article is 76 lines and you can run it without changes. For no-code building there are n8n and Gumloop.
How Much Does Running an AI Agent Cost?
It depends on the model and number of steps. Our agent with a cheap model paid $0.001 per task. The same steps on Claude Haiku cost $0.007-0.018. On expensive models in the $5 per million input tokens range, the cost grows dozens of times. Each step sends the entire history so far, so cost grows faster than the number of steps.
What Model to Choose for an Agent?
One that handles function calling well. For learning, the cheapest model with the “tools” filter on OpenRouter is enough. For tasks with many steps and unclear data, stronger models work better because they lose track of the plan less often.
Can an Agent Run on a Local Model?
Yes. Ollama has an OpenAI-compatible API (http://localhost:11434/v1), so in the code you only change base_url and the model name. Small local models more often confuse tool arguments.
How to Prevent an Agent from Doing Something Dangerous?
Only give it the tools it needs, preferably read-only. Restrict paths (in our case only the dane/ folder). Run irreversible actions through human confirmation. Log everything.
Sources and Verification Date
- Anthropic: Building effective agents (verified October 4, 2026).
- OpenAI Agents SDK, documentation (verified October 4, 2026).
- Claude Agent SDK, documentation (verified October 4, 2026).
- Exchange rates: NBP API, table A.
- Runs: our computer, October 4, 2026, 13:50-13:55.
More on this from QUANTSLANT
guides·RAG Step by Step: Chat with Your Documents in PythonBuild RAG from scratch in 70 lines of Python: local embeddings with Ollama, numpy vector search, and Chroma. 8 correct answers out of 10, zero hallucinations.

