Build Your First AI Agent in TypeScript
A from-scratch walkthrough of the agent loop — tools, reasoning, and termination — using the Claude API and plain TypeScript. No frameworks.
Everyone talks about “AI agents,” but strip away the marketing and an agent is just a loop: the model thinks, optionally calls a tool, reads the result, and repeats until it’s done. That’s it. In this post we’ll build a working agent in plain TypeScript — no LangChain, no abstractions you can’t see through.
By the end you’ll understand the three moving parts every agent framework is really wrapping: the tool schema, the agent loop, and the termination condition.
The mental model
A non-agentic LLM call is one round trip: prompt in, text out. An agent turns that into a conversation the model has with itself and your tools:
- Send the model the user’s goal plus a list of tools it may use.
- The model replies — either with a final answer, or a request to call a tool.
- If it called a tool, you run it and feed the result back.
- Go to step 2.
The loop ends when the model stops asking for tools and returns prose.
Defining a tool
Tools are just functions plus a JSON schema describing how to call them. Here’s a trivially simple one — a calculator — so we can focus on the wiring rather than the tool itself.
const tools = [
{
name: "calculate",
description: "Evaluate a basic arithmetic expression.",
input_schema: {
type: "object",
properties: {
expression: { type: "string", description: "e.g. '42 * (7 + 1)'" },
},
required: ["expression"],
},
},
] as const;
function runTool(name: string, input: { expression: string }): string {
if (name === "calculate") {
// In real code, never eval untrusted input. Use a math parser.
return String(Function(`"use strict";return (${input.expression})`)());
}
throw new Error(`Unknown tool: ${name}`);
}
The description fields are not decoration — they’re the model’s only
documentation. Treat them like API docs you’re writing for a junior engineer.
The agent loop
Now the heart of it. We keep a running messages array and loop until the
model returns an answer with no tool calls.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
async function runAgent(goal: string): Promise<string> {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: goal },
];
// Bound the loop so a confused model can't spin forever.
for (let step = 0; step < 10; step++) {
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
tools,
messages,
});
messages.push({ role: "assistant", content: response.content });
// No tool requested → the model is done.
if (response.stop_reason !== "tool_use") {
return response.content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("");
}
// Run every tool the model asked for and return the results.
const toolResults = response.content
.filter((b) => b.type === "tool_use")
.map((b) => ({
type: "tool_result" as const,
tool_use_id: b.id,
content: runTool(b.name, b.input as { expression: string }),
}));
messages.push({ role: "user", content: toolResults });
}
throw new Error("Agent exceeded step budget");
}
Three things to notice:
stop_reasonis the termination signal. When it isn’t"tool_use", the model has chosen to answer instead of act. That’s your exit.- We append the assistant turn before running tools. The conversation must stay coherent — the model needs to see its own tool request alongside the result.
- The step budget is non-negotiable. Without it, a single bad reasoning chain can rack up real money. Always cap the loop.
What this cost me — Josh: My first agent didn’t have that bounded loop. A tool kept returning an error the model couldn’t recover from, so it just retried the same call over and over — 30-odd iterations before I noticed and killed the process. That single run cost more than the rest of the day’s development combined. Now the
forloop bound and atool_resultthat actually explains the failure go in before I write anything else.
Why the loop matters more than the framework
Once you’ve written this, every agent library suddenly looks familiar. LangChain “agents,” the OpenAI Assistants API, CrewAI — they’re all variations on this loop with extra ergonomics bolted on: memory, retries, parallel tool calls, streaming. Useful, but not magic.
When you debug a misbehaving agent, you’ll come back to these same questions:
- Did the tool description tell the model what it needed?
- Did the tool result give the model something it could act on?
- Is the loop terminating for the right reason?
Where to go next
Add a second tool and watch the model choose between them. Then try giving it a
tool that can fail, and handle the error by feeding the failure back as a
tool_result — a robust agent recovers from tool errors rather than crashing.
If you want to go deeper on making tool outputs reliable, the same discipline applies to prompts: see Prompt Engineering Patterns That Survive Production. And when your agent needs to reason over your own documents, you’ll want retrieval.