To build a coding agent from scratch, write a while loop around one model call: send the conversation plus a list of tools, and every time the model asks to use a tool, run it and feed the result back into the same loop. That's the whole idea. The model never runs your code — it requests a tool, your harness executes it, and you hand back the output. Repeat until the model stops asking. No framework required.
The trending advice — "build a coding agent, not a wrapper" — is right, and it's less work than it sounds. Below is the real Anthropic Messages API loop, in the official anthropic Python SDK, with claude-opus-4-8.
The loop is the whole agent#
Here is the spine. Everything else in this post decorates it.
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
def run_agent(user_prompt: str, tools, dispatch) -> str:
messages = [{"role": "user", "content": user_prompt}]
while True:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
tools=tools, # tool schemas (below)
messages=messages,
)
# Append the model's turn verbatim — it carries the tool_use blocks.
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break # end_turn: the model is done
# The model asked for one or more tools. Run each, collect results.
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = dispatch(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id, # MUST match the request's id
"content": result,
})
# Feed results back as the next user turn, then loop.
messages.append({"role": "user", "content": tool_results})
return "".join(b.text for b in response.content if b.type == "text")
That's about 40 lines and it is a complete agent. The four load-bearing facts, straight from the docs:
- You pass
toolson every call. The model sees their schemas and decides when to use them. - When the model wants a tool, the response
stop_reasonis"tool_use"and itscontentcontains one or moretool_useblocks, each with anid, aname, and a parsedinput. - You append the assistant turn and a user turn of
tool_resultblocks. Each result'stool_use_idmust match theidof the request it answers — that's how the model pairs them. - You keep looping until
stop_reasonis"end_turn". (Handlemax_tokensandpause_turntoo in production; see the stop-reasons guide.)
An agent is not a new kind of program. It's a for-loop that lets a model pick the next line — and the API contract for that is four rules and one while True.
What makes it a coding agent: three tools#
The loop above is generic. Give it three tools and it can edit a codebase. A tool is just a name, a description, and a JSON Schema for its input — the model reads the description to decide when to call it.
tools = [
{
"name": "read_file",
"description": "Read a UTF-8 text file and return its contents.",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
{
"name": "write_file",
"description": "Write text to a file, overwriting it. Creates parent dirs.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
},
{
"name": "run_bash",
"description": "Run a shell command and return combined stdout/stderr. "
"Use for tests, grep, git. Commands are model-generated.",
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
},
]
Now dispatch — the only place your code actually does anything. This is where the model's request becomes a real side effect:
import pathlib, subprocess
def dispatch(name: str, args: dict) -> str:
try:
if name == "read_file":
return pathlib.Path(args["path"]).read_text()
if name == "write_file":
p = pathlib.Path(args["path"])
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(args["content"])
return f"wrote {len(args['content'])} bytes to {p}"
if name == "run_bash":
# ⚠️ Runs whatever the model emits. Sandbox this. See below.
out = subprocess.run(
args["command"], shell=True, capture_output=True,
text=True, timeout=60,
)
return (out.stdout + out.stderr) or "(no output)"
return f"unknown tool: {name}"
except Exception as e: # return errors, don't raise
return f"error: {e}"
Wire it together — run_agent("Add a failing test for the parser, then make it pass.", tools, dispatch) — and you have a coding agent in roughly 200 lines total. It reads files, writes patches, runs your test suite, reads the failures, and tries again, all inside that one while loop.
Two details that save you a debugging session: parse block.input as the structured object the SDK already gives you (never string-match the raw JSON), and when a tool fails, return the error text as the tool_result — optionally with "is_error": True — instead of raising. The model reads the failure and adapts, exactly as it would from a compiler error.
Be honest about what this does not give you#
The minimal loop is real and it works. It is also missing everything a production agent needs, and pretending otherwise is how people get burned:
- Sandboxing.
run_bashexecutes model-generated shell commands withshell=True. On your laptop that is a loaded gun. Run it in a container or VM, apply an allowlist, and never point it at anything you can't afford to lose. - Cost and loop control. Nothing here caps iterations or spend. A confused model can loop for a long time; add a
max_turnscounter and a token budget. - Retries and errors. No backoff on rate limits, no handling of
max_tokenstruncation. The SDK retries transient errors; the agent logic is on you. - Memory and context. Long runs overflow the context window. Compaction, context editing, and persistent memory are all real problems — and all bolt onto this loop rather than changing it.
That last point is the whole insight. Frameworks aren't lying about their value — retries, tracing, and memory are genuinely useful. They're just add-ons to a spine you now understand completely. The question isn't "loop or framework." Once you've seen the landscape of agent frameworks, models, and standards, the real choice is whether you'd rather own 40 lines you can read or import an abstraction you can't. When you do reach for structure — say, handling tool-call errors so a bad command doesn't derail the run — you'll be adding it to a machine whose every moving part you can see. Start from the loop. Add only what the task proves it needs.



