How to Build an Agent

August 26, 2025

Ever wondered how those fancy AI agents work? Building a functional AI agent is simpler than you think!

Today, we're building an AI agent in Node.js that can read files, list directories, and edit code in less than 300 lines. Let's dive in!

What Actually Is an AI Agent?

An AI agent is just an LLM with the ability to use tools. Think of it like giving your smart friend access to your computer – they can chat with you, but also check files, run commands, and make changes.

The formula is simple:

  • An LLM (we'll use Claude)
  • A conversation loop
  • Some tools (functions the AI can call)
  • A sprinkle of JavaScript magic

Prerequisites: What You'll Need

  • Node.js (18 or later)
  • An Anthropic API key (grab one here)
  • Your favorite text editor

Setting Up Our Playground

Let's start by creating our project. Open your terminal and run:

mkdir ai-agent-adventure
cd ai-agent-adventure
npm init -y
npm install @anthropic-ai/sdk

Now create an agent.js file. This is where the magic happens.

Building the Foundation: A Basic Chatbot

Let's start simple. Here's our skeleton:

#!/usr/bin/env node

const Anthropic = require('@anthropic-ai/sdk');
const readline = require('readline');

class Agent {
    constructor(apiKey) {
        this.anthropic = new Anthropic({ apiKey });
        this.messages = [];
        this.rl = readline.createInterface({
            input: process.stdin,
            output: process.stdout,
        });
    }

    async run() {
        console.log(
            "šŸ¤– Agent: Hey there! I'm your friendly neighborhood AI agent."
        );
        console.log("Type 'quit' to exit, or ask me anything!\n");

        while (true) {
            const input = await this.askUser('You: ');

            if (input.toLowerCase() === 'quit') {
                console.log('šŸ¤– Agent: Catch you later! šŸ‘‹');
                break;
            }

            await this.handleMessage(input);
        }

        this.rl.close();
    }

    askUser(prompt) {
        return new Promise((resolve) => {
            this.rl.question(prompt, resolve);
        });
    }

    async handleMessage(userInput) {
        this.messages.push({ role: 'user', content: userInput });

        try {
            const response = await this.runInference();
            console.log(`šŸ¤– Agent: ${response}`);
            this.messages.push({ role: 'assistant', content: response });
        } catch (error) {
            console.log(
                `šŸ˜… Agent: Oops, something went wrong: ${error.message}`
            );
        }
    }

    async runInference() {
        const response = await this.anthropic.messages.create({
            model: 'claude-3-5-sonnet-20241022',
            max_tokens: 1024,
            messages: this.messages,
        });

        return response.content[0].text;
    }
}

// Time to make the magic happen!
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
    console.log(
        'āŒ No API key found! Set ANTHROPIC_API_KEY environment variable.'
    );
    process.exit(1);
}

const agent = new Agent(apiKey);
agent.run().catch(console.error);

Test it:

export ANTHROPIC_API_KEY="your-key-here"
node agent.js

Great! We have a basic chatbot. Now let's give it some tools!

Adding Superpowers: Tool Integration

An agent without tools is just a fancy chatbot. The concept is simple: we tell Claude about available tools, and when it wants to use one, it responds in a special format. We catch that format, run the tool, and send the result back.

Let's modify our agent to support tools:

class Agent {
    constructor(apiKey, tools = []) {
        this.anthropic = new Anthropic({ apiKey });
        this.messages = [];
        this.tools = tools;
        this.rl = readline.createInterface({
            input: process.stdin,
            output: process.stdout,
        });
    }

    async handleMessage(userInput) {
        this.messages.push({ role: 'user', content: userInput });

        let continueProcessing = true;

        while (continueProcessing) {
            try {
                const response = await this.runInference();

                if (response.stop_reason === 'tool_use') {
                    // Claude wants to use a tool!
                    const toolUse = response.content.find(
                        (block) => block.type === 'tool_use'
                    );
                    const result = await this.executeTool(toolUse);

                    // Send the tool result back to Claude
                    this.messages.push({
                        role: 'user',
                        content: [
                            {
                                type: 'tool_result',
                                tool_use_id: toolUse.id,
                                content: result,
                            },
                        ],
                    });
                } else {
                    // Regular response, show it to user
                    const textBlock = response.content.find(
                        (block) => block.type === 'text'
                    );
                    console.log(`šŸ¤– Agent: ${textBlock.text}`);
                    this.messages.push({
                        role: 'assistant',
                        content: response.content,
                    });
                    continueProcessing = false;
                }
            } catch (error) {
                console.log(
                    `šŸ˜… Agent: Houston, we have a problem: ${error.message}`
                );
                continueProcessing = false;
            }
        }
    }

    async runInference() {
        const response = await this.anthropic.messages.create({
            model: 'claude-3-5-sonnet-20241022',
            max_tokens: 1024,
            messages: this.messages,
            tools: this.tools.map((tool) => tool.definition),
        });

        return response;
    }

    async executeTool(toolUse) {
        const tool = this.tools.find((t) => t.definition.name === toolUse.name);
        if (!tool) {
            return `Error: Unknown tool '${toolUse.name}'`;
        }

        try {
            return await tool.execute(toolUse.input);
        } catch (error) {
            return `Error executing ${toolUse.name}: ${error.message}`;
        }
    }
}

Our First Tool: The File Reader

Let's create a tool that can read files:

const fs = require('fs').promises;
const path = require('path');

class ReadFileTool {
    constructor() {
        this.definition = {
            name: 'read_file',
            description: 'Read the contents of a file',
            input_schema: {
                type: 'object',
                properties: {
                    file_path: {
                        type: 'string',
                        description: 'Path to the file to read',
                    },
                },
                required: ['file_path'],
            },
        };
    }

    async execute(input) {
        try {
            const content = await fs.readFile(input.file_path, 'utf8');
            return `File contents of ${input.file_path}:\n\n${content}`;
        } catch (error) {
            if (error.code === 'ENOENT') {
                return `Error: File '${input.file_path}' not found. Double-check that path!`;
            }
            throw error;
        }
    }
}

Let's add it to our agent:

// Add this at the bottom of your file, before agent.run()
const tools = [new ReadFileTool()];
const agent = new Agent(apiKey, tools);
agent.run().catch(console.error);

Now test it out:

You: Can you read the agent.js file and tell me what it does?
šŸ¤– Agent: I'll read that file for you!

Claude will automatically use the read_file tool, read your code, and explain it back to you. It's like having a rubber duck debugger, but one that actually talks back!

Tool Number Two: The Directory Explorer

Reading files is great, but sometimes you need to see what's available. Let's add a list_files tool:

class ListFilesTool {
    constructor() {
        this.definition = {
            name: 'list_files',
            description: 'List files and directories in a given path',
            input_schema: {
                type: 'object',
                properties: {
                    directory_path: {
                        type: 'string',
                        description:
                            'Path to the directory to list (default: current directory)',
                    },
                },
            },
        };
    }

    async execute(input) {
        const dirPath = input.directory_path || '.';

        try {
            const items = await fs.readdir(dirPath, { withFileTypes: true });
            const listing = items
                .map((item) => {
                    const name = item.isDirectory()
                        ? `${item.name}/`
                        : item.name;
                    return name;
                })
                .join('\n');

            return `Contents of ${dirPath}:\n${listing}`;
        } catch (error) {
            if (error.code === 'ENOENT') {
                return `Error: Directory '${dirPath}' not found.`;
            }
            throw error;
        }
    }
}

Update your agent instantiation:

const tools = [new ReadFileTool(), new ListFilesTool()];
const agent = new Agent(apiKey, tools);

Now you can ask: "What files are in this directory?" and Claude will dutifully list everything for you.

The Power Tool: File Editor

Here's where things get really interesting. Let's give our agent the ability to edit files. This is like giving a toddler access to permanent markers – exciting but potentially chaotic!

class EditFileTool {
    constructor() {
        this.definition = {
            name: 'edit_file',
            description:
                'Edit a file by replacing old content with new content',
            input_schema: {
                type: 'object',
                properties: {
                    file_path: {
                        type: 'string',
                        description: 'Path to the file to edit',
                    },
                    old_content: {
                        type: 'string',
                        description: 'The exact content to replace',
                    },
                    new_content: {
                        type: 'string',
                        description: 'The new content to replace it with',
                    },
                },
                required: ['file_path', 'old_content', 'new_content'],
            },
        };
    }

    async execute(input) {
        const { file_path, old_content, new_content } = input;

        try {
            // Check if file exists, create if it doesn't
            let currentContent = '';
            try {
                currentContent = await fs.readFile(file_path, 'utf8');
            } catch (error) {
                if (error.code === 'ENOENT') {
                    // File doesn't exist, create it
                    await fs.writeFile(file_path, new_content);
                    return `Created new file: ${file_path}`;
                }
                throw error;
            }

            // Replace content
            if (!currentContent.includes(old_content)) {
                return `Error: Could not find the specified content to replace in ${file_path}`;
            }

            const updatedContent = currentContent.replace(
                old_content,
                new_content
            );
            await fs.writeFile(file_path, updatedContent);

            return `Successfully edited ${file_path}`;
        } catch (error) {
            return `Error editing file: ${error.message}`;
        }
    }
}

Update your agent with all three tools:

const tools = [new ReadFileTool(), new ListFilesTool(), new EditFileTool()];
const agent = new Agent(apiKey, tools);

Putting It All Together

Here's our complete, battle-tested agent in all its glory:

#!/usr/bin/env node

const Anthropic = require('@anthropic-ai/sdk');
const readline = require('readline');
const fs = require('fs').promises;
const path = require('path');

class Agent {
    constructor(apiKey, tools = []) {
        this.anthropic = new Anthropic({ apiKey });
        this.messages = [];
        this.tools = tools;
        this.rl = readline.createInterface({
            input: process.stdin,
            output: process.stdout,
        });
    }

    async run() {
        console.log(
            "šŸ¤– Agent: Hey there! I'm your coding buddy with superpowers!"
        );
        console.log(
            'I can read files, list directories, and even edit code. Try me!\n'
        );

        while (true) {
            const input = await this.askUser('You: ');

            if (input.toLowerCase() === 'quit') {
                console.log(
                    'šŸ¤– Agent: Happy coding! May your bugs be few and your commits be clean! šŸš€'
                );
                break;
            }

            await this.handleMessage(input);
        }

        this.rl.close();
    }

    askUser(prompt) {
        return new Promise((resolve) => {
            this.rl.question(prompt, resolve);
        });
    }

    async handleMessage(userInput) {
        this.messages.push({ role: 'user', content: userInput });

        let continueProcessing = true;

        while (continueProcessing) {
            try {
                const response = await this.runInference();

                if (response.stop_reason === 'tool_use') {
                    const toolUse = response.content.find(
                        (block) => block.type === 'tool_use'
                    );
                    console.log(`šŸ”§ Using tool: ${toolUse.name}`);

                    const result = await this.executeTool(toolUse);

                    this.messages.push({
                        role: 'user',
                        content: [
                            {
                                type: 'tool_result',
                                tool_use_id: toolUse.id,
                                content: result,
                            },
                        ],
                    });
                } else {
                    const textBlock = response.content.find(
                        (block) => block.type === 'text'
                    );
                    console.log(`šŸ¤– Agent: ${textBlock.text}`);
                    this.messages.push({
                        role: 'assistant',
                        content: response.content,
                    });
                    continueProcessing = false;
                }
            } catch (error) {
                console.log(
                    `šŸ˜… Agent: Oops! Something went sideways: ${error.message}`
                );
                continueProcessing = false;
            }
        }
    }

    async runInference() {
        const response = await this.anthropic.messages.create({
            model: 'claude-3-5-sonnet-20241022',
            max_tokens: 1024,
            messages: this.messages,
            tools: this.tools.map((tool) => tool.definition),
        });

        return response;
    }

    async executeTool(toolUse) {
        const tool = this.tools.find((t) => t.definition.name === toolUse.name);
        if (!tool) {
            return `Error: Unknown tool '${toolUse.name}' - that's not in my toolbox!`;
        }

        try {
            return await tool.execute(toolUse.input);
        } catch (error) {
            return `Error executing ${toolUse.name}: ${error.message}`;
        }
    }
}

// Tool implementations
class ReadFileTool {
    constructor() {
        this.definition = {
            name: 'read_file',
            description: 'Read the contents of a file',
            input_schema: {
                type: 'object',
                properties: {
                    file_path: {
                        type: 'string',
                        description: 'Path to the file to read',
                    },
                },
                required: ['file_path'],
            },
        };
    }

    async execute(input) {
        try {
            const content = await fs.readFile(input.file_path, 'utf8');
            return `File contents of ${input.file_path}:\n\n${content}`;
        } catch (error) {
            if (error.code === 'ENOENT') {
                return `Error: File '${input.file_path}' not found. Double-check that path!`;
            }
            throw error;
        }
    }
}

class ListFilesTool {
    constructor() {
        this.definition = {
            name: 'list_files',
            description: 'List files and directories in a given path',
            input_schema: {
                type: 'object',
                properties: {
                    directory_path: {
                        type: 'string',
                        description:
                            'Path to the directory to list (default: current directory)',
                    },
                },
            },
        };
    }

    async execute(input) {
        const dirPath = input.directory_path || '.';

        try {
            const items = await fs.readdir(dirPath, { withFileTypes: true });
            const listing = items
                .map((item) => {
                    return item.isDirectory() ? `${item.name}/` : item.name;
                })
                .join('\n');

            return `Contents of ${dirPath}:\n${listing}`;
        } catch (error) {
            if (error.code === 'ENOENT') {
                return `Error: Directory '${dirPath}' doesn't exist.`;
            }
            throw error;
        }
    }
}

class EditFileTool {
    constructor() {
        this.definition = {
            name: 'edit_file',
            description:
                'Edit a file by replacing old content with new content',
            input_schema: {
                type: 'object',
                properties: {
                    file_path: {
                        type: 'string',
                        description: 'Path to the file to edit',
                    },
                    old_content: {
                        type: 'string',
                        description: 'The exact content to replace',
                    },
                    new_content: {
                        type: 'string',
                        description: 'The new content to replace it with',
                    },
                },
                required: ['file_path', 'old_content', 'new_content'],
            },
        };
    }

    async execute(input) {
        const { file_path, old_content, new_content } = input;

        try {
            let currentContent = '';
            try {
                currentContent = await fs.readFile(file_path, 'utf8');
            } catch (error) {
                if (error.code === 'ENOENT') {
                    await fs.writeFile(file_path, new_content);
                    return `Created new file: ${file_path}`;
                }
                throw error;
            }

            if (!currentContent.includes(old_content)) {
                return `Error: Couldn't find that content to replace in ${file_path}`;
            }

            const updatedContent = currentContent.replace(
                old_content,
                new_content
            );
            await fs.writeFile(file_path, updatedContent);

            return `Successfully edited ${file_path} - looking good!`;
        } catch (error) {
            return `Error editing file: ${error.message}`;
        }
    }
}

// Let's make this thing fly!
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
    console.log(
        'āŒ Hold up! You need to set ANTHROPIC_API_KEY environment variable.'
    );
    console.log('   Get your key from: https://console.anthropic.com/');
    process.exit(1);
}

const tools = [new ReadFileTool(), new ListFilesTool(), new EditFileTool()];

const agent = new Agent(apiKey, tools);
agent.run().catch(console.error);

Taking It for a Test Drive

Run it and try these commands:

export ANTHROPIC_API_KEY="your-key-here"
node agent.js
  • "What files are in this directory?"
  • "Read the package.json file"
  • "Create a hello.js file that prints 'Hello, World!'"
  • "Can you add a comment to the hello.js file explaining what it does?"

The Magic Behind the Curtain

What makes this work is surprisingly elegant. Claude understands that when you describe tools, it can "call" them by responding in a special format. We catch that format, run the actual function, and feed the result back.

The beautiful part is that Claude figures out when to use tools on its own. Ask it to "check what JavaScript files are here and improve the first one you find," and it'll chain list_files → read_file → edit_file without explicit instructions.

What's Next?

You've built a fully functional AI agent in under 300 lines! You could add tools for:

  • Running shell commands
  • Making HTTP requests
  • Working with databases
  • Integrating with APIs
  • Creating files from templates

Adding new tools is straightforward – just create a new class with a definition and execute method.

Wrapping Up

Building AI agents isn't rocket science. You need the right tools, a conversation loop, and an LLM. Once you understand this pattern, you can build agents that do practically anything.

Your 300-line agent can now read code, understand it, modify it, and explain what it did. Go forth and build agents that organize your photos, refactor your code, or write your documentation!


P.S. - If your agent starts demanding coffee breaks, that's just Claude being Claude. Consider it a feature, not a bug!