Build an AI Agent in TypeScript (No Framework, 2026)
An AI agent is a loop: send the conversation and a list of tools to a model, run the tool the model asks for, append the result, repeat until the model answers without a tool call. You don't need a framework for that. This guide builds the loop in plain TypeScript and adds the parts that matter once tools can change things: limits, argument validation, approval and evals.
Step 1: The model call (any OpenAI-compatible API)
Many providers and local runtimes (for example Ollama) accept the OpenAI-style Chat Completions format. One small function is enough:
type Msg = { role: "system" | "user" | "assistant" | "tool"; content: string | null; tool_calls?: any[]; tool_call_id?: string };
async function chat(messages: Msg[], tools: any[]) {
const res = await fetch(`${process.env.LLM_BASE_URL}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.LLM_API_KEY}` },
body: JSON.stringify({ model: process.env.LLM_MODEL, messages, tools }),
});
if (!res.ok) throw new Error(`LLM error ${res.status}`);
return (await res.json()).choices[0].message as Msg;
}Use a model that supports tool calling. API costs are yours.
Step 2: Typed tools with zod
import { z } from "zod";
const tools = {
get_weather: {
description: "Current weather for a city",
schema: z.object({ city: z.string().min(1) }),
sideEffects: false,
run: async ({ city }: { city: string }) => `Sunny in ${city}`, // replace with a real call
},
};
const toolSpecs = Object.entries(tools).map(([name, t]) => ({
type: "function",
function: { name, description: t.description, parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } },
}));(You can generate the JSON Schema from the zod schema with a helper; it's written by hand here to keep the example short.)
Step 3: The loop with hard limits
const MAX_STEPS = 8;
const MAX_TOOL_OUTPUT = 4_000; // characters
async function runAgent(task: string) {
const messages: Msg[] = [{ role: "system", content: "You are a helpful agent." }, { role: "user", content: task }];
for (let step = 0; step < MAX_STEPS; step++) {
const reply = await chat(messages, toolSpecs);
messages.push(reply);
if (!reply.tool_calls?.length) return reply.content;
for (const call of reply.tool_calls) {
const tool = tools[call.function.name as keyof typeof tools];
const parsed = tool?.schema.safeParse(JSON.parse(call.function.arguments || "{}"));
let output: string;
if (!tool) output = "Error: unknown tool";
else if (!parsed!.success) output = `Error: invalid arguments ${parsed!.error.message}`;
else if (tool.sideEffects && !(await askHuman(call.function.name, parsed!.data))) output = "Error: denied by user";
else output = String(await tool.run(parsed!.data));
messages.push({ role: "tool", tool_call_id: call.id, content: output.slice(0, MAX_TOOL_OUTPUT) });
}
}
throw new Error("Step limit reached");
}Three rules carry most of the weight:
- Limits live in code, not in the prompt. Max steps, a token budget, timeouts and a cap on tool output. Without the output cap, one large result can push everything else out of the context.
- Bad arguments go back to the model as an error, not into the tool.
- Side effects need a human yes.
askHumancan be a CLI prompt (readline) or a button in your UI.
Step 4: Tools from MCP servers
With the Model Context Protocol, tools live in separate servers. Your agent acts as an MCP client (over stdio or Streamable HTTP), lists the server's tools, maps them to the same toolSpecs format and forwards calls. Treat tools from servers you haven't marked as trusted like side effects: always ask first.
Step 5: Evals with a mock model
Replace chat() with a mock that returns scripted replies (a tool call, then an answer). Then CI can check the loop's behaviour without API costs: does it stop at the step limit, reject bad arguments, ask before side effects, cut long tool output? Real-model evals can run separately.
Step 6: Logging
Log steps, tool names, durations and errors. Leave prompts and answers out by default, so logs can be shared when something breaks.
Aramac AI Agent Starter (TypeScript)
The Aramac AI Agent Starter is this approach as a template: about 800 lines of readable TypeScript, no framework. Per the product page it includes an agent loop with hard limits (max steps, token budget, timeouts, max tool output), providers for any OpenAI-compatible API or Ollama, typed tools with zod, an MCP client (stdio + Streamable HTTP) designed to work with the Aramac MCP Server Starter Kit, human approval before every tool with side effects by default, prompt-injection hardening, logs without prompts or answers by default, an evals harness with a mock model (automated tests and evals, vitest), CLI, Dockerfile, GitHub Actions workflow and DE/EN docs. Requirements: Node.js 22 (or Docker). Bring your own model/API key; no models included, API costs are yours. Price: €39 incl. VAT, sold by Aramac GmbH. Licence: personal, non-commercial use only (licence). Not affiliated with OpenAI, Ollama or other providers named.
FAQ
Do I need a framework to build an AI agent in TypeScript?
No. The core is a loop around one model call and a tool map. Frameworks add abstractions; the loop above is enough to start and to understand what happens.
Which models work?
Any model behind an OpenAI-compatible Chat Completions API that supports tool calling, including local models via Ollama. Check your provider's docs for tool-calling support.
Does the agent remember past conversations?
Not in this setup. The agent keeps conversation context per run; anything beyond that you'd have to add yourself.
How do I stop an agent from doing something harmful?
Limit what tools can do, validate arguments, require human approval for side effects and cap the steps. AI output can be wrong, so tool actions stay your responsibility.
Is the AI Agent Starter a subscription?
No. €39 incl. VAT, paid once, delivered as a ZIP. Free updates until 16 Apr 2027 per the licence.
Related guides
_Not affiliated with OpenAI, Ollama, Anthropic or other providers named. Product names are trademarks of their owners._