Build a Research Agent with Claude

Build a CLI AI agent that autonomously calls tools to answer questions.

Introduction

30 Second Summary

When you ask a chatbot a question, it can only respond with what it already knows. But what if it could actually go out and do things for you, like fetching a web page, running a calculation, or reading a file on your computer?

In this project, you will build a command-line research agent powered by Claude that autonomously decides which tools to call, executes them, and chains results together to answer complex questions.

What You'll Build

A Node.js CLI application that uses Claude's tool use feature to give an AI agent the ability to perform calculations, fetch web pages, and read local files.

By the end of this project, you'll have:

  • A Node.js project integrated with the Anthropic SDK and connected to Claude Haiku 4.5.
  • Three working tools (calculator, web page fetcher, file reader) defined with JSON Schema that Claude can call autonomously.
  • A complete agentic loop that handles multi-step tool calling and feeds results back to Claude until it has a final answer.
  • An interactive CLI with conversation memory so you can ask follow-up questions that reference previous answers.
  • Secret Mission: Add a save_note tool and real-time cost tracking to monitor your API spend.

Are there any prerequisites?

You should have experience writing JavaScript and be comfortable using the terminal. You also need Node.js installed (version 18 or higher) and an Anthropic account with access to the API.

If you do not have an Anthropic account yet, you can sign up at console.anthropic.com.

Before We Start

Before We Start...

Set Up Your Project

Every AI agent needs a way to communicate with a language model. The Anthropic SDK gives your Node.js code a direct line to Claude.

In this step, you'll create your project, install the SDK, and confirm that everything connects properly.

In this step, get ready to:
  • Create a project directory and install the Anthropic SDK.
  • Set your API key as an environment variable.
  • Verify the connection with a test script.
Create your project and install dependencies

You'll create a dedicated folder for your agent and initialize it as a Node.js project that uses ES modules. This is the modern JavaScript import style that the Anthropic SDK expects.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir research-agent
cd research-agent
  • Initialize your Node.js project, enable ES modules, and install the Anthropic SDK by running these commands:
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk

What do these commands do?

  • npm init -y creates a package.json file that tracks your project's dependencies.
  • npm pkg set type=module enables ES module syntax (import instead of require), which the Anthropic SDK uses.
  • npm install @anthropic-ai/sdk downloads the official Anthropic library into your project.
  • Confirm the install worked by running:
ls

You should see node_modules, package.json, and package-lock.json in the output.

Seeing 'command not found' for npm?

This means Node.js isn't installed or isn't on your system PATH. Verify by running node --version. You should see v22 or higher. If not, install Node.js from the official site before continuing.

Help me troubleshoot my npm installation

  • Now open the research-agent folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the research-agent folder, then click Open.

You should see package.json and the node_modules folder in the VS Code Explorer sidebar on the left.

Set your API key

The Anthropic SDK automatically reads your API key from an environment variable called ANTHROPIC_API_KEY. You'll grab your key from the Anthropic console and set it in your terminal.

  • Go to the Anthropic API keys page and sign in to your Anthropic account.
  • Click Create Key.
  • Give your key a name (e.g. research-agent) and copy the key value. Store it somewhere safe because you won't see it again.
  • Back in VS Code, open the integrated terminal by pressing Ctrl+` (macOS and Windows).
  • Set the environment variable by running this command (paste your actual key in place of the placeholder):

macOS

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

Windows

$Env:ANTHROPIC_API_KEY = "your-api-key-here"

Why an environment variable?

Environment variables keep secrets out of your code. The Anthropic SDK automatically looks for ANTHROPIC_API_KEY in your environment, so you never need to paste the key directly into a file.

This variable only lasts for the current terminal session. If you close and reopen the terminal, you'll need to set it again.

Don't have an Anthropic account yet?

Sign up at console.anthropic.com. New accounts include free trial credits that are more than enough for this project.

Help me set up my Anthropic API key

Create and run the test script

Before building the full agent, you'll send a single message to Claude to confirm everything is wired up correctly.

  • In the VS Code Explorer sidebar, click the new file icon at the top of the file tree.
  • Name the file test-connection.js and press Enter.
  • Paste the following code into test-connection.js:
import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();

const message = await client.messages.create({
  model: 'claude-haiku-4-5',
  max_tokens: 256,
  messages: [{ role: 'user', content: 'Hello! Say "Connection successful" and nothing else.' }]
});

console.log(message.content[0].text);

What does this code do?

  • import Anthropic from '@anthropic-ai/sdk' loads the SDK into your file.
  • new Anthropic() creates a client that automatically reads your ANTHROPIC_API_KEY from the environment.
  • client.messages.create() sends a message to Claude Haiku 4.5 and returns the response.
  • The last line extracts and prints the text content from Claude's response.
  • Save test-connection.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Run the script in your terminal by running:
node test-connection.js

You should see Connection successful printed in your terminal. This confirms your API key works and Claude is responding.

Seeing an authentication error?

Double check that you set the environment variable in the same terminal session where you're running the script. If you opened a new terminal tab, you'll need to run the export (or $Env:) command again.

Also verify that your key was copied completely with no extra spaces at the beginning or end.

Help me debug my Claude API connection

✔️ Awesome, I've got everything!

Great. Make sure you've saved your file before moving on.

ⓧ I'd like to double check the full code

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();

const message = await client.messages.create({
  model: 'claude-haiku-4-5',
  max_tokens: 256,
  messages: [{ role: 'user', content: 'Hello! Say "Connection successful" and nothing else.' }]
});

console.log(message.content[0].text);

Your connection to Claude is live. Next up, you'll define the tools that give your agent real capabilities.

Define Your Tools

Your connection to Claude is working. Now it is time to give Claude actual capabilities beyond generating text.

In this step, you will define three tools that Claude can choose to call, then build the function that executes them. This is the foundation of tool use (also called function calling). Claude reads each tool's description and JSON Schema, decides which tool fits the question, and asks your code to run it.

In this step, get ready to:
  • Define three tools (calculator, web page fetcher, file reader) using JSON Schema.
  • Test that Claude requests a tool call when given the right question.
  • Implement the function that executes each tool and returns the result.
Create agent.js with your tool definitions

Each tool you define needs three things: a name Claude uses to request it, a description that helps Claude decide when to use it, and an input_schema that specifies the parameters Claude must provide.

  • Create a new file called agent.js in your research-agent folder.
  • Add the imports and SDK setup by pasting this code:
import Anthropic from '@anthropic-ai/sdk';
import { readFile } from 'fs/promises';
import * as readline from 'readline';

// Initialize the Anthropic client (reads ANTHROPIC_API_KEY from environment)
const client = new Anthropic();
const MODEL = 'claude-haiku-4-5';

What does this code do?

This sets up the same Anthropic client you used in test-connection.js, plus imports you will need later. readFile lets the file reader tool access local files. readline will power the interactive CLI in a later step.

The MODEL constant stores the model ID so you only need to change it in one place. claude-haiku-4-5 is fast, affordable, and handles tool use reliably.

  • Save agent.js.
  • You will not see any output yet. The next chunk adds the tool definitions Claude reads.

Now add the tools array that describes what each tool does and what inputs it accepts. Claude reads these definitions to decide which tool to call.

  • Below the MODEL line in agent.js, paste the following tool definitions:
const tools = [
  {
    name: "calculator",
    description: "Perform mathematical calculations. Supports arithmetic, percentages, and expressions like '(2 + 3) * 4' or '100 / 3'.",
    input_schema: {
      type: "object",
      properties: {
        expression: {
          type: "string",
          description: "A mathematical expression to evaluate using JavaScript math syntax"
        }
      },
      required: ["expression"]
    }
  },
  {
    name: "get_web_page",
    description: "Fetch the text content of a web page. Returns the first 5000 characters. Use this to look up current information from the web.",
    input_schema: {
      type: "object",
      properties: {
        url: {
          type: "string",
          description: "The full URL to fetch, including https://"
        }
      },
      required: ["url"]
    }
  },
  {
    name: "read_file",
    description: "Read the contents of a local file. Use this to access information stored in text files, JSON, CSV, or other readable formats.",
    input_schema: {
      type: "object",
      properties: {
        file_path: {
          type: "string",
          description: "The relative path to the file to read"
        }
      },
      required: ["file_path"]
    }
  }
];

What does this code do?

Each object in the tools array is a tool definition that follows the Anthropic API format.

  • The name is what Claude uses when requesting the tool.
  • The description helps Claude decide WHEN to use the tool. Write descriptions like you are explaining the tool to a colleague.
  • The input_schema uses JSON Schema to define what inputs Claude must provide. Each property has its own description so Claude knows what value to pass.
  • Save agent.js.
  • You will not see any output yet. The next substep tests that Claude recognizes these tools.

Seeing a syntax error when you save?

Make sure the tools array starts after the const MODEL line, not inside it. Check that every opening bracket has a matching closing bracket.

Help me find the issue with my brackets or commas

Test your tools with Claude

Before building the execution logic, confirm that Claude actually requests a tool when given a question that needs one.

  • Add this temporary test code at the bottom of agent.js, below the tools array:
// Temporary test - remove after verifying
const message = await client.messages.create({
  model: MODEL,
  max_tokens: 1024,
  tools,
  messages: [{ role: 'user', content: 'What is 234 * 567?' }]
});

console.log('stop_reason:', message.stop_reason);
console.log('content:', JSON.stringify(message.content, null, 2));

What does this code do?

This sends a math question to Claude along with your tools array. Because you defined a calculator tool, Claude should recognize that it needs the calculator to answer this question.

Instead of guessing the answer, Claude will respond with a tool_use content block asking your code to run the calculation.

  • Save agent.js and run it:
node agent.js
  • You should see stop_reason: tool_use in the output, plus a tool_use content block showing name: "calculator" and an input object containing the expression.

What is stop_reason: tool_use?

When Claude wants to call a tool, the API response comes back with stop_reason set to "tool_use" instead of "end_turn". This signals that Claude is pausing and waiting for your code to execute the tool and send back the result.

The content array includes a tool_use block with three fields: id (a unique identifier), name (which tool to call), and input (the parameters Claude chose based on your schema).

Seeing stop_reason: end_turn instead?

If Claude responded with text instead of requesting a tool, check that your tools parameter is included in the messages.create call. Also verify that the tools array has the correct structure with name, description, and input_schema fields.

Help me debug my tools array

  • Now remove the temporary test code you just added (everything below the tools array closing ];).
  • Save agent.js.
Implement the tool execution function

Claude tells you which tool to call and what inputs to use. Now you need the function that actually runs the logic for each tool.

  • Below the tools array in agent.js, add the executeTool function:
async function executeTool(name, input) {
  switch (name) {
    case "calculator": {
      try {
        // Evaluate the math expression using the Function constructor
        const result = Function('"use strict"; return (' + input.expression + ')')();
        return JSON.stringify({ result });
      } catch (error) {
        return JSON.stringify({ error: `Failed to evaluate "${input.expression}": ${error.message}` });
      }
    }
    case "get_web_page": {
      try {
        // Fetch the URL and return the first 5000 characters
        const response = await fetch(input.url);
        if (!response.ok) {
          return JSON.stringify({ error: `HTTP ${response.status}: ${response.statusText}` });
        }
        const text = await response.text();
        const content = text.slice(0, 5000);
        return JSON.stringify({ content, truncated: text.length > 5000 });
      } catch (error) {
        return JSON.stringify({ error: error.message });
      }
    }
    case "read_file": {
      try {
        // Read the file contents using the imported readFile function
        const content = await readFile(input.file_path, 'utf-8');
        return JSON.stringify({ content });
      } catch (error) {
        return JSON.stringify({ error: error.message });
      }
    }
    default:
      return JSON.stringify({ error: `Unknown tool: ${name}` });
  }
}

What does this code do?

The executeTool function takes the name and input from Claude's tool_use block and runs the matching logic.

  • The calculator case evaluates math expressions using JavaScript's Function constructor.
  • The get_web_page case uses Node.js built-in fetch to grab a URL and returns the first 5000 characters.
  • The read_file case reads a local file using the readFile import.

Every case returns a JSON string. This is important because Claude expects tool results as text content it can parse and reason about.

  • Save agent.js.
  • You will not see output yet. The next step connects executeTool to Claude in an automated loop.

Worried about the Function constructor?

Using Function() for the calculator is intentionally simple for this learning project. In a production system you would use a dedicated math parsing library to avoid code injection risks. Here, the only input comes from Claude's structured output.

Why does this use Function() and what would I use in production?

✔️ Awesome, I've got everything!

Great. Double check you have saved agent.js with all three sections: imports, tools array, and the executeTool function.

ⓧ I'd like to double check the full code

Here is the complete agent.js file at the end of this step:

import Anthropic from '@anthropic-ai/sdk';
import { readFile } from 'fs/promises';
import * as readline from 'readline';

const client = new Anthropic();
const MODEL = 'claude-haiku-4-5';

const tools = [
  {
    name: "calculator",
    description: "Perform mathematical calculations. Supports arithmetic, percentages, and expressions like '(2 + 3) * 4' or '100 / 3'.",
    input_schema: {
      type: "object",
      properties: {
        expression: {
          type: "string",
          description: "A mathematical expression to evaluate using JavaScript math syntax"
        }
      },
      required: ["expression"]
    }
  },
  {
    name: "get_web_page",
    description: "Fetch the text content of a web page. Returns the first 5000 characters. Use this to look up current information from the web.",
    input_schema: {
      type: "object",
      properties: {
        url: {
          type: "string",
          description: "The full URL to fetch, including https://"
        }
      },
      required: ["url"]
    }
  },
  {
    name: "read_file",
    description: "Read the contents of a local file. Use this to access information stored in text files, JSON, CSV, or other readable formats.",
    input_schema: {
      type: "object",
      properties: {
        file_path: {
          type: "string",
          description: "The relative path to the file to read"
        }
      },
      required: ["file_path"]
    }
  }
];

async function executeTool(name, input) {
  switch (name) {
    case "calculator": {
      try {
        const result = Function('"use strict"; return (' + input.expression + ')')();
        return JSON.stringify({ result });
      } catch (error) {
        return JSON.stringify({ error: `Failed to evaluate "${input.expression}": ${error.message}` });
      }
    }
    case "get_web_page": {
      try {
        const response = await fetch(input.url);
        if (!response.ok) {
          return JSON.stringify({ error: `HTTP ${response.status}: ${response.statusText}` });
        }
        const text = await response.text();
        const content = text.slice(0, 5000);
        return JSON.stringify({ content, truncated: text.length > 5000 });
      } catch (error) {
        return JSON.stringify({ error: error.message });
      }
    }
    case "read_file": {
      try {
        const content = await readFile(input.file_path, 'utf-8');
        return JSON.stringify({ content });
      } catch (error) {
        return JSON.stringify({ error: error.message });
      }
    }
    default:
      return JSON.stringify({ error: `Unknown tool: ${name}` });
  }
}

You now have tools defined and ready to execute. Next up, you will build the agentic loop that automatically calls Claude, runs the tools, feeds results back, and repeats until Claude has a complete answer.

Build the Agentic Loop

Right now, your agent can define tools and execute them, but it has no way to keep going after a tool call returns. It processes one request and stops.

The agentic loop is the pattern that fixes this. It sends a message to Claude, checks whether Claude wants to call a tool, executes that tool, feeds the result back, and repeats until Claude decides it has enough information to answer.

In this step, get ready to:
  • Build the runAgent function with a while loop that keeps calling Claude until it stops requesting tools.
  • Handle multiple tool calls in a single response.
  • Test the agent with a multi-step question that forces chained tool calls.
Build the runAgent function

The runAgent function is the core of your agent. It manages the conversation, sends messages to Claude, and loops whenever Claude requests a tool.

  • In agent.js, add the following runAgent function below your executeTool function:
async function runAgent(userMessage, conversationHistory) {
  // Add the user's message to the conversation
  conversationHistory.push({ role: "user", content: userMessage });

  // Make the initial API call with tools available
  let response = await client.messages.create({
    model: MODEL,
    max_tokens: 1024,
    tools,
    messages: conversationHistory
  });

  // Keep looping as long as Claude wants to use tools
  while (response.stop_reason === "tool_use") {
    // Find all tool_use blocks in the response
    const toolUseBlocks = response.content.filter(block => block.type === "tool_use");
    const toolResults = [];

    // Execute each requested tool and collect results
    for (const toolUse of toolUseBlocks) {
      console.log(`  [Tool] Calling ${toolUse.name}(${JSON.stringify(toolUse.input)})`);
      const result = await executeTool(toolUse.name, toolUse.input);
      console.log(`  [Result] ${result.slice(0, 100)}${result.length > 100 ? '...' : ''}`);
      toolResults.push({
        type: "tool_result",
        tool_use_id: toolUse.id,
        content: result
      });
    }

    // Append the assistant's response and tool results to the conversation
    conversationHistory.push({ role: "assistant", content: response.content });
    conversationHistory.push({ role: "user", content: toolResults });

    // Call Claude again with the updated conversation
    response = await client.messages.create({
      model: MODEL,
      max_tokens: 1024,
      tools,
      messages: conversationHistory
    });
  }

  // Loop exited — Claude gave a final text answer
  conversationHistory.push({ role: "assistant", content: response.content });

  // Extract and return just the text content
  const textContent = response.content
    .filter(block => block.type === "text")
    .map(block => block.text)
    .join("\n");

  return textContent;
}

What does this code do?

This function implements the agentic loop pattern that powers every production AI agent.

  • The while (response.stop_reason === "tool_use") loop is the key. Claude signals it wants tools by returning stop_reason: "tool_use" instead of "end_turn".
  • Claude can request multiple tools at once. The filter finds all tool_use blocks, and the for loop executes each one. All results get sent back together as tool_result blocks.
  • The conversation history grows with each loop iteration. Claude sees its own prior responses plus the tool results, giving it context to decide what to do next.
  • Once Claude stops requesting tools (stop_reason is no longer "tool_use"), the loop exits and the function extracts the final text answer.

Why a manual loop instead of a helper library?

The Anthropic SDK does provide a helper that automates this loop. However, building it yourself teaches the actual message flow that every AI agent uses under the hood.

Production systems often need custom loop logic for rate limiting, logging, or error recovery. Those customizations are much easier when you understand the mechanics yourself.

  • Save agent.js.

Seeing a syntax error when you save?

  • Make sure the new function is placed after the closing brace of executeTool, not inside it.
  • Check that every opening brace { has a matching closing brace }. The function has nested blocks (while, for, push) that are easy to misclose.

Ask for help: Why am I getting a syntax error in my runAgent function?

Test with a multi-step question

Your function is defined but nothing calls it yet. You need a quick test to confirm the loop works before building the full interactive interface in the next step.

  • At the bottom of agent.js, add these lines to run a test:
// Temporary test — remove after verifying
const history = [];
const answer = await runAgent(
  "Fetch https://jsonplaceholder.typicode.com/todos/1 and tell me if the userId is greater than 5",
  history
);
console.log("\nFinal answer:", answer);

Why this specific question?

This question forces Claude to chain multiple steps. It needs to first fetch the URL to get the JSON data, then reason about (or calculate) whether the userId value is greater than 5. You will see at least one tool call logged in your terminal before the final answer appears.

  • Save agent.js and run the test by executing this command in your terminal:
node agent.js

You should see [Tool] and [Result] log lines showing Claude calling the get_web_page tool, followed by a final text answer explaining whether the userId is greater than 5.

Seeing an authentication error?

  • Confirm your ANTHROPIC_API_KEY environment variable is still set in this terminal session. If you opened a new terminal window, you need to export it again.
  • If you see a timeout or network error, the fetch to jsonplaceholder.typicode.com may have failed. Check your internet connection and try again.

Ask for help: My agentic loop test is failing with an error

  • Once you see the final answer printed, remove the temporary test lines from the bottom of agent.js.
  • Save agent.js again.

✔️ Awesome, I've got everything!

Great. Make sure you removed the temporary test lines and saved agent.js.

ⓧ I'd like to double check the full code

Here is the complete agent.js file at this point (without the temporary test lines):

import Anthropic from '@anthropic-ai/sdk';
import { readFile } from 'fs/promises';
import * as readline from 'readline';

const client = new Anthropic();
const MODEL = 'claude-haiku-4-5';

const tools = [
  {
    name: "calculator",
    description: "Perform mathematical calculations. Supports arithmetic, percentages, and expressions like '(2 + 3) * 4' or '100 / 3'.",
    input_schema: {
      type: "object",
      properties: {
        expression: {
          type: "string",
          description: "A mathematical expression to evaluate using JavaScript math syntax"
        }
      },
      required: ["expression"]
    }
  },
  {
    name: "get_web_page",
    description: "Fetch the text content of a web page. Returns the first 5000 characters. Use this to look up current information from the web.",
    input_schema: {
      type: "object",
      properties: {
        url: {
          type: "string",
          description: "The full URL to fetch, including https://"
        }
      },
      required: ["url"]
    }
  },
  {
    name: "read_file",
    description: "Read the contents of a local file. Use this to access information stored in text files, JSON, CSV, or other readable formats.",
    input_schema: {
      type: "object",
      properties: {
        file_path: {
          type: "string",
          description: "The relative path to the file to read"
        }
      },
      required: ["file_path"]
    }
  }
];

async function executeTool(name, input) {
  switch (name) {
    case "calculator": {
      try {
        const result = Function('"use strict"; return (' + input.expression + ')')();
        return JSON.stringify({ result });
      } catch (error) {
        return JSON.stringify({ error: `Failed to evaluate "${input.expression}": ${error.message}` });
      }
    }
    case "get_web_page": {
      try {
        const response = await fetch(input.url);
        if (!response.ok) {
          return JSON.stringify({ error: `HTTP ${response.status}: ${response.statusText}` });
        }
        const text = await response.text();
        const content = text.slice(0, 5000);
        return JSON.stringify({ content, truncated: text.length > 5000 });
      } catch (error) {
        return JSON.stringify({ error: error.message });
      }
    }
    case "read_file": {
      try {
        const content = await readFile(input.file_path, 'utf-8');
        return JSON.stringify({ content });
      } catch (error) {
        return JSON.stringify({ error: error.message });
      }
    }
    default:
      return JSON.stringify({ error: `Unknown tool: ${name}` });
  }
}

async function runAgent(userMessage, conversationHistory) {
  conversationHistory.push({ role: "user", content: userMessage });

  let response = await client.messages.create({
    model: MODEL,
    max_tokens: 1024,
    tools,
    messages: conversationHistory
  });

  while (response.stop_reason === "tool_use") {
    const toolUseBlocks = response.content.filter(block => block.type === "tool_use");
    const toolResults = [];

    for (const toolUse of toolUseBlocks) {
      console.log(`  [Tool] Calling ${toolUse.name}(${JSON.stringify(toolUse.input)})`);
      const result = await executeTool(toolUse.name, toolUse.input);
      console.log(`  [Result] ${result.slice(0, 100)}${result.length > 100 ? '...' : ''}`);
      toolResults.push({
        type: "tool_result",
        tool_use_id: toolUse.id,
        content: result
      });
    }

    conversationHistory.push({ role: "assistant", content: response.content });
    conversationHistory.push({ role: "user", content: toolResults });

    response = await client.messages.create({
      model: MODEL,
      max_tokens: 1024,
      tools,
      messages: conversationHistory
    });
  }

  conversationHistory.push({ role: "assistant", content: response.content });

  const textContent = response.content
    .filter(block => block.type === "text")
    .map(block => block.text)
    .join("\n");

  return textContent;
}

Your agent can now autonomously chain tool calls to answer complex questions. Next up, you will add a system prompt and an interactive CLI so you can have real conversations with it.

Add a System Prompt and Interactive CLI

Your agent can chain tool calls and deliver complete answers. But right now, you have to edit the code every time you want to ask a new question.

In this step, you'll add a system prompt that shapes how Claude behaves and build an interactive command-line interface. This turns your agent into a conversational assistant that remembers everything you've discussed.

In this step, get ready to:
  • Add a system prompt that guides Claude's behavior and tool usage.
  • Build an interactive CLI loop with readline.
  • Test multi-turn conversation with persistent memory.
Add a system prompt to shape Claude's behavior

A system prompt is a set of instructions that runs before every user message. It tells Claude who it is, what tools it has, and how to behave when things go wrong.

  • In agent.js, find the line that says const MODEL = 'claude-haiku-4-5'; and add the following system prompt directly below it:
const systemPrompt = `You are a helpful research assistant. You have access to tools that let you:
- Calculate mathematical expressions
- Fetch web pages for current information
- Read local files

When a user asks a question, use your tools to gather information before answering. Always explain what you're doing and why. If a tool returns an error, let the user know and try an alternative approach.`;

What does this system prompt do?

The system prompt establishes Claude's identity as a research assistant and lists its capabilities. It also sets behavioral guidelines: explain reasoning, handle errors gracefully, and proactively use tools.

Without a system prompt, Claude decides on its own personality for each conversation. The prompt gives you consistent, predictable behavior every time.

Now you need to pass this system prompt to Claude in your API calls. The system parameter is separate from the messages array.

  • In the runAgent function, find the first client.messages.create call. It currently looks like this:
  let response = await client.messages.create({
    model: MODEL,
    max_tokens: 1024,
    tools,
    messages: conversationHistory
  });
  • Add system: systemPrompt, between max_tokens: 1024, and tools, so it looks like this:
  let response = await client.messages.create({
    model: MODEL,
    max_tokens: 1024,
    system: systemPrompt,
    tools,
    messages: conversationHistory
  });
  • Find the second client.messages.create call inside the while loop. It currently looks like this:
    response = await client.messages.create({
      model: MODEL,
      max_tokens: 1024,
      tools,
      messages: conversationHistory
    });
  • Add system: systemPrompt, in the same position, between max_tokens: 1024, and tools, so it looks like this:
    response = await client.messages.create({
      model: MODEL,
      max_tokens: 1024,
      system: systemPrompt,
      tools,
      messages: conversationHistory
    });
  • Save agent.js.

Why pass systemPrompt in both calls?

Your agentic loop calls client.messages.create multiple times. Each call is a fresh API request, so the system prompt must be included every time. Without it on the second call, Claude would lose its behavioral instructions mid-loop.

Build the interactive CLI with conversation memory

Right now your agent only answers one hardcoded question per run. A real assistant needs an interactive loop where you type questions and it responds, remembering everything from the session.

  • At the bottom of agent.js (after the closing } of the runAgent function), add this main function and its call:
async function main() {
  // Create readline interface for terminal input/output
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });

  // Shared array that persists across all questions in this session
  const conversationHistory = [];

  console.log("Research Agent ready! Type your questions (type 'quit' to exit).\n");

  const askQuestion = () => {
    rl.question("You: ", async (input) => {
      const userInput = input.trim();

      // Exit command
      if (userInput.toLowerCase() === 'quit') {
        console.log("Goodbye!");
        rl.close();
        return;
      }

      // Skip empty input
      if (!userInput) {
        askQuestion();
        return;
      }

      try {
        // Pass the shared conversationHistory so the agent remembers context
        const answer = await runAgent(userInput, conversationHistory);
        console.log(`\nAssistant: ${answer}\n`);
      } catch (error) {
        console.error(`Error: ${error.message}\n`);
      }

      // Recursively ask the next question
      askQuestion();
    });
  };

  askQuestion();
}

main();

What does this code do?

  • readline.createInterface connects Node.js to your terminal so it can read typed input and display output.
  • conversationHistory is defined once outside the loop. Every call to runAgent appends messages to this same array, so Claude sees the full conversation each time.
  • askQuestion calls itself recursively after each answer, creating a continuous question-and-answer loop until the user types quit.

Why does conversation memory matter?

Claude's API is stateless. Every request is independent with no built-in memory. By passing the growing conversationHistory array on each call, you're manually giving Claude the context of everything that happened before. This is how every AI chatbot maintains the illusion of memory.

  • Also remove any test code you previously had at the bottom of agent.js (such as a standalone runAgent() call with a hardcoded question). The main() function now handles everything.
  • Save agent.js.
Test multi-turn conversation
  • Run your agent by executing this command:
node agent.js

You should see the prompt: Research Agent ready! Type your questions (type 'quit' to exit).

  • Type a question that requires a tool, such as: What is 1500 * 0.18?
  • Wait for the response. You should see the [Tool] and [Result] logs, followed by Claude's final answer.
  • Now test conversation memory by asking a follow-up question that references the previous answer, such as: What if I double that number?

Claude should remember the previous result and calculate based on it. This confirms your conversation history is working.

  • Type quit to exit the agent.

Agent not responding or crashing?

  • If you see an authentication error, confirm your ANTHROPIC_API_KEY environment variable is still set in your current terminal session.
  • If you see a syntax error, make sure you added system: systemPrompt, with a trailing comma in both client.messages.create calls.
  • If the follow-up question doesn't reference prior context, check that conversationHistory is defined outside the askQuestion function (not inside it).

Help me debug my research agent

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

Here's the complete agent.js file. Compare it with yours to find any differences.

import Anthropic from '@anthropic-ai/sdk';
import { readFile } from 'fs/promises';
import * as readline from 'readline';

const client = new Anthropic();
const MODEL = 'claude-haiku-4-5';

const systemPrompt = `You are a helpful research assistant. You have access to tools that let you:
- Calculate mathematical expressions
- Fetch web pages for current information
- Read local files

When a user asks a question, use your tools to gather information before answering. Always explain what you're doing and why. If a tool returns an error, let the user know and try an alternative approach.`;

const tools = [
  {
    name: "calculator",
    description: "Perform mathematical calculations. Supports arithmetic, percentages, and expressions like '(2 + 3) * 4' or '100 / 3'.",
    input_schema: {
      type: "object",
      properties: {
        expression: {
          type: "string",
          description: "A mathematical expression to evaluate using JavaScript math syntax"
        }
      },
      required: ["expression"]
    }
  },
  {
    name: "get_web_page",
    description: "Fetch the text content of a web page. Returns the first 5000 characters. Use this to look up current information from the web.",
    input_schema: {
      type: "object",
      properties: {
        url: {
          type: "string",
          description: "The full URL to fetch, including https://"
        }
      },
      required: ["url"]
    }
  },
  {
    name: "read_file",
    description: "Read the contents of a local file. Use this to access information stored in text files, JSON, CSV, or other readable formats.",
    input_schema: {
      type: "object",
      properties: {
        file_path: {
          type: "string",
          description: "The relative path to the file to read"
        }
      },
      required: ["file_path"]
    }
  }
];

async function executeTool(name, input) {
  switch (name) {
    case "calculator": {
      try {
        const result = Function('"use strict"; return (' + input.expression + ')')();
        return JSON.stringify({ result });
      } catch (error) {
        return JSON.stringify({ error: `Failed to evaluate "${input.expression}": ${error.message}` });
      }
    }
    case "get_web_page": {
      try {
        const response = await fetch(input.url);
        if (!response.ok) {
          return JSON.stringify({ error: `HTTP ${response.status}: ${response.statusText}` });
        }
        const text = await response.text();
        const content = text.slice(0, 5000);
        return JSON.stringify({ content, truncated: text.length > 5000 });
      } catch (error) {
        return JSON.stringify({ error: error.message });
      }
    }
    case "read_file": {
      try {
        const content = await readFile(input.file_path, 'utf-8');
        return JSON.stringify({ content });
      } catch (error) {
        return JSON.stringify({ error: error.message });
      }
    }
    default:
      return JSON.stringify({ error: `Unknown tool: ${name}` });
  }
}

async function runAgent(userMessage, conversationHistory) {
  conversationHistory.push({ role: "user", content: userMessage });

  let response = await client.messages.create({
    model: MODEL,
    max_tokens: 1024,
    system: systemPrompt,
    tools,
    messages: conversationHistory
  });

  while (response.stop_reason === "tool_use") {
    const toolUseBlocks = response.content.filter(block => block.type === "tool_use");
    const toolResults = [];

    for (const toolUse of toolUseBlocks) {
      console.log(`  [Tool] Calling ${toolUse.name}(${JSON.stringify(toolUse.input)})`);
      const result = await executeTool(toolUse.name, toolUse.input);
      console.log(`  [Result] ${result.slice(0, 100)}${result.length > 100 ? '...' : ''}`);
      toolResults.push({
        type: "tool_result",
        tool_use_id: toolUse.id,
        content: result
      });
    }

    conversationHistory.push({ role: "assistant", content: response.content });
    conversationHistory.push({ role: "user", content: toolResults });

    response = await client.messages.create({
      model: MODEL,
      max_tokens: 1024,
      system: systemPrompt,
      tools,
      messages: conversationHistory
    });
  }

  conversationHistory.push({ role: "assistant", content: response.content });

  const textContent = response.content
    .filter(block => block.type === "text")
    .map(block => block.text)
    .join("\n");

  return textContent;
}

async function main() {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });

  const conversationHistory = [];

  console.log("Research Agent ready! Type your questions (type 'quit' to exit).\n");

  const askQuestion = () => {
    rl.question("You: ", async (input) => {
      const userInput = input.trim();

      if (userInput.toLowerCase() === 'quit') {
        console.log("Goodbye!");
        rl.close();
        return;
      }

      if (!userInput) {
        askQuestion();
        return;
      }

      try {
        const answer = await runAgent(userInput, conversationHistory);
        console.log(`\nAssistant: ${answer}\n`);
      } catch (error) {
        console.error(`Error: ${error.message}\n`);
      }

      askQuestion();
    });
  };

  askQuestion();
}

main();

Your research agent is fully operational. It has a personality, knows how to use its tools, and remembers everything you've discussed. Next up, extend it with a write tool and real-time cost tracking in the Secret Mission.

Secret mission

Add a Save Note Tool and Cost Tracking

Your agent can read the world, but it can't write anything back. In this extension, you'll give it the power to save research findings to local files and add real-time cost tracking so you always know what each conversation costs.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and the Anthropic API is pay-per-use. There are no ongoing costs unless you actively run the agent.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The research-agent project folder on your Desktop (includes agent.js, test-connection.js, node_modules/, and ./notes/).
  • Anthropic API key (created at console.anthropic.com).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your research agent.

  • Your project folder stays on your Desktop, ready to run with node agent.js anytime.
  • Your API key remains active at console.anthropic.com. You only pay when you use it.
  • The ANTHROPIC_API_KEY environment variable is session-scoped. It disappears when you close your terminal. You will need to export it again next time.

Pause - I'll come back to this later

Shut down the agent if it is still running and free up your terminal. Your files stay on disk for later.

  • Press Ctrl+C in your terminal to stop the agent if it is still running.
  • Close the terminal session. The ANTHROPIC_API_KEY variable disappears automatically.
  • Your project folder and API key stay intact. When you return, open a new terminal, export your key again, and run node agent.js.

Delete - I don't want to use this again

Remove all project files and revoke your API key so nothing is left behind.

  • Delete the project folder by running these commands in your terminal:

macOS

cd ~/Desktop
rm -rf research-agent

Windows

cd ~\Desktop
Remove-Item -Recurse -Force research-agent
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see research-agent in the output.

  • Revoke your API key: go to console.anthropic.com, open the API Keys page, find the key you created for this project, and click the delete or revoke option next to it.
  • The ANTHROPIC_API_KEY environment variable disappears automatically when you close your terminal. No extra action needed.

Nice Work!

Nice Work!

Nice work! You just built a fully functional AI agent that autonomously decides which tools to call, executes them, and chains results together to answer complex questions.

You've learned how to:

  • Build a working AI agent that autonomously calls tools and chains multiple tool calls to answer complex questions.
  • Implement the agentic loop pattern: the core mechanism behind every production AI application that takes real-world actions.
  • Define tools using JSON Schema and handle multi-tool responses with conversation memory and system prompts.
  • (Secret Mission) Added a save_note write tool and real-time cost tracking to monitor API spend across your session.

Ready to quiz yourself?