Originally published at GeekCoding101.
Every product launch in 2026 seems to include the word agent: coding agents, browser agents, research agents, "agentic workflows". In my Agentic Frameworks guide I compared the big frameworks for building them. But before you pick a framework, it's worth understanding what an agent really is, because the core idea fits in about 50 lines of Python. In this post we'll define it, look at the loop that powers every agent, and build one from scratch. 🚀
An AI agent is a system where a large language model decides its own next steps and takes actions through tools in a loop, until a goal is reached.
Compare three levels:
| Chatbot | Workflow | Agent | |
|---|---|---|---|
| Who decides the steps? | Nobody, one answer | You, in code | The model |
| Can it act? | No | Yes, fixed actions | Yes, picks its own actions |
| Example | "Explain OAuth" | "Summarize every new ticket, then post to Slack" | "Find out why the build is failing and fix it" |
A good rule of thumb: if you can draw the flowchart in advance, build a workflow; it's cheaper, faster and more predictable. Reach for an agent when the path depends on what the model discovers along the way, like debugging, research, or cleaning up messy data.
Memory, planning and multi-agent setups are refinements on top of these four. Everything else is detail.
The model never runs anything itself. It asks for a tool call (in the Claude API, a tool_use block with stop_reason: "tool_use"), your code runs it, and you send back a tool_result. The whole conversation, including every tool call and result, is resent on each turn, which is how the agent "remembers" what it already did.
Let's build a small file agent: it can list, read and write text files inside a workspace/ folder, and we'll ask it to clean up a messy to-do list. I'm using the official Anthropic Python SDK, but the same loop works with any provider that supports tool calling.
uv init file-agent
cd file-agent
uv add anthropic
export ANTHROPIC_API_KEY=sk-ant-... # from console.anthropic.com
Save this as agent.py:
"""A minimal AI agent: Claude plus three file tools, in a loop."""
from pathlib import Path
import anthropic
WORKDIR = Path("workspace").resolve()
WORKDIR.mkdir(exist_ok=True)
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
TOOLS = [
{
"name": "list_files",
"description": "List the files in the workspace folder.",
"input_schema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{
"name": "read_file",
"description": "Read a text file from the workspace folder.",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string", "description": "File name inside the workspace"}},
"required": ["path"],
"additionalProperties": False,
},
},
{
"name": "write_file",
"description": "Create or overwrite a text file in the workspace folder.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File name inside the workspace"},
"content": {"type": "string", "description": "Full new content of the file"},
},
"required": ["path", "content"],
"additionalProperties": False,
},
},
]
def safe_path(name: str) -> Path:
"""Resolve a file name inside WORKDIR and refuse anything that escapes it."""
path = (WORKDIR / name).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"{name} is outside the workspace")
return path
def run_tool(name: str, args: dict) -> str:
if name == "list_files":
return "\n".join(p.name for p in sorted(WORKDIR.iterdir())) or "(empty)"
if name == "read_file":
return safe_path(args["path"]).read_text(encoding="utf-8")
if name == "write_file":
safe_path(args["path"]).write_text(args["content"], encoding="utf-8")
return f"Wrote {len(args['content'])} characters to {args['path']}"
raise ValueError(f"Unknown tool {name}")
def run_agent(task: str, max_steps: int = 20) -> str:
messages = [{"role": "user", "content": task}]
for step in range(max_steps):
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=16000,
system="You are a careful assistant that works on files in a workspace folder. "
"Look at what exists before changing anything.",
tools=TOOLS,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return "".join(b.text for b in response.content if b.type == "text")
results = []
for block in response.content:
if block.type != "tool_use":
continue
print(f"[step {step}] {block.name}({block.input})")
try:
output, is_error = run_tool(block.name, block.input), False
except Exception as e: # report failures to the model instead of crashing
output, is_error = f"Error: {e}", True
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output, "is_error": is_error})
messages.append({"role": "user", "content": results})
return "Stopped: too many steps."
if __name__ == "__main__":
(WORKDIR / "todo.txt").write_text("buy milk\nwrite blog post about agents\nbuy milk\n", encoding="utf-8")
print(run_agent("Clean up todo.txt: remove duplicates, sort it, and save the result as todo-clean.txt."))
uv run agent.py
On a run you'll see something like this (the exact steps vary, since the model decides them):
[step 0] list_files({})
[step 1] read_file({'path': 'todo.txt'})
[step 2] write_file({'path': 'todo-clean.txt', 'content': 'buy milk\nwrite blog post about agents\n'})
Done! I removed the duplicate "buy milk" entry, sorted the list, and saved it to todo-clean.txt.
Nobody told it to call list_files first or to read before writing. It decided that, partly because the system prompt says to look before changing anything.
Tool definitions are prompts. The model only knows a tool through its name, description and input_schema. Clear descriptions ("Full new content of the file") prevent most wrong calls. additionalProperties: False keeps the model from inventing parameters.
Append the whole assistant turn. messages.append({"role": "assistant", "content": response.content}) keeps the text, any thinking blocks and the tool_use blocks exactly as returned. Extracting only the text would break the next request, because every tool_result must match a tool_use id from the previous turn.
Return all results together. One assistant turn can contain several tool calls (the model reads two files in parallel, for example). Send all their results back in one user message.
Errors are results, not crashes. When a tool fails, we send "is_error": True with the message. The model reads it and usually recovers, for example by listing files after guessing a wrong name.
Stop on anything but tool_use. end_turn means done, but max_tokens or refusal also end the loop here instead of spinning forever.
safe_path is the guardrail. The model chooses file names, so never trust them: resolving the path and checking it stays inside WORKDIR blocks ../../.ssh/id_rsa-style escapes. Together with max_steps, this is the minimum safety every agent needs.
Once you understand the loop, you don't have to write it every time. The Anthropic SDK's tool runner (beta) builds the schemas from type hints and docstrings and runs the loop for you:
import anthropic
from anthropic import beta_tool
from agent import WORKDIR, safe_path
client = anthropic.Anthropic()
@beta_tool
def list_files() -> str:
"""List the files in the workspace folder."""
return "\n".join(p.name for p in sorted(WORKDIR.iterdir())) or "(empty)"
@beta_tool
def read_file(path: str) -> str:
"""Read a text file from the workspace folder.
Args:
path: File name inside the workspace.
"""
return safe_path(path).read_text(encoding="utf-8")
@beta_tool
def write_file(path: str, content: str) -> str:
"""Create or overwrite a text file in the workspace folder.
Args:
path: File name inside the workspace.
content: Full new content of the file.
"""
safe_path(path).write_text(content, encoding="utf-8")
return f"Wrote {len(content)} characters to {path}"
runner = client.beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=16000,
tools=[list_files, read_file, write_file],
messages=[{"role": "user", "content": "Clean up todo.txt: remove duplicates, sort it, save as todo-clean.txt."}],
)
final = runner.until_done()
print(final.content[-1].text)
Frameworks like LangGraph, CrewAI, the OpenAI Agents SDK or Google ADK add more on top (state graphs, multiple agents, tracing, deployment), but underneath they all run this same loop.
max_steps, and log token usage per run. write_file.
A chatbot answers one message at a time. An agent pursues a goal across many steps, choosing and calling tools, reading the results and deciding what to do next on its own.
No. As shown above, a working agent is a loop around a model API plus a few tool functions. Frameworks help once you need persistence, multiple agents, tracing or deployment.
"Agentic AI" describes systems with this agent behavior: models that plan, use tools and act with some autonomy, as opposed to single-shot text generation.
MCP standardizes how agents get tools. Instead of writing read_file yourself, your agent can connect to an existing MCP server for files, GitHub, databases and more. See What Is an MCP Server?
More in the Agent Stack Insider series 👇