Strip away the frameworks, the YAML, and the marketing, and every AI agent — including the coding agents you probably used today — runs the same four-step loop:
- Perceive — read the inputs: the task, previous steps, memory, and the list of available tools.
- Reason — the LLM decides what to do next: call a tool, or produce a final answer.
- Act — execute the decision: run the tool, call the API, write the file.
- Observe — feed the result back into context. Repeat from the top.
The loop runs until the task is complete or a stop condition is reached. That's the entire engine. Memory, multi-agent systems, deployment — all of it is scaffolding around this loop. Which means once you can write the loop yourself, agents stop being magic.
The complete loop, runnable
Here it is with the Claude API — complete, not pseudocode. TOOLS is your list of tool definitions and execute_tool is your dispatcher that actually runs them:
def run_agent(task: str, max_iterations: int = 10) -> str:
messages = [{"role": "user", "content": task}]
for _ in range(max_iterations):
# 1. Ask Claude what to do next
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=TOOLS,
messages=messages,
)
# 2. Append Claude's response to history VERBATIM.
# response.content carries the tool_use blocks the next
# request needs — keep all of it, don't extract only text.
messages.append({"role": "assistant", "content": response.content})
# 3. If Claude didn't ask for a tool, it's done — return the answer.
if response.stop_reason != "tool_use":
return "".join(b.text for b in response.content if b.type == "text")
# 4. Execute every tool Claude requested.
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
# 5. Feed results back in ONE user message — loop repeats from step 1.
messages.append({"role": "user", "content": tool_results})
return "Reached max iterations without a final answer."
The five decisions that matter
Every line above encodes a decision people usually learn the expensive way:
- A hard iteration limit. An agent without a stop condition can loop indefinitely, consuming tokens and money.
max_iterationsis not optional. - Append the response verbatim.
response.contentcarries thetool_useblocks the next request needs. Extract only the text and the loop breaks in ways that look like model stupidity but are your bug. - Full history every iteration. The model re-reads everything each turn — that context is what makes its next decision good.
- Tool results go back as a user message, each keyed to its
tool_use_id, and parallel results belong in one message. This is the API convention; violate it and you get errors or silently confused agents. - Check
stop_reasonexplicitly.tool_usemeans "run a tool and come back"; anything else (end_turn,max_tokens) means this turn is over. Handle both paths deliberately.
Where this goes next
This loop plus a couple of tools is a genuinely useful agent — a research assistant, a file organizer, a monitoring bot. What it isn't, yet, is production: it has no persistent memory, no guardrails around what tools may do, no evals telling you whether changes made it better or worse, and no cost control. Those are solvable problems too — the same loop, hardened.
Next up: give your agent a memory — the first thing this loop is missing.