Module 7: Project — Build a Mini Coding Agent
Implement the Agentic Loop
Description
This is the most important capsule of the project. Here you implement the agentic loop — the while loop that turns an LLM API into an agent. In the previous capsule, the LLM requested to execute a tool but nobody executed it. Now you'll close the cycle: the LLM requests → you execute → you send it the result → the LLM continues.
The Loop Architecture
┌─────────────────────────────────────────────────────┐
│ AGENTIC LOOP │
│ │
│ 1. Send messages to the LLM (with tools available) │
│ │ │
│ ▼ │
│ 2. LLM responds with: │
│ ├── TEXT → show to the user → END │
│ └── TOOL_USE → continue to step 3 │
│ │ │
│ ▼ │
│ 3. Execute the tool locally │
│ │ │
│ ▼ │
│ 4. Add the result to the message history │
│ │ │
│ └──────── back to step 1 │
│ │
└─────────────────────────────────────────────────────┘
The Code: Agentic Loop with Anthropic
This is the complete loop. Read it carefully before running it — each part corresponds to a concept from the previous modules.
import os
import json
from datetime import datetime
from dotenv import load_dotenv
from anthropic import Anthropic
load_dotenv()
client = Anthropic()
MODEL = "claude-sonnet-4-20250514"
MAX_ITERATIONS = 20
SYSTEM_PROMPT = """You are a helpful coding agent. You can read files,
list directories, write files, and run commands to help the user
with coding tasks.
When given a task:
1. First explore to understand the context
2. Plan your approach
3. Execute step by step
4. Verify your work
Always explain what you're doing and why."""
TOOLS = [
{
"name": "read_file",
"description": "Read the contents of a file. Returns the file content as text.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file to read"}
},
"required": ["path"]
}
},
{
"name": "list_directory",
"description": "List files and subdirectories in a directory.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory path. Defaults to '.'", "default": "."}
},
"required": []
}
},
{
"name": "write_file",
"description": "Write content to a file. Creates the file if it doesn't exist, overwrites if it does.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to write to"},
"content": {"type": "string", "description": "Content to write"}
},
"required": ["path", "content"]
}
},
{
"name": "run_command",
"description": "Run a shell command and return stdout and stderr. Use for running tests, checking files, etc.",
"input_schema": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "The shell command to execute"}
},
"required": ["command"]
}
}
]
def execute_tool(tool_name: str, tool_input: dict) -> str:
"""Execute a tool and return the result as a string."""
# For now, a placeholder — capsule 04 implements the real tools
return f"[Tool {tool_name} executed with args: {json.dumps(tool_input)}] (placeholder)"
def log_step(iteration: int, step_type: str, content: str):
"""Log each step of the agentic loop."""
timestamp = datetime.now().strftime("%H:%M:%S")
print(f"\n{'='*60}")
print(f"[{timestamp}] Iteration {iteration} | {step_type}")
print(f"{'='*60}")
print(content[:500]) # Limit output for readability
if len(content) > 500:
print(f"... ({len(content)} characters total)")
def run_agent(user_task: str):
"""Run the agentic loop for a given task."""
print(f"\n{'#'*60}")
print(f"TASK: {user_task}")
print(f"{'#'*60}")
messages = [
{"role": "user", "content": user_task}
]
iteration = 0
while iteration < MAX_ITERATIONS:
iteration += 1
log_step(iteration, "LLM REQUEST", f"Sending {len(messages)} messages to the LLM...")
response = client.messages.create(
model=MODEL,
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=TOOLS,
messages=messages
)
log_step(iteration, "LLM RESPONSE",
f"Stop reason: {response.stop_reason}\n"
f"Tokens - Input: {response.usage.input_tokens}, "
f"Output: {response.usage.output_tokens}")
if response.stop_reason == "end_turn":
# The LLM finished — extract the final text
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
log_step(iteration, "DONE", final_text)
return final_text
# Process tool calls
tool_results = []
for block in response.content:
if block.type == "text":
log_step(iteration, "THINKING", block.text)
elif block.type == "tool_use":
log_step(iteration, "TOOL CALL",
f"Tool: {block.name}\nArgs: {json.dumps(block.input, indent=2)}")
result = execute_tool(block.name, block.input)
log_step(iteration, "TOOL RESULT", result)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
# Add the LLM's response and the results to the history
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
log_step(iteration, "MAX ITERATIONS", "The iteration limit was reached.")
return "Error: the maximum iteration limit was reached."
if __name__ == "__main__":
task = input("\nDescribe the task for the agent: ")
result = run_agent(task)
print(f"\n{'#'*60}")
print("FINAL RESULT:")
print(result)
Code Breakdown
The system prompt
SYSTEM_PROMPT = """You are a helpful coding agent..."""
This is what the agent "is." It's the equivalent of the CLAUDE.md but hardcoded. It tells the LLM what role it has and how to work.
Connection with Module 04: Configuration files (CLAUDE.md) work exactly like this — they inject context at the start.
The tool definitions
TOOLS = [{"name": "read_file", "description": "...", "input_schema": {...}}, ...]
These are the agent's "hands." The LLM reads these definitions and decides which one to use based on the description.
Connection with Module 03: Real tool calling — the LLM generates JSON, it doesn't execute anything.
The main loop
while iteration < MAX_ITERATIONS:
response = client.messages.create(...)
if response.stop_reason == "end_turn":
return final_text # END OF THE LOOP
# Process tool calls...
messages.append(...) # Add to the history
# Back to the start of the while
Connection with Module 03: Observe → Think → Act → Observe, implemented as a while loop with a stop condition.
The stop condition
if response.stop_reason == "end_turn":
# The LLM decided it finished
return final_text
The LLM "decides" it finished when it generates text instead of a tool call. Its stop_reason is "end_turn" instead of "tool_use".
Connection with Module 03: The stop conditions of the agentic loop — here you see the two main ones: task completed (end_turn) and iteration limit (MAX_ITERATIONS).
The message history
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
Each iteration adds to the history. The history grows with each step.
Connection with Module 02: Each message consumes tokens from the context window. That's why MAX_ITERATIONS exists — without it, the history would grow indefinitely.
The logging
def log_step(iteration, step_type, content):
print(f"[{timestamp}] Iteration {iteration} | {step_type}")
The logging records each decision. It's your "window" into the agent's reasoning.
Connection with Module 05: Trust calibration is based on observing the decisions. Without logging, you can't verify.
Running the Loop (with placeholders)
python mini_agent.py
Describe the task for the agent: What files are in the current directory?
############################################################
TASK: What files are in the current directory?
############################################################
============================================================
[14:23:01] Iteration 1 | LLM REQUEST
============================================================
Sending 1 messages to the LLM...
============================================================
[14:23:03] Iteration 1 | LLM RESPONSE
============================================================
Stop reason: tool_use
Tokens - Input: 485, Output: 62
============================================================
[14:23:03] Iteration 1 | TOOL CALL
============================================================
Tool: list_directory
Args: {
"path": "."
}
============================================================
[14:23:03] Iteration 1 | TOOL RESULT
============================================================
[Tool list_directory executed with args: {"path": "."}] (placeholder)
============================================================
[14:23:05] Iteration 2 | LLM RESPONSE
============================================================
Stop reason: end_turn
============================================================
[14:23:05] Iteration 2 | DONE
============================================================
Based on the directory listing...
The loop works — with placeholders. Capsule 04 replaces the placeholders with real tools.
Adaptation for OpenAI
If you use OpenAI instead of Anthropic, here's the complete loop adapted. The structure is identical — only the names of the fields in the API change:
import os
import json
from datetime import datetime
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI()
MODEL = "gpt-4o-mini"
MAX_ITERATIONS = 20
SYSTEM_PROMPT = """You are a helpful coding agent. You can read files,
list directories, write files, and run commands to help the user
with coding tasks.
When given a task:
1. First explore to understand the context
2. Plan your approach
3. Execute step by step
4. Verify your work
Always explain what you're doing and why."""
# OpenAI uses "parameters" instead of "input_schema" and wraps it in "function"
TOOLS_OPENAI = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read the contents of a file. Returns the file content as text.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file to read"}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "list_directory",
"description": "List files and subdirectories in a directory.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory path. Defaults to '.'"}
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Write content to a file. Creates it if it doesn't exist.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to write to"},
"content": {"type": "string", "description": "Content to write"}
},
"required": ["path", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "run_command",
"description": "Run a shell command and return stdout/stderr.",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "The shell command to execute"}
},
"required": ["command"]
}
}
}
]
def run_agent_openai(user_task: str):
"""Agentic loop adapted for OpenAI API."""
# OpenAI injects the system prompt as the first message
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_task}
]
iteration = 0
while iteration < MAX_ITERATIONS:
iteration += 1
log_step(iteration, "LLM REQUEST", f"Sending {len(messages)} messages...")
response = client.chat.completions.create(
model=MODEL,
max_tokens=4096,
tools=TOOLS_OPENAI,
messages=messages
)
choice = response.choices[0]
log_step(iteration, "LLM RESPONSE",
f"Finish reason: {choice.finish_reason}\n"
f"Tokens - Input: {response.usage.prompt_tokens}, "
f"Output: {response.usage.completion_tokens}")
# Stop condition: "stop" is the equivalent of "end_turn"
if choice.finish_reason == "stop":
final_text = choice.message.content or ""
log_step(iteration, "DONE", final_text)
return final_text
# Add the assistant's response to the history
messages.append(choice.message)
# Process tool calls
if choice.message.tool_calls:
for tool_call in choice.message.tool_calls:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
log_step(iteration, "TOOL CALL",
f"Tool: {name}\nArgs: {json.dumps(args, indent=2)}")
result = execute_tool(name, args)
log_step(iteration, "TOOL RESULT", result)
# OpenAI uses role "tool" with tool_call_id
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result
})
return "Error: the maximum iteration limit was reached."
KEY DIFFERENCES ANTHROPIC vs OPENAI:
1. System prompt: Anthropic uses a separate parameter, OpenAI injects it as a message
2. Stop reason: "end_turn" (Anthropic) vs "stop" (OpenAI)
3. Tool results: role "user" with tool_result (Anthropic) vs role "tool" (OpenAI)
4. Tool definitions: "input_schema" (Anthropic) vs "parameters" wrapped in "function" (OpenAI)
5. Tool calls: response.content blocks (Anthropic) vs message.tool_calls (OpenAI)
THE STRUCTURE OF THE LOOP IS IDENTICAL:
while → send → did it finish? → execute tools → add to the history → repeat
What to Observe
While you run the loop, observe:
QUESTIONS FOR YOUR ANALYSIS:
1. How many iterations does it take for a simple task?
2. Does the LLM choose the correct tool on the first try?
3. Is the "thinking" (text before tool calls) useful?
4. How many tokens does each iteration consume?
5. Does the stop condition work correctly?
6. Does the LLM ever request a tool it doesn't need?
TAKE NOTES — you'll use them in capsule 05.
Summary
WHAT YOU IMPLEMENTED:
→ The complete agentic loop (while + stop condition)
→ Sending messages with tools to the LLM
→ Processing tool calls
→ A message history that grows with each iteration
→ Logging of each step
WHAT'S MISSING:
→ The real tools (capsule 04 — they're placeholders now)
→ Safety (capsule 04 — directory and command restrictions)
THE KEY INSIGHT:
→ The agentic loop is a while loop
→ The LLM generates JSON (tool calls), it doesn't execute anything
→ YOUR CODE executes the tools and sends the result
→ The "agent" is your while loop + the LLM's API
Next capsule: 04 - Add tools: file and shell — implement the real tools with safety.