Back to Tutorials
IntermediateAI/ML

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.

by Michael Eakins
21 min read
12/15/2025

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

AI AgentsLangChainOpenAITypeScriptTool CallingClaude API

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:

  1. Add More Tools: Implement database queries, email sending, calendar management
  2. Build Custom Tools: Create domain-specific tools for your use case
  3. Deploy to Production: Use Docker, Kubernetes, or serverless (AWS Lambda, Cloud Run)
  4. Implement UI: Build a web interface with React and WebSockets for streaming
  5. Add Authentication: Integrate OAuth or API keys for multi-tenant systems

Explore related topics:

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.

Last updated: 12/15/2025