Module 7: Project — Build a Mini Coding Agent
Add Tools: File and Shell
Description
The agentic loop from the previous capsule works, but the tools are placeholders. In this capsule you replace them with real implementations: read_file, write_file, list_directory, and run_command. You also add basic safety so the agent can't do dangerous things.
Safety First: The WORKSPACE
Before implementing the tools, we define a "sandbox" directory where the agent can operate:
import os
import subprocess
WORKSPACE = os.path.abspath("./test_workspace")
def ensure_workspace():
"""Create the workspace directory if it doesn't exist."""
os.makedirs(WORKSPACE, exist_ok=True)
def safe_path(path: str) -> str:
"""Resolve a path and ensure it's within the workspace."""
resolved = os.path.abspath(os.path.join(WORKSPACE, path))
if not resolved.startswith(WORKSPACE):
raise ValueError(f"Access denied: path '{path}' is outside the workspace")
return resolved
WHAT DOES safe_path DO?
→ Converts relative paths to absolute
→ Verifies that the resulting path is INSIDE the workspace
→ If someone tries "../../etc/passwd" → error
→ It's your FIRST level of security
CONNECTION WITH MODULE 04:
→ This is sandboxing in its simplest form
→ Real coding agents do the same (more sophisticated)
→ The principle of least privilege: access only to the workspace
Implement the 4 Tools
Replace the placeholder execute_tool function with this real version:
def execute_tool(tool_name: str, tool_input: dict) -> str:
"""Execute a tool and return the result as a string."""
try:
if tool_name == "read_file":
return tool_read_file(tool_input["path"])
elif tool_name == "write_file":
return tool_write_file(tool_input["path"], tool_input["content"])
elif tool_name == "list_directory":
return tool_list_directory(tool_input.get("path", "."))
elif tool_name == "run_command":
return tool_run_command(tool_input["command"])
else:
return f"Error: unknown tool '{tool_name}'"
except Exception as e:
return f"Error executing {tool_name}: {str(e)}"
Tool 1: read_file
def tool_read_file(path: str) -> str:
"""Read a file from the workspace."""
full_path = safe_path(path)
if not os.path.exists(full_path):
return f"Error: file '{path}' not found"
if not os.path.isfile(full_path):
return f"Error: '{path}' is not a file"
with open(full_path, "r") as f:
content = f.read()
if len(content) > 10000:
content = content[:10000] + f"\n... (truncated, {len(content)} chars total)"
return content
WHAT DOES IT DO?
→ Resolves the path within the workspace (security)
→ Verifies the file exists and is a file
→ Reads the content
→ Truncates if it's very long (protects the context window)
CONNECTION WITH MODULE 02:
→ Truncating to 10,000 chars prevents filling the context window
→ A huge file would consume too many tokens
→ In real agents, this limit is configurable
Tool 2: write_file
def tool_write_file(path: str, content: str) -> str:
"""Write content to a file in the workspace."""
full_path = safe_path(path)
directory = os.path.dirname(full_path)
if directory:
os.makedirs(directory, exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
return f"Successfully wrote {len(content)} characters to '{path}'"
WHAT DOES IT DO?
→ Resolves the path within the workspace
→ Creates intermediate directories if they don't exist
→ Writes the content to the file
→ Returns a confirmation with the number of characters
SECURITY NOTE:
→ safe_path prevents writing outside the workspace
→ In a real agent, you'd ask for confirmation before writing
→ Here we simplify it for the educational project
Tool 3: list_directory
def tool_list_directory(path: str = ".") -> str:
"""List contents of a directory in the workspace."""
full_path = safe_path(path)
if not os.path.exists(full_path):
return f"Error: directory '{path}' not found"
if not os.path.isdir(full_path):
return f"Error: '{path}' is not a directory"
entries = []
for entry in sorted(os.listdir(full_path)):
entry_path = os.path.join(full_path, entry)
if os.path.isdir(entry_path):
entries.append(f" {entry}/")
else:
size = os.path.getsize(entry_path)
entries.append(f" {entry} ({size} bytes)")
return f"Contents of '{path}':\n" + "\n".join(entries)
WHAT DOES IT DO?
→ Lists files and folders with basic information
→ Distinguishes directories (with /) from files (with size)
→ Sorts alphabetically
CONNECTION WITH MODULE 04:
→ It's the equivalent of list_dir that real agents use
→ It gives the LLM a "map" of the directory
→ The first tool the agent usually uses (exploration)
Tool 4: run_command
ALLOWED_COMMANDS = ["python", "cat", "echo", "ls", "wc", "head", "tail", "grep", "find"]
def tool_run_command(command: str) -> str:
"""Run a shell command with safety restrictions."""
cmd_parts = command.split()
if not cmd_parts:
return "Error: empty command"
base_command = cmd_parts[0]
if base_command not in ALLOWED_COMMANDS:
return (f"Error: command '{base_command}' is not allowed. "
f"Allowed commands: {', '.join(ALLOWED_COMMANDS)}")
try:
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=30,
cwd=WORKSPACE
)
output = ""
if result.stdout:
output += f"STDOUT:\n{result.stdout}"
if result.stderr:
output += f"STDERR:\n{result.stderr}"
if result.returncode != 0:
output += f"\nReturn code: {result.returncode}"
if not output.strip():
output = "(no output)"
if len(output) > 5000:
output = output[:5000] + f"\n... (truncated)"
return output
except subprocess.TimeoutExpired:
return "Error: command timed out after 30 seconds"
WHAT DOES IT DO?
→ Verifies that the command is in the allowed list
→ Runs INSIDE the workspace (cwd=WORKSPACE)
→ Captures stdout and stderr
→ 30-second timeout (prevents infinite loops)
→ Truncates long output
SAFETY:
→ ALLOWED_COMMANDS: only certain commands allowed
→ You can't run rm, sudo, curl, etc.
→ cwd=WORKSPACE: the command runs in the sandbox
→ timeout=30: prevents processes that hang
CONNECTION WITH MODULE 04:
→ This is the permission system in miniature
→ Real agents have similar lists
→ The principle of least privilege applied
Prepare the Test Workspace
Create some files so the agent has something to work with:
mkdir -p test_workspace
cat > test_workspace/hello.py << 'EOF'
def greet(name):
return f"Hello, {name}!"
if __name__ == "__main__":
print(greet("World"))
EOF
cat > test_workspace/calculator.py << 'EOF'
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
def divide(a, b):
if b == 0:
return "Error: division by zero"
return a / b
EOF
cat > test_workspace/README.md << 'EOF'
# Test Project
This is a simple test project for the mini coding agent.
## Files
- hello.py: A simple greeting function
- calculator.py: Basic math operations
EOF
Test the Complete Agent
Make sure ensure_workspace() is called at the start and run:
python mini_agent.py
Test task 1: Exploration
Describe the task: What files are in the workspace and what does each one do?
Expected result: the agent lists the directory, reads each file, and describes what each one does.
Test task 2: Modification
Describe the task: Add a "power(a, b)" function to calculator.py
that computes a raised to the power of b.
Expected result: it reads calculator.py, adds the function, and possibly verifies by running the file.
Test task 3: Creation
Describe the task: Create a test_calculator.py file with tests
for all the functions in calculator.py.
Expected result: it reads calculator.py to see the functions, creates the test file, and possibly runs them.
What to Observe During the Tests
FOR EACH TASK, DOCUMENT:
1. ITERATIONS
→ How many iterations did it take?
→ Was it efficient or did it go in circles?
2. TOOL SELECTION
→ Which tools did it choose and in what order?
→ Was the sequence logical?
→ Did it ever choose an incorrect tool?
3. REASONING
→ Was the "thinking" (text) useful?
→ Did it explain why it chose each tool?
→ Did it ever reason incorrectly?
4. RESULT
→ Did it complete the task correctly?
→ Were there errors it had to fix?
→ Is the generated code correct?
5. TOKENS
→ How many tokens did it consume in total?
→ Did the history grow quickly?
THESE OBSERVATIONS are the input for capsule 05.
Common Problems and Solutions
PROBLEM: "The LLM requests a tool that doesn't exist"
→ Check the tool definitions — is the description clear?
→ Does the tool's name match exactly?
PROBLEM: "The agent enters an infinite loop"
→ MAX_ITERATIONS should stop it
→ If not, check the stop condition (stop_reason)
PROBLEM: "The agent can't read a file"
→ Is the file INSIDE test_workspace/?
→ Is safe_path working correctly?
PROBLEM: "run_command rejects a command I need"
→ Add the command to ALLOWED_COMMANDS
→ But think about whether it's really safe
PROBLEM: "The output is too long and confusing"
→ Adjust the truncation in log_step (currently 500 chars)
→ Or redirect the output to a log file
The Complete Code: mini_agent.py
Below is the mini_agent.py file assembled with all the pieces from capsules 03 and 04. Copy it complete and replace all the content of your file:
import os
import json
import subprocess
from datetime import datetime
from dotenv import load_dotenv
from anthropic import Anthropic
load_dotenv()
# --- Configuration ---
client = Anthropic()
MODEL = "claude-sonnet-4-20250514"
MAX_ITERATIONS = 20
# Sandbox directory where the agent can operate
WORKSPACE = os.path.abspath("./test_workspace")
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"]
}
}
]
# --- Safety ---
def ensure_workspace():
"""Create the workspace directory if it doesn't exist."""
os.makedirs(WORKSPACE, exist_ok=True)
def safe_path(path: str) -> str:
"""Resolve a path and ensure it stays within the workspace (sandboxing)."""
resolved = os.path.abspath(os.path.join(WORKSPACE, path))
if not resolved.startswith(WORKSPACE):
raise ValueError(f"Access denied: path '{path}' is outside the workspace")
return resolved
# --- Tool implementations ---
def tool_read_file(path: str) -> str:
"""Read a file from the workspace."""
full_path = safe_path(path)
if not os.path.exists(full_path):
return f"Error: file '{path}' not found"
if not os.path.isfile(full_path):
return f"Error: '{path}' is not a file"
with open(full_path, "r") as f:
content = f.read()
# Truncate large files to protect the context window
if len(content) > 10000:
content = content[:10000] + f"\n... (truncated, {len(content)} chars total)"
return content
def tool_write_file(path: str, content: str) -> str:
"""Write content to a file in the workspace."""
full_path = safe_path(path)
directory = os.path.dirname(full_path)
if directory:
os.makedirs(directory, exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
return f"Successfully wrote {len(content)} characters to '{path}'"
def tool_list_directory(path: str = ".") -> str:
"""List contents of a directory in the workspace."""
full_path = safe_path(path)
if not os.path.exists(full_path):
return f"Error: directory '{path}' not found"
if not os.path.isdir(full_path):
return f"Error: '{path}' is not a directory"
entries = []
for entry in sorted(os.listdir(full_path)):
entry_path = os.path.join(full_path, entry)
if os.path.isdir(entry_path):
entries.append(f" {entry}/")
else:
size = os.path.getsize(entry_path)
entries.append(f" {entry} ({size} bytes)")
return f"Contents of '{path}':\n" + "\n".join(entries)
ALLOWED_COMMANDS = ["python", "cat", "echo", "ls", "wc", "head", "tail", "grep", "find"]
def tool_run_command(command: str) -> str:
"""Run a shell command with safety restrictions."""
cmd_parts = command.split()
if not cmd_parts:
return "Error: empty command"
base_command = cmd_parts[0]
if base_command not in ALLOWED_COMMANDS:
return (f"Error: command '{base_command}' is not allowed. "
f"Allowed commands: {', '.join(ALLOWED_COMMANDS)}")
try:
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=30,
cwd=WORKSPACE
)
output = ""
if result.stdout:
output += f"STDOUT:\n{result.stdout}"
if result.stderr:
output += f"STDERR:\n{result.stderr}"
if result.returncode != 0:
output += f"\nReturn code: {result.returncode}"
if not output.strip():
output = "(no output)"
# Truncate long output to protect the context window
if len(output) > 5000:
output = output[:5000] + f"\n... (truncated)"
return output
except subprocess.TimeoutExpired:
return "Error: command timed out after 30 seconds"
# --- Tool dispatcher ---
def execute_tool(tool_name: str, tool_input: dict) -> str:
"""Route a tool call to the correct implementation."""
try:
if tool_name == "read_file":
return tool_read_file(tool_input["path"])
elif tool_name == "write_file":
return tool_write_file(tool_input["path"], tool_input["content"])
elif tool_name == "list_directory":
return tool_list_directory(tool_input.get("path", "."))
elif tool_name == "run_command":
return tool_run_command(tool_input["command"])
else:
return f"Error: unknown tool '{tool_name}'"
except Exception as e:
return f"Error executing {tool_name}: {str(e)}"
# --- Logging ---
def log_step(iteration: int, step_type: str, content: str):
"""Log each step of the agentic loop for observability."""
timestamp = datetime.now().strftime("%H:%M:%S")
print(f"\n{'='*60}")
print(f"[{timestamp}] Iteration {iteration} | {step_type}")
print(f"{'='*60}")
print(content[:500])
if len(content) > 500:
print(f"... ({len(content)} characters total)")
# --- Agentic loop ---
def run_agent(user_task: str):
"""The core agentic loop: send task → LLM responds → execute tools → repeat."""
print(f"\n{'#'*60}")
print(f"TASK: {user_task}")
print(f"{'#'*60}")
# The history starts with the user's task
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...")
# Step 1: Send to the LLM with the available tools
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}")
# Step 2: If stop_reason is "end_turn", the LLM finished
if response.stop_reason == "end_turn":
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
log_step(iteration, "DONE", final_text)
return final_text
# Step 3: Process the LLM's 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)}")
# Execute the tool locally
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
})
# Step 4: Add the response + results to the history and go back to step 1
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__":
ensure_workspace()
task = input("\nDescribe the task for the agent: ")
result = run_agent(task)
print(f"\n{'#'*60}")
print("FINAL RESULT:")
print(result)
RUN:
python mini_agent.py
TOTAL: ~250 lines
That's ALL you need for a functional coding agent.
IF YOU USE OPENAI:
Replace the imports, client, and MODEL section.
Adapt run_agent according to the capsule 03 section.
The structure of the loop is identical.
Summary
WHAT YOU IMPLEMENTED:
→ 4 functional tools: read_file, write_file, list_directory, run_command
→ Safety: workspace sandboxing + allowed commands + timeouts + truncation
→ An agent that can explore, read, write, and run code
WHAT YOU LEARNED:
→ Tools are simple Python functions
→ The LLM only REQUESTS, you EXECUTE
→ Safety is the developer's responsibility (you)
→ ~265 lines is enough for a functional agent
CONNECTIONS:
→ Module 02: truncation protects the context window
→ Module 03: real tool calling = JSON from the LLM → Python function
→ Module 04: sandboxing, permissions, file + shell tools
→ Module 05: observing decisions to calibrate confidence
Next capsule: 05 - Analysis and documentation — run the agent on real tasks and document your insights.