Building an AI Agent From Scratch: 300 Lines, One Loop, No Framework
Every agent framework I tried in 2023 solved a problem I did not have yet, and hid the problem I did have: the loop. So I built the last three agents without one. Parse, call a tool, check the result, decide again. A while loop with a step budget, a state object, and a tool registry. The first vers

Every agent framework I tried in 2023 solved a problem I did not have yet, and hid the problem I did have: the loop.
So I built the last three agents without one. Parse, call a tool, check the result, decide again. A while loop with a step budget, a state object, and a tool registry. The first version fit in 300 lines and I could print every decision it made.
This is the whole build, plus the three failure modes that made me keep the loop boring.
Frameworks optimize for the demo: chains, planners, retries, memory, tracing, a graph editor. They also add a layer between you and the model's behavior at exactly the moment you need to see it.
My rule now: build the loop by hand until the product works, then adopt a framework only if a specific feature pays for its weight. In practice that means I have never adopted one.
The trade is real. You write retries, timeouts, and logging yourself. That is roughly 200 lines you would rather not own. You also get a stack trace that points at your code instead of inside a library.
import json, time MAX_STEPS = 8 DEADLINE = time. monotonic () + 45 def run ( user_message, tools, model ): state = { " messages ": [{ " role ": " user ", " content ": user_message }], " tool_results ": [], " steps ": 0 } while state [ " steps " ] < MAX_STEPS and time. monotonic () < DEADLINE: state [ " steps " ] += 1 action = model. next_action ( state, tools. schemas ()) if action [ " type " ] == " final ": return action [ " content " ] name = action [ " name " ] if name not in tools: state [ " tool_results " ]. append ({ " error ": f " unknown tool: { name } " }) continue try: result = tools [ name ]( ** action [ " arguments " ]) except TypeError as exc: # bad arguments result = { " error ": f " invalid arguments: { exc } " } except TimeoutError: result = { " error ": " tool timed out after 10s " } state [ " tool_results " ]. append ({ " tool ": name, " result ": result }) return { " error ": " step budget exhausted ", " steps ": state [ " steps " ]} Enter fullscreen mode Exit fullscreen mode Three things are doing the real work here.
A step budget. Eight is enough for search, detail, compare, answer. Without it, a confused model will keep calling the same tool. I have watched one do it eleven times.
A wall-clock deadline. Steps and seconds fail differently. A slow tool with a small step count still hangs the request.
Tool errors as data, not exceptions. If a tool throws and the loop dies, the user sees a 500. If the error goes back into state, the model gets a chance to fix its arguments. That single change cut my failed sessions by more than half.
The loop is 30 lines. The state design decides whether the agent is debuggable.
I keep one dict with three keys: the message list, a list of tool results, and a small scratchpad for facts the agent has confirmed. Every tool result is stored as structured data, never as prose pasted into the prompt.
state [ " facts " ][ " hotel_id " ] = result [ " hotelList " ][ 0 ][ " hotelId " ] Enter fullscreen mode Exit fullscreen mode That line matters more than it looks. When the model later says "the hotel we discussed", it is referring to a value that exists, not to a paragraph it half-remembers. Most of the hallucination I saw in early versions came from facts living only in the transcript.
At the end of each turn I render the facts into a compact block, four or five lines, rather than replaying the whole conversation. Context stays small and the model stays on topic.
A tool is a Python function plus a JSON Schema. The schema is the interface the model actually sees, so I write it before the function.
def search_hotels ( place: str, check_in: str, nights: int = 2 ):... schema = { " name ": " search_hotels ", " description ": " Find bookable hotels near a place. Returns hotel id, name, nightly price, currency. ", " parameters ": { " type ": " object ", " properties ": { " place ": { " type ": " string ", " description ": " City, station, or landmark " }, " check_in ": { " type ": " string ", " description ": " YYYY-MM-DD " }, " nights ": { " type ": " integer ", " minimum ": 1, " maximum ": 14 } }, " required ": [ " place ", " check_in " ] } } Enter fullscreen mode Exit fullscreen mode Two rules I follow and break at my peril.
Name the output, not the action. "Returns hotel id, name, nightly price" tells the model what it will get. "Searches hotels" does not.
Constrain the obvious mistakes. minimum, maximum, enums, and date formats in the description remove a whole class of retries.
First real user, first bug: the agent searched the same city four times with different spacing in the date string, never satisfied with the result.
The fix was not a better prompt. It was deduplication in state: hash the arguments, refuse a second identical call, and tell the model it already has that answer. One line, and the behavior stopped.
A tool returned {"hotels": []} because the dates were in the past. The model summarized it as "no availability that weekend" and moved on.
Now every tool returns either data or an explicit reason. Empty list plus a reason becomes an error state the loop can act on. A model cannot distinguish "no results" from "I broke" unless you tell it.
Tool results are large. A hotel search with five properties and full amenity lists is 4,000 tokens. Three calls and the conversation is mostly JSON.
The fix is boring: project the result before it goes into state. Keep the eight fields the agent needs, drop images, descriptions, and nested policy objects. If the user asks for detail, call the detail tool then.
Halfway through my second agent I noticed I was writing the same adapter for the third time: auth header, retries, response mapping, error strings. That is the point where an MCP server earns its place, because the server owns the schema and you own the loop.
The swap for my travel project was a config block. This is the whole integration:
{ "mcpServers": { "rollinggo-hotel": { "type": "http", "url": "https://mcp.rollinggo.ai/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } Enter fullscreen mode Exit fullscreen mode Three tools come with it: searchHotels, getHotelDetail, getHotelSearchTags. My loop did not change. The tool registry got smaller, because the server validates arguments and returns typed errors. The 1,900 lines of adapter code I had written across three projects became 240 lines of formatting and error handling.
For a from-scratch agent, that is the right division of labor: your 300 lines stay yours, and the data layer stops being your problem. The 5-minute redirect mode guide covers the setup and explains how the booking handoff works without you touching payments.
Unit tests do not catch agent bugs. Replay does.
I log every step: model action, tool name, arguments, result hash, latency. Then a test suite replays 40 real traces against the current code and asserts on outcomes, not wording. "Must contain a hotel ID that exists in the fixture", "must not call the same tool twice with identical arguments".
Forty traces caught every regression I shipped in the last six months. It is the cheapest quality investment in the project.
A confirmation gate for write tools. Anything that books, pays, or deletes should require an explicit user step, not a model decision. My loop has no writes today, which is why it is safe.
Structured logging to a file, not stdout. When a session goes wrong at 2 a.m., you want the trace, not a memory of it.
Everything else, memory, planning, multi-agent orchestration, can wait until a user asks for something the loop cannot do. Most of the time, the loop can do it.