Build Your First AI Agent from Scratch with Python
There is a certain type of person who hears the phrase “AI agent” and, instead of nodding vaguely and moving on, immediately opens a…
Build Your First AI Agent
from Scratch with Python

There is a certain type of person who hears the phrase “AI agent” and, instead of nodding vaguely and moving on, immediately opens a terminal window. If you clicked on this article, you are probably that person. This article is written for you.
The term AI agent is everywhere right now — in every product announcement, every research paper, every breathless LinkedIn post. Yet most explanations either treat it as too obvious to define or too complex to explain simply. Neither is true.
This article cuts through both extremes. By the end, you will have built a fully functional AI research agent — one that searches the web autonomously, reads source pages, synthesises its findings, and writes a structured report to a file — all without a single human prompt after the initial instruction.
An AI agent is a language model connected to tools, running inside a loop, with memory of what it has already done. That is the entire definition.
More importantly, you will understand precisely how it works — not as a black box, but line by line. Because the single most useful thing you can know about AI agents in 2025 is that every production framework — LangChain, AutoGen, OpenAI Assistants — is built on the same 50-line loop you are about to write yourself.
1. What Distinguishes an Agent from a Chatbot
A standard language model interaction is stateless and reactive: the model receives a prompt, produces a response, and terminates. An agent introduces three additional capabilities that together create autonomous behaviour.

Figure 1 — Structural comparison: standard LLM call vs. autonomous AI agent
The three properties that define an agent are as follows:
- Tool use — the ability to call external functions such as a web search, a file writer, or an API. Without tools, the model can only produce text; with tools, it can take actions in the world.
- Memory — a record of prior actions and observations within the current session. This gives the agent continuity: it knows what it has already done and what it found.
- Autonomy — the ability to decide the next action without human input at each step. The agent is given a goal; it determines the sequence of steps needed to reach it.
2. The ReAct Architecture
The dominant pattern for AI agents is ReAct — Reasoning and Acting — introduced by Yao et al. (2022). The agent alternates between a reasoning step, in which it decides what to do next, and an acting step, in which it executes that decision and observes the result. This cycle continues until the terminal condition is reached.

Figure 2 — The ReAct loop: Reason, Act, Observe — repeating until the terminal condition is met
The loop operates in four phases on every iteration:
- Reason — The LLM reads the full conversation history and decides which tool to call next, outputting a structured
ACTIONandINPUT. - Act — The agent parses the LLM output, looks up the tool name in the registry, and executes the corresponding Python function.
- Observe — The tool’s result is appended to the conversation history as a new user message, making it visible to the LLM on the next iteration.
- Terminate — When the LLM determines the goal is complete, it responds with
DONE:and the loop exits.
Every major agentic system — OpenAI Assistants, Anthropic tool use, LangChain agents, Microsoft AutoGen — is built on this same fundamental loop. Understanding it at this level means you understand all of them at their core.
3. Environment and Dependencies
The implementation requires Python 3.9 or later. Create an isolated virtual environment before installing any packages.
# Create and activate a virtual environment
python -m venv agent-env
source agent-env/bin/activate # Windows: agent-env\Scripts\activate
# Install all dependencies
pip install openai duckduckgo-search python-dotenv requests beautifulsoup4
Here is what each package contributes to the system:
- openai: Client library for the LLM. Compatible with OpenAI, Groq, and any provider that implements the OpenAI API specification.
- duckduckgo-search: Free web search with no API key required. This becomes the agent’s primary information-retrieval tool.
- python-dotenv: Loads API credentials from a
.envfile at startup, keeping secrets out of source code entirely. - requests: Standard HTTP client for fetching the raw HTML content of web pages.
- beautifulsoup4: HTML parser that strips markup and navigation elements, returning clean, readable text the LLM can process.
# .env — this file must never be committed to version control
OPENAI_API_KEY=sk-your-key-here
# Alternative: Groq provides free inference for open-source models
GROQ_API_KEY=gsk-your-groq-key-here
Free Alternative — Groq: Groq provides free inference for Llama 3.1 70B and Mixtral with generous rate limits. Register at console.groq.com — no credit card required. The code in this article detects which key is present and configures itself automatically.
4. Project Architecture
The codebase is separated into four single-responsibility modules. This separation makes each component individually understandable, testable, and replaceable without touching the others.

Figure 3 — Module dependency graph: four files, each with a single responsibility
my-ai-agent/
├── .env # API credentials (must be gitignored)
├── main.py # Entry point — accepts the user goal
├── agent.py # ReAct loop implementation
├── llm.py # LLM client abstraction layer
├── tools.py # Tool definitions and registry
└── research_report.txt # Output — generated at runtime
5. Building the Four Modules
01. tools.py — The Tool Registry
Every tool the agent can use is defined here as a plain Python function, then registered in a dictionary the agent loop reads at runtime. Adding a new capability means adding one function and one dictionary entry — nothing else in the system changes.
# tools.py
import requests
from bs4 import BeautifulSoup
from duckduckgo_search import DDGS
def search_web(query: str, max_results: int = 5) -> list[dict]:
"""Search the web via DuckDuckGo. Returns title, URL, and snippet."""
results = []
with DDGS() as ddgs:
for r in ddgs.text(query, max_results=max_results):
results.append({
"title": r["title"],
"url": r["href"],
"snippet": r["body"]
})
return results
def read_webpage(url: str, max_chars: int = 3000) -> str:
"""Fetch a URL and return its clean, readable text content."""
try:
headers = {"User-Agent": "Mozilla/5.0"}
response = requests.get(url, headers=headers, timeout=10)
soup = BeautifulSoup(response.content, "html.parser")
# Strip non-content elements before extracting text
for tag in soup(["script", "style", "nav", "footer"]):
tag.decompose()
lines = [l for l in
soup.get_text(separator="\n", strip=True).splitlines()
if l.strip()]
return "\n".join(lines)[:max_chars]
except Exception as e:
return f"Error fetching page: {e}"
def save_report(filename: str, content: str) -> str:
"""Write the final research report to disk."""
with open(filename, "w", encoding="utf-8") as f:
f.write(content)
return f"Report saved to {filename}"
# Registry — maps the names the LLM will use to functions + metadata
TOOLS = {
"search_web": {
"function": search_web,
"description": "Search the web. Input: a search query string.",
"input_type": "string"
},
"read_webpage": {
"function": read_webpage,
"description": "Read and extract text from a URL. Input: a URL string.",
"input_type": "string"
},
"save_report": {
"function": save_report,
"description": 'Save the final report. Input: JSON with "filename" and "content" keys.',
"input_type": "json"
}
}
02. llm.py — The Language Model Client
A thin wrapper around the OpenAI SDK. Isolating this layer means switching to a different model or provider requires modifying exactly one file, with no changes anywhere else in the codebase.
# llm.py
import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
# Detect which provider to use based on which key is present
_groq = bool(os.getenv("GROQ_API_KEY"))
client = OpenAI(
api_key = os.getenv("GROQ_API_KEY") if _groq else os.getenv("OPENAI_API_KEY"),
base_url = "https://api.groq.com/openai/v1" if _groq else None
)
MODEL = "llama3-70b-8192" if _groq else "gpt-4o-mini"
def think(messages: list[dict], system_prompt: str) -> str:
"""Send the full conversation history to the LLM and return its reply."""
response = client.chat.completions.create(
model = MODEL,
messages = [{"role": "system", "content": system_prompt}] + messages,
temperature = 0.3,
max_tokens = 2000
)
return response.choices[0].message.content
03. agent.py — The ReAct Loop
This is the core of the system. It implements the Think → Act → Observe cycle: parses the tool call from the LLM’s output, executes it, and appends the result back to the conversation memory before the next iteration.
# agent.py
import json, re
from llm import think
from tools import TOOLS
TOOL_DOCS = "\n".join(
f"- {name}: {info['description']}"
for name, info in TOOLS.items()
)
SYSTEM_PROMPT = f"""You are an autonomous research agent.
Available tools:
{TOOL_DOCS}
To call a tool, respond in this exact format:
ACTION: <tool_name>
INPUT: <input value>
When research is complete and the report has been saved, respond:
DONE: <one-sentence summary>
Think step by step. Always search before reading pages."""
def _parse_action(text: str):
"""Extract tool name and input string from the LLM response."""
a = re.search(r"ACTION:\s*(\w+)", text)
i = re.search(r"INPUT:\s*(.+?)(?=ACTION:|DONE:|$)", text, re.DOTALL)
return (a.group(1).strip(), i.group(1).strip()) if a else (None, None)
def run_agent(goal: str, max_steps: int = 15):
"""Run the ReAct loop until DONE or max_steps is reached."""
messages = [{"role": "user", "content": goal}]
for step in range(1, max_steps + 1):
print(f"\n[Step {step}]")
# ── THINK ─────────────────────────────────────────
reply = think(messages, SYSTEM_PROMPT)
print("LLM :", reply[:200])
messages.append({"role": "assistant", "content": reply})
# ── TERMINAL CHECK ────────────────────────────────
if "DONE:" in reply:
print("Agent: task complete.")
break
# ── ACT ───────────────────────────────────────────
tool_name, tool_input = _parse_action(reply)
if not tool_name or tool_name not in TOOLS:
messages.append({"role": "user",
"content": "Use a tool or respond DONE:."})
continue
print(f"Tool : {tool_name} | {tool_input[:80]}")
try:
fn = TOOLS[tool_name]["function"]
result = fn(**json.loads(tool_input)) \
if TOOLS[tool_name]["input_type"] == "json" \
else fn(tool_input)
except Exception as e:
result = f"Tool error: {e}"
# ── OBSERVE ───────────────────────────────────────
messages.append({
"role": "user",
"content": f"Result from {tool_name}:\n{str(result)[:1500]}\n\nContinue."
})
04. main.py — The Entry Point
A minimal interface that accepts a research topic and constructs a precise, well-specified goal prompt for the agent. The quality of this goal prompt directly determines the quality of the output.
# main.py
from agent import run_agent
if __name__ == "__main__":
topic = input("Research topic: ")
goal = f"""
Research this topic thoroughly: "{topic}"
Required steps:
1. Perform at least 2 web searches using different query angles.
2. Read the 3 most relevant pages you find.
3. Write a structured report with these sections:
Introduction, Key Findings, Recent Developments, Conclusion.
4. Save the report as "research_report.txt" using save_report.
5. Respond DONE: once the file has been saved.
"""
run_agent(goal)
print("\nReport written to research_report.txt")
6. Running the Agent
python main.py
Research topic: Latest advances in transformer architectures 2025
[Step 1]
LLM : I will begin with a broad web search on transformer advances.
Tool: search_web | latest transformer architecture advances 2025
[Step 2]
LLM : Several results look relevant. Reading the most detailed article.
Tool: read_webpage | https://arxiv.org/...
[Step 3]
LLM : Good content retrieved. Performing a second search for benchmarks.
Tool: search_web | transformer model efficiency benchmarks 2025
[Step 4 – 8] # Agent reads additional pages, accumulates findings
[Step 9]
LLM : Sufficient information gathered. Writing and saving the report.
Tool: save_report | {"filename": "research_report.txt", "content": "..."}
[Step 10]
LLM : DONE: Comprehensive report on transformer architectures saved to disk.
Agent: task complete.
Report written to research_report.txt
7. How Memory Works
The agent’s memory is the messages list. Every thought the LLM produces and every tool result observed is appended to this list. On the next step, the complete history is sent to the LLM, giving it full context of everything it has done so far.

Figure 4 — In-context memory: the messages array grows each step, providing continuity
This approach is called in-context memory. It requires no external database and is sufficient for tasks completable within a single session. Its constraint is the context window: very long tasks will eventually exceed the model’s token limit. Production systems address this in one of three ways:
- Summarisation — periodically compress older messages into a shorter summary, then continue
- Sliding window — keep only the most recent N messages in the active context
- Vector store memory — embed past observations and retrieve only the most relevant ones per step
8. Extending the System
Adding a Calculation Tool
Add this function to tools.py and register it in the TOOLS dictionary exactly as the others are registered.
def calculate(expression: str) -> str:
"""Safely evaluate a numeric expression."""
try:
result = eval(expression, {"__builtins__": {}})
return str(result)
except Exception as e:
return f"Calculation error: {e}"
Switching to a Fully Local Model
Install Ollama, pull any supported model, and change two lines in llm.py. No other file requires modification.
# llm.py — switch to fully local inference at zero cost
client = OpenAI(
base_url = "http://localhost:11434/v1",
api_key = "ollama"
)
MODEL = "llama3.2" # runs entirely on your own hardware
Adding a Web Interface with Streamlit
# app.py
import streamlit as st
from agent import run_agent
st.title("AI Research Agent")
topic = st.text_input("Research topic")
if st.button("Run"):
with st.spinner("Agent working..."):
run_agent(f'Research "{topic}" and save as research_report.txt')
with open("research_report.txt") as f:
st.markdown(f.read())
streamlit run app.py
9. Common Issues and Resolutions
Agent loops without terminating
- The goal prompt does not specify an explicit terminal condition.
- Resolution: Add a clear instruction to the goal: “Respond DONE: once the report has been saved to disk.”
KeyError on tool lookup
- The LLM produced a tool name that is not registered in
TOOLS. - Resolution: Add the unrecognised name to the registry, or reinforce the tool list explicitly in the system prompt.
Empty or partial search results
- DuckDuckGo rate-limiting on rapid successive calls.
- Resolution: Insert
time.sleep(2)between search invocations insearch_web.
Webpage returns an error string
- The target site blocks automated requests.
- Resolution: Handle
Exceptiongracefully inread_webpageand allow the agent to move to the next URL.
Context window exceeded on long tasks
- The
messageslist has grown beyond the model's token limit. - Resolution: Reduce
max_charsinread_webpage, or truncate older messages before each LLM call.
10. Concepts Covered

Figure 5 — The six foundational concepts implemented in this article
- ReAct Pattern: The Reason + Act loop implemented in
agent.py. The foundational architecture behind every major agent framework. - Tool Use: The mechanism by which an LLM calls external functions. Defined in
tools.pyas a registry of named functions. - In-Context Memory: The
messageslist that accumulates all thoughts, actions, and observations across the session. - Prompt Engineering: The
SYSTEM_PROMPTthat defines the agent's behaviour, output format, and termination condition. - LLM Integration: The abstraction layer in
llm.pythat makes the agent provider-agnostic and trivially swappable. - Goal-Directed Execution: The agent’s ability to pursue an open-ended goal through a sequence of self-determined actions without human input at each step.
11. Further Reading
- Production agent frameworks: LangChain, LlamaIndex, CrewAI
- Multi-agent coordination: Microsoft AutoGen, OpenAI Swarm
- Long-term persistent memory: ChromaDB, FAISS, Pinecone vector stores
- Agent evaluation: RAGAS framework, LLM-as-judge patterns
- The original ReAct paper: Yao et al., 2022 — arxiv.org/abs/2210.03629
- Local model inference: Ollama — ollama.com
Closing Remarks
An AI agent, stripped of its marketing language, is a language model with access to tools, running inside a loop, with a record of what it has already done. The system built in this article implements all three of those properties in fewer than 150 lines of Python.
Understanding the architecture at this level — where the reasoning happens, how tools are dispatched, how memory accumulates — is the prerequisite for building anything more sophisticated. Production frameworks abstract these mechanics, but they do not change them. Every LangChain agent, every AutoGen workflow, every OpenAI Assistant runs on this same foundation.
The logical next step is to extend the tool registry: add a code execution tool, a file-reading tool, or a third-party API integration. Each new capability expands the problem space the agent can address without modifying a single line of the loop itself.
메타데이터
- post_id
- a1c90b5224ef
- slug
- build-your-first-ai-agent-from-scratch-with-python-a1c90b5224ef
- url
- https://medium.com/@zainulabideen5/build-your-first-ai-agent-from-scratch-with-python-a1c90b5224ef
- canonical_url
- https://medium.com/@zainulabideen5/build-your-first-ai-agent-from-scratch-with-python-a1c90b5224ef
- author_url
- https://medium.com/@zainulabideen5
- status
- ok
- fetched_at
- 2026-06-09 15:37:30