Stop Rewriting AI Agent Boilerplate: A Production-Ready TypeScript Foundation (Tool Use, Memory, Retries, Logging)
Every time I started a new AI agent project, I ended up rebuilding the same infrastructure.
The core agent loop, tool calling and execution, persistent memory storage, retry logic with exponential backoff, and structured logging for debugging.
The foundational infrastructure every serious AI agent depends on.
Over and over. Copy-pasting from older projects. Renaming variables. Adjusting edge cases. Hoping I didn’t introduce subtle bugs into the agent architecture.
If you’ve built more than one LLM-powered system, you know exactly what I mean.
At some point, I realized I wasn’t building agents — I was rebuilding scaffolding.
So I stopped.
Instead of rewriting the same AI agent boilerplate every time, I packaged everything into a clean, modular TypeScript foundation — a reusable agent architecture that handles tool use, memory persistence, retries, and structured logs out of the box.
In this article, I’ll break down exactly how the foundation works — so you can either use it directly or build your own production-ready AI agent framework from scratch.
The Problem With AI Agent Tutorials
Most tutorials show you this:
const response = await anthropic.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello!" }],
});
console.log(response.content);
That's fine for a demo. But the moment you try to do anything real, you hit walls:
-
What about tool calls? Claude doesn't just return text — it returns
tool_useblocks that you have to execute and feed back. - What about multi-turn? A real agent loops until the task is done, not just one request/response.
- What happens when the API fails? Rate limits, timeouts, network errors — none of the tutorials handle this.
- How do you persist memory? Your agent forgets everything the moment the process restarts.
I got tired of solving these same problems every single project. Here's how I solved them once and for all.
The Core Agent Loop
This is the heart of everything. Most tutorials get this wrong.
async function runAgent(task: string, config: AgentConfig): Promise<void> {
const messages: MessageParam[] = [
{ role: "user", content: task }
];
for (let turn = 0; turn < config.maxTurns; turn++) {
const response = await retryWithBackoff(() =>
anthropic.messages.create({
model: config.model,
max_tokens: config.maxTokens,
tools: getToolDefinitions(config.tools),
messages,
})
);
logger.info(`Turn ${turn + 1}`, {
stopReason: response.stop_reason,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
});
// Collect tool results from this turn
const toolResults: ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type === "text") {
logger.info("Agent:", { text: block.text });
} else if (block.type === "tool_use") {
logger.info("Tool call:", { name: block.name, input: block.input });
const result = await executeToolCall(block, config.tools);
logger.info("Tool result:", { name: block.name, result });
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: result,
});
}
}
// Add assistant response to history
messages.push({ role: "assistant", content: response.content });
// If there were tool calls, send results back
if (toolResults.length > 0) {
messages.push({ role: "user", content: toolResults });
}
// Stop when Claude is done
if (response.stop_reason === "end_turn" && toolResults.length === 0) {
logger.info("Agent completed task");
return;
}
}
logger.warn("Reached max turns without completing task");
}
Key things this handles that tutorials skip:
- Multiple tool calls per turn — Claude can call 3 tools at once. You need to execute all of them and return all results together.
-
Mixed content — A response can contain both text and tool calls. Loop through
response.content, not justresponse.content[0]. -
Correct turn structure — Tool results go in a
usermessage, not anassistantmessage. Get this wrong and you'll get API errors. -
Termination condition — Stop when
stop_reason === "end_turn"AND there are no pending tool calls.
Retry Logic That Actually Works
API calls fail. This is a fact of life. Rate limits, network blips, timeouts. Your agent will hit them.
Here's the retry implementation:
const RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504];
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelayMs = 1000
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
const isRetryable =
error instanceof Anthropic.APIError &&
RETRYABLE_STATUS_CODES.includes(error.status);
if (!isRetryable || attempt === maxRetries) {
throw error;
}
const delay = baseDelayMs * Math.pow(2, attempt);
const jitter = Math.random() * 200;
logger.warn(`API error, retrying in ${delay}ms`, {
attempt: attempt + 1,
status: (error as Anthropic.APIError).status,
});
await sleep(delay + jitter);
}
}
throw lastError!;
}
Exponential backoff with jitter — the standard pattern. Retries on 429 (rate limit), 500, 502, 503, 504. Fails fast on 400, 401, 403 (those are your bugs, not transient errors).
Persistent Memory
Real agents need memory. Here's a simple file-based store:
export class MemoryStore {
private data: Record<string, unknown> = {};
private filePath: string;
constructor(filePath: string) {
this.filePath = filePath;
this.load();
}
private load(): void {
try {
const raw = readFileSync(this.filePath, "utf-8");
this.data = JSON.parse(raw);
} catch {
this.data = {};
}
}
private save(): void {
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
}
set(key: string, value: unknown): void {
this.data[key] = value;
this.save();
}
get(key: string): unknown {
return this.data[key] ?? null;
}
keys(): string[] {
return Object.keys(this.data);
}
}
Dead simple. Reads from disk on init, writes on every change. Survives process restarts. For a production system you'd swap this for Redis or Postgres — but this gets you started without any dependencies.
The memory tools that Claude can call:
export const memoryWriteTool: ToolHandler = {
definition: {
name: "memory_write",
description: "Save a value to persistent memory under a given key. Use this to remember things between conversations.",
input_schema: {
type: "object",
properties: {
key: { type: "string", description: "The key to store the value under" },
value: { description: "The value to store (any JSON-serializable type)" },
},
required: ["key", "value"],
},
},
execute: async (input) => {
const { key, value } = input as { key: string; value: unknown };
memory.set(key, value);
return `Saved "${key}" to memory`;
},
};
The Tool Registry Pattern
Adding tools should be trivial. Here's the pattern that makes it 10 lines:
export interface ToolHandler {
definition: Tool;
execute: (input: Record<string, unknown>) => Promise<string>;
}
export function getToolDefinitions(tools: Record<string, ToolHandler>): Tool[] {
return Object.values(tools).map((t) => t.definition);
}
export async function executeToolCall(
block: ToolUseBlock,
tools: Record<string, ToolHandler>
): Promise<string> {
const handler = tools[block.name];
if (!handler) {
return `Error: Unknown tool "${block.name}"`;
}
try {
return await handler.execute(block.input as Record<string, unknown>);
} catch (error) {
return `Error executing ${block.name}: ${(error as Error).message}`;
}
}
To add a new tool:
export const myTool: ToolHandler = {
definition: {
name: "my_tool",
description: "What it does — be specific, Claude reads this.",
input_schema: {
type: "object",
properties: {
input: { type: "string", description: "The input" },
},
required: ["input"],
},
},
execute: async (input) => {
return `Processed: ${input["input"]}`;
},
};
Register it when creating your agent:
runAgent(task, {
tools: { my_tool: myTool, ...defaultTools },
});
That's it.
Structured Logging
You need to see what your agent is doing. console.log isn't enough in production.
type LogLevel = "debug" | "info" | "warn" | "error";
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0, info: 1, warn: 2, error: 3,
};
export const logger = {
info: (message: string, data?: Record<string, unknown>) =>
log("info", message, data),
warn: (message: string, data?: Record<string, unknown>) =>
log("warn", message, data),
error: (message: string, data?: Record<string, unknown>) =>
log("error", message, data),
};
function log(level: LogLevel, message: string, data?: Record<string, unknown>) {
const currentLevel = (process.env.LOG_LEVEL as LogLevel) ?? "info";
if (LOG_LEVELS[level] < LOG_LEVELS[currentLevel]) return;
const entry = {
timestamp: new Date().toISOString(),
level: level.toUpperCase(),
message,
...(data && { data }),
};
console.log(JSON.stringify(entry));
}
JSON logs. Timestamps. Log levels. Works with any log aggregation tool.
Putting It All Together
Here's a complete example — a research agent that looks something up and saves findings to memory:
import { runAgent } from "./agent";
import { calculatorTool } from "./tools/calculator";
import { memoryReadTool, memoryWriteTool } from "./tools/memory";
const task = process.argv[2] ?? "Research the Anthropic Claude API and summarize the key features. Save your summary to memory under the key 'claude_summary'.";
await runAgent(task, {
tools: {
calculator: calculatorTool,
memory_read: memoryReadTool,
memory_write: memoryWriteTool,
},
maxTurns: 20,
model: "claude-sonnet-4-6",
maxTokens: 4096,
});
Run it:
npm start "Calculate compound interest on $10,000 at 7% for 10 years and save the result to memory"
Output:
{"timestamp":"2026-02-27T04:00:00.000Z","level":"INFO","message":"Turn 1","data":{"stopReason":"tool_use","inputTokens":512,"outputTokens":128}}
{"timestamp":"2026-02-27T04:00:00.001Z","level":"INFO","message":"Tool call:","data":{"name":"calculator","input":{"expression":"10000 * (1 + 0.07)^10"}}}
{"timestamp":"2026-02-27T04:00:00.002Z","level":"INFO","message":"Tool result:","data":{"name":"calculator","result":"19671.51"}}
{"timestamp":"2026-02-27T04:00:00.100Z","level":"INFO","message":"Tool call:","data":{"name":"memory_write","input":{"key":"compound_interest_result","value":19671.51}}}
{"timestamp":"2026-02-27T04:00:00.101Z","level":"INFO","message":"Agent completed task"}
Get the Full Codebase
If you want the complete, ready-to-run codebase — all of the above wired together, TypeScript configured, examples included, .env.example set up — I've packaged it up.
AI Agent Boilerplate on Gumroad →
$39. MIT license. Clone it, own it, ship with it.
Or build it yourself from this article — everything you need is here.
Either way: stop rewriting the same scaffolding. Build the actual thing.
Built questions? Drop them in the comments-> happy to help.
Top comments (0)