July 8, 2026Β·8 min readΒ·By Innovibe

How to Build an AI Agent in Node.js from Scratch

AI agents aren't magic β€” they're loops. Here's how to build a real one with Node.js, the OpenAI API, and a set of tools your agent can actually call.

AINode.jsAutomationTutorial

AI agents are everywhere in conversation right now, but most tutorials either hand-wave the implementation or bury you in a framework before you understand what's actually happening. This post builds a real agent from scratch β€” no LangChain, no abstractions β€” so you can see exactly how the loop works.

What is an AI agent, actually?

An AI agent is a loop. At each step, the model looks at the current state (conversation history + available tools), decides what to do next, and either calls a tool or returns a final answer. That's it.

User message β†’ Model β†’ Tool call OR final answer
                 ↑           ↓
            Tool result β†β”€β”€β”€β”€β”˜

The model doesn't "think" between steps β€” you send a message, get a response, execute any tool it asked for, feed the result back, repeat.

Step 1: Define your tools

Tools are just functions with a JSON Schema description. The model reads the description and decides when to call them.

const tools = [
  {
    type: "function",
    function: {
      name: "search_web",
      description: "Search the web for current information on a topic",
      parameters: {
        type: "object",
        properties: {
          query: {
            type: "string",
            description: "The search query"
          }
        },
        required: ["query"]
      }
    }
  },
  {
    type: "function",
    function: {
      name: "read_file",
      description: "Read the contents of a local file",
      parameters: {
        type: "object",
        properties: {
          path: {
            type: "string",
            description: "Absolute path to the file"
          }
        },
        required: ["path"]
      }
    }
  }
]

Step 2: Implement the tool functions

These are the actual implementations the agent will call. Keep them simple and focused.

import { readFileSync } from 'fs'

async function executeTool(name: string, args: Record<string, string>) {
  switch (name) {
    case "search_web":
      // Replace with your actual search API (Brave, Serper, Tavily, etc.)
      const results = await searchAPI(args.query)
      return JSON.stringify(results.slice(0, 3))

    case "read_file":
      try {
        return readFileSync(args.path, 'utf-8')
      } catch (e) {
        return `Error reading file: ${e}`
      }

    default:
      return `Unknown tool: ${name}`
  }
}

Step 3: Write the agent loop

This is the core. Send messages, handle tool calls, repeat until the model gives a final answer.

import OpenAI from 'openai'

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })

async function runAgent(userMessage: string): Promise<string> {
  const messages: OpenAI.ChatCompletionMessageParam[] = [
    {
      role: "system",
      content: "You are a helpful assistant with access to tools. Use them when needed."
    },
    {
      role: "user",
      content: userMessage
    }
  ]

  // Agent loop β€” runs until model gives a final text response
  while (true) {
    const response = await client.chat.completions.create({
      model: "gpt-4o",
      messages,
      tools,
      tool_choice: "auto"
    })

    const message = response.choices[0].message
    messages.push(message)

    // No tool calls = final answer
    if (!message.tool_calls || message.tool_calls.length === 0) {
      return message.content ?? ""
    }

    // Execute each tool call and feed results back
    for (const toolCall of message.tool_calls) {
      const args = JSON.parse(toolCall.function.arguments)
      const result = await executeTool(toolCall.function.name, args)

      messages.push({
        role: "tool",
        tool_call_id: toolCall.id,
        content: result
      })
    }
  }
}

// Use it
const answer = await runAgent("What's the weather in Vancouver today and what should I wear?")
console.log(answer)

Step 4: Add a safety limit

Agents can loop forever if something goes wrong. Always add a max-steps guard.

async function runAgent(userMessage: string, maxSteps = 10): Promise<string> {
  let steps = 0
  // ... same setup ...

  while (steps < maxSteps) {
    steps++
    const response = await client.chat.completions.create({ ... })
    
    if (!message.tool_calls?.length) {
      return message.content ?? ""
    }
    
    // execute tools...
  }

  return "Agent reached maximum steps without completing the task."
}

What makes a good agent tool?

  • Focused β€” one thing, done well. Don't build a do_everything tool.
  • Descriptive β€” the description is what the model reads. Be specific about when to use it and what it returns.
  • Error-safe β€” always return a string, even on failure. The model needs to read the error and recover.
  • Fast β€” slow tools kill the agent experience. Cache aggressively.

Where to go from here

Once this pattern is solid, the interesting work is in:

  • Memory β€” storing past conversations and retrieving relevant ones (RAG over your own history)
  • Planning β€” having the model write a plan before executing it, rather than just reacting
  • Parallelism β€” running multiple tool calls in parallel with Promise.all
  • Streaming β€” streaming the final response back to the user so they see progress

The agent loop itself stays the same regardless of how complex your tools get. That's the insight β€” complexity lives in the tools, not the loop.


Building an AI agent for your product? Talk to us β€” we've shipped production agent systems across several client products.

K
Innovibe
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

Do I need a GPU to run this in production?+

For hosted APIs (OpenAI, Anthropic, Google) β€” no. You call an HTTPS endpoint. GPUs only matter if you're self-hosting models, which is overkill for most production use cases.

How do I keep LLM costs under control?+

Cache identical prompts aggressively, use the smallest model that meets your quality bar, and set hard token limits per request. A response cache alone can cut costs 40–60% on typical workloads.

What's the difference between fine-tuning and RAG?+

Fine-tuning bakes knowledge into model weights β€” expensive, slow to update. RAG retrieves context at query time β€” cheap to update, easier to debug. Use RAG for most production use cases and fine-tune only when you need a very specific tone or format.

How do I handle database migrations safely in production?+

Always run migrations before deploying new code (never after). Use additive-only migrations β€” add columns with defaults, never rename or drop in the same release. Blue-green deployments make this much safer.

When should I add a Redis cache vs just querying Postgres?+

Add Redis when the same query runs more than ~10 times per second, when query time exceeds 10ms and latency matters, or when you need pub/sub. Don't add it preemptively β€” optimise the query first.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation