cd /news/ai-agents/build-a-readable-typescript-agent-lo… · home › topics › ai-agents › article
[ARTICLE · art-170051] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

Build a readable TypeScript agent loop with tools and traces

A developer published Agent Loop Starter, an MIT-licensed TypeScript repository that implements a minimal tool-calling agent loop without an agent framework, using strict TypeScript, ESM, Zod v4, and the official OpenAI SDK. The loop maintains conversation history, appends assistant replies and tool results with matching call IDs, and routes malformed JSON, unknown tools, and execution failures back to the model as recoverable tool errors rather than crashing. Tools are defined with Zod schemas converted via z.toJSONSchema, with a calculator, timezone lookup, and keyword note search included as dependency-free examples.

by read5 min views5 publishedNov 15, 2026

An agent that calls tools needs a few concrete pieces: conversation history, a model interface, an allowlist of tools, and a decision about when to stop. Writing those pieces yourself makes the behavior visible. You can follow a failed tool call from its arguments to the error message sent back to the model.

I put that approach into Agent Loop Starter, a small MIT-licensed TypeScript repository. It uses strict TypeScript, ESM, Zod v4, and the official OpenAI SDK. There is no agent framework between the loop and the tools.

The model receives messages and tool definitions. Its reply contains text, tool calls, or both. Each tool call has an ID, a name, and arguments encoded as JSON text. Keeping the arguments raw lets malformed JSON follow the same recovery path as other tool errors.

The loop begins with one user message. Every model reply is appended as an assistant message. Tool results follow it, carrying the matching call IDs. On the next request, the model sees what it asked for and what actually happened.

Here is the model-call section from src/agent.ts, inside while (steps < maxSteps):

if (signal?.aborted) return stopped("aborted");
const step = ++steps;
const started = performance.now();
let response;
try {
  try {
    response = await withAbort(() => model.complete({ messages, tools: definitions, ...(signal ? { signal } : {}) }), signal);
  } finally {
    emit({ type: "model_call", step, durationMs: performance.now() - started });
  }
} catch (error) {
  if (signal?.aborted) return stopped("aborted");
  throw error; // Transport/auth errors are different from recoverable tool errors.
}

withAbort stops waiting if the signal fires. It also passes the signal through to the model adapter. Authentication and transport failures reject the run, giving the caller a chance to decide whether to retry.

The next decision is short:

if (signal?.aborted) return stopped("aborted");
messages.push({ role: "assistant", ...response });
if (!response.toolCalls?.length) {
  emit({ type: "final", step, durationMs: 0, output: response.content });
  return { output: response.content, stopReason: "final", steps, trace };
}

A reply with tool calls enters a sequential execution loop. After appending all results, execution returns to the next model call. A reply without tool calls ends the run.

A tool needs a description the model can read and validation the program can enforce. defineTool takes a Zod object schema, converts its input shape with z.toJSONSchema, and validates arguments before calling execute. The argument type is inferred from that schema.

This is the calculator definition from src/tools/calculator.ts:

export const calculator = defineTool({
  name: "calculator",
  description: "Calculate arithmetic with +, -, *, /, %, parentheses, decimals and unary signs. No exponents. Uses JavaScript floating-point numbers.",
  schema: z.object({ expression: z.string().min(1).max(512) }).strict(),
  execute: ({ expression }) => ({ value: evaluateExpression(expression) }),
});

expression is a string inside execute. The schema also rejects extra fields. The arithmetic implementation is a recursive descent parser with precedence, parentheses, and unary signs. It never evaluates JavaScript. It rejects division by zero, excessive nesting, and non-finite results.

The other examples return the current time for an IANA timezone through Intl, and search three built-in notes by keyword. All three run without external services. Tools return JSON values so results can go into conversation history and traces.

A model can request an unknown tool, send broken JSON, or use the wrong argument types. A valid call can also fail during execution. These cases become tool error messages rather than crashing the loop.

The execution boundary includes these lines:

const tool = registry.get(call.name);
if (!tool) throw new Error(`Unknown tool: ${call.name}`);
const args: unknown = JSON.parse(call.arguments);
const result = await withAbort(() => tool.invoke(args, signal ? { signal } : {}), signal);
// Reject undefined, non-finite numbers, and other non-JSON results at the boundary.
outcome = { result: z.json().parse(result) };

The surrounding catch produces { error: message }. Success produces { result: value }. Both are serialized into tool messages with the original call ID. The next model request can correct the arguments or explain the failure. Recovery is possible, but it is still the model's decision.

The default limit is eight model calls. A final answer counts as a step. A response can request several tools, and all calls on the last allowed step still execute. If another model request would exceed the limit, the result has stopReason: "max_steps" and empty output.

The other stop reasons are "final" and "aborted". An AbortSignal can interrupt waiting for a model or tool. Work that ignores cancellation may continue underneath, so pass the signal into any I/O you add. Step limits do not bound total tokens, elapsed time, or tool fan-out.

Every run returns a typed TraceEvent[]. Events cover model calls, tool calls, results, errors, final answers, and stopped runs. Each carries a step index and duration. The optional synchronous onEvent callback receives events in order for logs or a UI.

Tests use ScriptedModel to prescribe replies or inspect requests. One test forces an invalid argument, checks the error in the next request, requests a corrected calculation, and then returns a final answer. That tests the actual conversation flow without making a network request.

Trace assertions strip durations and compare the remaining event objects. Separate assertions check that durations are finite and nonnegative. Tests also cover unknown tools, multiple tool calls, cancellation, and the default limit. The OpenAI adapter uses mocked fetch in its test.

Trace data can contain sensitive arguments and results. Redact it before sending it to a logging service.

git clone https://github.com/buildfound/agent-loop-starter.git
cd agent-loop-starter
npm install
npm run agent -- --offline "what is 12*7?"
npm test

The offline demo extracts an arithmetic expression and scripts a calculator call followed by its answer. It is not an offline language model. For OpenAI, copy .env.example to .env, set OPENAI_API_KEY, and omit --offline. The adapter uses Chat Completions; OPENAI_MODEL defaults to gpt-4o-mini.

To extend the starter, define a tool with a bounded schema and add it to the array passed to runAgent. That array is the allowlist. Start with read-only capabilities. Tools that change external state need explicit approval boundaries; never add shell access without sandboxing.

A different provider only needs to implement Model.complete and translate messages and function definitions. The loop stays visible in one file, so changes to stopping rules, error handling, or trace events remain easy to review.

Built by BuildFound, a founder-led AI product studio.

── more in #ai-agents 4 stories · sorted by recency
── more on @agent loop starter 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/build-a-readable-typ…] indexed:0 read:5min 2026-11-15 · —