Skip to main content
← Back to Blog
2025-12-15 · 14 min readAI AgentsUpdated Oct 03, 2026

How to Build an AI Agent in 10 Minutes (Node.js + Claude)

One file, one tool, one loop: a working Claude agent in Node.js that remembers the conversation and calls a tool, with every step matching the companion repo.

Avnish Yadav
Avnish Yadav
Developer & Automation Builder
4 views
How to Build an AI Agent in 10 Minutes (Node.js + Claude)

When I first tried to build an AI agent, every tutorial I found either skipped the details that matter or buried a simple idea under a framework. The idea itself is small: send the conversation to a model, let it ask for a tool, run the tool, send the result back, repeat until it answers. You can write that in one Node.js file.

This post walks through exactly that file. By the end you will have a terminal agent that talks to Claude, remembers what you said earlier in the conversation, and calls a real tool (it reads your machine's clock). It also handles the cases a quick demo usually ignores: a tool that fails, a model that asks for tools forever, a refusal, a reply that runs out of tokens and an API that is down. The complete code lives in a public repo, github.com/avnishyadav25/ai-agent-10-min, and every snippet below matches its agent.js.

I first published this guide in December 2025. This version is a rewrite: the model the original used has been retired, and a few parts of the original loop were wrong in ways that only show up once Claude calls two tools at once. I list those fixes near the end.

What an agent actually is

An agent is a loop around a model. Strip away the vocabulary and there are four parts:

  1. The model does the reasoning. Here that is Claude through the official Anthropic SDK for Node.js.
  2. Memory is the list of messages you send with every request. The API itself keeps nothing between calls, so if you want the agent to remember your name, you send the earlier turns again.
  3. Tools are functions you describe to the model with a name, a description and a JSON schema for the input. The model never runs them. It asks you to run one, and you decide whether to.
  4. The loop checks why the model stopped. If it stopped to use a tool (stop_reason: "tool_use"), you run the tool and send back a tool_result. Anything else ends the turn.

That is the whole pattern. Frameworks add planning, retries, tracing and persistence on top, but the core is the same, and it is much easier to debug once you have written it by hand.

What you need

  • Node.js 20 or newer. The current Anthropic TypeScript/JavaScript SDK supports Node.js 20 LTS and later.
  • An Anthropic API key. Create one in the Claude Console and set up billing there.
  • About ten minutes if you clone the repo, a bit longer if you type it out.

The default model is Claude Opus 5.5 (claude-opus-5-5). On 2026-10-03, Anthropic's models overview says to start with it for most workloads. The code reads the model id from an environment variable, so switching to another current model, for example claude-sonnet-5-5 for lower cost and latency, is a one-line change in .env.

Step 1: set up the project

The fastest route is to clone the companion repo:

git clone https://github.com/avnishyadav25/ai-agent-10-min.git
cd ai-agent-10-min
npm install
cp .env.example .env

If you would rather build it from an empty folder, the only dependencies are the SDK and dotenv:

mkdir ai-agent-10-min && cd ai-agent-10-min
npm init -y
npm install @anthropic-ai/sdk dotenv

Either way, your .env holds the key and, optionally, a model override. This is the repo's .env.example:

# Copy to .env and fill in. Never commit .env.
ANTHROPIC_API_KEY=your-anthropic-api-key-here
# Optional: override the model (default: claude-opus-5-5)
# ANTHROPIC_MODEL=claude-opus-5-5

Add .env to .gitignore before your first commit. A leaked key is the most common way a weekend project turns into a billing surprise.

The repo's package.json defines three scripts, and I use all of them below:

{
  "scripts": {
    "start": "node agent.js",
    "demo": "node agent.js --demo",
    "test": "node --test"
  }
}

Step 2: a first call to Claude

Before adding memory or tools, make sure a plain request works. Create agent.js:

require('dotenv').config({ quiet: true });
const Anthropic = require('@anthropic-ai/sdk');

const DEFAULT_MODEL = 'claude-opus-5-5';
const model = process.env.ANTHROPIC_MODEL || DEFAULT_MODEL;
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from the environment

// A response is a list of content blocks; only the "text" blocks are the answer.
const textOf = (content) =>
  content.filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();

async function main() {
  const response = await client.messages.create({
    model,
    max_tokens: 16000,
    messages: [{ role: 'user', content: 'Hello! In one sentence, what can you help me with?' }],
  });
  console.log(textOf(response.content));
}

main();

Run it with node agent.js. Two details here are easy to get wrong.

Print text by filtering on type === 'text'. The original version of this post printed response.content[0].text. That worked on older models, but a response is a list of blocks and the first one is not always text. Claude Opus 5.5 always uses adaptive thinking, so a response can start with a thinking block, and reading .text on it gives you undefined. The textOf helper keeps only the text blocks.

max_tokens is 16000, not 1024. On models that think before answering, the thinking happens inside the same output budget. With 1024 tokens a thoughtful answer, or a tool call, can be cut off before it finishes. The repo uses 16000, which leaves room for both and is still far below the model's output limit.

Step 3: add memory

Memory is an array. Push each user message onto it, send the whole array, and push the assistant's reply back:

const conversationHistory = []; // short-term memory, sent with every request

async function chat(message) {
  conversationHistory.push({ role: 'user', content: message });
  const response = await client.messages.create({
    model,
    max_tokens: 16000,
    messages: conversationHistory,
  });
  // Keep the FULL assistant content (text, thinking and tool_use blocks), unchanged.
  conversationHistory.push({ role: 'assistant', content: response.content });
  return textOf(response.content);
}

The important line is the last push. The original post stored only the extracted text. That loses information: the API expects thinking blocks to be passed back exactly as it returned them, and once tools are involved the assistant turn has to include its tool_use blocks or the matching tool_result makes no sense. So store response.content as it came back, and only turn it into text when you print it.

With this in place, "My name is Avnish" followed by "What is my name?" works, because the second request carries the first exchange with it. Nothing more clever is happening. For what to do when a conversation grows long, see my notes on memory for LLM agents.

Step 4: describe a tool and run it

A tool has two halves: the description Claude sees, and the code that runs on your machine. The repo keeps them side by side:

// 1. Tools: what Claude may ask us to run. The description tells Claude when to use it.
const tools = [
  {
    name: 'get_current_time',
    description:
      'Returns the current local date and time of the machine running the agent. ' +
      'Use this to answer questions about the current time or date.',
    input_schema: { type: 'object', properties: {} }, // no input needed
  },
];

// 2. The code that actually runs each tool, by name.
const toolFunctions = {
  get_current_time: () => {
    const now = new Date();
    return `The current time is ${now.toLocaleTimeString()} on ${now.toDateString()}.`;
  },
};

The description does real work. Claude decides whether to call a tool from its name and description, so say what it returns and when to use it.

Next, a function that runs every tool_use block in a response and builds the replies:

// Run every tool_use block in a response; ALL results go back in ONE user message.
async function runTools(content, impls = toolFunctions) {
  const results = [];
  for (const block of content) {
    if (block.type !== 'tool_use') continue;
    try {
      const fn = impls[block.name];
      if (!fn) throw new Error(`Tool "${block.name}" not found.`);
      const output = await fn(block.input);
      results.push({ type: 'tool_result', tool_use_id: block.id, content: String(output) });
    } catch (err) {
      // Tell Claude the tool failed instead of crashing; it can explain or try something else.
      results.push({ type: 'tool_result', tool_use_id: block.id, content: `Error: ${err.message}`, is_error: true });
    }
  }
  return results;
}

Two rules from Anthropic's tool-use docs are built into this function:

  • One tool_result per tool_use, all in the next user message. Claude can ask for several tools in one response. The original post pushed an assistant message and a user message for each tool call. That happens to work with one call and breaks with two, because the second assistant message repeats the first and the API sees tool_use ids without results immediately after them. Here runTools returns an array, and the loop sends it as a single message.
  • Errors go back as is_error: true. An unknown tool name or a thrown exception becomes a tool_result with is_error: true and a readable message. Claude can then explain the problem or try something else, and your process doesn't crash. The original post sent back a plain "Error: Tool not found." without the flag, so Claude had to guess that it was an error.

Step 5: the agent loop

Now combine memory and tools into one function. This is createAgent from the repo, unchanged:

const MAX_TOOL_ROUNDS = 5; // safety break

// 3. The agent: memory (the messages array) + the loop.
function createAgent({ client, model = DEFAULT_MODEL, maxTokens = 16000, impls = toolFunctions, log = () => {} }) {
  const conversationHistory = []; // short-term memory, sent with every request

  async function chat(message) {
    const turnStart = conversationHistory.length;
    const rollback = () => { conversationHistory.length = turnStart; };
    conversationHistory.push({ role: 'user', content: message });

    try {
      for (let round = 0; ; round++) {
        const response = await client.messages.create({
          model,
          max_tokens: maxTokens,
          messages: conversationHistory,
          tools,
        });
        const toolCalls = response.content.filter((b) => b.type === 'tool_use');

        if (response.stop_reason === 'refusal') {
          rollback(); // don't keep a declined request in memory
          const category = response.stop_details?.category;
          return `(Claude declined this request${category ? `: ${category}` : ''}.)`;
        }
        if (response.stop_reason === 'max_tokens' && toolCalls.length) {
          rollback(); // a cut-off tool call can't be answered
          return '(Reply cut off mid tool call: raise max_tokens and ask again.)';
        }

        // Keep the FULL assistant content (text, thinking and tool_use blocks), unchanged.
        conversationHistory.push({ role: 'assistant', content: response.content });

        if (response.stop_reason !== 'tool_use') {
          const text = textOf(response.content);
          return response.stop_reason === 'max_tokens' ? `${text}\n(Reply cut off: max_tokens reached.)` : text;
        }

        if (round >= MAX_TOOL_ROUNDS) {
          // Answer the pending calls so the history stays valid, then stop.
          conversationHistory.push({
            role: 'user',
            content: toolCalls.map((b) => ({
              type: 'tool_result', tool_use_id: b.id, content: 'Tool limit reached.', is_error: true,
            })),
          });
          return '(Stopped: too many tool calls in a row.)';
        }

        for (const b of toolCalls) log(`[tool] ${b.name} ${JSON.stringify(b.input)}`);
        conversationHistory.push({ role: 'user', content: await runTools(response.content, impls) });
      }
    } catch (err) {
      rollback(); // API failed: never leave a half-finished exchange in memory
      throw err;
    }
  }

  return { chat, history: conversationHistory };
}

Here is what each branch is for, in the order the loop checks them.

Refusal. Current Claude models include safety classifiers that can decline a request. You get a normal response with stop_reason: "refusal", not an exception, and stop_details.category names the policy area. The loop reports it and rolls the turn out of memory so a declined request doesn't sit in the history for every later call.

max_tokens in the middle of a tool call. If the output budget runs out while Claude is writing a tool_use block, that block is incomplete. You can't run it and you can't answer it, so it must never go into history. The loop rolls back and tells you to raise the limit. Anthropic's stop-reason docs give the same advice: retry with a higher max_tokens.

Store the full assistant content. Same rule as Step 3, and now it matters more: the tool_use blocks have to be in the assistant message that comes right before your tool_result message.

Any other stop reason ends the turn. Usually that is end_turn. If the answer was cut off by max_tokens, the loop returns the text it got and says so, rather than pretending the answer is complete.

The safety break. A model that keeps asking for tools would loop forever and spend money while doing it. After five tool rounds the loop stops. It doesn't just break out, though: the last response contains tool_use blocks, and a history that ends with unanswered tool calls is invalid for the next request. So it answers each pending call with an is_error result ("Tool limit reached.") first, then stops. The original post broke out without answering them, which made the next message fail.

Rollback on API errors. Every turn remembers where it started (turnStart). If any API call inside the turn throws, the history is cut back to that point before the error is re-thrown. Without this, a rate limit in the middle of a tool round leaves a user message with no reply, or a tool_use with no result, and every later request fails with it.

Step 6: turn errors into messages a person can act on

The SDK throws typed errors for each HTTP status, plus APIConnectionError when it can't reach the API at all. Check them from most specific to least specific, because they all extend APIError:

// 4. Turn SDK errors into a short, useful message (most specific first).
function describeError(err) {
  if (err instanceof Anthropic.AuthenticationError) return 'Authentication failed (401): check ANTHROPIC_API_KEY.';
  if (err instanceof Anthropic.PermissionDeniedError) return 'Permission denied (403) for this key or model.';
  if (err instanceof Anthropic.NotFoundError) return 'Not found (404): check ANTHROPIC_MODEL.';
  if (err instanceof Anthropic.RateLimitError) return 'Rate limited (429): wait a moment and try again.';
  if (err instanceof Anthropic.BadRequestError) return `Bad request (400): ${err.message}`;
  if (err instanceof Anthropic.APIConnectionError) return 'Could not reach the Anthropic API: check your network.';
  if (err instanceof Anthropic.APIError) return `Anthropic API error ${err.status ?? ''}: ${err.message}`;
  return `Unexpected error: ${err.message}`;
}

You don't need your own retry loop for this agent. The SDK already retries connection errors, 429s and 5xx errors twice with a short backoff before it throws, so by the time describeError sees a RateLimitError, waiting a moment really is the right advice.

The last piece is a small command-line interface. It checks for the key, builds the agent with the model from ANTHROPIC_MODEL, and either replays a scripted demo or opens an interactive chat:

const readline = require('node:readline/promises');

// 5. A small terminal chat, or `--demo` to replay three scripted messages.
async function main() {
  if (!process.env.ANTHROPIC_API_KEY) {
    console.error('Missing ANTHROPIC_API_KEY. Copy .env.example to .env and add your key.');
    process.exitCode = 1;
    return;
  }
  const model = process.env.ANTHROPIC_MODEL || DEFAULT_MODEL;
  const agent = createAgent({ client: new Anthropic(), model, log: (line) => console.log(line) });

  const ask = async (question) => {
    try {
      console.log(`Agent: ${await agent.chat(question)}\n`);
    } catch (err) {
      console.error(`${describeError(err)}\n`);
    }
  };

  if (process.argv.includes('--demo')) {
    for (const q of ['Hi there, my name is Avnish.', "What's the current time?", 'What did I tell you my name was?']) {
      console.log(`You: ${q}`);
      await ask(q);
    }
    return;
  }

  console.log(`AI agent ready (${model}). Type a message, or "exit" to quit.\n`);
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  rl.on('close', () => process.exit(0)); // Ctrl+D / Ctrl+C
  for (;;) {
    const question = (await rl.question('You: ')).trim();
    if (question.toLowerCase() === 'exit') break;
    if (question) await ask(question);
  }
  rl.close();
}

if (require.main === module) main();

module.exports = { createAgent, describeError, runTools, tools, toolFunctions, DEFAULT_MODEL };

In the repo, the require lines, DEFAULT_MODEL, MAX_TOOL_ROUNDS and textOf sit at the top of the file, followed by sections 1 to 5 in the order above. The module.exports line exists so the tests can import the agent without starting the CLI.

Step 7: run it

npm start      # interactive chat; type "exit" to quit
npm run demo   # name -> time (tool call) -> name again (memory)
npm test       # offline tests with a stubbed client: no key, no network

npm run demo exercises all three ideas in one go. The first message gives the agent your name. The second makes Claude call get_current_time, and you will see a [tool] get_current_time {} line before the answer. The third asks for your name back, which only works because the first exchange is still in conversationHistory.

npm test uses Node's built-in test runner and a fake client, so it runs without an API key. It covers a tool round trip, two tool calls answered in one message, unknown and failing tools, memory across turns, the safety break, refusals, and an API error that rolls memory back. If you change the loop, run it before you trust your change.

What changed from the first version of this post

If you followed the December 2025 version, these are the changes, all checked against Anthropic's docs on 2026-10-03:

  • Model. The original used claude-3-haiku-20240307, which Anthropic retired on 2026-04-20. Requests to a retired model fail. The code now uses claude-opus-5-5 by default and reads ANTHROPIC_MODEL so the next change is a config edit, not a code edit.
  • The loop. One user message with every tool_result per round; is_error for failing tools and for calls left unanswered by the safety break; full response.content in history; text printed by filtering blocks.
  • Failure handling. Refusals, max_tokens cut-offs and typed API errors are handled, and a failed turn is rolled out of memory. max_tokens went from 1024 to 16000.
  • Setup. The API key now comes from the Claude Console at platform.claude.com, and the code lives in a repo with npm start, npm run demo and npm test.

Tradeoffs and gotchas

Context grows with every turn. Memory here is the full history, resent each time. Claude Opus 5.5 has a 1M-token context window, so a chat in your terminal won't hit the limit soon, but you pay for every input token on every call. Long-running agents need trimming or summarising. The original post suggested Claude 3 Opus for its large context; that model was retired on 2026-01-05, and current models all have far more room than it did.

Cost and latency. Each tool round is another API call, and each call resends the history. On 2026-10-03 Opus 5.5 is priced at $4 per million input tokens and $20 per million output tokens, and Sonnet 5.5 at $2 and $10. If the agent's job is simple, set ANTHROPIC_MODEL=claude-sonnet-5-5 and compare. Check the pricing page before you budget, because prices change.

Tools are where the risk is. get_current_time reads a clock and nothing else. The moment a tool can write files, send email or call an API with your credentials, treat every tool_use as a request, not a command: validate the input, and put a confirmation step in front of anything destructive. Tool results that carry content from outside (web pages, emails) can also contain instructions aimed at the model, so keep them inside tool_result blocks.

You can't force a tool on the newest models. On Claude Opus 5.5 and Sonnet 5.5, tool_choice of any or a named tool returns a 400 error. Leave tool_choice at its default (auto) and write a clear tool description instead.

Memory is per process. Restart the script and the agent forgets you. That is fine for learning. For more than one user you would store each user's history in a database and load it per request.

Frequently asked questions

Why Claude, and why Opus 5.5 by default?

I like Claude's tool-use format for a first agent because tools, tool calls and tool results are just content blocks in ordinary user and assistant messages. There is no special role to learn, which makes the loop easy to read. As for the model, Anthropic's own guidance on 2026-10-03 is to start with Claude Opus 5.5 for most workloads, so that is the default. Because the code reads ANTHROPIC_MODEL, you can switch to Sonnet 5.5 for speed and cost without touching agent.js.

Can I use OpenAI or Gemini instead?

Yes, the pattern carries over: a list of messages, tool definitions with a JSON schema, a loop that runs requested tools and sends results back. The request and response shapes differ between providers, so runTools, the stop-reason checks and the error classes would need rewriting for that SDK. The structure of createAgent would not change much.

How do I add my own tool?

Add a definition to tools and a function with the same name to toolFunctions. Give it an input_schema that describes its arguments, and write the description as if you were explaining to a colleague when to use it. If the function throws, runTools already turns that into an is_error result.

How would I run this for many users?

Move conversationHistory out of memory and into a database keyed by user or session, put createAgent behind an HTTP endpoint, and add logging for every tool call. You would also want a limit on history size and a per-user rate limit. None of that changes the loop itself, which is why it is worth getting the loop right first.

Should I use a framework instead?

Write this version first. Once you know what a tool_result is and why the history must stay valid, a framework is a convenience rather than a mystery, and you can debug it when it misbehaves. The Anthropic SDK also has a tool runner helper that manages this loop for you; it makes more sense after you have built the loop yourself. For a longer walkthrough of the same ideas, see building AI agents from scratch, and when something goes wrong, debugging AI agents.

Next steps

Clone the repo, run npm run demo, then replace get_current_time with a tool that does something you actually need: look up an order, read a calendar, search your notes. That is where an agent stops being a demo. For a larger example with a human approval step before anything is sent, see my AI inbox agent project, a Python pipeline that classifies, summarises and drafts replies to Gmail.

Sources

Verified against the sources below on October 3, 2026. Products and docs change often: check the linked sources if something looks different.

  1. Models overview (Claude API docs)
  2. Model deprecations (Claude API docs)
  3. Handle tool calls (Claude API docs)
  4. Parallel tool use (Claude API docs)
  5. Handling stop reasons (Claude API docs)
  6. Refusals and fallback (Claude API docs)
  7. Claude API errors
  8. TypeScript SDK: handling errors and retries
Share
Discussion

Comments

Loading comments...

Add a comment

Comments are reviewed before they appear.