{"slug": "build-a-readable-typescript-agent-loop-with-tools-and-traces", "title": "Build a readable TypeScript agent loop with tools and traces", "summary": "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.", "body_md": "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.\n\nI put that approach into [Agent Loop Starter](https://github.com/buildfound/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.\n\nThe 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.\n\nThe 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.\n\nHere is the model-call section from `src/agent.ts`, inside `while (steps < maxSteps)`:\n\n``` js\nif (signal?.aborted) return stopped(\"aborted\");\nconst step = ++steps;\nconst started = performance.now();\nlet response;\ntry {\n  try {\n    response = await withAbort(() => model.complete({ messages, tools: definitions, ...(signal ? { signal } : {}) }), signal);\n  } finally {\n    emit({ type: \"model_call\", step, durationMs: performance.now() - started });\n  }\n} catch (error) {\n  if (signal?.aborted) return stopped(\"aborted\");\n  throw error; // Transport/auth errors are different from recoverable tool errors.\n}\n```\n\n`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.\n\nThe next decision is short:\n\n```\nif (signal?.aborted) return stopped(\"aborted\");\nmessages.push({ role: \"assistant\", ...response });\nif (!response.toolCalls?.length) {\n  emit({ type: \"final\", step, durationMs: 0, output: response.content });\n  return { output: response.content, stopReason: \"final\", steps, trace };\n}\n```\n\nA 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.\n\nA 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.\n\nThis is the calculator definition from `src/tools/calculator.ts`:\n\n``` js\nexport const calculator = defineTool({\n  name: \"calculator\",\n  description: \"Calculate arithmetic with +, -, *, /, %, parentheses, decimals and unary signs. No exponents. Uses JavaScript floating-point numbers.\",\n  schema: z.object({ expression: z.string().min(1).max(512) }).strict(),\n  execute: ({ expression }) => ({ value: evaluateExpression(expression) }),\n});\n```\n\n`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.\n\nThe 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.\n\nA 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.\n\nThe execution boundary includes these lines:\n\n``` js\nconst tool = registry.get(call.name);\nif (!tool) throw new Error(`Unknown tool: ${call.name}`);\nconst args: unknown = JSON.parse(call.arguments);\nconst result = await withAbort(() => tool.invoke(args, signal ? { signal } : {}), signal);\n// Reject undefined, non-finite numbers, and other non-JSON results at the boundary.\noutcome = { result: z.json().parse(result) };\n```\n\nThe 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.\n\nThe 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.\n\nThe 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.\n\nEvery 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.\n\nTests 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.\n\nTrace 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.\n\nTrace data can contain sensitive arguments and results. Redact it before sending it to a logging service.\n\n```\ngit clone https://github.com/buildfound/agent-loop-starter.git\ncd agent-loop-starter\nnpm install\nnpm run agent -- --offline \"what is 12*7?\"\nnpm test\n```\n\nThe 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`.\n\nTo 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.\n\nA 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.\n\nBuilt by [BuildFound](https://buildfound.com), a founder-led AI product studio.", "url": "https://wpnews.pro/news/build-a-readable-typescript-agent-loop-with-tools-and-traces", "canonical_source": "https://dev.to/fekdaoui_/build-a-readable-typescript-agent-loop-with-tools-and-traces-5h72", "published_at": "2026-11-15 17:07:31+00:00", "updated_at": "2026-11-15 17:19:18.609465+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "large-language-models"], "entities": ["Agent Loop Starter", "TypeScript", "Zod", "OpenAI SDK", "GitHub"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/build-a-readable-typescript-agent-loop-with-tools-and-traces", "markdown": "https://wpnews.pro/news/build-a-readable-typescript-agent-loop-with-tools-and-traces.md", "text": "https://wpnews.pro/news/build-a-readable-typescript-agent-loop-with-tools-and-traces.txt", "jsonld": "https://wpnews.pro/news/build-a-readable-typescript-agent-loop-with-tools-and-traces.jsonld"}}