Building a tool-using AI agent in TypeScript from scratch

Build a tool-using AI agent in TypeScript from scratch: the agent loop, Zod-validated tool calls, a step limit and error handling, with no framework needed.

By Hamza Ghouri

You can build a tool-using AI agent in TypeScript in about a hundred lines, with no framework. That's worth doing at least once, because frameworks hide the one part you most need to understand: the loop. The model asks for a tool, your code runs it, the result goes back to the model, and that repeats until the model answers or you stop it.

This post builds that loop from scratch with Node.js, fetch and Zod. If you want the concepts first, start with agentic AI explained for engineers.

What we're building

A small agent that answers questions about a code repository. It gets one tool, read_file, scoped to a single directory. You ask "where is the retry logic for payments?" and it reads files until it can answer.

The pieces:

  • A callModel() wrapper around your LLM provider's HTTP API.
  • A tool registry with a description, a JSON Schema for the model and a Zod schema for validation.
  • The loop, with a hard step limit.

I'm calling a generic endpoint here. Every provider's tool-calling format is a little different, so the wrapper's job is to translate their response into one shape your loop understands. Swapping providers then means changing one function.

The model wrapper and the tools

import { z } from "zod";
import { readFile } from "node:fs/promises";
import path from "node:path";

type Message =
  | { role: "system" | "user"; content: string }
  | { role: "assistant"; toolCall: { id: string; name: string; args: unknown } }
  | { role: "tool"; toolCallId: string; content: string };

type ModelReply =
  | { type: "final"; text: string }
  | { type: "tool_call"; id: string; name: string; args: unknown };

type Tool = {
  description: string;
  parameters: object; // JSON Schema, sent to the model
  schema: z.ZodType; // Zod, used to check what the model sends back
  run: (args: any) => Promise<string>;
};

const ROOT = path.resolve("./repo");

const tools: Record<string, Tool> = {
  read_file: {
    description: "Read a UTF-8 text file. Path is relative to the repository root.",
    parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
    schema: z.object({ path: z.string().min(1) }),
    run: async ({ path: rel }: { path: string }) => {
      const full = path.resolve(ROOT, rel);
      if (!full.startsWith(ROOT + path.sep)) throw new Error("path is outside the repository");
      return readFile(full, "utf8");
    },
  },
};

async function callModel(messages: Message[]): Promise<ModelReply> {
  const res = await fetch(process.env.MODEL_API_URL!, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${process.env.MODEL_API_KEY}`,
    },
    body: JSON.stringify({
      messages,
      tools: Object.entries(tools).map(([name, t]) => ({
        name,
        description: t.description,
        parameters: t.parameters,
      })),
    }),
    signal: AbortSignal.timeout(60_000),
  });
  if (!res.ok) throw new Error(`model API returned ${res.status}`);
  return (await res.json()) as ModelReply;
}

Two things to point out. The path check in read_file matters: the model will happily ask for ../../.env if something in its context nudges it there, so the tool enforces the boundary itself. And the JSON Schema and the Zod schema describe the same shape twice. For a handful of tools I accept the duplication. With many tools, generate one from the other.

The agent loop

async function runAgent(question: string, maxSteps = 10): Promise<string> {
  const messages: Message[] = [
    {
      role: "system",
      content: "Answer questions about the repository. Use read_file to inspect code. Answer as soon as you can.",
    },
    { role: "user", content: question },
  ];

  for (let step = 0; step < maxSteps; step++) {
    const reply = await callModel(messages);
    if (reply.type === "final") return reply.text;

    messages.push({ role: "assistant", toolCall: { id: reply.id, name: reply.name, args: reply.args } });

    const tool = tools[reply.name];
    let result: string;
    if (!tool) {
      result = `Error: unknown tool "${reply.name}"`;
    } else {
      const parsed = tool.schema.safeParse(reply.args);
      result = parsed.success
        ? await tool.run(parsed.data).catch((err: Error) => `Error: ${err.message}`)
        : `Error: invalid arguments: ${parsed.error.message}`;
    }

    // Large files would eat the context window in a few steps.
    messages.push({ role: "tool", toolCallId: reply.id, content: result.slice(0, 8000) });
  }

  return "Stopped after reaching the step limit without a final answer.";
}

That's the whole agent.

Why each guard is there

Every line of the loop that isn't a model call is a guard, and each one exists because of a specific failure:

  • The step limit. Without it, a confused model can loop forever, reading the same file or retrying the same broken call, and you pay for every turn.
  • Unknown tool names. Models sometimes invent tools that sound plausible. Returning an error message lets the model correct itself instead of crashing the run.
  • Schema validation. Arguments arrive as untrusted JSON. safeParse turns a bad call into feedback the model can act on.
  • Catching tool errors. A missing file is normal. Feeding the error back as text lets the model try another path.
  • Truncating results. Tool output is the fastest way to blow the context budget. Truncate, or better, make tools return less in the first place.
  • The timeout. A hung request should fail the step and leave your server free.

Errors go back to the model as tool results because that's how it learns what happened. Throwing would end the run on the first typo.

What to add before production

This version is fine for a script. Before real users touch it, I'd add:

  1. A token budget alongside the step budget, tracked from your provider's usage numbers.
  2. Logging per run: a run id, every message, every tool call and its duration. You'll debug agents by reading transcripts.
  3. Risk levels on tools. Read tools run freely. Write tools need a narrower scope. Irreversible tools need human approval. I cover this in guardrails for AI agents in production.
  4. Context management. Long runs need old tool results summarised or dropped, as described in context windows, memory and why AI agents forget.
  5. Progress events for the UI. Emit an event before each tool call ("reading src/payments/retry.ts") and stream it to the client. In the AI assistant I built for the Recond Flutter app, the UI shows what the agent is doing while it works, and that visibility does a lot for trust in a multi-step answer.
  6. Recorded test cases. A set of questions with known answers that you replay whenever you change the prompt or tools.

The short version

An agent is a for loop around a model call, with a tool registry and a step limit. Validate every argument, feed errors back as text, cap the output size, and log everything. Start there, and add a framework only when you can name the specific problem it solves for you.

Want this kind of work on your product?

Get in touch