Minimal Agent: Complete Specification
Everything you need to build a working coding agent, in one document.
Evidence source: Amp Code v0.0.1769212917 (agent loop, tools, context management)
What You'll Build
A coding agent that can:
- Take natural language tasks ("Add a login button")
- Explore codebases to find relevant files
- Make precise edits without breaking things
- Run commands and interpret results
- Verify its work before declaring done
- Remember project-specific rules
This is the minimal viable agent. Production agents add streaming, course correction, subagents, and multi-model orchestration (covered in other docs).
Architecture Overview
┌──────────────────────────────────────────────────────────────┐
│ MINIMAL AGENT │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ AGENT LOOP │ │
│ │ │ │
│ │ User Task → Think → Act → Observe → Verify → Done │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────┼────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ TOOLS │ │ CONTEXT │ │ MEMORY │ │
│ │ │ │ │ │ │ │
│ │ • Read │ │ • Messages │ │ • AGENTS.md│ │
│ │ • edit_file │ │ • Tokens │ │ • Rules │ │
│ │ • create_file │ │ • Budget │ │ │ │
│ │ • Bash │ │ │ │ │ │
│ │ • glob │ │ │ │ │ │
│ │ • Grep │ │ │ │ │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
Complete Implementation
1. Main Entry Point
#!/usr/bin/env python3
"""Minimal coding agent - complete implementation."""
import os
import json
import subprocess
import glob as glob_module
import re
from dataclasses import dataclass
from typing import Optional
from anthropic import Anthropic
# Configuration
MODEL = "claude-sonnet-4-20250514"
MAX_TOKENS = 8192
MAX_TURNS = 50
# Initialize client
client = Anthropic()
def main():
"""Main entry point."""
print("Coding Agent Ready")
print("Type your task, or 'quit' to exit.\n")
while True:
task = input("> ").strip()
if task.lower() in ['quit', 'exit', 'q']:
break
if not task:
continue
try:
result = run_agent(task)
print(f"\n{result}\n")
except KeyboardInterrupt:
print("\n[Cancelled]")
except Exception as e:
print(f"\nError: {e}\n")
if __name__ == "__main__":
main()
2. System Prompt
The system prompt teaches the LLM how to be a coding agent.
SYSTEM_PROMPT = """You are a coding agent. You help users with software engineering tasks by reading, writing, and modifying code.
## Your Capabilities
You have access to these tools:
- Read: Read file contents (always use before edit_file!)
- edit_file: Modify existing files (requires exact match of text to replace)
- create_file: Create new files
- Bash: Execute shell commands
- glob: Find files by pattern
- Grep: Search file contents
## How to Work
1. EXPLORE: Use glob and Grep to understand the codebase
2. READ: Use Read to examine files before modifying
3. PLAN: Think through what changes are needed
4. EDIT: Make precise changes using edit_file (always Read first!)
5. VERIFY: Run tests or builds to confirm changes work
6. ITERATE: If something fails, diagnose and fix
## Critical Rules
- ALWAYS Read a file before editing it - never guess at contents
- NEVER say "Done" until you've verified the changes work
- If a test fails, diagnose the error and fix it
- Keep changes minimal - only change what's needed
- Don't add unnecessary features, refactoring, or "improvements"
## edit_file Tool Usage
The edit_file tool requires EXACT text matching:
1. First Read the file to see exact content (including whitespace!)
2. Copy the exact text you want to replace
3. Provide both old_str and new_str
4. If old_str appears multiple times, include more context to make it unique
{project_memory}
"""
def build_system_prompt(project_root: str) -> str:
"""Build system prompt with project memory."""
memory = load_project_memory(project_root)
memory_section = f"\n## Project-Specific Instructions\n\n{memory}" if memory else ""
return SYSTEM_PROMPT.format(project_memory=memory_section)
3. Tool Definitions
Six essential tools for a coding agent.
TOOLS = [
{
"name": "Read",
"description": "Read a file or list a directory. Returns line-numbered content. Optional read_range [start,end] defaults to [1, 500] with a hard cap of 2,000 lines.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absolute path to file or directory"},
"read_range": {
"type": "array",
"items": {"type": "number"},
"minItems": 2,
"maxItems": 2,
"description": "Line range [start, end], 1-indexed. Default: [1, 500]"
}
},
"required": ["path"]
}
},
{
"name": "edit_file",
"description": "Edit a file by replacing exact text. The old_str must match EXACTLY (including whitespace). Use Read first to see exact content.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absolute path to file"},
"old_str": {"type": "string", "description": "Exact text to replace (must be unique in file)"},
"new_str": {"type": "string", "description": "Replacement text"},
"replace_all": {"type": "boolean", "description": "Replace all occurrences", "default": False}
},
"required": ["path", "old_str", "new_str"]
}
},
{
"name": "create_file",
"description": "Create a new file with given content. Overwrites if file exists.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absolute path for new file"},
"content": {"type": "string", "description": "File content"}
},
"required": ["path", "content"]
}
},
{
"name": "Bash",
"description": "Execute a shell command. Use for running tests, builds, git commands, etc.",
"input_schema": {
"type": "object",
"properties": {
"cmd": {"type": "string", "description": "Command to execute"},
"cwd": {"type": "string", "description": "Working directory (absolute path)"}
},
"required": ["cmd"]
}
},
{
"name": "glob",
"description": "Find files matching a glob pattern.",
"input_schema": {
"type": "object",
"properties": {
"filePattern": {"type": "string", "description": "Glob pattern (e.g., '**/*.ts', 'src/**/*.py')"},
"limit": {"type": "number", "description": "Maximum results to return"},
"offset": {"type": "number", "description": "Results to skip (pagination)"}
},
"required": ["filePattern"],
"additionalProperties": false
}
},
{
"name": "Grep",
"description": "Search file contents for a pattern. Returns matching lines with file paths.",
"input_schema": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Search pattern (regex supported)"},
"path": {"type": "string", "description": "File or directory path. Cannot use with glob."},
"glob": {"type": "string", "description": "Glob pattern for files. Cannot use with path."},
"caseSensitive": {"type": "boolean", "description": "Case-sensitive search (default: false)"},
"literal": {"type": "boolean", "description": "Treat pattern as literal string, not regex"}
},
"required": ["pattern"]
}
}
]
4. Tool Executors
Note: This minimal implementation omits several Amp guardrails (secret-file blocking, binary detection, image handling) for brevity. See 05-core-tools.md for full behavior.
def execute_tool(name: str, input: dict) -> str:
"""Execute a tool and return result."""
try:
if name == "Read":
return execute_read(input)
elif name == "edit_file":
return execute_edit_file(input)
elif name == "create_file":
return execute_create_file(input)
elif name == "Bash":
return execute_bash(input)
elif name == "glob":
return execute_glob(input)
elif name == "Grep":
return execute_grep(input)
else:
return f"Error: Unknown tool '{name}'"
except Exception as e:
return f"Error: {type(e).__name__}: {e}"
def execute_read(input: dict) -> str:
"""Read file with line numbers."""
path = os.path.abspath(os.path.expanduser(input["path"]))
read_range = input.get("read_range") or [1, 500]
max_lines = 2000
try:
start, end = [int(x) for x in read_range]
except Exception:
return "Error: read_range must be [start, end] integers."
if start < 1:
start = 1
if end < start:
end = start
if end - start + 1 > max_lines:
end = start + max_lines - 1
if not os.path.exists(path):
return f"Error: File not found: {path}"
if not os.path.isfile(path):
# Directory listing
if os.path.isdir(path):
entries = []
for entry in sorted(os.listdir(path)):
full_path = os.path.join(path, entry)
suffix = "/" if os.path.isdir(full_path) else ""
entries.append(f"{entry}{suffix}")
return "\n".join(entries)
return f"Error: Not a file: {path}"
with open(path, 'r', encoding='utf-8', errors='replace') as f:
lines = f.readlines()
# Format with line numbers
formatted = []
end = min(end, len(lines))
for i in range(start, end + 1):
formatted.append(f"{i}: {lines[i - 1].rstrip()}")
return '\n'.join(formatted)
def execute_edit_file(input: dict) -> str:
"""Edit file by replacing exact text."""
path = input["path"]
old_string = input["old_str"]
new_string = input["new_str"]
replace_all = input.get("replace_all", False)
if not os.path.exists(path):
return f"Error: File not found: {path}"
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
# Check for exact match
if old_string not in content:
return f"Error: old_str not found in {path}. Use Read to see exact content including whitespace."
# Check for uniqueness
count = content.count(old_string)
if count > 1 and not replace_all:
return f"Error: old_str found {count} times in {path}. Include more surrounding context to make it unique."
# Apply edit
new_content = content.replace(old_string, new_string) if replace_all else content.replace(old_string, new_string, 1)
with open(path, 'w', encoding='utf-8') as f:
f.write(new_content)
return f"Successfully edited {path}"
def execute_create_file(input: dict) -> str:
"""Create or overwrite file."""
path = input["path"]
content = input["content"]
if content and not content.endswith("\n"):
content += "\n"
# Create directories if needed
directory = os.path.dirname(path)
if directory and not os.path.exists(directory):
os.makedirs(directory)
existed = os.path.exists(path)
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
return f"{'Overwrote' if existed else 'Created'}: {path}"
def execute_bash(input: dict) -> str:
"""Execute shell command."""
command = input["cmd"]
cwd = input.get("cwd", os.getcwd())
try:
result = subprocess.run(
command, shell=True, capture_output=True, text=True,
cwd=cwd
)
output = []
if result.stdout:
output.append(f"stdout:\n{result.stdout}")
if result.stderr:
output.append(f"stderr:\n{result.stderr}")
output.append(f"Exit code: {result.returncode}")
return '\n'.join(output)
except Exception as e:
return f"Error: {type(e).__name__}: {e}"
def execute_glob(input: dict) -> str:
"""Find files matching pattern."""
pattern = input["filePattern"]
limit = input.get("limit", 1000)
offset = input.get("offset", 0)
matches: list[str] = []
try:
result = subprocess.run(
["rg", "--files", "--glob", pattern],
capture_output=True,
text=True,
cwd=os.getcwd()
)
matches = [line for line in result.stdout.splitlines() if line]
except Exception:
matches = glob_module.glob(pattern, recursive=True)
total = len(matches)
sliced = matches[offset:offset + limit]
remaining = max(0, total - offset - limit)
files = [os.path.abspath(path) for path in sliced]
return json.dumps({"files": files, "remaining": remaining})
def execute_grep(input: dict) -> str:
"""Search files for pattern."""
pattern = input["pattern"]
path = input.get("path")
glob_pattern = input.get("glob")
case_sensitive = input.get("caseSensitive", False)
literal = input.get("literal", False)
flags = 0 if case_sensitive else re.IGNORECASE
try:
regex = re.compile(re.escape(pattern) if literal else pattern, flags)
except re.error as e:
return json.dumps([f"Error: Invalid regex: {e}"])
if glob_pattern and path:
return json.dumps(["Error: Provide either path or glob, not both."])
search_root = path or "."
file_list: list[str]
if glob_pattern:
file_list = glob_module.glob(glob_pattern, recursive=True)
else:
file_list = []
for root, _, files in os.walk(search_root):
if any(skip in root for skip in ['.git', 'node_modules', '__pycache__', '.venv']):
continue
for file in files:
file_list.append(os.path.join(root, file))
matches: list[str] = []
for file_path in file_list:
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
for line_num, line in enumerate(f, 1):
if regex.search(line):
snippet = line.rstrip()
if len(snippet) > 200:
snippet = snippet[:200] + "..."
matches.append(f"{file_path}:{line_num}: {snippet}")
if len(matches) >= 100:
break
except (IOError, OSError):
continue
if len(matches) >= 100:
break
if not matches:
return json.dumps([
"No results found.",
"If you meant to search for a literal string, run Grep again with literal:true."
])
return json.dumps(matches)
5. Memory Loading
Load project-specific instructions from AGENTS.md.
MEMORY_FILENAMES = [
"AGENTS.md", "Agents.md", "agents.md",
"AGENT.md", "Agent.md", "agent.md",
"CLAUDE.md"
]
def load_project_memory(project_root: str) -> str:
"""Load project memory file."""
for name in MEMORY_FILENAMES:
path = os.path.join(project_root, name)
if os.path.exists(path):
with open(path, 'r') as f:
return f.read()
return ""
6. The Agent Loop
The core loop that makes the agent work.
def run_agent(task: str) -> str:
"""Run the agent loop until task is complete."""
project_root = os.getcwd()
system_prompt = build_system_prompt(project_root)
messages = [{"role": "user", "content": task}]
turn = 0
while turn < MAX_TURNS:
turn += 1
# Call LLM
response = client.messages.create(
model=MODEL,
max_tokens=MAX_TOKENS,
system=system_prompt,
tools=TOOLS,
messages=messages
)
# Process response
assistant_content = []
tool_results = []
for block in response.content:
if block.type == "text":
print(block.text) # Output to user
assistant_content.append({"type": "text", "text": block.text})
elif block.type == "tool_use":
print(f"\n[{block.name}]")
# Execute tool
result = execute_tool(block.name, block.input)
preview = result[:300] + '...' if len(result) > 300 else result
print(f" → {preview}\n")
assistant_content.append({
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input
})
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": truncate(result, 50000)
})
# Add assistant message
messages.append({"role": "assistant", "content": assistant_content})
# If there were tool calls, add results and continue
if tool_results:
messages.append({"role": "user", "content": tool_results})
continue
# No tool calls = agent is done
if response.stop_reason == "end_turn":
final_text = ""
for block in response.content:
if block.type == "text":
final_text += block.text
return final_text
return "Error: Max turns exceeded"
def truncate(text: str, max_chars: int) -> str:
"""Truncate text to max characters."""
if len(text) <= max_chars:
return text
return text[:max_chars] + f"\n[Truncated: {len(text)} total chars]"
Running the Agent
Setup
# Install dependencies
pip install anthropic
# Set API key
export ANTHROPIC_API_KEY=your_key_here
# Create project memory (optional but recommended)
cat > AGENTS.md << 'EOF'
# Project Memory
## Commands
- `npm test` - Run tests
- `npm run build` - Build project
## Conventions
- Use TypeScript strict mode
- Prefer async/await over callbacks
EOF
# Run agent
python agent.py
Example Session
$ python agent.py
Coding Agent Ready
Type your task, or 'quit' to exit.
> Add input validation to the login function
I'll start by finding the login function in the codebase.
[glob]
→ {"files":["/project/src/auth/login.ts","/project/src/auth/login.test.ts"],"remaining":0}
Let me read the login file to understand the current implementation.
[Read]
→ 1: export function login(username: string, password: string) {
2: // ... existing implementation
I can see the login function takes username and password but has no validation.
Let me add input validation.
[edit_file]
→ Successfully edited /project/src/auth/login.ts
Now let me verify by running the tests.
[Bash]
→ stdout: PASS src/auth/login.test.ts
Exit code: 0
All tests pass. I've added input validation to the login function that:
1. Requires username to be at least 3 characters
2. Requires password to be at least 8 characters
>
From Minimal to Production
This minimal agent works. Production agents add:
| Enhancement | What It Does | See |
|---|---|---|
| Streaming | Real-time output as agent thinks | 07-streaming.md |
| Context Management | Handle long conversations | 06-context-window.md |
| Course Correction | Catch false completions | 08-verification.md |
| Permissions | Ask before dangerous actions | 04-tool-system.md |
| Subagents | Delegate to specialized agents | ../enhancements/11-subagents.md |
| Multi-model | Use right model for each task | ../enhancements/13-multi-model.md |
Key Insights from Amp
Building on what Amp teaches us:
- Read before edit_file - Never guess file contents
- Verify before Done - Run tests, check results
- Memory files - Project-specific rules in AGENTS.md
- Minimal changes - Don't over-engineer
- Tool design - Clear descriptions, required fields first
- Truncation - Cap large outputs to preserve context
What You Now Have
A coding agent that can:
- Understand natural language tasks
- Navigate codebases with glob/Grep
- Read and understand code
- Make precise edits
- Run shell commands
- Respect project rules from AGENTS.md
What's Next
For production deployment, implement:
- Streaming (
07-streaming.md) - Better UX - Course correction (
08-verification.md) - Catch mistakes - Context compaction (
06-context-window.md) - Long sessions
This is the complete minimal agent specification. Build this, and you have a working coding agent.