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:

  1. Read before edit_file - Never guess file contents
  2. Verify before Done - Run tests, check results
  3. Memory files - Project-specific rules in AGENTS.md
  4. Minimal changes - Don't over-engineer
  5. Tool design - Clear descriptions, required fields first
  6. 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:

  1. Streaming (07-streaming.md) - Better UX
  2. Course correction (08-verification.md) - Catch mistakes
  3. Context compaction (06-context-window.md) - Long sessions

This is the complete minimal agent specification. Build this, and you have a working coding agent.