Agentic AI

Build Your Own AI Agent in 100 Lines of JavaScript (Seriously)

A hands-on guide to the pattern that powers Claude Code, Cursor, and every other agentic AI tool you've been pretending to understand at parties.

The Moment That Changes Everything

You've used ChatGPT. You've copy-pasted code from Claude. Maybe you've even integrated an LLM into an app — send a prompt, get a response, move on with your life.

But then you see something like Claude Code autonomously reading files, writing code, running tests, fixing its own bugs, and you think: "Okay, what sorcery is this?"

The Heap

The Heap

Discarded robots refuse to die. Engineer Kira discovers their awakening—and a war brewing in the scrap. Dark dystopian SF. Consciousness vs. corporate power.

Learn More

Here's the secret that'll either excite you or make you feel a little silly for not figuring it out sooner: it's a while loop.

That's it. The entire magic behind agentic AI — the thing that separates a chatbot from an autonomous agent — is a loop that keeps asking the model "are you done yet?" until it says yes.

Let's build one.


Wait, What Exactly Is an "Agentic Loop"?

Before we write any code, let's make sure we're on the same page about what this thing actually is — because the term "agentic" gets thrown around a lot these days, usually by people trying to sell you something.

A regular LLM call works like ordering at a drive-through:

You: "I'd like a burger." LLM: "Here's your burger." Transaction complete. Everyone goes home.

An agentic loop works like hiring a contractor:

You: "Renovate my kitchen." Agent: "Let me look at the kitchen first." (uses a tool) Agent: "Okay, I need to check the plumbing." (uses another tool) Agent: "Found a problem. Let me fix it." (uses yet another tool) Agent: "All done! Here's what I did."

The contractor doesn't come back to you after every step asking "what next?" They assess the situation, decide what needs doing, do it, check the results, and keep going until the job's done. That's agency.

In code terms, the pattern looks like this:

        ┌──────────────┐
        │ User Prompt  │
        └──────┬───────┘
               ↓
        ┌──────────────┐
        │     LLM      │◄─────────────┐
        └──────┬───────┘              │
               ↓                      │
        ┌──────────────┐              │
        │ Needs tool?  │              │
        └──┬───────┬───┘              │
           │       │                  │
       NO  ↓       ↓ YES             │
           │  ┌──────────────┐        │
           │  │  Call tool   │        │
           │  └──────┬───────┘        │
           │         ↓               │
           │  ┌──────────────┐        │
           │  │ Feed result  │────────┘
           │  │    back      │
           │  └──────────────┘
           ↓
    ┌──────────────┐
    │ Final Answer │
    └──────────────┘

Every agentic system — from Claude Code to custom automation pipelines to that startup's demo that just raised $50M — is fundamentally this loop with different tools plugged in. Once you understand the pattern, you'll start seeing it everywhere. It's like learning what the Wilhelm Scream is — you can never unhear it.


Why Would You Build One?

Fair question. Here are a few reasons you might want your own agentic loop instead of just using someone else's:

You want AI that can actually do things. A regular LLM can tell you how to read a file. An agent can just… read the file. And then read the next one. And then write a summary. And then email it to your boss. (Okay, maybe don't automate that last part.)

You want to understand how the tools you use every day work. Claude Code, Cursor, GitHub Copilot's agent mode — they're all variations on this pattern. Understanding the loop means understanding the tools.

You want to customize the tools. Maybe you need an agent that can query your specific database, call your internal APIs, or interact with your deployment pipeline. Off-the-shelf agents can't do that. Yours can.

You want control. You decide which tools the agent has access to, how many iterations it can run, whether it needs permission before doing dangerous things, and when to pull the plug. It's your loop, your rules.

Now let's build it.


Prerequisites

  • Node.js v18 or later
  • An Anthropic API key (grab one at console.anthropic.com)
  • Basic familiarity with JavaScript and async/await
  • A healthy sense of wonder (optional but recommended)

Step 1: Set Up Shop

Clone the repository and install dependencies:

git clone https://github.com/grizzlypeaksoftware/gps-agentic-loop-tutorial.git
cd gps-agentic-loop-tutorial
npm install

Or, if you prefer to start from scratch:

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

Set your API key as an environment variable:

export ANTHROPIC_API_KEY="sk-ant-your-key-here"

When we're done, your project will have three files doing three jobs:

my-agent/
├── package.json
├── tools.js        # What the agent CAN do
├── agent.js        # The loop that RUNS the agent
└── main.js         # Where you TALK to the agent

Simple. Let's fill them in.


Step 2: Give Your Agent Some Tools

An agent without tools is just a chatbot with delusions of grandeur. Tools are what turn "I think I should read that file" into actually reading the file.

The Anthropic API needs two things for each tool:

  1. A JSON schema definition — a description card that tells the model "here's what this tool does and what parameters it takes"
  2. An implementation function — the actual code that runs when the model decides to use it

Think of it like a restaurant menu (the schema) paired with the kitchen (the implementation). The model reads the menu and places an order; your code runs the kitchen.

Create tools.js:

const fs = require("fs").promises;

// ──────────────────────────────────────────────
// Tool Definitions (the menu)
// ──────────────────────────────────────────────

const toolDefinitions = [
  {
    name: "get_weather",
    description: "Get the current weather for a given city.",
    input_schema: {
      type: "object",
      properties: {
        city: {
          type: "string",
          description: "The city name, e.g. 'San Francisco'"
        }
      },
      required: ["city"]
    }
  },
  {
    name: "calculate",
    description: "Evaluate a mathematical expression and return the result.",
    input_schema: {
      type: "object",
      properties: {
        expression: {
          type: "string",
          description: "A math expression to evaluate, e.g. '2 + 2 * 10'"
        }
      },
      required: ["expression"]
    }
  },
  {
    name: "read_file",
    description: "Read the contents of a file from disk.",
    input_schema: {
      type: "object",
      properties: {
        path: {
          type: "string",
          description: "The file path to read"
        }
      },
      required: ["path"]
    }
  },
  {
    name: "write_file",
    description: "Write content to a file on disk. Creates the file if it doesn't exist.",
    input_schema: {
      type: "object",
      properties: {
        path: {
          type: "string",
          description: "The file path to write to"
        },
        content: {
          type: "string",
          description: "The content to write"
        }
      },
      required: ["path", "content"]
    }
  },
  {
    name: "list_files",
    description: "List files and directories at a given path.",
    input_schema: {
      type: "object",
      properties: {
        path: {
          type: "string",
          description: "The directory path to list (defaults to current directory)"
        }
      },
      required: []
    }
  }
];

// ──────────────────────────────────────────────
// Tool Implementations (the kitchen)
// ──────────────────────────────────────────────

const toolImplementations = {

  get_weather: async ({ city }) => {
    // In production, you'd call a real weather API.
    // This fake data works great for demos and
    // impressing people at hackathons.
    const fakeData = {
      "San Francisco": "62°F, foggy",
      "New York": "45°F, cloudy",
      "Anchorage": "12°F, snow",
      "Austin": "78°F, sunny",
      "London": "52°F, rain"
    };
    return fakeData[city] || `No weather data available for "${city}"`;
  },

  calculate: async ({ expression }) => {
    try {
      // WARNING: eval() is used for demo purposes ONLY.
      // In production, use a safe math parser like 'mathjs'.
      // Unless you enjoy surprise security incidents.
      const result = eval(expression);
      return String(result);
    } catch (err) {
      return `Error evaluating expression: ${err.message}`;
    }
  },

  read_file: async ({ path }) => {
    try {
      const content = await fs.readFile(path, "utf-8");
      return content;
    } catch (err) {
      return `Error reading file: ${err.message}`;
    }
  },

  write_file: async ({ path, content }) => {
    try {
      await fs.writeFile(path, content, "utf-8");
      return `Successfully wrote ${content.length} characters to ${path}`;
    } catch (err) {
      return `Error writing file: ${err.message}`;
    }
  },

  list_files: async ({ path = "." }) => {
    try {
      const entries = await fs.readdir(path, { withFileTypes: true });
      const formatted = entries.map(e => {
        const type = e.isDirectory() ? "[dir]" : "[file]";
        return `${type} ${e.name}`;
      });
      return formatted.join("\n");
    } catch (err) {
      return `Error listing files: ${err.message}`;
    }
  }
};

module.exports = { toolDefinitions, toolImplementations };

A Quick Note on Tool Definitions

Each tool definition has three parts that are worth understanding:

  • name — A unique identifier. The model uses this to say "I want to call this tool."
  • description — Natural language that helps the model understand when to use it. This matters more than you'd think. A vague description leads to a confused agent. Write these like you're explaining the tool to a smart coworker who's never seen your codebase.
  • input_schema — A JSON Schema describing the parameters. The model generates valid JSON matching this schema. It's surprisingly good at it.

Step 3: Build the Loop (Here's Where It Gets Good)

This is the main event. The agentic loop itself. It's the engine that turns a pile of tools and a question into autonomous behavior. And despite the mystique, it fits comfortably in a single function.

Create agent.js:

const Anthropic = require("@anthropic-ai/sdk").default;
const { toolDefinitions, toolImplementations } = require("./tools");

const client = new Anthropic();

async function runAgent(userMessage, options = {}) {
  const {
    model = "claude-sonnet-4-5-20250514",
    maxIterations = 10,
    systemPrompt = "You are a helpful assistant with access to tools. Use them when needed to answer questions accurately. Think step by step.",
    verbose = true
  } = options;

  // The conversation history — this is the agent's memory.
  // It grows with each iteration as we accumulate:
  //   1. The original user message
  //   2. Assistant responses (including tool calls)
  //   3. Tool results (fed back as user messages)
  const messages = [
    { role: "user", content: userMessage }
  ];

  // ─── THE LOOP ──────────────────────────────────
  // This is it. This is the whole trick.
  for (let iteration = 0; iteration < maxIterations; iteration++) {
    if (verbose) {
      console.log(`\n${"─".repeat(50)}`);
      console.log(`Iteration ${iteration + 1}`);
      console.log(`${"─".repeat(50)}`);
    }

    // STEP A: Call the model
    const response = await client.messages.create({
      model,
      max_tokens: 4096,
      system: systemPrompt,
      tools: toolDefinitions,
      messages
    });

    if (verbose) {
      console.log(`Stop reason: ${response.stop_reason}`);
    }

    // STEP B: Check if the model is done
    // "end_turn" means "I have enough info, here's my answer"
    if (response.stop_reason === "end_turn") {
      const finalText = response.content
        .filter(block => block.type === "text")
        .map(block => block.text)
        .join("\n");

      if (verbose) console.log("\n Agent finished.\n");
      return finalText;
    }

    // STEP C: The model wants to use tools — let's run them
    messages.push({
      role: "assistant",
      content: response.content
    });

    const toolResults = [];

    for (const block of response.content) {
      if (block.type !== "tool_use") continue;

      const { id, name, input } = block;

      if (verbose) {
        console.log(`\n Tool call: ${name}`);
        console.log(`   Input: ${JSON.stringify(input)}`);
      }

      const impl = toolImplementations[name];
      let result;

      if (impl) {
        try {
          result = await impl(input);
        } catch (err) {
          result = `Tool execution error: ${err.message}`;
        }
      } else {
        result = `Error: Unknown tool "${name}"`;
      }

      if (verbose) {
        const preview = result.length > 200
          ? result.substring(0, 200) + "..."
          : result;
        console.log(`   Result: ${preview}`);
      }

      toolResults.push({
        type: "tool_result",
        tool_use_id: id,
        content: result
      });
    }

    // STEP D: Feed results back and go again
    messages.push({
      role: "user",
      content: toolResults
    });

    // ...and we loop back to Step A.
    // The model sees the tool results and decides what to do next.
  }

  // Safety net
  return "Agent reached maximum iterations without completing.";
}

module.exports = { runAgent };

Let's Talk About What Just Happened

The entire pattern is a four-step cycle that repeats until the model says "I'm done":

| Step | What Happens | In Human Terms | |------|-------------|----------------| | A. Call the model | Send the full conversation history to Claude | "Hey, here's everything so far. What do you want to do?" | | B. Check stop_reason | If "end_turn" → extract text, exit. If "tool_use" → continue. | "Are you done?" / "No, I need to do something first." | | C. Execute tools | Run each requested tool, collect results. | The agent does the thing. | | D. Feed results back | Append everything to the message history, loop back to A. | "Okay, here's what happened. Now what?" |

Here's the part that's easy to miss and kind of mind-blowing once it clicks: you don't write any branching logic. You don't write if (question is about weather) then call weather tool. The model figures that out on its own. You give it a toolbox and a question, and it decides which tools to pick up and in what order. That's the "agentic" part — the model has agency over its own workflow.


Step 4: Take It for a Spin

Create main.js to test your shiny new agent:

const { runAgent } = require("./agent");

async function main() {
  // Test 1: Simple tool use
  console.log("═══════════════════════════════════════════");
  console.log("TEST 1: Simple tool use");
  console.log("═══════════════════════════════════════════");

  const answer1 = await runAgent(
    "What's the weather like in Anchorage right now?"
  );
  console.log("Final answer:", answer1);

  // Test 2: Multi-step reasoning
  // The agent should call calculate AND get_weather,
  // then combine the results.
  console.log("\n\n═══════════════════════════════════════════");
  console.log("TEST 2: Multi-step reasoning");
  console.log("═══════════════════════════════════════════");

  const answer2 = await runAgent(
    "What's 42 * 17? And is that number higher or lower than the temperature in New York?"
  );
  console.log("Final answer:", answer2);

  // Test 3: File operations
  console.log("\n\n═══════════════════════════════════════════");
  console.log("TEST 3: File operations");
  console.log("═══════════════════════════════════════════");

  const answer3 = await runAgent(
    "Read my package.json file and tell me what dependencies I have installed."
  );
  console.log("Final answer:", answer3);

  // Test 4: Chained tool calls
  // The agent should list files, pick one, read it, summarize it.
  console.log("\n\n═══════════════════════════════════════════");
  console.log("TEST 4: Chained tool calls");
  console.log("═══════════════════════════════════════════");

  const answer4 = await runAgent(
    "List the files in the current directory, then read the contents of agent.js and give me a one-paragraph summary of what it does."
  );
  console.log("Final answer:", answer4);
}

main().catch(console.error);

Run it:

node main.js

You should see the agent thinking, calling tools, receiving results, and eventually delivering its final answers. Watch the iteration count — for a simple weather query it'll be 1-2 iterations, but for chained operations like Test 4, you'll see it loop multiple times as it lists files, then reads one, then formulates its summary.


Step 5: Level Up — Streaming

Nobody likes staring at a blank screen while the model thinks. Streaming lets you print the model's text as it arrives, token by token, which makes the agent feel alive:

async function callModelStreaming(model, systemPrompt, tools, messages) {
  const stream = await client.messages.stream({
    model,
    max_tokens: 4096,
    system: systemPrompt,
    tools,
    messages
  });

  // Print text tokens as they arrive
  stream.on("text", (text) => {
    process.stdout.write(text);
  });

  // Wait for the complete message
  const response = await stream.finalMessage();
  return response;
}

Drop this into your agent.js and replace the client.messages.create() call. Same loop, same logic, much better vibes.


Step 6: Don't Let Your Agent Go Rogue (Production Guardrails)

So you've built an agent that can read files, write files, and do math. That's cool until it decides to write_file your production config with something creative. Here are the guardrails every real-world agent needs.

Tool Approval for Dangerous Actions

Some tools are safe to auto-execute (checking the weather never hurt anyone). Others… not so much. Add a confirmation step for the scary ones:

const readline = require("readline");

async function confirmToolCall(name, input) {
  const safeTools = ["get_weather", "calculate", "read_file", "list_files"];

  if (safeTools.includes(name)) return true;

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

  return new Promise((resolve) => {
    console.log(`\n  Agent wants to call: ${name}`);
    console.log(`   Input: ${JSON.stringify(input, null, 2)}`);
    rl.question("   Allow? (y/n): ", (answer) => {
      rl.close();
      resolve(answer.toLowerCase() === "y");
    });
  });
}

// In your tool execution loop:
const approved = await confirmToolCall(name, input);
if (!approved) {
  result = "Tool call was denied by the user.";
} else {
  result = await impl(input);
}

This is essentially what Claude Code does when it asks "Allow tool?" — now you know how simple it is under the hood.

Token Budget Tracking

Each iteration adds to the context window, and context windows cost money. Track your spending and bail out before you accidentally fund someone's yacht:

let totalInputTokens = 0;
let totalOutputTokens = 0;
const TOKEN_BUDGET = 100000;

// After each API call:
totalInputTokens += response.usage.input_tokens;
totalOutputTokens += response.usage.output_tokens;

if (totalInputTokens > TOKEN_BUDGET) {
  console.warn("Token budget exceeded. Stopping agent.");
  return "Agent stopped: token budget exceeded.";
}

Retry Logic for Rate Limits

APIs have bad days too. Wrap your calls with exponential backoff:

async function callWithRetry(fn, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (err.status === 429 && attempt < maxRetries - 1) {
        const waitTime = Math.pow(2, attempt) * 1000;
        console.log(`Rate limited. Retrying in ${waitTime / 1000}s...`);
        await new Promise(r => setTimeout(r, waitTime));
        continue;
      }
      throw err;
    }
  }
}

Parallel Tool Execution

Sometimes the model requests multiple tools at once. Don't run them one at a time like some kind of animal — parallelize them:

const toolResults = await Promise.all(
  response.content
    .filter(block => block.type === "tool_use")
    .map(async (block) => {
      const impl = toolImplementations[block.name];
      let result;
      try {
        result = await impl(block.input);
      } catch (err) {
        result = `Tool error: ${err.message}`;
      }
      return {
        type: "tool_result",
        tool_use_id: block.id,
        content: result
      };
    })
);

Step 7: Make It Conversational

Want to turn your one-shot agent into an interactive chat? Wrap the loop in a REPL:

const readline = require("readline");
const { runAgent } = require("./agent");

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

  console.log("Agent ready. Type 'exit' to quit.\n");

  const askQuestion = () => {
    rl.question("You: ", async (input) => {
      if (input.toLowerCase() === "exit") {
        console.log("Goodbye!");
        rl.close();
        return;
      }

      try {
        const response = await runAgent(input, { verbose: false });
        console.log(`\nAgent: ${response}\n`);
      } catch (err) {
        console.error(`Error: ${err.message}\n`);
      }

      askQuestion();
    });
  };

  askQuestion();
}

chat();

Note: This version starts a fresh conversation for each message. To maintain context across turns, you'd persist the messages array between calls to runAgent instead of creating a new one each time. That's left as an exercise for the reader — mostly because "exercise for the reader" is fun to write, and partially because it'll take you about 10 minutes.


What You Built (and Why It Matters)

Let's zoom out. Here's the entire architecture in three bullet points:

  • tools.js defines what the agent can do — both the descriptions the model reads and the code that actually executes
  • agent.js is the agentic loop — call the model, check if it wants tools, execute them, feed results back, repeat
  • main.js is where you send prompts and get answers

The key insight — the one that makes this whole thing click — is that the model drives the loop, not your code. You provide tools and a loop structure, but Claude decides when to call tools, which ones to call, how many times to loop, and when to stop. You're not writing if/else chains to handle every scenario. You're giving the model agency over its own workflow and trusting it to figure things out.

This is why it's called an "agentic" loop. The model has agency. It's the contractor, not the drive-through worker.

And now you know how to build one from scratch.


Where to Go From Here

Now that you have the pattern, the only limit is which tools you plug in:

  • Shell execution — let the agent run commands (carefully!)
  • Database queries — give it access to your data
  • API calls — connect it to external services
  • Web scraping — let it browse the internet
  • Memory/state — persist context between conversations
  • A UI — wrap it in a web app or CLI

Every one of those is just another entry in toolDefinitions and toolImplementations. The loop stays the same. It's turtles all the way down — except the turtles are tool definitions, and the stack is surprisingly shallow.

Happy building.


Further Reading

Powered by Contentful