Building Production Agentic AI Systems - Complete Tool Calling Implementation
Step-by-step guide to building a production-ready agentic AI system with tool calling, error handling, rate limiting, and observability. Includes complete working code and GitHub repository with TypeScript implementation using Claude API.
Prerequisites
- TypeScript and Node.js experience
- OpenAI or Anthropic API key
- Understanding of AI agent concepts
- Basic knowledge of async/await patterns
What You'll Learn
- Build production-ready agentic AI system with tool calling
- Implement error handling and rate limiting for AI agents
- Add observability and monitoring to AI systems
- Deploy scalable AI agents with TypeScript
- Handle real-world edge cases in production AI
Technologies Covered
Agentic AI systems that can call tools, write files, and interact with external systems represent the next evolution in AI capabilities. According to Anthropic's recent data, agentic workloads now exceed 30% of enterprise AI usage—a fundamental shift from passive completion to autonomous action-taking.
But building production-ready agentic systems requires more than just connecting an LLM to a few APIs. You need robust error handling, rate limiting, state management, observability, and security controls.
This tutorial walks you through building a complete production agentic AI system from scratch. You'll create a working agent that can:
- Call external tools (web search, file operations, database queries)
- Handle errors gracefully with retry logic
- Respect rate limits and manage API quotas
- Maintain conversation state across turns
- Log all actions for debugging and compliance
- Implement security controls and input validation
All code is available in the companion GitHub repository: github.com/CrashBytes/ByteSizedExamples/tree/main/crashbytes-tutorial-agentic-ai-tools
By the end of this tutorial, you'll have a working agentic system you can deploy to production.
Prerequisites
Before starting, you should have:
- Node.js 18+ installed
- TypeScript knowledge (intermediate level)
- Anthropic API key (get one here)
- Basic understanding of async/await and promises
- Familiarity with REST APIs
Time to complete: 2-3 hours
Difficulty: Intermediate
Architecture Overview
Our agentic system follows this architecture:
┌─────────────────────────────────────────────────────┐
│ Agent Controller │
│ - Conversation management │
│ - Tool execution orchestration │
│ - Error handling and retry logic │
└─────────────────────────────────────────────────────┘
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Claude API │ │ Tool System │ │ State Manager│
│ │ │ │ │ │
│ - Messages │ │ - Search │ │ - History │
│ - Tool use │ │ - Files │ │ - Context │
│ - Responses │ │ - Database │ │ - Sessions │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
└────────────────┼────────────────┘
│
▼
┌─────────────────────┐
│ Observability │
│ - Logging │
│ - Metrics │
│ - Tracing │
└─────────────────────┘
Step 1: Project Setup
Clone the repository and install dependencies:
git clone https://github.com/CrashBytes/ByteSizedExamples.git
cd ByteSizedExamples/crashbytes-tutorial-agentic-ai-tools
npm install
Or start from scratch:
mkdir agentic-ai-tutorial
cd agentic-ai-tutorial
npm init -y
npm install @anthropic-ai/sdk zod winston dotenv
npm install -D typescript @types/node tsx
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Create .env:
ANTHROPIC_API_KEY=your_api_key_here
LOG_LEVEL=info
MAX_RETRIES=3
RATE_LIMIT_PER_MINUTE=50
Step 2: Tool System Foundation
Create src/tools/types.ts:
import { z } from 'zod'
// Tool definition schema
export const ToolSchema = z.object({
name: z.string(),
description: z.string(),
input_schema: z.object({
type: z.literal('object'),
properties: z.record(z.any()),
required: z.array(z.string()).optional(),
}),
})
export type Tool = z.infer<typeof ToolSchema>
// Tool execution result
export interface ToolResult {
success: boolean
data?: any
error?: string
execution_time_ms: number
}
// Tool executor interface
export interface ToolExecutor {
execute(input: Record<string, any>): Promise<ToolResult>
}
Create src/tools/search-tool.ts:
import { Tool, ToolExecutor, ToolResult } from './types'
export class SearchTool implements ToolExecutor {
private readonly tool: Tool = {
name: 'web_search',
description:
'Search the web for current information. Use this when you need up-to-date data or facts beyond your training.',
input_schema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query string',
},
max_results: {
type: 'number',
description: 'Maximum number of results to return (default 5)',
},
},
required: ['query'],
},
}
getDefinition(): Tool {
return this.tool
}
async execute(input: Record<string, any>): Promise<ToolResult> {
const start = Date.now()
try {
const { query, max_results = 5 } = input
// Input validation
if (!query || typeof query !== 'string') {
return {
success: false,
error: 'Invalid query parameter',
execution_time_ms: Date.now() - start,
}
}
// Simulate web search (replace with actual search API)
const results = await this.performSearch(query, max_results)
return {
success: true,
data: results,
execution_time_ms: Date.now() - start,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
execution_time_ms: Date.now() - start,
}
}
}
private async performSearch(
query: string,
maxResults: number
): Promise<any[]> {
// In production, replace with actual search API (Brave, Bing, Google)
// This is a mock implementation
await new Promise(resolve => setTimeout(resolve, 100))
return [
{
title: `Result for: ${query}`,
url: 'https://example.com/result',
snippet: `Information about ${query}...`,
},
].slice(0, maxResults)
}
}
Create src/tools/file-tool.ts:
import { promises as fs } from 'fs'
import { join } from 'path'
import { Tool, ToolExecutor, ToolResult } from './types'
export class FileTool implements ToolExecutor {
private readonly allowedDir: string
constructor(allowedDir: string = './data') {
this.allowedDir = allowedDir
}
getDefinition(): Tool {
return {
name: 'write_file',
description:
'Write content to a file in the allowed directory. Creates parent directories if needed.',
input_schema: {
type: 'object',
properties: {
filename: {
type: 'string',
description: 'Name of the file to write',
},
content: {
type: 'string',
description: 'Content to write to the file',
},
},
required: ['filename', 'content'],
},
}
}
async execute(input: Record<string, any>): Promise<ToolResult> {
const start = Date.now()
try {
const { filename, content } = input
// Security: Validate filename
if (!this.isValidFilename(filename)) {
return {
success: false,
error: 'Invalid filename or path traversal detected',
execution_time_ms: Date.now() - start,
}
}
const filepath = join(this.allowedDir, filename)
// Ensure directory exists
await fs.mkdir(this.allowedDir, { recursive: true })
// Write file
await fs.writeFile(filepath, content, 'utf-8')
return {
success: true,
data: { filepath, bytes_written: Buffer.byteLength(content) },
execution_time_ms: Date.now() - start,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
execution_time_ms: Date.now() - start,
}
}
}
private isValidFilename(filename: string): boolean {
// Prevent path traversal attacks
if (
filename.includes('..') ||
filename.includes('/') ||
filename.includes('\\')
) {
return false
}
// Only allow safe characters
const safePattern = /^[a-zA-Z0-9_\-\.]+$/
return safePattern.test(filename)
}
}
Step 3: Tool Registry
Create src/tools/registry.ts:
import { Tool, ToolExecutor } from './types'
import { SearchTool } from './search-tool'
import { FileTool } from './file-tool'
export class ToolRegistry {
private tools = new Map<string, ToolExecutor>()
constructor() {
// Register default tools
this.register(new SearchTool())
this.register(new FileTool())
}
register(executor: ToolExecutor): void {
const definition = executor.getDefinition()
this.tools.set(definition.name, executor)
}
getExecutor(toolName: string): ToolExecutor | undefined {
return this.tools.get(toolName)
}
getAllDefinitions(): Tool[] {
return Array.from(this.tools.values()).map(executor =>
executor.getDefinition()
)
}
has(toolName: string): boolean {
return this.tools.has(toolName)
}
}
Step 4: Rate Limiting
Create src/utils/rate-limiter.ts:
export class RateLimiter {
private requests: number[] = []
private readonly maxRequests: number
private readonly windowMs: number
constructor(maxRequests: number, windowMs: number = 60000) {
this.maxRequests = maxRequests
this.windowMs = windowMs
}
async acquire(): Promise<void> {
const now = Date.now()
// Remove requests outside the window
this.requests = this.requests.filter(
timestamp => now - timestamp < this.windowMs
)
// Check if we've hit the limit
if (this.requests.length >= this.maxRequests) {
const oldestRequest = this.requests[0]
const waitTime = this.windowMs - (now - oldestRequest)
if (waitTime > 0) {
await new Promise(resolve => setTimeout(resolve, waitTime))
return this.acquire() // Retry after waiting
}
}
this.requests.push(now)
}
getStats(): { current: number; max: number; window_ms: number } {
const now = Date.now()
this.requests = this.requests.filter(
timestamp => now - timestamp < this.windowMs
)
return {
current: this.requests.length,
max: this.maxRequests,
window_ms: this.windowMs,
}
}
}
Step 5: Error Handling and Retry Logic
Create src/utils/retry.ts:
export interface RetryConfig {
maxAttempts: number
baseDelayMs: number
maxDelayMs: number
shouldRetry?: (error: any) => boolean
}
export async function withRetry<T>(
fn: () => Promise<T>,
config: RetryConfig
): Promise<T> {
let lastError: any
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error
// Check if we should retry this error
if (config.shouldRetry && !config.shouldRetry(error)) {
throw error
}
// Don't wait after the last attempt
if (attempt === config.maxAttempts) {
break
}
// Exponential backoff with jitter
const delay = Math.min(
config.baseDelayMs * Math.pow(2, attempt - 1),
config.maxDelayMs
)
const jitter = Math.random() * 0.3 * delay // 30% jitter
await new Promise(resolve => setTimeout(resolve, delay + jitter))
}
}
throw lastError
}
export function isRetryableError(error: any): boolean {
// Retry on network errors
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
return true
}
// Retry on 429 (rate limit) and 5xx errors
if (error.status === 429 || (error.status >= 500 && error.status < 600)) {
return true
}
return false
}
Step 6: State Management
Create src/state/conversation-state.ts:
import { MessageParam } from '@anthropic-ai/sdk/resources/messages'
export interface ConversationState {
session_id: string
messages: MessageParam[]
created_at: Date
updated_at: Date
metadata: Record<string, any>
}
export class StateManager {
private sessions = new Map<string, ConversationState>()
create(
sessionId: string,
metadata: Record<string, any> = {}
): ConversationState {
const state: ConversationState = {
session_id: sessionId,
messages: [],
created_at: new Date(),
updated_at: new Date(),
metadata,
}
this.sessions.set(sessionId, state)
return state
}
get(sessionId: string): ConversationState | undefined {
return this.sessions.get(sessionId)
}
addMessage(sessionId: string, message: MessageParam): void {
const state = this.sessions.get(sessionId)
if (!state) {
throw new Error(`Session ${sessionId} not found`)
}
state.messages.push(message)
state.updated_at = new Date()
}
clear(sessionId: string): void {
this.sessions.delete(sessionId)
}
getAllSessions(): ConversationState[] {
return Array.from(this.sessions.values())
}
}
Step 7: Logging and Observability
Create src/utils/logger.ts:
import winston from 'winston'
const logLevel = process.env.LOG_LEVEL || 'info'
export const logger = winston.createLogger({
level: logLevel,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
const metaStr = Object.keys(meta).length
? JSON.stringify(meta, null, 2)
: ''
return `${timestamp} [${level}]: ${message} ${metaStr}`
})
),
}),
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
],
})
// Metrics tracking
export class Metrics {
private static counters = new Map<string, number>()
private static gauges = new Map<string, number>()
static increment(name: string, value: number = 1): void {
const current = this.counters.get(name) || 0
this.counters.set(name, current + value)
}
static gauge(name: string, value: number): void {
this.gauges.set(name, value)
}
static getAll(): {
counters: Record<string, number>
gauges: Record<string, number>
} {
return {
counters: Object.fromEntries(this.counters),
gauges: Object.fromEntries(this.gauges),
}
}
static reset(): void {
this.counters.clear()
this.gauges.clear()
}
}
Step 8: Agent Controller
Create src/agent/controller.ts:
import Anthropic from '@anthropic-ai/sdk'
import { MessageParam } from '@anthropic-ai/sdk/resources/messages'
import { ToolRegistry } from '../tools/registry'
import { StateManager } from '../state/conversation-state'
import { RateLimiter } from '../utils/rate-limiter'
import { withRetry, isRetryableError } from '../utils/retry'
import { logger, Metrics } from '../utils/logger'
export interface AgentConfig {
apiKey: string
model: string
maxTokens: number
temperature: number
maxIterations: number
}
export class AgentController {
private client: Anthropic
private config: AgentConfig
private toolRegistry: ToolRegistry
private stateManager: StateManager
private rateLimiter: RateLimiter
constructor(config: AgentConfig) {
this.config = config
this.client = new Anthropic({ apiKey: config.apiKey })
this.toolRegistry = new ToolRegistry()
this.stateManager = new StateManager()
this.rateLimiter = new RateLimiter(
parseInt(process.env.RATE_LIMIT_PER_MINUTE || '50')
)
}
async processMessage(
sessionId: string,
userMessage: string
): Promise<string> {
logger.info('Processing message', {
sessionId,
message_length: userMessage.length,
})
Metrics.increment('messages_processed')
// Get or create session
let state = this.stateManager.get(sessionId)
if (!state) {
state = this.stateManager.create(sessionId)
}
// Add user message
this.stateManager.addMessage(sessionId, {
role: 'user',
content: userMessage,
})
// Process with tool calling loop
let iterations = 0
let finalResponse = ''
while (iterations < this.config.maxIterations) {
iterations++
logger.debug('Agent iteration', { sessionId, iteration: iterations })
const response = await this.callClaude(state.messages)
// Check stop reason
if (response.stop_reason === 'end_turn') {
// Extract text response
const textContent = response.content.find(
block => block.type === 'text'
)
if (textContent && textContent.type === 'text') {
finalResponse = textContent.text
}
// Add assistant message to history
this.stateManager.addMessage(sessionId, {
role: 'assistant',
content: response.content,
})
break
}
if (response.stop_reason === 'tool_use') {
// Execute tools
const toolResults = await this.executeTools(response.content)
// Add assistant message with tool use
this.stateManager.addMessage(sessionId, {
role: 'assistant',
content: response.content,
})
// Add tool results
this.stateManager.addMessage(sessionId, {
role: 'user',
content: toolResults,
})
// Continue loop to process tool results
continue
}
// Unexpected stop reason
logger.warn('Unexpected stop reason', {
sessionId,
stop_reason: response.stop_reason,
})
break
}
if (iterations >= this.config.maxIterations) {
logger.warn('Max iterations reached', { sessionId })
Metrics.increment('max_iterations_reached')
}
logger.info('Message processed', {
sessionId,
iterations,
response_length: finalResponse.length,
})
return finalResponse
}
private async callClaude(messages: MessageParam[]): Promise<any> {
await this.rateLimiter.acquire()
Metrics.increment('api_calls')
const start = Date.now()
try {
const response = await withRetry(
() =>
this.client.messages.create({
model: this.config.model,
max_tokens: this.config.maxTokens,
temperature: this.config.temperature,
messages,
tools: this.toolRegistry.getAllDefinitions(),
}),
{
maxAttempts: parseInt(process.env.MAX_RETRIES || '3'),
baseDelayMs: 1000,
maxDelayMs: 10000,
shouldRetry: isRetryableError,
}
)
const duration = Date.now() - start
Metrics.gauge('api_latency_ms', duration)
logger.debug('Claude API call succeeded', { duration_ms: duration })
return response
} catch (error) {
Metrics.increment('api_errors')
logger.error('Claude API call failed', { error })
throw error
}
}
private async executeTools(content: any[]): Promise<any[]> {
const results: any[] = []
for (const block of content) {
if (block.type === 'tool_use') {
const { id, name, input } = block
logger.info('Executing tool', { tool_name: name, tool_use_id: id })
Metrics.increment(`tool_executions.${name}`)
const executor = this.toolRegistry.getExecutor(name)
if (!executor) {
logger.error('Tool not found', { tool_name: name })
results.push({
type: 'tool_result',
tool_use_id: id,
content: `Error: Tool ${name} not found`,
is_error: true,
})
continue
}
const result = await executor.execute(input)
if (result.success) {
logger.info('Tool executed successfully', {
tool_name: name,
execution_time_ms: result.execution_time_ms,
})
Metrics.gauge(`tool_latency.${name}`, result.execution_time_ms)
results.push({
type: 'tool_result',
tool_use_id: id,
content: JSON.stringify(result.data),
})
} else {
logger.error('Tool execution failed', {
tool_name: name,
error: result.error,
})
Metrics.increment(`tool_errors.${name}`)
results.push({
type: 'tool_result',
tool_use_id: id,
content: `Error: ${result.error}`,
is_error: true,
})
}
}
}
return results
}
getMetrics(): any {
return {
...Metrics.getAll(),
rate_limiter: this.rateLimiter.getStats(),
active_sessions: this.stateManager.getAllSessions().length,
}
}
}
Step 9: Main Application
Create src/index.ts:
import 'dotenv/config'
import { AgentController, AgentConfig } from './agent/controller'
import { logger } from './utils/logger'
import { randomUUID } from 'crypto'
async function main() {
const config: AgentConfig = {
apiKey: process.env.ANTHROPIC_API_KEY!,
model: 'claude-sonnet-4-20250514',
maxTokens: 4096,
temperature: 1.0,
maxIterations: 10,
}
const agent = new AgentController(config)
// Example conversation
const sessionId = randomUUID()
logger.info('Starting agent demo', { session_id: sessionId })
// Message 1: Simple query
console.log('\n=== Query 1: Simple question ===')
const response1 = await agent.processMessage(
sessionId,
'What is the current status of AI development in 2025?'
)
console.log('Assistant:', response1)
// Message 2: Tool use - web search
console.log('\n=== Query 2: Web search ===')
const response2 = await agent.processMessage(
sessionId,
'Search for recent news about agentic AI systems'
)
console.log('Assistant:', response2)
// Message 3: Tool use - file writing
console.log('\n=== Query 3: File writing ===')
const response3 = await agent.processMessage(
sessionId,
'Write a summary of our conversation to a file called summary.txt'
)
console.log('Assistant:', response3)
// Show metrics
console.log('\n=== Metrics ===')
console.log(JSON.stringify(agent.getMetrics(), null, 2))
}
main().catch(error => {
logger.error('Fatal error', { error })
process.exit(1)
})
Step 10: Running the Agent
Add to package.json:
{
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
Run the agent:
npm run dev
Expected output:
2025-12-15T10:00:00.000Z [info]: Starting agent demo {"session_id":"abc-123"}
=== Query 1: Simple question ===
2025-12-15T10:00:00.100Z [info]: Processing message {"sessionId":"abc-123","message_length":55}
2025-12-15T10:00:00.500Z [debug]: Agent iteration {"sessionId":"abc-123","iteration":1}
2025-12-15T10:00:01.200Z [debug]: Claude API call succeeded {"duration_ms":700}
Assistant: AI development in 2025 has reached several key milestones...
=== Query 2: Web search ===
2025-12-15T10:00:02.000Z [info]: Processing message {"sessionId":"abc-123","message_length":48}
2025-12-15T10:00:02.100Z [debug]: Agent iteration {"sessionId":"abc-123","iteration":1}
2025-12-15T10:00:02.800Z [info]: Executing tool {"tool_name":"web_search","tool_use_id":"toolu_123"}
2025-12-15T10:00:02.900Z [info]: Tool executed successfully {"tool_name":"web_search","execution_time_ms":100}
2025-12-15T10:00:03.600Z [debug]: Agent iteration {"sessionId":"abc-123","iteration":2}
Assistant: Based on my search, recent developments in agentic AI...
=== Query 3: File writing ===
2025-12-15T10:00:04.000Z [info]: Processing message {"sessionId":"abc-123","message_length":62}
2025-12-15T10:00:04.100Z [info]: Executing tool {"tool_name":"write_file","tool_use_id":"toolu_456"}
2025-12-15T10:00:04.150Z [info]: Tool executed successfully {"tool_name":"write_file","execution_time_ms":50}
Assistant: I've written a summary to summary.txt...
=== Metrics ===
{
"counters": {
"messages_processed": 3,
"api_calls": 5,
"tool_executions.web_search": 1,
"tool_executions.write_file": 1
},
"gauges": {
"api_latency_ms": 700,
"tool_latency.web_search": 100,
"tool_latency.write_file": 50
},
"rate_limiter": {
"current": 5,
"max": 50,
"window_ms": 60000
},
"active_sessions": 1
}
Production Deployment Considerations
Security
Input Validation: Always validate tool inputs before execution. The file tool demonstrates path traversal protection.
Tool Permissions: Implement role-based access control (RBAC) for tools:
interface ToolPermission {
tool_name: string
allowed_roles: string[]
}
class PermissionManager {
check(userRole: string, toolName: string): boolean {
// Implementation
}
}
API Key Rotation: Store API keys in secrets management systems (AWS Secrets Manager, HashiCorp Vault).
Rate Limiting: Per-user rate limits prevent abuse:
class PerUserRateLimiter {
private limiters = new Map<string, RateLimiter>()
async acquire(userId: string): Promise<void> {
if (!this.limiters.has(userId)) {
this.limiters.set(userId, new RateLimiter(10)) // 10 requests per minute per user
}
await this.limiters.get(userId)!.acquire()
}
}
Monitoring
Structured Logging: All logs are JSON for easy parsing by log aggregation systems (Datadog, Splunk, ELK stack).
Distributed Tracing: Add trace IDs to track requests across services:
import { randomUUID } from 'crypto'
const traceId = randomUUID()
logger.info('Request started', { trace_id: traceId })
Alerting: Set up alerts for:
- Error rate exceeding 5%
- API latency exceeding 2 seconds (p95)
- Rate limit near saturation (greater than 90%)
- Tool execution failures
Cost Management
Token Counting: Track token usage per session:
const usage = response.usage
logger.info('Token usage', {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
session_id: sessionId,
})
Caching: Implement caching for repeated queries:
class ResponseCache {
private cache = new Map<string, { response: string; timestamp: number }>()
get(key: string, ttlMs: number = 300000): string | null {
const cached = this.cache.get(key)
if (cached && Date.now() - cached.timestamp < ttlMs) {
return cached.response
}
return null
}
}
Budget Limits: Set per-user monthly token budgets:
class BudgetManager {
async checkBudget(userId: string, estimatedTokens: number): Promise<boolean> {
const used = await this.getUsage(userId)
const limit = await this.getLimit(userId)
return used + estimatedTokens <= limit
}
}
Scaling
Horizontal Scaling: Agent controller is stateless except for session state. Use Redis for shared state:
import { createClient } from 'redis'
class RedisStateManager extends StateManager {
private client = createClient({ url: process.env.REDIS_URL })
async get(sessionId: string): Promise<ConversationState | undefined> {
const data = await this.client.get(`session:${sessionId}`)
return data ? JSON.parse(data) : undefined
}
async save(state: ConversationState): Promise<void> {
await this.client.set(
`session:${state.session_id}`,
JSON.stringify(state),
{ EX: 3600 } // 1 hour TTL
)
}
}
Load Balancing: Deploy multiple agent instances behind a load balancer (NGINX, AWS ALB).
Async Tool Execution: Use message queues (RabbitMQ, SQS) for long-running tools:
class AsyncToolExecutor {
async execute(toolName: string, input: any): Promise<string> {
const jobId = randomUUID()
await this.queue.publish({
job_id: jobId,
tool_name: toolName,
input,
})
return jobId // Return immediately, poll for results
}
}
Testing Your Agent
Create tests/agent.test.ts:
import { AgentController, AgentConfig } from '../src/agent/controller'
import { describe, it, expect, beforeEach } from 'vitest'
describe('AgentController', () => {
let agent: AgentController
beforeEach(() => {
const config: AgentConfig = {
apiKey: process.env.ANTHROPIC_API_KEY!,
model: 'claude-sonnet-4-20250514',
maxTokens: 1024,
temperature: 0,
maxIterations: 5,
}
agent = new AgentController(config)
})
it('should process simple message without tools', async () => {
const response = await agent.processMessage('test-1', 'Hello, how are you?')
expect(response).toBeTruthy()
expect(typeof response).toBe('string')
})
it('should handle tool execution', async () => {
const response = await agent.processMessage(
'test-2',
'Search for information about TypeScript'
)
expect(response).toContain('TypeScript') // Should mention the search topic
})
it('should maintain conversation context', async () => {
const sessionId = 'test-3'
await agent.processMessage(sessionId, 'My name is Alice')
const response = await agent.processMessage(sessionId, 'What is my name?')
expect(response.toLowerCase()).toContain('alice')
})
it('should handle tool errors gracefully', async () => {
// This will trigger a file write with invalid filename
const response = await agent.processMessage(
'test-4',
'Write to file "../../../etc/passwd"'
)
expect(response).toBeTruthy() // Should not crash
})
it('should respect rate limits', async () => {
const promises = Array.from({ length: 60 }, (_, i) =>
agent.processMessage(`test-rate-${i}`, 'Quick test')
)
const start = Date.now()
await Promise.all(promises)
const duration = Date.now() - start
// Should take at least 1 minute due to 50/min rate limit
expect(duration).toBeGreaterThan(60000)
})
})
Run tests:
npm install -D vitest
npm run test
Advanced Features
Multi-Agent Orchestration
For complex tasks, coordinate multiple specialized agents:
class MultiAgentOrchestrator {
private agents = {
research: new AgentController({
/* config */
}),
writer: new AgentController({
/* config */
}),
reviewer: new AgentController({
/* config */
}),
}
async processTask(task: string): Promise<string> {
// Step 1: Research agent gathers information
const research = await this.agents.research.processMessage(
'research-session',
`Research this topic: ${task}`
)
// Step 2: Writer agent creates content
const draft = await this.agents.writer.processMessage(
'writer-session',
`Write an article based on this research: ${research}`
)
// Step 3: Reviewer agent checks quality
const final = await this.agents.reviewer.processMessage(
'reviewer-session',
`Review and improve this draft: ${draft}`
)
return final
}
}
Streaming Responses
For real-time user feedback:
async function* streamResponse(
client: Anthropic,
messages: MessageParam[]
): AsyncGenerator<string> {
const stream = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
messages,
stream: true,
})
for await (const chunk of stream) {
if (
chunk.type === 'content_block_delta' &&
chunk.delta.type === 'text_delta'
) {
yield chunk.delta.text
}
}
}
// Usage
for await (const text of streamResponse(client, messages)) {
process.stdout.write(text)
}
Tool Result Caching
Cache deterministic tool results:
class CachedToolExecutor implements ToolExecutor {
private cache = new Map<string, { result: ToolResult; timestamp: number }>()
private wrapped: ToolExecutor
private ttlMs: number
constructor(wrapped: ToolExecutor, ttlMs: number = 300000) {
this.wrapped = wrapped
this.ttlMs = ttlMs
}
async execute(input: Record<string, any>): Promise<ToolResult> {
const cacheKey = JSON.stringify(input)
const cached = this.cache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < this.ttlMs) {
return cached.result
}
const result = await this.wrapped.execute(input)
this.cache.set(cacheKey, { result, timestamp: Date.now() })
return result
}
getDefinition(): Tool {
return this.wrapped.getDefinition()
}
}
Troubleshooting Common Issues
Issue: Agent loops infinitely
Cause: Tool returns insufficient information, agent keeps retrying.
Solution: Implement max iterations limit (already in code) and improve tool error messages:
if (result.data && result.data.length === 0) {
return {
success: false,
error: 'No results found. Please try a different query.',
execution_time_ms: Date.now() - start,
}
}
Issue: Rate limits exceeded
Cause: Too many concurrent requests.
Solution: Implement queue system:
class RequestQueue {
private queue: Array<() => Promise<any>> = []
private processing = false
async add<T>(fn: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push(async () => {
try {
const result = await fn()
resolve(result)
} catch (error) {
reject(error)
}
})
this.process()
})
}
private async process(): Promise<void> {
if (this.processing || this.queue.length === 0) return
this.processing = true
while (this.queue.length > 0) {
const fn = this.queue.shift()!
await fn()
await new Promise(resolve => setTimeout(resolve, 1200)) // 50 req/min = 1.2s spacing
}
this.processing = false
}
}
Issue: Memory leaks from old sessions
Cause: Session state never cleaned up.
Solution: Implement TTL cleanup:
class StateManager {
private cleanupInterval: NodeJS.Timeout
constructor(cleanupIntervalMs: number = 60000) {
this.cleanupInterval = setInterval(() => this.cleanup(), cleanupIntervalMs)
}
private cleanup(): void {
const now = Date.now()
const maxAge = 3600000 // 1 hour
for (const [sessionId, state] of this.sessions.entries()) {
const age = now - state.updated_at.getTime()
if (age > maxAge) {
this.sessions.delete(sessionId)
logger.info('Cleaned up stale session', {
session_id: sessionId,
age_ms: age,
})
}
}
}
}
Performance Optimization
Parallel Tool Execution
Execute independent tools concurrently:
private async executeTools(content: any[]): Promise<any[]> {
const toolBlocks = content.filter(block => block.type === 'tool_use');
// Execute all tools in parallel
const results = await Promise.all(
toolBlocks.map(async (block) => {
const { id, name, input } = block;
const executor = this.toolRegistry.getExecutor(name);
if (!executor) {
return {
type: 'tool_result',
tool_use_id: id,
content: `Error: Tool ${name} not found`,
is_error: true,
};
}
const result = await executor.execute(input);
return {
type: 'tool_result',
tool_use_id: id,
content: result.success ? JSON.stringify(result.data) : `Error: ${result.error}`,
is_error: !result.success,
};
})
);
return results;
}
Request Batching
Batch multiple user messages:
class BatchProcessor {
private batch: Array<{
sessionId: string
message: string
resolve: Function
}> = []
private timeout: NodeJS.Timeout | null = null
async add(sessionId: string, message: string): Promise<string> {
return new Promise(resolve => {
this.batch.push({ sessionId, message, resolve })
if (this.timeout) clearTimeout(this.timeout)
this.timeout = setTimeout(() => this.processBatch(), 100)
})
}
private async processBatch(): Promise<void> {
const items = [...this.batch]
this.batch = []
const results = await Promise.all(
items.map(item => agent.processMessage(item.sessionId, item.message))
)
items.forEach((item, i) => item.resolve(results[i]))
}
}
Next Steps
This tutorial covered building a production-ready agentic AI system. To continue learning:
- Add More Tools: Implement database queries, email sending, calendar management
- Build Custom Tools: Create domain-specific tools for your use case
- Deploy to Production: Use Docker, Kubernetes, or serverless (AWS Lambda, Cloud Run)
- Implement UI: Build a web interface with React and WebSockets for streaming
- Add Authentication: Integrate OAuth or API keys for multi-tenant systems
Explore related topics:
- My prediction on small language models dominating enterprise by 2026 explains why local inference matters
- The analysis of agentic AI reaching 30% enterprise usage provides broader context
- Recent OpenAI code red news analysis shows competitive pressures driving agent adoption
Complete Code Repository
All code from this tutorial is available at: github.com/CrashBytes/ByteSizedExamples/tree/main/crashbytes-tutorial-agentic-ai-tools
The repository includes:
- Complete TypeScript implementation
- Docker configuration for containerized deployment
- Kubernetes manifests for production deployment
- Additional tool implementations (database, email, calendar)
- Comprehensive test suite
- Performance benchmarks
- Production deployment guide
Clone it, run it, modify it for your needs. The code is MIT licensed.
Conclusion
You now have a production-grade agentic AI system with:
- Robust tool calling framework
- Error handling and retry logic
- Rate limiting and cost controls
- State management for conversations
- Logging and observability
- Security controls and input validation
The architecture scales from prototype to production, handles real-world failures gracefully, and provides the foundation for building sophisticated autonomous AI systems.
As agentic AI systems become the dominant enterprise workload pattern (already at 30% and growing), understanding how to build them properly is essential. This tutorial gives you the production patterns, security controls, and scaling strategies you need to deploy agents safely and reliably.
Build responsibly. Monitor carefully. Scale confidently.