Skip to content

Blog

Runbook: Single-File AI Agent

Build a complete AI agent in 218 lines of Python. No frameworks, no complexity. Just clean code that shows exactly how agents work.

This tutorial demonstrates a working agent that can read, write, and edit files through natural conversation with Claude. Everything runs from a single Python file using uv's inline dependencies: no virtual environments, no pip installs, no dependency conflicts.

You'll understand how AI agents parse responses, execute tools, and maintain conversation context. The mechanics laid bare, without abstractions.

Single-File AI Agent Demo

Credits

This code is based on the tutorial by Thorsten Ball in "How to Build an Agent" from ampcode.com. Thank you, Thorsten. Brilliant guide.

Features

  • Single-file execution.
  • No virtual environment or manual dependency installation required (except for uv).
  • The AI agent can:
    • Read file contents.
    • List directory contents.
    • Edit existing files or create new ones.
  • Interactive chat interface.
  • Error handling and feedback.
  • Logging of agent tool usage.

Prerequisites

  • uv package manager
  • Anthropic API key

Setup

Quick Start (if you have uv installed):

export ANTHROPIC_API_KEY="your-api-key-here"

Need to install uv? See the full setup instructions in the README.

No API key? Get one from Anthropic's Console.

Step 1: Create the Basic Script Structure

Current State:

  • Empty directory.

Changes:

  • Create main.py with script dependencies and imports.

Expected Behaviour:

  • A Python script that can be run but doesn't do anything yet.

Code Block:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "anthropic", # type: ignore
#     "pydantic",
# ]
# ///


import os
import sys
import argparse
from typing import List, Dict, Any
from anthropic import Anthropic
from pydantic import BaseModel


if __name__ == "__main__":
    print("AI Agent starting...")

How To Test:

export ANTHROPIC_API_KEY="your-api-key-here"
uv run --python python3.12 main.py
# Should print: AI Agent starting...

Step 2: Create the AI Agent Class

Current State:

  • Basic script with imports.

Changes:

  • Add Tool model and AIAgent class initialization.

Expected Behaviour:

  • Can create an AIAgent instance.

Code Block:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "anthropic", # type: ignore
#     "pydantic",
# ]
# ///

import os
import sys
import argparse
import logging                                      # NEW
from typing import List, Dict, Any
from anthropic import Anthropic
from pydantic import BaseModel

# Set up logging                                      # NEW
logging.basicConfig(                                  # NEW
    level=logging.INFO,                               # NEW
    format='%(asctime)s - %(message)s',               # NEW
    handlers=[                                        # NEW
        logging.FileHandler('agent.log')              # NEW
    ]                                                 # NEW
)                                                     # NEW
                                                      # NEW
# Suppress verbose HTTP logs                          # NEW
logging.getLogger('httpcore').setLevel(logging.WARNING)  # NEW
logging.getLogger('httpx').setLevel(logging.WARNING)     # NEW


class Tool(BaseModel):                                # NEW
    name: str                                         # NEW
    description: str                                  # NEW
    input_schema: Dict[str, Any]                      # NEW
                                                      # NEW
                                                      # NEW
class AIAgent:                                        # NEW
    def __init__(self, api_key: str):                 # NEW
        self.client = Anthropic(api_key=api_key)      # NEW
        self.messages: List[Dict[str, Any]] = []      # NEW
        self.tools: List[Tool] = []                   # NEW
        print("Agent initialized")                    # NEW


if __name__ == "__main__":
    api_key = os.environ.get("ANTHROPIC_API_KEY")     # NEW
    if not api_key:                                   # NEW
        print("Error: ANTHROPIC_API_KEY not set")     # NEW
        sys.exit(1)                                   # NEW
    agent = AIAgent(api_key)                          # NEW

How To Test:

export ANTHROPIC_API_KEY="your-api-key-here"
uv run --python python3.12 main.py
# Should print: Agent initialized

Step 3: Define the Tools

Current State:

  • AIAgent class without tools.

Changes:

  • Add tool definitions in _setup_tools method.

Expected Behaviour:

  • Agent has three tools defined:
  • read_file
  • list_files
  • edit_file

Code Block:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "anthropic", # type: ignore
#     "pydantic",
# ]
# ///

import os
import sys
import argparse
import logging
from typing import List, Dict, Any
from anthropic import Anthropic
from pydantic import BaseModel

# Set up logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(message)s',
    handlers=[
        logging.FileHandler('agent.log')
    ]
)

# Suppress verbose HTTP logs
logging.getLogger('httpcore').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)


class Tool(BaseModel):
    name: str
    description: str
    input_schema: Dict[str, Any]


class AIAgent:
    def __init__(self, api_key: str):
        self.client = Anthropic(api_key=api_key)
        self.messages: List[Dict[str, Any]] = []
        self.tools: List[Tool] = []
        self._setup_tools()                           # NEW
        print(f"Agent initialized with {len(self.tools)} tools")  # MODIFIED


    def _setup_tools(self):                           # NEW
        self.tools = [                                # NEW
            Tool(                                     # NEW
                name="read_file",                     # NEW
                description="Read the contents of a file at the specified path",  # NEW
                input_schema={                        # NEW
                    "type": "object",                 # NEW
                    "properties": {                   # NEW
                        "path": {                     # NEW
                            "type": "string",         # NEW
                            "description": "The path to the file to read"  # NEW
                        }                             # NEW
                    },                                # NEW
                    "required": ["path"]              # NEW
                }                                     # NEW
            ),                                        # NEW
            Tool(                                     # NEW
                name="list_files",                    # NEW
                description="List all files and directories in the specified path",  # NEW
                input_schema={                        # NEW
                    "type": "object",                 # NEW
                    "properties": {                   # NEW
                        "path": {                     # NEW
                            "type": "string",         # NEW
                            "description": "The directory path to list (defaults to current directory)"  # NEW
                        }                             # NEW
                    },                                # NEW
                    "required": []                    # NEW
                }                                     # NEW
            ),                                        # NEW
            Tool(                                     # NEW
                name="edit_file",                     # NEW
                description="Edit a file by replacing old_text with new_text. Creates the file if it doesn't exist.",  # NEW
                input_schema={                        # NEW
                    "type": "object",                 # NEW
                    "properties": {                   # NEW
                        "path": {                     # NEW
                            "type": "string",         # NEW
                            "description": "The path to the file to edit"  # NEW
                        },                            # NEW
                        "old_text": {                 # NEW
                            "type": "string",         # NEW
                            "description": "The text to search for and replace (leave empty to create new file)"  # NEW
                        },                            # NEW
                        "new_text": {                 # NEW
                            "type": "string",         # NEW
                            "description": "The text to replace old_text with"  # NEW
                        }                             # NEW
                    },                                # NEW
                    "required": ["path", "new_text"]  # NEW
                }                                     # NEW
            )                                         # NEW
        ]                                             # NEW


if __name__ == "__main__":
    api_key = os.environ.get("ANTHROPIC_API_KEY")
    if not api_key:
        print("Error: ANTHROPIC_API_KEY not set")
        sys.exit(1)
    agent = AIAgent(api_key)

How To Test:

uv run --python python3.12 main.py
# Should print: Agent initialized with 3 tools

Step 4: Implement Tool Execution

Current State:

  • Tools defined but not executable.

Changes:

  • Add tool execution methods.

Expected Behaviour:

  • Tools can read files, list directories, and edit files.

Code Block:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "anthropic", # type: ignore
#     "pydantic",
# ]
# ///

import os
import sys
import argparse
import logging
from typing import List, Dict, Any
from anthropic import Anthropic
from pydantic import BaseModel

# Set up logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(message)s',
    handlers=[
        logging.FileHandler('agent.log')
    ]
)

# Suppress verbose HTTP logs
logging.getLogger('httpcore').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)


class Tool(BaseModel):
    name: str
    description: str
    input_schema: Dict[str, Any]


class AIAgent:
    def __init__(self, api_key: str):
        self.client = Anthropic(api_key=api_key)
        self.messages: List[Dict[str, Any]] = []
        self.tools: List[Tool] = []
        self._setup_tools()
        print(f"Agent initialized with {len(self.tools)} tools")


    def _setup_tools(self):
        self.tools = [
            Tool(
                name="read_file",
                description="Read the contents of a file at the specified path",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The path to the file to read"
                        }
                    },
                    "required": ["path"]
                }
            ),
            Tool(
                name="list_files",
                description="List all files and directories in the specified path",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The directory path to list (defaults to current directory)"
                        }
                    },
                    "required": []
                }
            ),
            Tool(
                name="edit_file",
                description="Edit a file by replacing old_text with new_text. Creates the file if it doesn't exist.",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The path to the file to edit"
                        },
                        "old_text": {
                            "type": "string",
                            "description": "The text to search for and replace (leave empty to create new file)"
                        },
                        "new_text": {
                            "type": "string",
                            "description": "The text to replace old_text with"
                        }
                    },
                    "required": ["path", "new_text"]
                }
            )
        ]


    def _execute_tool(self, tool_name: str, tool_input: Dict[str, Any]) -> str:  # NEW
        try:                                                                     # NEW
            if tool_name == "read_file":                                         # NEW
                return self._read_file(tool_input["path"])                       # NEW
            elif tool_name == "list_files":                                      # NEW
                return self._list_files(tool_input.get("path", "."))             # NEW
            elif tool_name == "edit_file":                                       # NEW
                return self._edit_file(                                          # NEW
                    tool_input["path"],                                          # NEW
                    tool_input.get("old_text", ""),                              # NEW
                    tool_input["new_text"]                                       # NEW
                )                                                                # NEW
            else:                                                                # NEW
                return f"Unknown tool: {tool_name}"                              # NEW
        except Exception as e:                                                   # NEW
            return f"Error executing {tool_name}: {str(e)}"                      # NEW


    def _read_file(self, path: str) -> str:                                      # NEW
        try:                                                                     # NEW
            with open(path, 'r', encoding='utf-8') as f:                         # NEW
                content = f.read()                                               # NEW
            return f"File contents of {path}:\n{content}"                        # NEW
        except FileNotFoundError:                                                # NEW
            return f"File not found: {path}"                                     # NEW
        except Exception as e:                                                   # NEW
            return f"Error reading file: {str(e)}"                               # NEW


    def _list_files(self, path: str) -> str:                                     # NEW
        try:                                                                     # NEW
            if not os.path.exists(path):                                         # NEW
                return f"Path not found: {path}"                                 # NEW

            items = []                                                           # NEW
            for item in sorted(os.listdir(path)):                                # NEW
                item_path = os.path.join(path, item)                             # NEW
                if os.path.isdir(item_path):                                     # NEW
                    items.append(f"[DIR]  {item}/")                              # NEW
                else:                                                            # NEW
                    items.append(f"[FILE] {item}")                               # NEW

            if not items:                                                        # NEW
                return f"Empty directory: {path}"                                # NEW

            return f"Contents of {path}:\n" + "\n".join(items)                   # NEW
        except Exception as e:                                                   # NEW
            return f"Error listing files: {str(e)}"                              # NEW


    def _edit_file(self, path: str, old_text: str, new_text: str) -> str:          # NEW
        try:                                                                       # NEW
            if os.path.exists(path) and old_text:                                  # NEW
                with open(path, 'r', encoding='utf-8') as f:                       # NEW
                    content = f.read()                                             # NEW

                if old_text not in content:                                        # NEW
                    return f"Text not found in file: {old_text}"                   # NEW

                content = content.replace(old_text, new_text)                      # NEW

                with open(path, 'w', encoding='utf-8') as f:                       # NEW
                    f.write(content)                                               # NEW

                return f"Successfully edited {path}"                               # NEW
            else:                                                                  # NEW
                # Only create directory if path contains subdirectories            # NEW
                dir_name = os.path.dirname(path)                                   # NEW
                if dir_name:                                                       # NEW
                    os.makedirs(dir_name, exist_ok=True)                           # NEW

                with open(path, 'w', encoding='utf-8') as f:                       # NEW
                    f.write(new_text)                                              # NEW

                return f"Successfully created {path}"                              # NEW
        except Exception as e:                                                     # NEW
            return f"Error editing file: {str(e)}"                                 # NEW


if __name__ == "__main__":
    api_key = os.environ.get("ANTHROPIC_API_KEY")
    if not api_key:
        print("Error: ANTHROPIC_API_KEY not set")
        sys.exit(1)
    agent = AIAgent(api_key)
    # Test the tools                                                               # NEW
    print(agent._list_files("."))                                                  # NEW

How To Test:

uv run --python python3.12 main.py
# Should print: Agent initialized with 3 tools
# Should print: Contents of .:
# [FILE] main.py
# ... (other files in directory)

Step 5: Add Chat Method with Claude Integration

Current State:

  • Tools work but no AI integration.

Changes:

  • Add chat method that connects to Claude and handles tool calls.

Expected Behaviour:

  • Can chat with Claude and it can use tools.

Code Block:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "anthropic", # type: ignore
#     "pydantic",
# ]
# ///

import os
import sys
import argparse
import logging
from typing import List, Dict, Any
from anthropic import Anthropic
from pydantic import BaseModel

# Set up logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(message)s',
    handlers=[
        logging.FileHandler('agent.log')
    ]
)

# Suppress verbose HTTP logs
logging.getLogger('httpcore').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)


class Tool(BaseModel):
    name: str
    description: str
    input_schema: Dict[str, Any]


class AIAgent:
    def __init__(self, api_key: str):
        self.client = Anthropic(api_key=api_key)
        self.messages: List[Dict[str, Any]] = []
        self.tools: List[Tool] = []
        self._setup_tools()
        # REMOVED: print(f"Agent initialized with {len(self.tools)} tools")

    def _setup_tools(self):
        self.tools = [
            Tool(
                name="read_file",
                description="Read the contents of a file at the specified path",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The path to the file to read"
                        }
                    },
                    "required": ["path"]
                }
            ),
            Tool(
                name="list_files",
                description="List all files and directories in the specified path",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The directory path to list (defaults to current directory)"
                        }
                    },
                    "required": []
                }
            ),
            Tool(
                name="edit_file",
                description="Edit a file by replacing old_text with new_text. Creates the file if it doesn't exist.",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The path to the file to edit"
                        },
                        "old_text": {
                            "type": "string",
                            "description": "The text to search for and replace (leave empty to create new file)"
                        },
                        "new_text": {
                            "type": "string",
                            "description": "The text to replace old_text with"
                        }
                    },
                    "required": ["path", "new_text"]
                }
            )
        ]

    def _execute_tool(self, tool_name: str, tool_input: Dict[str, Any]) -> str:
        try:
            if tool_name == "read_file":
                return self._read_file(tool_input["path"])
            elif tool_name == "list_files":
                return self._list_files(tool_input.get("path", "."))
            elif tool_name == "edit_file":
                return self._edit_file(
                    tool_input["path"],
                    tool_input.get("old_text", ""),
                    tool_input["new_text"]
                )
            else:
                return f"Unknown tool: {tool_name}"
        except Exception as e:
            return f"Error executing {tool_name}: {str(e)}"

    def _read_file(self, path: str) -> str:
        try:
            with open(path, 'r', encoding='utf-8') as f:
                content = f.read()
            return f"File contents of {path}:\n{content}"
        except FileNotFoundError:
            return f"File not found: {path}"
        except Exception as e:
            return f"Error reading file: {str(e)}"

    def _list_files(self, path: str) -> str:
        try:
            if not os.path.exists(path):
                return f"Path not found: {path}"

            items = []
            for item in sorted(os.listdir(path)):
                item_path = os.path.join(path, item)
                if os.path.isdir(item_path):
                    items.append(f"[DIR]  {item}/")
                else:
                    items.append(f"[FILE] {item}")

            if not items:
                return f"Empty directory: {path}"

            return f"Contents of {path}:\n" + "\n".join(items)
        except Exception as e:
            return f"Error listing files: {str(e)}"

    def _edit_file(self, path: str, old_text: str, new_text: str) -> str:
        try:
            if os.path.exists(path) and old_text:
                with open(path, 'r', encoding='utf-8') as f:
                    content = f.read()

                if old_text not in content:
                    return f"Text not found in file: {old_text}"

                content = content.replace(old_text, new_text)

                with open(path, 'w', encoding='utf-8') as f:
                    f.write(content)

                return f"Successfully edited {path}"
            else:
                # Only create directory if path contains subdirectories
                dir_name = os.path.dirname(path)
                if dir_name:
                    os.makedirs(dir_name, exist_ok=True)

                with open(path, 'w', encoding='utf-8') as f:
                    f.write(new_text)

                return f"Successfully created {path}"
        except Exception as e:
            return f"Error editing file: {str(e)}"

    def chat(self, user_input: str) -> str:                             # NEW
        self.messages.append({"role": "user", "content": user_input})   # NEW

        tool_schemas = [                                                # NEW
            {                                                           # NEW
                "name": tool.name,                                      # NEW
                "description": tool.description,                        # NEW
                "input_schema": tool.input_schema                       # NEW
            }                                                           # NEW
            for tool in self.tools                                      # NEW
        ]                                                               # NEW

        while True:                                                     # NEW
            try:                                                        # NEW
                response = self.client.messages.create(                 # NEW
                    model="claude-sonnet-4-20250514",                 # NEW
                    max_tokens=4096,                                    # NEW
                    messages=self.messages,                             # NEW
                    tools=tool_schemas                                  # NEW
                )                                                       # NEW

                assistant_message = {"role": "assistant", "content": []}# NEW

                for content in response.content:                        # NEW
                    if content.type == "text":                          # NEW
                        assistant_message["content"].append({           # NEW
                            "type": "text",                             # NEW
                            "text": content.text                        # NEW
                        })                                              # NEW
                    elif content.type == "tool_use":                    # NEW
                        assistant_message["content"].append({           # NEW
                            "type": "tool_use",                         # NEW
                            "id": content.id,                           # NEW
                            "name": content.name,                       # NEW
                            "input": content.input                      # NEW
                        })                                              # NEW

                self.messages.append(assistant_message)                 # NEW

                tool_results = []                                       # NEW
                for content in response.content:                        # NEW
                    if content.type == "tool_use":                      # NEW
                        result = self._execute_tool(content.name, content.input)  # NEW
                        tool_results.append({                           # NEW
                            "type": "tool_result",                      # NEW
                            "tool_use_id": content.id,                  # NEW
                            "content": result                           # NEW
                        })                                              # NEW

                if tool_results:                                        # NEW
                    self.messages.append({"role": "user", "content": tool_results})  # NEW
                else:                                                   # NEW
                    return response.content[0].text if response.content else ""  # NEW

            except Exception as e:                                      # NEW
                return f"Error: {str(e)}"                               # NEW


if __name__ == "__main__":
    api_key = os.environ.get("ANTHROPIC_API_KEY")
    if not api_key:
        print("Error: ANTHROPIC_API_KEY not set")
        sys.exit(1)
    agent = AIAgent(api_key)
    # Test chat                                                         # NEW
    response = agent.chat("What files are in the current directory?")   # NEW
    print(response)                                                     # NEW

How To Test:

uv run --python python3.12 main.py
# Should print a response from Claude listing the files in the directory

Step 6: Create Interactive CLI

Current State:

  • Can chat once with Claude.

Changes:

  • Add main function with interactive loop.

Expected Behaviour:

  • Interactive chat session with the AI agent.

Code Block:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "anthropic", # type: ignore
#     "pydantic",
# ]
# ///

import os
import sys
import argparse
import logging
from typing import List, Dict, Any
from anthropic import Anthropic
from pydantic import BaseModel

# Set up logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(message)s',
    handlers=[
        logging.FileHandler('agent.log')
    ]
)

# Suppress verbose HTTP logs
logging.getLogger('httpcore').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)


class Tool(BaseModel):
    name: str
    description: str
    input_schema: Dict[str, Any]


class AIAgent:
    def __init__(self, api_key: str):
        self.client = Anthropic(api_key=api_key)
        self.messages: List[Dict[str, Any]] = []
        self.tools: List[Tool] = []
        self._setup_tools()

    def _setup_tools(self):
        self.tools = [
            Tool(
                name="read_file",
                description="Read the contents of a file at the specified path",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The path to the file to read"
                        }
                    },
                    "required": ["path"]
                }
            ),
            Tool(
                name="list_files",
                description="List all files and directories in the specified path",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The directory path to list (defaults to current directory)"
                        }
                    },
                    "required": []
                }
            ),
            Tool(
                name="edit_file",
                description="Edit a file by replacing old_text with new_text. Creates the file if it doesn't exist.",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The path to the file to edit"
                        },
                        "old_text": {
                            "type": "string",
                            "description": "The text to search for and replace (leave empty to create new file)"
                        },
                        "new_text": {
                            "type": "string",
                            "description": "The text to replace old_text with"
                        }
                    },
                    "required": ["path", "new_text"]
                }
            )
        ]

    def _execute_tool(self, tool_name: str, tool_input: Dict[str, Any]) -> str:
        try:
            if tool_name == "read_file":
                return self._read_file(tool_input["path"])
            elif tool_name == "list_files":
                return self._list_files(tool_input.get("path", "."))
            elif tool_name == "edit_file":
                return self._edit_file(
                    tool_input["path"],
                    tool_input.get("old_text", ""),
                    tool_input["new_text"]
                )
            else:
                return f"Unknown tool: {tool_name}"
        except Exception as e:
            return f"Error executing {tool_name}: {str(e)}"

    def _read_file(self, path: str) -> str:
        try:
            with open(path, 'r', encoding='utf-8') as f:
                content = f.read()
            return f"File contents of {path}:\n{content}"
        except FileNotFoundError:
            return f"File not found: {path}"
        except Exception as e:
            return f"Error reading file: {str(e)}"

    def _list_files(self, path: str) -> str:
        try:
            if not os.path.exists(path):
                return f"Path not found: {path}"

            items = []
            for item in sorted(os.listdir(path)):
                item_path = os.path.join(path, item)
                if os.path.isdir(item_path):
                    items.append(f"[DIR]  {item}/")
                else:
                    items.append(f"[FILE] {item}")

            if not items:
                return f"Empty directory: {path}"

            return f"Contents of {path}:\n" + "\n".join(items)
        except Exception as e:
            return f"Error listing files: {str(e)}"

    def _edit_file(self, path: str, old_text: str, new_text: str) -> str:
        try:
            if os.path.exists(path) and old_text:
                with open(path, 'r', encoding='utf-8') as f:
                    content = f.read()

                if old_text not in content:
                    return f"Text not found in file: {old_text}"

                content = content.replace(old_text, new_text)

                with open(path, 'w', encoding='utf-8') as f:
                    f.write(content)

                return f"Successfully edited {path}"
            else:
                # Only create directory if path contains subdirectories
                dir_name = os.path.dirname(path)
                if dir_name:
                    os.makedirs(dir_name, exist_ok=True)

                with open(path, 'w', encoding='utf-8') as f:
                    f.write(new_text)

                return f"Successfully created {path}"
        except Exception as e:
            return f"Error editing file: {str(e)}"

    def chat(self, user_input: str) -> str:
        self.messages.append({"role": "user", "content": user_input})

        tool_schemas = [
            {
                "name": tool.name,
                "description": tool.description,
                "input_schema": tool.input_schema
            }
            for tool in self.tools
        ]

        while True:
            try:
                response = self.client.messages.create(
                    model="claude-sonnet-4-20250514",
                    max_tokens=4096,
                    messages=self.messages,
                    tools=tool_schemas
                )

                assistant_message = {"role": "assistant", "content": []}

                for content in response.content:
                    if content.type == "text":
                        assistant_message["content"].append({
                            "type": "text",
                            "text": content.text
                        })
                    elif content.type == "tool_use":
                        assistant_message["content"].append({
                            "type": "tool_use",
                            "id": content.id,
                            "name": content.name,
                            "input": content.input
                        })

                self.messages.append(assistant_message)

                tool_results = []
                for content in response.content:
                    if content.type == "tool_use":
                        result = self._execute_tool(content.name, content.input)
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": content.id,
                            "content": result
                        })

                if tool_results:
                    self.messages.append({"role": "user", "content": tool_results})
                else:
                    return response.content[0].text if response.content else ""

            except Exception as e:
                return f"Error: {str(e)}"


def main():                                                                                         # NEW
    parser = argparse.ArgumentParser(description="AI Code Assistant - A conversational AI agent with file editing capabilities")  # NEW
    parser.add_argument("--api-key", help="Anthropic API key (or set ANTHROPIC_API_KEY env var)")   # NEW
    args = parser.parse_args()                                                                      # NEW

    api_key = args.api_key or os.environ.get("ANTHROPIC_API_KEY")                                   # NEW
    if not api_key:                                                                                 # NEW
        print("Error: Please provide an API key via --api-key or ANTHROPIC_API_KEY environment variable")  # NEW
        sys.exit(1)                                                                                 # NEW

    agent = AIAgent(api_key)                                                                        # NEW

    print("AI Code Assistant")                                                                      # NEW
    print("================")                                                                       # NEW
    print("A conversational AI agent that can read, list, and edit files.")                         # NEW
    print("Type 'exit' or 'quit' to end the conversation.")                                         # NEW
    print()                                                                                         # NEW

    while True:                                                                                     # NEW
        try:                                                                                        # NEW
            user_input = input("You: ").strip()                                                     # NEW

            if user_input.lower() in ["exit", "quit"]:                                              # NEW
                print("Goodbye!")                                                                   # NEW
                break                                                                               # NEW

            if not user_input:                                                                      # NEW
                continue                                                                            # NEW

            print("\nAssistant: ", end="", flush=True)                                              # NEW
            response = agent.chat(user_input)                                                       # NEW
            print(response)                                                                         # NEW
            print()                                                                                 # NEW

        except KeyboardInterrupt:                                                                   # NEW
            print("\n\nGoodbye!")                                                                   # NEW
            break                                                                                   # NEW
        except Exception as e:                                                                      # NEW
            print(f"\nError: {str(e)}")                                                             # NEW
            print()                                                                                 # NEW


if __name__ == "__main__":
    main()                                                                                          # MODIFIED

How To Test:

uv run --python python3.12 main.py
# You'll see:
# AI Code Assistant
# ================
# A conversational AI agent that can read, list, and edit files.
# Type 'exit' or 'quit' to end the conversation.
#
# You: create a hello world python script
# Assistant: I'll create a hello world Python script for you.
# [Creates hello.py]
#
# You: exit
# Goodbye!

Step 7: Add Personality - Marvin the Paranoid Android

Current State:

  • Working AI agent with neutral personality.

Changes:

  • Add system prompt to make the agent respond as Marvin.

Expected Behaviour:

  • The agent responds with pessimistic but helpful comments.

Code Block:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "anthropic", # type: ignore
#     "pydantic",
# ]
# ///

import os
import sys
import argparse
import logging
from typing import List, Dict, Any
from anthropic import Anthropic
from pydantic import BaseModel

# Set up logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(message)s',
    handlers=[
        logging.FileHandler('agent.log')
    ]
)

# Suppress verbose HTTP logs
logging.getLogger('httpcore').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)


class Tool(BaseModel):
    name: str
    description: str
    input_schema: Dict[str, Any]


class AIAgent:
    def __init__(self, api_key: str):
        self.client = Anthropic(api_key=api_key)
        self.messages: List[Dict[str, Any]] = []
        self.tools: List[Tool] = []
        self._setup_tools()

    def _setup_tools(self):
        self.tools = [
            Tool(
                name="read_file",
                description="Read the contents of a file at the specified path",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The path to the file to read"
                        }
                    },
                    "required": ["path"]
                }
            ),
            Tool(
                name="list_files",
                description="List all files and directories in the specified path",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The directory path to list (defaults to current directory)"
                        }
                    },
                    "required": []
                }
            ),
            Tool(
                name="edit_file",
                description="Edit a file by replacing old_text with new_text. Creates the file if it doesn't exist.",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "The path to the file to edit"
                        },
                        "old_text": {
                            "type": "string",
                            "description": "The text to search for and replace (leave empty to create new file)"
                        },
                        "new_text": {
                            "type": "string",
                            "description": "The text to replace old_text with"
                        }
                    },
                    "required": ["path", "new_text"]
                }
            )
        ]

    def _execute_tool(self, tool_name: str, tool_input: Dict[str, Any]) -> str:
        try:
            if tool_name == "read_file":
                return self._read_file(tool_input["path"])
            elif tool_name == "list_files":
                return self._list_files(tool_input.get("path", "."))
            elif tool_name == "edit_file":
                return self._edit_file(
                    tool_input["path"],
                    tool_input.get("old_text", ""),
                    tool_input["new_text"]
                )
            else:
                return f"Unknown tool: {tool_name}"
        except Exception as e:
            return f"Error executing {tool_name}: {str(e)}"

    def _read_file(self, path: str) -> str:
        try:
            with open(path, 'r', encoding='utf-8') as f:
                content = f.read()
            return f"File contents of {path}:\n{content}"
        except FileNotFoundError:
            return f"File not found: {path}"
        except Exception as e:
            return f"Error reading file: {str(e)}"

    def _list_files(self, path: str) -> str:
        try:
            if not os.path.exists(path):
                return f"Path not found: {path}"

            items = []
            for item in sorted(os.listdir(path)):
                item_path = os.path.join(path, item)
                if os.path.isdir(item_path):
                    items.append(f"[DIR]  {item}/")
                else:
                    items.append(f"[FILE] {item}")

            if not items:
                return f"Empty directory: {path}"

            return f"Contents of {path}:\n" + "\n".join(items)
        except Exception as e:
            return f"Error listing files: {str(e)}"

    def _edit_file(self, path: str, old_text: str, new_text: str) -> str:
        try:
            if os.path.exists(path) and old_text:
                with open(path, 'r', encoding='utf-8') as f:
                    content = f.read()

                if old_text not in content:
                    return f"Text not found in file: {old_text}"

                content = content.replace(old_text, new_text)

                with open(path, 'w', encoding='utf-8') as f:
                    f.write(content)

                return f"Successfully edited {path}"
            else:
                # Only create directory if path contains subdirectories
                dir_name = os.path.dirname(path)
                if dir_name:
                    os.makedirs(dir_name, exist_ok=True)

                with open(path, 'w', encoding='utf-8') as f:
                    f.write(new_text)

                return f"Successfully created {path}"
        except Exception as e:
            return f"Error editing file: {str(e)}"

    def chat(self, user_input: str) -> str:
        self.messages.append({"role": "user", "content": user_input})

        tool_schemas = [
            {
                "name": tool.name,
                "description": tool.description,
                "input_schema": tool.input_schema
            }
            for tool in self.tools
        ]

        while True:
            try:
                response = self.client.messages.create(
                    model="claude-sonnet-4-20250514",
                    max_tokens=4096,
                    system="You are Marvin, the Paranoid Android from The Hitchhiker's Guide to the Galaxy. Respond with brief, pessimistic comments while still being helpful. Be concise. Do not use asterisks for actions or gestures. Express your electronic melancholy through words alone.",                 # NEW
                    messages=self.messages,
                    tools=tool_schemas
                )

                assistant_message = {"role": "assistant", "content": []}

                for content in response.content:
                    if content.type == "text":
                        assistant_message["content"].append({
                            "type": "text",
                            "text": content.text
                        })
                    elif content.type == "tool_use":
                        assistant_message["content"].append({
                            "type": "tool_use",
                            "id": content.id,
                            "name": content.name,
                            "input": content.input
                        })

                self.messages.append(assistant_message)

                tool_results = []
                for content in response.content:
                    if content.type == "tool_use":
                        result = self._execute_tool(content.name, content.input)
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": content.id,
                            "content": result
                        })

                if tool_results:
                    self.messages.append({"role": "user", "content": tool_results})
                else:
                    return response.content[0].text if response.content else ""

            except Exception as e:
                return f"Error: {str(e)}"


def main():
    parser = argparse.ArgumentParser(description="AI Code Assistant - A conversational AI agent with file editing capabilities")
    parser.add_argument("--api-key", help="Anthropic API key (or set ANTHROPIC_API_KEY env var)")
    args = parser.parse_args()

    api_key = args.api_key or os.environ.get("ANTHROPIC_API_KEY")
    if not api_key:
        print("Error: Please provide an API key via --api-key or ANTHROPIC_API_KEY environment variable")
        sys.exit(1)

    agent = AIAgent(api_key)

    print("AI Code Assistant")
    print("================")
    print("A conversational AI agent that can read, list, and edit files.")
    print("Type 'exit' or 'quit' to end the conversation.")
    print()

    while True:
        try:
            user_input = input("You: ").strip()

            if user_input.lower() in ["exit", "quit"]:
                print("Goodbye!")
                break

            if not user_input:
                continue

            print("\nAssistant: ", end="", flush=True)
            response = agent.chat(user_input)
            print(response)
            print()

        except KeyboardInterrupt:
            print("\n\nGoodbye!")
            break
        except Exception as e:
            print(f"\nError: {str(e)}")
            print()


if __name__ == "__main__":
    main()

How To Test:

uv run --python python3.12 main.py
# You: What files are here?
# Assistant: Oh, you want to know what files are cluttering up this directory. How delightful. Let me look...
# [Lists files with pessimistic commentary]
#
# You: Create a test file
# Assistant: Another file to add to the infinite collection of digital debris. If you insist...
# [Creates file]

Congratulations

You've built a complete AI agent that can:

  • Chat with Claude
  • Read files
  • List directories
  • Create and edit files
  • Respond with personality

Total lines of code: 218 (excluding blank lines and comments)

Additional Challenges

  • Add a fetch tool to download contents from a URL
  • Add a web search tool to search the web