5 Patterns I Use to Ship Production AI Agents in TypeScript
Five reliable patterns for shipping production AI agents in TypeScript: prompt design, tool schemas, server-side execution, retries, and observability.
5 Patterns I Use to Ship Production AI Agents in TypeScript
Building production AI agents in TypeScript involves more than just connecting a large language model (LLM) to a user interface. These systems call an LLM, take structured action via tools, run server-side, and reliably handle errors and retries. It's a world apart from a chat UI demo.
In this post, I'll walk you through the five patterns I reach for whenever I'm tasked with shipping something that real users depend on, like OpenAI tool calling, MCP (Model Context Protocol), and TypeScript-first validation libraries such as Zod and valibot. Let’s dive right in.
Why "Patterns" Beat "Frameworks" for AI Agents
LangChain-style frameworks abstract too aggressively for production agents where every retry, every tool call, and every error path matters. Hand-rolled patterns in TypeScript give you precise control over types, errors, observability, and bundle size.
The trap is that these frameworks demo beautifully, then fight you in production — exactly where control over error handling and retries matters most. That is the gap hand-rolled patterns close.
Pattern 1: Anchored System Prompts
The system prompt encapsulates the agent's personality, capabilities, and constraints in one document. Avoid inlining prompts as string literals at the call site. Instead, store them in versioned files, such as src/agents/<agent-name>/prompt.md, and import them with ?raw in Vite. This ensures prompts get reviewed in pull requests.
Anchoring the voice with concrete style references is crucial. For the Blogger Agent, this means quoting two of my existing posts to help the model imitate the cadence. Here's a simple setup for calling OpenAI's responses API:
import systemPrompt from './prompt.md?raw';
const messages = [
{ role: "system", content: systemPrompt },
{ role: "user", content: "Write a blog post about..." },
];
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages,
});Pitfall: prompts that drift over time without commits become impossible to debug. Treat them like code.
Pattern 2: Strict Tool Schemas with Zod
Models can hallucinate tool arguments. Validate every tool call response with Zod (or valibot) before you act on it. Never trust the model's output shape. Here's how a createBlogPost tool schema might look:
import { z } from 'zod';
const createBlogPostSchema = z.object({
title: z.string().min(1),
description: z.string(),
tags: z.array(z.string()).nonempty(),
body: z.string(),
});
// Inside your function
const toolCall = { /*...*/ };
createBlogPostSchema.parse(toolCall.arguments);Using zodToJsonSchema can keep the OpenAI tool definition and runtime validator in sync from a single source of truth.
Pitfall: schemas that are too loose. If the model returns an empty tags array and your code doesn't reject it, the post ships with no tags.
Pattern 3: Server-side Execution, Always
Never call the LLM from the client. Three reasons: API keys can leak, bundle size explodes, and rate limits apply per-user instead of per-server. For a TanStack Start blog, agents live in server/api/ routes or TanStack Start server functions. For Next.js, they reside in Route Handlers or Server Actions.
Here's an example of a server function:
async function handleRequest(input: CreateBlogInput) {
const user = await authenticateUser(input.token);
const response = await callLLM(input);
const validatedOutput = validateResponse(response);
await saveToDatabase(validatedOutput, user.id);
return validatedOutput;
}I deploy on Vercel, where server execution aligns with their serverless architecture, providing scalability and simplicity.
Pattern 4: Bounded Retries with Exponential Backoff
Production LLM calls fail. Rate limits, transient network errors, and content policy refusals on edge inputs are common. Wrap every model call in a retry helper:
async function withRetry<T>(fn: () => Promise<T>, retries = 3): Promise<T> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await fn();
} catch (error) {
if (isRetryable(error) && attempt < retries - 1) {
await delay(Math.pow(2, attempt) * 1000);
} else {
throw error;
}
}
}
}Distinguish retryable errors (e.g., 5xx, rate limit) from non-retryable ones (e.g., validation failure, content policy refusal). The latter requires a different prompt, not a retry.
Pitfall: silent retries with no telemetry. Without logging every attempt, you'll never see your real failure rate.
Pattern 5: Observability — Log Every Call, Every Token, Every Dollar
Every LLM call should emit a structured log event with details such as agent name, model, input token count, output token count, latency, cost estimate, and a redacted preview of the prompt.
Use Vercel's logging or any structured log sink to answer questions like "what did this agent cost yesterday?" Here's a simple logging helper:
function logLLMCall(event: LLMCallEvent) {
console.log({
agent: event.agentName,
model: event.model,
tokens: { input: event.inputTokens, output: event.outputTokens },
latency: event.latency,
cost: event.costEstimate,
success: event.success,
});
}OpenAI's usage field in the response can help map to a cost estimate per model.
Pitfall: ship without observability and the monthly bill becomes the only signal — by then you have weeks of spend you cannot attribute to any agent, model, or feature.
What I Would Not Do
I would not reach for an agent framework (LangChain, AutoGen, CrewAI) for a single-purpose agent. The abstraction tax is too high. I would not store prompts in a database for "easy editing". Prompts belong in version control next to the code that depends on them. I would not skip output validation because "GPT-4 is reliable enough now". It is not, and the failure modes are silent.
Final Thoughts
These five patterns are not novel — they are the boring stuff that separates a demo from a production agent. The interesting work is choosing what your agent does, not how reliably it does it.
For a concrete example of all five patterns in one codebase, check out my Blogger Agent project. To read about the journey of building an AI agent that manages my blog, head over to the Blogger Agent build post. For more about me, visit the about page.

Software engineer building AI-powered products and the guardrails that make AI-assisted development safe to ship. Web3 frontend at LimeChain. Based in Sofia, Bulgaria.