Back to Blog
TypeScriptAIAgentsArchitectureTutorial

Building Reliable AI Agents with TypeScript: Architecture Patterns That Scale

7 min read  · 1,344 wordsBy Orandi Felix

Building Reliable AI Agents with TypeScript: Architecture Patterns That Scale

The difference between an AI agent demo and an AI agent in production is usually not the model. It's everything around the model: how state is managed between calls, what happens when a tool call fails halfway through a multi-step task, and whether the whole thing can be debugged six weeks later when it does something strange in front of a customer.

I've rebuilt the same agent architecture in TypeScript three times now, each time removing something that seemed clever at first and turned out to make debugging harder. What's left is a small set of patterns that hold up once an agent is actually running unattended.

Model State As Data, Not As Control Flow#

The most common mistake I see is treating an agent's state as implicit, scattered across variables and closures inside a long-running function. That works fine until the process crashes mid-task, or you need to resume a conversation from where it left off, or you want to log exactly what the agent was thinking at step four.

The fix is to make state an explicit, serializable object from the start.

interface AgentState {
  id: string;
  status: "idle" | "thinking" | "executing_tool" | "waiting_for_input" | "done" | "failed";
  messages: Message[];
  currentStep: number;
  toolCallHistory: ToolCall[];
  context: Record<string, unknown>;
  error?: { message: string; step: number; recoverable: boolean };
}
 
interface ToolCall {
  id: string;
  toolName: string;
  input: unknown;
  output?: unknown;
  status: "pending" | "success" | "failed";
  timestamp: string;
}

Because this is just data, you can serialize it to a database after every step, resume from a crash by loading the last saved state, and replay a failed run in a test to see exactly what happened. None of that is possible if state lives only in memory inside a running function.

The Agent Loop, Kept Boring On Purpose#

Agent loops get complicated fast when people try to handle every case inline. Keeping the core loop small, and pushing the interesting logic into the state transitions, makes the whole thing much easier to reason about.

async function runAgentStep(state: AgentState, tools: ToolRegistry): Promise<AgentState> {
  if (state.status === "done" || state.status === "failed") {
    return state;
  }
 
  try {
    const decision = await getNextAction(state);
 
    if (decision.type === "tool_call") {
      return await executeTool(state, decision.toolName, decision.input, tools);
    }
 
    if (decision.type === "final_answer") {
      return {
        ...state,
        status: "done",
        messages: [...state.messages, { role: "assistant", content: decision.content }],
      };
    }
 
    return { ...state, status: "waiting_for_input" };
  } catch (err) {
    return {
      ...state,
      status: "failed",
      error: {
        message: err instanceof Error ? err.message : "Unknown error",
        step: state.currentStep,
        recoverable: false,
      },
    };
  }
}

Every branch returns a new state object rather than mutating the old one. That single discipline, treating state as immutable, is what makes it safe to log every intermediate state for debugging without worrying about a later step silently changing what you already logged.

Tool Calls Need Their Own Error Boundary#

A tool call failing is not the same kind of problem as the agent itself crashing, and conflating the two makes error handling much harder than it needs to be. A tool might fail because of a bad network call, a rate limit, or invalid input from the model. Most of those are recoverable if the agent knows about them.

async function executeTool(
  state: AgentState,
  toolName: string,
  input: unknown,
  tools: ToolRegistry
): Promise<AgentState> {
  const tool = tools.get(toolName);
  if (!tool) {
    return appendToolResult(state, toolName, input, {
      status: "failed",
      error: `Unknown tool: ${toolName}`,
    });
  }
 
  const parsed = tool.inputSchema.safeParse(input);
  if (!parsed.success) {
    // Feed the validation error back to the model instead of crashing.
    // Most models can self-correct a malformed tool call on the next turn.
    return appendToolResult(state, toolName, input, {
      status: "failed",
      error: `Invalid input: ${parsed.error.message}`,
    });
  }
 
  try {
    const output = await tool.execute(parsed.data);
    return appendToolResult(state, toolName, input, { status: "success", output });
  } catch (err) {
    return appendToolResult(state, toolName, input, {
      status: "failed",
      error: err instanceof Error ? err.message : "Tool execution failed",
    });
  }
}

The key decision here is that a failed tool call becomes part of the conversation state, not an exception that unwinds the whole agent. The model sees the error and gets a chance to try something different on the next step. Reserve actual thrown exceptions for failures the agent has no way to recover from.

Validating tool input with a schema (zod works well here) before execution catches a whole category of bugs where the model calls a tool with slightly malformed arguments. Feeding that validation error back as a tool result, rather than crashing, lets the model self-correct most of the time.

Defining Tools As Typed Contracts#

Tools are the interface between the model's text output and your actual code, so it's worth being deliberate about how they're defined. A typed tool registry catches mismatches at compile time instead of in production.

import { z } from "zod";
 
interface Tool<TInput = unknown, TOutput = unknown> {
  name: string;
  description: string;
  inputSchema: z.ZodType<TInput>;
  execute: (input: TInput) => Promise<TOutput>;
}
 
function defineTool<TInput, TOutput>(config: Tool<TInput, TOutput>): Tool<TInput, TOutput> {
  return config;
}
 
const searchDocsTool = defineTool({
  name: "search_docs",
  description: "Search internal documentation for relevant context",
  inputSchema: z.object({
    query: z.string().min(1),
    limit: z.number().min(1).max(20).default(5),
  }),
  execute: async ({ query, limit }) => {
    const results = await searchDocuments(query, limit);
    return results.map((r) => ({ title: r.title, excerpt: r.excerpt, url: r.url }));
  },
});
 
class ToolRegistry {
  private tools = new Map<string, Tool>();
 
  register(tool: Tool) {
    this.tools.set(tool.name, tool);
  }
 
  get(name: string): Tool | undefined {
    return this.tools.get(name);
  }
 
  getSchemasForModel() {
    return Array.from(this.tools.values()).map((t) => ({
      name: t.name,
      description: t.description,
      parameters: t.inputSchema,
    }));
  }
}

This pays off most when an agent has more than three or four tools. Without a shared contract, it's easy for a tool's actual implementation to drift from what the model was told about it, usually discovered only when the agent starts calling it wrong.

Observability Is Not Optional#

An agent that works in testing and misbehaves in production without leaving a trace is nearly impossible to fix. Every step of the loop above returns state that's already structured for logging, so wiring up observability is mostly a matter of actually doing it consistently.

function logStep(state: AgentState) {
  console.log(
    JSON.stringify({
      agentId: state.id,
      step: state.currentStep,
      status: state.status,
      lastToolCall: state.toolCallHistory.at(-1),
      timestamp: new Date().toISOString(),
    })
  );
}
 
async function runAgent(initialState: AgentState, tools: ToolRegistry, maxSteps = 20) {
  let state = initialState;
 
  while (state.status !== "done" && state.status !== "failed" && state.currentStep < maxSteps) {
    state = await runAgentStep(state, tools);
    state = { ...state, currentStep: state.currentStep + 1 };
    logStep(state);
    await persistState(state); // save after every step, not just at the end
  }
 
  if (state.currentStep >= maxSteps && state.status !== "done") {
    state = { ...state, status: "failed", error: { message: "Max steps exceeded", step: state.currentStep, recoverable: false } };
  }
 
  return state;
}

Persisting state after every step, not just at the end, is what makes a crashed agent resumable instead of just lost. It also means when something goes wrong at 3am, you have the exact sequence of states leading up to it instead of a stack trace and a guess.

What This Doesn't Solve#

This architecture handles state, tool failures, and observability well, but it doesn't solve prompt design, and a badly designed prompt will still produce an agent that calls the wrong tools confidently. It also doesn't solve concurrency: if you need multiple agents collaborating or a single agent handling parallel tool calls, the state model above needs to be extended to handle merge conflicts, which is a genuinely harder problem. And a max-step limit is a blunt safety net, not a substitute for actually understanding why an agent might loop in the first place.

Start with the state model. Once state is explicit and serializable, most of the other reliability problems become much easier to see and fix one at a time, instead of debugging a black box that only exists in memory while it's running.

Share this article: