DEV Community

Cover image for Your First AI Agent in TypeScript Without a Framework
Gabriel Anhaia
Gabriel Anhaia

Posted on

Your First AI Agent in TypeScript Without a Framework


"Agent" sounds like architecture. In code it is a loop that appends
to an array until the model stops asking for things.

That is not a simplification for teaching purposes — it is what the
frameworks are wrapping. Writing it once means that when a framework
later does something unexpected, you know which part of this is
misbehaving.

The state

import type { MessageParam } from "@anthropic-ai/sdk/resources";

type AgentState = {
  messages: MessageParam[];
  turns: number;
  costUsd: number;
};
Enter fullscreen mode Exit fullscreen mode

Three fields. messages is the conversation, and it is the only
thing the model sees — an agent has no memory beyond this array.
turns and costUsd exist so the loop can stop, which turns out to
matter more than anything else here.

The dispatch table

type Handler = (args: unknown, ctx: Ctx) => Promise<unknown>;

const REGISTRY = new Map<string, {
  schema: z.ZodType;
  handler: Handler;
  description: "string;"
}>([
  ["search_docs", {
    schema: z.object({ query: z.string().min(3) }),
    description: "\"Search internal documentation. Use for questions \" +"
                 "about product behaviour or configuration.",
    handler: async ({ query }: any, ctx) => ctx.docs.search(query),
  }],
  ["get_order", {
    schema: z.object({ orderId: z.string().regex(/^ord_[a-z0-9]{10}$/) }),
    description: "\"Fetch one order by id. Use only with an id from \" +"
                 "search_orders; do not construct one.",
    handler: async ({ orderId }: any, ctx) => ctx.orders.byId(orderId),
  }],
]);
Enter fullscreen mode Exit fullscreen mode

A Map from name to behaviour. That is the whole "tool system". The
descriptions are written for the model — what the tool does, and when
to choose it.

Turning the registry into tool definitions is mechanical:

const toolDefs = [...REGISTRY].map(([name, t]) => ({
  name,
  description: "t.description,"
  input_schema: zodToJsonSchema(t.schema, { target: "openApi3" }),
}));
Enter fullscreen mode Exit fullscreen mode

The loop

export async function runAgent(
  task: string,
  ctx: Ctx,
  limits = { maxTurns: 12, maxCostUsd: 0.5 },
): Promise<AgentResult> {
  const state: AgentState = {
    messages: [{ role: "user", content: task }],
    turns: 0,
    costUsd: 0,
  };

  while (true) {
    if (state.turns >= limits.maxTurns) {
      return { status: "turn_limit", state };
    }
    if (state.costUsd >= limits.maxCostUsd) {
      return { status: "budget", state };
    }

    const res = await client.messages.create({
      model: "claude-opus-5",
      max_tokens: 2048,
      system: SYSTEM,
      tools: toolDefs,
      messages: state.messages,
    });

    state.turns++;
    state.costUsd += costOf("claude-opus-5", res.usage);
    state.messages.push({ role: "assistant", content: res.content });

    if (res.stop_reason !== "tool_use") {
      return { status: "done", text: textOf(res.content), state };
    }

    const results = await Promise.all(
      res.content
        .filter((b) => b.type === "tool_use")
        .map((b) => execute(b, ctx)),
    );

    state.messages.push({ role: "user", content: results });
  }
}
Enter fullscreen mode Exit fullscreen mode

Read the shape: call the model, record what it said, and either
return because it stopped asking for tools, or run the tools it asked
for and go round again.

The limit checks sit at the top of the loop rather than the bottom,
so they are evaluated before spending anything. That ordering is the
difference between a ceiling and a post-mortem.

Executing one tool call

async function execute(
  block: ToolUseBlock,
  ctx: Ctx,
): Promise<ToolResultBlockParam> {
  const entry = REGISTRY.get(block.name);
  if (!entry) {
    return err(block.id, `Unknown tool '${block.name}'. Available: ` +
                          [...REGISTRY.keys()].join(", "));
  }

  const parsed = entry.schema.safeParse(block.input);
  if (!parsed.success) {
    return err(block.id, parsed.error.issues
      .map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
  }

  try {
    const out = await entry.handler(parsed.data, ctx);
    return {
      type: "tool_result",
      tool_use_id: block.id,
      content: JSON.stringify(out).slice(0, 20_000),
    };
  } catch (e) {
    ctx.logger.error("tool failed", { tool: block.name, err: e });
    return err(block.id, "Tool execution failed.");
  }
}

const err = (id: string, msg: string): ToolResultBlockParam => ({
  type: "tool_result", tool_use_id: id, content: msg, is_error: true,
});
Enter fullscreen mode Exit fullscreen mode

Every path returns a result block. None throws. An exception escaping
here kills the whole run over one bad argument, when the model could
have corrected itself on the next turn.

The .slice(0, 20_000) is not cosmetic. A tool that returns a large
row set puts all of it into context, where it costs money on every
subsequent turn and can crowd out the actual task. Truncate at the
boundary, and say so if you truncate.

The loop: model call, tool dispatch, results appended, repeat until stop_reason changes.

The three lines that matter most

Of everything above, three lines prevent the failures that make
first agents memorable.

if (state.turns >= limits.maxTurns) — without it, a model that
keeps calling the same tool loops until you notice. The published
incidents of runaway agent spend are all this line missing.

state.costUsd += costOf(...) — the loop measures itself. A turn cap
alone does not bound cost, because turns vary enormously in size.

.slice(0, 20_000) — context growth is quadratic in effect. Every
turn resends the whole array, so one oversized tool result is paid
for on every remaining turn.

Making it observable

A loop you cannot see is a loop you cannot debug. One line per
iteration is enough to start:

ctx.logger.info("agent turn", {
  runId: ctx.runId,
  turn: state.turns,
  stopReason: res.stop_reason,
  tools: res.content
    .filter((b) => b.type === "tool_use")
    .map((b) => b.name),
  costUsd: +state.costUsd.toFixed(6),
  messageCount: state.messages.length,
});
Enter fullscreen mode Exit fullscreen mode

tools per turn is the field you will use most — a run that calls
search_docs six times in a row is stuck, and that pattern is
obvious in the log and invisible everywhere else.

Where the loop stops being enough

Being honest about this is more useful than defending the loop.

Persistence across restarts. The state is in memory. If the
process dies at turn nine, everything is gone. You can serialise
state to Redis after each turn, and at that point you are building
a checkpointer.

Branching. The loop is linear. "Run these three investigations in
parallel, then merge" is expressible with Promise.all inside a
turn, but a real branch-and-join with independent state per branch is
a graph, and expressing a graph as a while loop gets ugly fast.

Human interrupts. Pausing before a destructive action, persisting
that pause, and resuming days later when someone clicks approve
needs durable state plus a resume entry point. Substantially more
than a loop.

Streaming intermediate state. Showing a user what the agent is
doing as it happens means the loop must emit events, which means
turning it inside out into a generator or an emitter.

If you need one of those, reach for LangGraph.js. If you need none of
them, this is the better engineering — the entire control flow fits
on one screen and there is no library behaviour to reason about.

The four capabilities that justify a graph, against a linear loop.

The one to write first

Write the loop first even if you expect to adopt a framework. It
takes an afternoon, it makes the framework's abstractions legible
rather than magical, and you will recognise which of the four
capabilities above you are actually buying — instead of adopting a
graph because agents are supposed to have one.


If this was useful

AI That Acts builds this
agent properly — the registry, the validation boundary, the guards,
observability, and the point where a loop stops being the right
shape.

AI That Acts — Tool Calling in TypeScript

Stateful agents, checkpointing, and multi-agent teams are book four.
The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)