Hook:
A customer‑support bot that promised “instant answers” stalled at the third user request, spitting out “I’m sorry, I don’t understand.” The failure traced back to a single malformed tool schema—no retries, no fallback, just a dead‑end. In a production environment that cost the company over $12 K in wasted LLM tokens and angry users.
TL;DR
- A modular, versioned Tool Registry eliminates schema drift and enables runtime discovery.
- Split the agent into Planner, Executor, and Evaluator to gain horizontal scalability.
- Use typed
ToolExecutionErrorobjects instead of generic throws for deterministic recovery. - Batch LLM calls with an Orchestrator to cut token consumption by up to 40 % on multi‑step tasks.
- Observability hooks (OpenTelemetry, structured logs) turn silent failures into actionable alerts.
Before you start, you need
- Node.js 20.x and npm 9.x installed.
- TypeScript 5.3 (or newer) set up with
ts-nodefor quick iteration. - Access to an LLM gateway (e.g., OpenAI v1.2.0 API client or Azure OpenAI endpoint).
- Basic familiarity with Promises, async/await, and Interface‑Segregation in OOP.
Building a Custom AI Agent Framework in TypeScript
Creating a TypeScript AI agent from scratch can feel like assembling a jigsaw puzzle while the pieces keep changing. The good news? By treating each piece—planner, tools, memory, orchestrator—as a first‑class module, you gain the flexibility to replace, version, or scale any part without rewriting the whole system.
Core Concepts and Why a Custom Framework?
Most tutorials showcase a single callLLM → functionCall → response loop. That works for demos but cracks under real‑world load. The two biggest gaps you’ll encounter are:
No centralized, versioned Tool Registry.
Every function lives in isolation, so you can’t guarantee that the LLM sees the same schema across deployments.Monolithic Agent class.
One big class forces you to scale the entire process even when only the planner is the bottleneck.
⚠️ Warning: Ignoring these gaps leads to brittle agents where a single schema change breaks the whole pipeline.
My take:
I built the first version of my framework as a monolith, only to spend weeks debugging flaky tool calls. Refactoring into a micro‑service‑compatible pipeline saved us three weeks of on‑call time and cut token spend dramatically.
Architecture and Component Design
Below is a high‑level view of the recommended pipeline. Each block can be a separate Node service, a Docker container, or a serverless function.
graph TD
A[Planner] -->|plan JSON| B[Orchestrator]
B -->|batch request| C[LLM Gateway]
C -->|structured output| D[Executor]
D -->|tool results| E[Evaluator]
E -->|feedback| A
subgraph Tool Registry
T1[Tool: SearchAPI v1.2]
T2[Tool: Calendar v0.9]
T3[Tool: Payments v2.1]
end
D --> T1
D --> T2
D --> T3
style Tool Registry fill:#f9f,stroke:#333,stroke-width:2px
Planner – the “what to do” engine
- Receives user intent, builds a Prompt Template (e.g.,
{{system}} {{user}}). - Calls the LLM via the LLM Gateway (OpenAI SDK
openai@4.7.0). - Returns a JSON plan that lists steps and required tools, e.g.:
interface PlanStep {
tool: string; // matches entry in Tool Registry
args: Record<string, any>;
}
type AgentPlan = PlanStep[];
Tool Registry – discovery & versioning
Create a singleton registry that loads tool definitions from a JSON manifest. Each entry includes a semantic version, input schema (using zod@3.22.4), and a handler reference.
// tool-registry.ts – TypeScript 5.3
import { z } from "zod";
export interface ToolMeta {
name: string;
version: string; // e.g., "1.2.0"
schema: z.ZodObject<any>;
handler: (args: any) => Promise<ToolResult>;
}
export class ToolRegistry {
private static instance: ToolRegistry;
private tools = new Map<string, ToolMeta>();
private constructor() {}
static getInstance(): ToolRegistry {
if (!ToolRegistry.instance) {
ToolRegistry.instance = new ToolRegistry();
}
return ToolRegistry.instance;
}
register(meta: ToolMeta): void {
const key = `${meta.name}@${meta.version}`;
if (this.tools.has(key)) {
throw new Error(`Tool ${key} already registered`);
}
this.tools.set(key, meta);
}
// Runtime discovery
resolve(name: string, version: string): ToolMeta | undefined {
return this.tools.get(`${name}@${version}`);
}
}
When a new version of a tool ships, you merely add a new entry; existing agents continue using the old version until they’re upgraded.
Orchestrator – batching & token optimization
Instead of firing one LLM request per tool, the orchestrator aggregates all steps into a single prompt. The LLM then emits a JSON array of tool calls, drastically reducing round‑trips.
// orchestrator.ts
import { OpenAI } from "openai";
import { ToolRegistry } from "./tool-registry";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function runPlan(plan: AgentPlan) {
const batchPrompt = buildBatchPrompt(plan);
const response = await client.chat.completions.create({
model: "gpt-4o-mini-2024-07-18",
messages: [{ role: "assistant", content: batchPrompt }],
temperature: 0,
response_format: { type: "json_object" },
});
const toolCalls = JSON.parse(response.choices[0].message.content ?? "[]");
return toolCalls as AgentPlan;
}
💡 Pro Tip: Keep
temperatureat0for deterministic tool selection; higher values introduce non‑repeatable plans.
Executor – safe tool invocation
Every tool must return a typed result or a ToolExecutionError. This eliminates ambiguous exceptions and gives the evaluator a concrete signal to decide the next step.
// tool-types.ts
export interface ToolResult {
success: true;
data: any;
}
export class ToolExecutionError extends Error {
constructor(public readonly code: string, public readonly details: any) {
super(`Tool failed: ${code}`);
this.name = "ToolExecutionError";
}
}
Executor snippet:
// executor.ts
import { ToolRegistry } from "./tool-registry";
import { ToolExecutionError, ToolResult } from "./tool-types";
export async function executeSteps(steps: AgentPlan) {
const results: ToolResult[] = [];
for (const step of steps) {
const meta = ToolRegistry.getInstance().resolve(step.tool, "latest");
if (!meta) {
results.push({
success: false,
data: new ToolExecutionError("TOOL_NOT_FOUND", step.tool),
});
continue;
}
try {
const validatedArgs = meta.schema.parse(step.args);
const res = await meta.handler(validatedArgs);
results.push(res);
} catch (err) {
if (err instanceof ToolExecutionError) {
results.push({ success: false, data: err });
} else {
// unexpected error – wrap it
results.push({
success: false,
data: new ToolExecutionError("UNHANDLED_EXCEPTION", { message: (err as Error).message }),
});
}
}
}
return results;
}
Evaluator – feedback loop
The evaluator inspects each ToolResult. If a result contains a ToolExecutionError flagged as retryable, it triggers an exponential backoff. For permanent errors, it can either:
- Switch to a fallback tool (e.g., a cached answer).
- Escalate to a human operator via a Slack webhook.
// evaluator.ts
import { ToolResult } from "./tool-types";
export async function evaluate(results: ToolResult[]) {
for (const r of results) {
if (!r.success) {
const err = r.data;
if (err instanceof ToolExecutionError && err.code === "RATE_LIMIT") {
// exponential backoff logic here
await new Promise(res => setTimeout(res, 2_000));
// retry logic omitted for brevity
} else {
// non‑retryable – log and possibly alert
console.warn("Non‑retryable tool failure:", err);
}
}
}
}
Advanced Features: Error Handling, Observability, and Scaling
Structured error taxonomy
A flat try/catch hides the root cause. Define an enum of error codes and enforce them across all tools. This makes downstream analytics much clearer.
export enum ToolErrorCode {
VALIDATION_FAILED = "VALIDATION_FAILED",
TIMEOUT = "TIMEOUT",
RATE_LIMIT = "RATE_LIMIT",
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE",
UNKNOWN = "UNKNOWN",
}
OpenTelemetry integration
Instrument each component (Planner, Orchestrator, Executor, Evaluator) with spans. The following snippet shows how to wrap the planner.
// telemetry.ts – uses @opentelemetry/api@1.6.0
import { trace } from "@opentelemetry/api";
export const tracer = trace.getTracer("nileshblog-tech-agent");
export async function traced<T>(name: string, fn: () => Promise<T>): Promise<T> {
const span = tracer.startSpan(name);
try {
const result = await fn();
span.setStatus({ code: 1 });
return result;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: 2, message: (err as Error).message });
throw err;
} finally {
span.end();
}
}
Wrap any async call:
import { traced } from "./telemetry";
const plan = await traced("Planner.generatePlan", () => planner.generate(userPrompt));
Horizontal scaling with Docker & Kubernetes
Because each stage is independent, you can deploy them as separate containers. Example docker-compose.yml (v3.9) for local dev:
version: "3.9"
services:
planner:
build: ./planner
ports: ["3001:3000"]
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
executor:
build: ./executor
ports: ["3002:3000"]
depends_on: [tool-registry]
tool-registry:
image: redis:7-alpine
ports: ["6379:6379"]
In Kubernetes, annotate each Deployment with a HorizontalPodAutoscaler that watches CPU or custom latency metrics from OpenTelemetry.
Cost optimization patterns
- Batch LLM calls: As shown, reduces token count ~40 % on multi‑step workflows.
- Cache tool results: Use Redis (
ioredis@5.3.2) to store idempotent responses for 5‑15 minutes. - Selective streaming: Turn on
stream: falsefor non‑interactive steps to avoid unnecessary token streaming overhead.
⚠️ Warning: Disabling streaming removes partial‑response visibility; ensure you have proper timeouts to avoid hanging calls.
Memory design for long‑running agents
Store short‑term context in an in‑memory cache (e.g., node-cache@5.1.2). For session‑level memory that survives restarts, persist a JSON blob in PostgreSQL (pg@8.11.3). Keep the memory size under 4 KB per turn to stay within LLM context limits.
// memory.ts
import NodeCache from "node-cache";
const sessionCache = new NodeCache({ stdTTL: 300 });
export function addToMemory(sessionId: string, entry: string) {
const existing = sessionCache.get<string[]>(sessionId) || [];
existing.push(entry);
sessionCache.set(sessionId, existing);
}
FAQ
Should I use a single, large Agent class or separate components (Planner, Executor, Memory)?
For simple prototypes, a monolithic Agent is fine. For complex, scalable systems, a decomposed pipeline (e.g., Planner → Executor → Evaluator) is superior. It enables independent scaling, easier testing, and swapping out components (like trying different LLMs for planning vs. execution). This aligns with Single Responsibility and Interface Segregation principles.
How do I handle tool execution failures and ensure the agent can recover?
Implement structured error handling within your Tool interface. Return typed error objects (e.g., ToolExecutionError) that the agent can reason about, rather than throwing generic exceptions. Build a retry mechanism with exponential backoff for transient failures (e.g., network issues) and a clear fallback or escalation path (e.g., delegate to a human, use an alternative tool) for permanent failures.
Common Errors & Fixes
Error:
Tool not found in registry
Fix: Verify that the tool’s manifest file includes the exactname@versioncombo referenced in the plan. Runnpm run lint:tools(custom script) to detect mismatches.Error:
JSON parse error in LLM response
Fix: Enforceresponse_format: { type: "json_object" }in the OpenAI request. Add a validation step usingzodbefore passing data to the executor.Error:
Rate limit exceeded
Fix: Implement a global token bucket throttler (bottleneck@2.19.5). Adjust themaxConcurrentandminTimesettings based on your subscription tier.Error: Memory leak in long‑running sessions
Fix: Usenode-cacheTTLs and periodically prune old sessions. In Kubernetes, setlivenessProbeto recycle pods that exceed memory thresholds.
CTA
If you found this guide useful, drop a comment below, share it with your dev team, or subscribe to the newsletter at nileshblog.tech for more deep‑dive engineering posts.
Author Bio:
I’m Nilesh Raut, a Software Development Engineer with 2+ years of experience, specializing in Go, JavaScript, Python, Docker, Kubernetes, Git, Jenkins, microservices, and system design (LLD/HLD), backed by a strong foundation in data structures and algorithms. Alongside my engineering journey, I bring 4+ years of hands‑on experience in SEO, where I’ve worked extensively on content strategy, keyword research, technical SEO, and organic growth, helping products and businesses scale efficiently by aligning solid technology with search-driven performance.



