Drop the framework from your mental model for a second. A code agent — the thing that reads your repo, runs the tests, edits a file, and reports what it found — is a while loop wrapped around a model that is unusually good at guessing which of your functions to call next. That is the whole machine. Everything a framework stacks on top is ergonomics, and almost every production bug you will hit lives in the three primitives underneath: the calling contract, the memory, and where the tools come from.
I think the single most useful thing you can do before adopting an agent framework is build the forty-line version yourself. Once you have, every framework's failure mode becomes legible, because you have already met it in miniature.
The loop is the entire trick
The model never runs anything. It emits a structured request — "call run_tests with these arguments" — and stops. Your code runs the function, appends the result to the conversation, and calls the model again. Repeat until the model answers with prose instead of a tool request.
messages = [{"role": "user", "content": task}]
while True:
reply = model.create(messages=messages, tools=TOOLS)
if reply.stop_reason != "tool_use":
break # the model answered instead of calling a tool
messages.append({"role": "assistant", "content": reply.content})
results = []
for block in reply.content:
if block.type == "tool_use":
output = dispatch(block.name, block.input) # your code runs here
results.append({
"type": "tool_result",
"tool_use_id": block.id, # echo the id back
"content": output,
})
messages.append({"role": "user", "content": results})
Three rules in that loop are non-obvious and each one, gotten wrong, produces a silent failure rather than a crash:
- Append the assistant turn back verbatim, tool-request blocks and all. Strip them to "just the text" and the next turn has no idea what it asked for.
- Match every result to its
tool_use_id. The model fired the request; the id is how the answer finds its way home.
- Return a parallel batch in one turn. A model can request three tools at once. Split those results across multiple messages and you quietly train it to stop parallelizing.
A tool itself is nothing exotic: a name, a description, and a JSON Schema for its arguments. The description is the real interface — the API documentation the model programs against — so "Run the project's linter on a file; call this before proposing an edit" earns its keep in a way that "runs the linter" does not.
Memory is a list until it isn't
Notice what serves as the agent's memory in that loop: the messages array. There is no hidden store, no vector database, no session object. The agent remembers exactly what is in the list, and the list is resent in full on every single turn.
That is elegant and it is also the first thing to break. A real coding task — grep the codebase, read six files, run the suite, read the failures — piles up tool output fast, and the list eventually outgrows the model's context window. When it does, the earliest turns fall off the front and the agent forgets the very instruction it was given.
The message array is the cheapest possible memory and the reason your agent gets amnesia at hour two. Both facts are the same fact.
Three moves buy you headroom, in rising order of effort:
- A sliding window keeps the last N turns and drops the middle. Cheap, lossy, fine for shallow tasks.
- Compaction summarizes older turns into a dense synopsis the model carries forward, trading fidelity for room.
- External memory hands the model a scratchpad — a file it reads at the start of a task and writes to as it learns — so state survives even a full context reset. This is what lets an agent resume work across sessions instead of starting cold every time.
Pick the weakest one that survives your longest realistic task. Reaching for external memory on a task that fits in one window is just latency you paid for nothing.
The third primitive is the one that quietly decides whether your agent stays a toy. Writing run_tests for your own repo is an afternoon. Wiring the same agent to GitHub, Postgres, Slack, a browser, and a filesystem is a bespoke integration for each — and every agent anyone builds re-implements the same connectors from scratch.
The Model Context Protocol exists to kill that duplication. It is an open standard, introduced by Anthropic, that defines one wire format for exposing tools to any AI application — "a USB-C port for AI," as the spec puts it. The shape is worth knowing before you adopt it:
- Architecture is host, client, server. Your agent embeds an MCP client; each integration is an MCP server; the host application wires them together.
- Messages are plain JSON-RPC 2.0 — requests, responses, and notifications over a stateful session.
- Servers expose three primitives. Tools are actions with side effects the model chooses to call. Resources are read-only data the application pulls in. Prompts are reusable templates a user can invoke.
- Transports come in two flavors:
stdio for a local server running as a subprocess (no network, lowest latency), and Streamable HTTP for remote servers, which can push incremental results over Server-Sent Events.
The payoff is concrete. Make your agent an MCP client once, and every existing MCP server — for GitHub, for a database, for your internal tools — plugs in without another line of glue. You write the loop; the ecosystem writes the connectors.
Where it actually breaks
The forty-line agent runs. Making it safe is a separate, short list.
Cap the iterations — a confused model with no ceiling loops forever, burning tokens against a mistake it cannot escape. Gate anything destructive: a send_email or DROP TABLE should pause for approval, a read-only grep should not. This is why a typed tool beats a raw bash tool for risky actions — your harness sees send_email(to, body) and can intercept it, whereas bash("curl -X POST ...") is an opaque string it cannot reason about. And parse arguments as JSON, never by string-matching the serialized input, which breaks the moment escaping shifts.
The loop gives the agent reach; the guardrails decide whether that reach is an asset or an incident.
So build the small version. Write the loop, feel the memory fill, watch a runaway iteration, then plug in one MCP server and see an integration you did not write light up. When you do finally reach for a framework, you will be choosing it for what it saves you — not trusting it for what it hides.
Sources: What is the Model Context Protocol (MCP)?, MCP Specification — Transports