Your own AI Agent in TypeScript

typescript ai agent

Build from Scratch — Types, Classes, and the Chat Completions API

If you can read a while loop, you can understand every line of an agent.

Frameworks like LangChain, CrewAI and AutoGen are great for shipping, but terrible for learning. When you write Agent(executor=...).run(), a black box makes a hundred design decisions for you — and hides every single one. The surprising truth? There is no deep machinery inside that box. The entire “agent” idea fits in a single while loop.

This guide builds that loop from scratch in TypeScript — with real types, a clean class hierarchy, and the global fetch API. No SDKs, no agent frameworks, just the Chat Completions API and a handful of carefully typed functions. You will end up with a working agent that can calculate, read local files and search the web, and the TypeScript compiler will catch your mistakes before you ever hit the API.

Everything is on GitHub: github.com/juustesout/javascript-agents-example (the TypeScript port lives in the same repo as the JS version).


1. Installation

You need Node.js 18+ (for the global fetch) and an OpenAI API key. Two dev dependencies:

git clone https://github.com/juustesout/javascript-agents-example.git
cd javascript-agents-example
npm install
cp .env.example .env

Open .env and add your key:

OPENAI_API_KEY=sk-proj-your-key-here
OPENAI_MODEL=gpt-4o-mini
AGENT_VERBOSE=1

The .gitignore already has **/.env. The agent loads it automatically via its own built-in loadEnv() function. Now compile and run:

npm run build        # tsc — compiles .ts to dist/
npm run demo         # build + run the demo
npm run game         # build + run the negotiation game

Only two dev dependencies: typescript and @types/node. That is all the setup there is.


2. Project Structure

node-agents-examples-ts/
├── 01_mini_agent/
│   ├── agent.ts          # The core loop + 3 tools, fully typed
│   ├── demo.ts           # Entry point with example prompts
│   └── sample.txt        # A test file for the file reader tool
├── 02_agent_vs_agent/
│   └── game.ts           # Two agents negotiate a $100 split
├── dist/                 # Compiled output (tsc writes here)
├── tsconfig.json         # ES2022, NodeNext module resolution
└── package.json

Same structure as the JavaScript version, but every file is typed. The tsconfig.json targets ES2022 with NodeNext module resolution, so import statements compile to .js extensions that Node can run directly.


3. The Core Loop — How Every Agent Works

Here is the whole trick in four steps:

  1. The model receives the full conversation history plus a list of tool schemas (name, description, expected JSON arguments).
  2. The model replies with either plain text (it is ready to answer) or one or more tool calls (JSON like {"name": "calculator", "arguments": {"expression": "2+2"}}).
  3. If it is a tool call, you run the matching function and append the result as a role="tool" message. Not a framework — your code.
  4. Repeat from step 1 until the model returns plain text. That text is the final answer.

The whole loop: model → tool → result → model → … → answer.

TypeScript types — the safety net

The first thing you notice in the TypeScript version is the type definitions. Every message, every tool call, every response shape is explicitly typed:

export type ToolCall = {
  id: string;
  type: "function";
  function: {
    name: string;
    arguments: string;
  };
};

export type ChatMessage = {
  role: "system" | "user" | "assistant" | "tool";
  content: string | null;
  tool_call_id?: string;
  tool_calls?: ToolCall[];
};

export type ModelReply = {
  content: string | null;
  tool_calls?: ToolCall[];
};

These types are not decorative — they are the contract between your code and the OpenAI API. If you accidentally set role: "system" on a tool result, the TypeScript compiler flags it immediately. The ModelReply type tells you exactly what shape the API response should have, so you never have to guess.

The MiniAgent class

export class MiniAgent {
  systemPrompt: string;
  model: string;
  temperature: number;
  maxToolIters: number;
  verbose: boolean;
  baseUrl: string;
  apiKey?: string;
  tools: Record<string, Tool>;
  messages: ChatMessage[];

  constructor(
    systemPrompt: string,
    {
      model = null,
      temperature = 0.7,
      maxToolIters = DEFAULT_MAX_TOOL_ITERS,
      verbose = DEFAULT_VERBOSE,
      baseUrl = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1",
      apiKey = process.env.OPENAI_API_KEY,
    }: {
      model?: string | null;
      temperature?: number;
      maxToolIters?: number;
      verbose?: boolean;
      baseUrl?: string;
      apiKey?: string;
    } = {},
  ) {
    this.systemPrompt = systemPrompt;
    this.model = model || DEFAULT_MODEL;
    this.temperature = temperature;
    this.maxToolIters = maxToolIters;
    this.verbose = verbose;
    this.baseUrl = baseUrl.replace(/\/+$/, "");
    this.apiKey = apiKey;
    this.tools = {};
    this.messages = [];
  }
}

The constructor takes a destructured options object — every parameter is optional and has a default. The messages array is typed as ChatMessage[], so adding a message with the wrong structure is a compile-time error, not a runtime surprise.

The run() method — the heart

async run(userPrompt: string, { reset = true }: { reset?: boolean } = {}): Promise<string> {
  if (reset || this.messages.length === 0) {
    this.messages = [
      { role: "system", content: this.systemPrompt },
      { role: "user", content: userPrompt },
    ];
  } else {
    this.messages.push({ role: "user", content: userPrompt });
  }

  for (let iteration = 1; iteration <= this.maxToolIters; iteration++) {
    this._log(`iteration ${iteration}: calling the model...`, _DIM);
    const reply = await this._callModel();

    if (reply.tool_calls && reply.tool_calls.length > 0) {
      await this._handleToolCalls(reply.tool_calls);
      continue;                   // loop back to the model
    }

    const answer = reply.content || "";
    this.messages.push({ role: "assistant", content: answer });
    this._log("final answer received.", _DIM);
    return answer;
  }

  throw new Error(`No answer after ${this.maxToolIters} iterations.`);
}

The TypeScript return type Promise<string> tells the caller exactly what to expect. No surprises.

How _callModel() works

This is the only network call in the whole project. It converts the tool registry into OpenAI’s JSON schema format and sends the conversation alongside it:

async _callModel(): Promise<ModelReply> {
  const toolSchemas = Object.values(this.tools).map((tool) => tool.toOpenAISchema());

  const response = await fetch(`${this.baseUrl}/chat/completions`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${this.apiKey}`,
    },
    body: JSON.stringify({
      model: this.model,
      messages: this.messages,
      temperature: this.temperature,
      tools: toolSchemas.length ? toolSchemas : undefined,
      tool_choice: toolSchemas.length ? "auto" : undefined,
    }),
  });

  if (!response.ok) {
    const text = await response.text();
    throw new Error(`OpenAI API error ${response.status}: ${text}`);
  }

  const data = await response.json();
  return data.choices[0].message as ModelReply;
}

The return type assertion as ModelReply tells the compiler what shape the API response has — the rest of the code is fully typed from this point on.

How tool results get back into context

async _handleToolCalls(toolCalls: ToolCall[]): Promise<void> {
  this.messages.push({
    role: "assistant",
    content: null,
    tool_calls: toolCalls.map((call) => ({
      id: call.id,
      type: "function",
      function: {
        name: call.function.name,
        arguments: call.function.arguments,
      },
    })),
  });

  for (const call of toolCalls) {
    const result = await this._executeTool(call.function.name, call.function.arguments);
    this.messages.push({ role: "tool", tool_call_id: call.id, content: result });
  }
}

Every tool_call_id must pair back to the matching call. The TypeScript compiler enforces that tool_call_id is a string, preventing a whole class of bugs.

Executing a tool

async _executeTool(name: string, argumentsJson: string): Promise<string> {
  const tool = this.tools[name];
  if (!tool) return `Error: unknown tool '${name}'.`;

  try {
    const args = JSON.parse(argumentsJson);
    if (typeof args !== "object" || args === null || Array.isArray(args)) {
      throw new TypeError("tool arguments must be a JSON object");
    }
    this._log(`  -> ${name}(${JSON.stringify(args)})`, _YELLOW);
    result = String(await tool.func(args as Record<string, unknown>));
  } catch (exc) {
    result = `Error calling '${name}': ${exc instanceof Error ? exc.message : String(exc)}`;
  }

  this._log(`  <- result: ${JSON.stringify(String(result).slice(0, 120))}`, _MAGENTA);
  return String(result);
}

The as Record<string, unknown> cast tells TypeScript that the parsed JSON object is safe to pass to the tool function — the actual validation happens at runtime in the function itself.


4. Tools — Plain Functions, Typed Schemas

A tool in this system is a class with a name, description, JSON Schema and a function:

class Tool {
  name: string;
  description: string;
  parameters: ToolParameters;
  func: (args: Record<string, unknown>) => string | Promise<string>;

  toOpenAISchema() {
    return {
      type: "function",
      function: {
        name: this.name,
        description: this.description,
        parameters: this.parameters,
      },
    };
  }
}

The ToolParameters type is the JSON Schema shape:

export type ToolParameters = {
  type: "object";
  properties: Record<string, Record<string, unknown>>;
  required?: string[];
};

Three tools ship with the agent, each registered via addTool():

addTool(
  name: string,
  description: string,
  parameters: ToolParameters,
  func: (args: Record<string, unknown>) => string | Promise<string>
): this { ... }

Tool 1: Calculator — hand-written recursive descent parser

Same parser as the JavaScript version: a recursive descent parser that only accepts arithmetic — + - * / // % **, parentheses, and the constants pi and e. No eval(), no new Function(), no arbitrary code execution. Each grammar rule is a function:

export function calculator(expression: string): string {
  try {
    return String(_parseExpression(expression));
  } catch (exc) {
    return `Error: ${exc instanceof Error ? exc.message : String(exc)}`;
  }
}

Tool 2: Safe file reader (path traversal defence)

Uses path.relative() to block any attempt to escape the allowed directory:

export function read_local_file(filepath: string): string {
  try {
    const base = resolve(_DEFAULT_BASE_DIR);
    const target = resolve(base, String(filepath));
    const rel = relative(base, target);
    if (rel.startsWith("..") || isAbsolute(rel)) {
      return `Error: '${filepath}' escapes the allowed directory. Path traversal blocked.`;
    }
    if (!existsSync(target)) return `Error: file not found.`;
    const stat = statSync(target);
    if (stat.size > _MAX_FILE_BYTES) return `Error: file too large.`;
    return readFileSync(target, "utf8").replace(/\s+$/, "");
  } catch (exc) {
    return `Error reading file: ${exc instanceof Error ? exc.message : String(exc)}`;
  }
}

Tool 3: Web search (zero API key, DuckDuckGo scraping)

Scrapes the public DuckDuckGo HTML endpoint with fetch and regex — zero dependencies, zero API keys:

export async function web_search(query: string, max_results = 5): Promise<string> {
  const res = await fetch(
    `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`,
    { headers: { "User-Agent": "..." },
      signal: AbortSignal.timeout(15_000) }
  );
  const html = await res.text();
  const anchors = [...html.matchAll(/class="result__a" ... /gs)];
  const snippets = [...html.matchAll(/class="result__snippet" ... /gs)];
  // Build formatted results
  return lines.join("\n\n");
}

Registering tools

Tools are registered via chained addTool() calls, wrapping each function to extract the named argument from the args object:

export function registerBuiltinTools(agent: MiniAgent): MiniAgent {
  agent
    .addTool("calculator", "Evaluate a math expression...",
      { type: "object", properties: {
          expression: { type: "string", description: "..." },
        }, required: ["expression"] },
      (args) => calculator(String(args.expression))
    )
    .addTool("read_local_file", "Read a text file...",
      { type: "object", properties: {
          filepath: { type: "string", description: "..." },
        }, required: ["filepath"] },
      (args) => read_local_file(String(args.filepath))
    )
    .addTool("web_search", "Search the web...",
      { type: "object", properties: {
          query: { type: "string", description: "..." },
          max_results: { type: "integer", description: "..." },
        }, required: ["query"] },
      (args) => web_search(String(args.query), Number(args.max_results ?? 5))
    );
  return agent;
}

5. Running the Agent

npm run build
node dist/01_mini_agent/demo.js

# Or with a custom prompt:
node dist/01_mini_agent/demo.js "What is the speed of light times 3600?"

# Or use the npm script shortcut:
npm run demo "What is 2 ** 10 + 1000?"

What you see — the coloured trace of the loop:

USER: What is 2 ** 10 + 1000? Use the calculator.

[agent] iteration 1: calling the model...
[agent]   -> calculator({"expression":"2 ** 10 + 1000"})
[agent]   <- result: "2024"
[agent] iteration 2: calling the model...
[agent] final answer received.

FINAL ANSWER: The result of 2^10 + 1000 is 2024.

Multi-step (web search + calculation):

USER: Search the web for the speed of light, then calculate
       how far light travels in one hour.

[agent] iteration 1: calling the model...
[agent]   -> web_search({"query":"speed of light in vacuum"})
[agent]   <- result: "299,792,458 m/s"
[agent] iteration 2: calling the model...
[agent]   -> calculator({"expression":"299792458 * 3600"})
[agent]   <- result: "1079252848800"
[agent] iteration 3: calling the model...
[agent] final answer received.

FINAL ANSWER: Light travels ~1.08 trillion meters in one hour.

6. Two Agents, One Conversation — Multi-Agent Negotiation

Two MiniAgent instances, one system prompt each, take turns proposing a split of $100. The only difference is the prompt — Alice is “The Rational Negotiator”, Bob is “The Bold Trader”:

const alice = new MiniAgent(systemPromptA, { temperature: 0.6, verbose: false });
const bob   = new MiniAgent(systemPromptB, { temperature: 0.6, verbose: false });

The game loop parses each agent’s reply with a regex to extract the action:

const _ACTION_RE = /(OFFER|ACCEPT|COUNTER|REJECT)/i;

function parseMove(text: string): { action: string; keep?: number } | null {
  const match = _ACTION_RE.exec(text);
  if (!match) return null;
  const action = match[1].toUpperCase();
  if (action === "ACCEPT" || action === "REJECT") return { action };
  const numbers = text.match(/\b(\d{1,3})\b/g);
  const keep = numbers ? parseInt(numbers[0], 10) : null;
  return { action, keep };
}
npm run game
npm run game -- --quiet   # only parsed moves

Example session:

Alice opens the negotiation...
Alice -> Alice keeps $50, gives $50
Bob   -> Bob keeps $70, gives $30
Alice -> Alice keeps $40, gives $60
Bob   -> Bob keeps $75, gives $25
Alice rejects the deal.

Impasse: no deal reached. Both agents walk away with $0.

The lesson: multi-agent is not a framework feature. It is two single-agent loops passing messages through a shared state, with a text protocol and a parser that extracts structure.


7. Putting It All Together

Here is the complete checklist to build your own agent from scratch in TypeScript:

  1. Define your typesChatMessage, ToolCall, ModelReply, ToolParameters. The compiler is your safety net.
  2. Write a Tool class — name, description, JSON Schema, function. The schema tells the model what the tool does; the function is what actually runs.
  3. Build the MiniAgent class — a message list (ChatMessage[]), a tool registry (Record<string, Tool>), and a run() method with the while-loop.
  4. Implement _callModel() — one fetch() call to /v1/chat/completions with the typed message list and tool schemas.
  5. Handle tool calls — append the assistant message first, then execute each tool and append the result with the matching tool_call_id.
  6. Multi-agent — instantiate the same class with different system prompts. Define a protocol (OFFER/ACCEPT/COUNTER/REJECT). Parse with regex. That is the whole pattern.

Frameworks add caching, streaming, middleware, GUIs and marketing — but underneath every single one is this same three-step loop, plain types, and a while-loop. Once you have written it by hand, every framework becomes obvious.


Going Further

  • Swap the model — change OPENAI_MODEL or point OPENAI_BASE_URL at OpenRouter, Anthropic, or a local Ollama instance.
  • Add your own tools — write a function, write a JSON Schema, call agent.addTool(). The TypeScript types will catch mismatched arguments.
  • Stream the output — set stream: true in the API call and process chunks. The loop stays the same; only the I/O changes.
  • Persist conversationsJSON.stringify(agent.messages) saves the full typed history. Reload with JSON.parse() and a type assertion.
  • Extend the negotiation — more players, different rules, a marketplace. The game logic is yours because you wrote it.

The full source is at github.com/juustesout/javascript-agents-example. Clone it, open agent.ts and read the whole thing — you already know enough.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top