What Is an AI Agent? How to Build One from Scratch in Python A developer published a from-scratch guide to building an AI agent in roughly 50 lines of Python using the Anthropic SDK, defining an agent as a large language model that decides its own next steps and acts through tools in a loop until a goal is reached. The walkthrough builds a file agent with list, read and write tools confined to a workspace folder, and argues that teams should prefer cheaper, more predictable workflows whenever the steps can be drawn in advance. Originally published at GeekCoding101 https://geekcoding101.com/posts/what-is-an-ai-agent-and-how-to-build-one . Every product launch in 2026 seems to include the word agent : coding agents, browser agents, research agents, "agentic workflows". In my Agentic Frameworks guide https://geekcoding101.com/posts/a-quick-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, Keep the whole assistant turn text, thinking and tool calls in the history. messages.append {"role": "assistant", "content": response.content} if response.stop reason = "tool use": end turn done , max tokens, refusal...: stop and return what we have. 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} All results of one turn go back together in a single user message. 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: python 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? https://geekcoding101.com/posts/what-is-an-mcp-server More in the Agent Stack Insider series 👇