Skip to main content
Crashbytes logoCrashbytes
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Browse Articles
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Network
Theme
Browse Articles
Crashbytes logoCrashbytes

Expert insights on web development, technology trends, and programming best practices. Learn from real-world experiences and cutting-edge techniques that help you build better software.

Follow Us

Our Sites

  • ๐Ÿ”ฎ Predictions
  • ๐Ÿ“ฐ Breaking News
  • ๐ŸŽจ AI Art
  • ๐Ÿ“– Short Stories
  • View All โ†’
  • Products โ†’

Sitemap

  • Home
  • All Articles
  • Open Source
  • Services
  • About Us
  • Contact
  • Donate Compute

Popular Topics

  • Serverless
  • Cloud Architecture
  • DevOps
  • Kubernetes
  • Platform Engineering

Resources

  • Privacy Policy
  • Terms of Service
  • Sitemap
  • RSS Feed
  • PGP Key

Stay Updated

Get the latest articles, tutorials, and insights delivered to your inbox. Join our community of developers and never miss an update.

ยฉ 2021-2026 Crashbytesยฎ by Blackhole Software, LLC. All rights reserved.
| Reg. U.S. Pat. & Tm. Off.

Made for the developer community

  1. Home
  2. /
  3. Articles
  4. /
  5. Building Production Agentic AI Systems - Complete Tool Calling Implementation Tutorial
TutorialDecember 15, 202522 min readโ€ข By Michael Eakins

Building Production Agentic AI Systems - Complete Tool Calling Implementation Tutorial

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.

Quick Takeaways

What you'll learn in this article

22 min read
Intermediate
  • 1

    Call external tools (web search, file operations, database queries)

  • 2

    Handle errors gracefully with retry logic

  • 3

    Respect rate limits and manage API quotas

  • 4

    Maintain conversation state across turns

  • 5

    Log all actions for debugging and compliance

Keep reading for detailed implementation, code examples, and real-world results

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)
  }
}
Advertisement

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,
    }
  }
}
Advertisement

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:

  • 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.

Advertisement

Was this article helpful?

Your feedback helps us improve our content and create more valuable resources

We appreciate honest feedback - it helps us serve you better

Work with us

This analysis is what we do for clients

CrashBytes consults on enterprise AI strategy and implementation, builds custom web and mobile software, and places senior engineers on corp-to-corp engagements.

See Services

Enjoyed this? Get the next one.

Join developers getting CrashBytes articles, tutorials, and predictions in their inbox. No spam, unsubscribe anytime.

Related Topics

Agentic AITutorialTool CallingClaude APITypeScriptProduction EngineeringAI AgentsAPI IntegrationError HandlingRate LimitingObservabilityMonday Tutorial
Back to Articles
โ† PreviousTime's Person of the Year Signals AI's Transition from Disruption to InstitutionNext โ†’Building Production-Ready AI Agents with Observable Metrics - Why 95 Percent of Implementations Fail

From across the CrashBytes network

More than the blog โ€” predictions, news, fiction, and AI art.

PredictionCustom AI Chips Reach Commodity Status by Q4 2027: Cloud Provider Competition Drives Democratization
NewsWeek In Review July 19-25, 2026 - The Week The Money Moved To The Metering Layer
Short StoryThe Answer Key
AI ArtThe Room That Remembers

Continue Your Learning Journey

Explore more articles related to Tutorial and expand your knowledge.

๐Ÿ“„Tutorial

Instrument an MCP Tool-Use Agent with OpenTelemetry Tracing in TypeScript

A hands-on TypeScript tutorial for making an autonomous, tool-using AI agent observable. You build a small, dependency-light agent loop and wrap it in OpenTelemetry traces โ€” a root span per invocation, child spans for every model call and every MCP tool call, using the gen_ai.* and MCP semantic conventions โ€” then prove the span tree with deterministic, in-memory tests. Runs offline with zero API keys.

24 min readRead more
๐Ÿ“„Tutorial

Build a Verifiable Agent-Commit Provenance Trail in TypeScript

A hands-on TypeScript tutorial for proving which agent, model, prompt, and supervisor produced a code changeset โ€” and detecting any later tampering. You build canonical changeset hashing, ed25519-signed attestations, and an append-only chained ledger you can verify offline, with zero runtime dependencies and zero API keys.

26 min readRead more
๐Ÿ“„Technology

Building AI Agents with NVIDIA NemoClaw and NeMo Agent Toolkit โ€” A Complete Tutorial

Step-by-step tutorial on building, securing, and deploying autonomous AI agents using NVIDIA NemoClaw and the NeMo Agent Toolkit. Covers installation, agent workflows, sandbox policies, inference profiles, multi-agent orchestration, and production deployment on the NVIDIA AI stack.

23 min readRead more
๐Ÿ“„Tutorial

Implementing RAG in a React Native App: On-Device and Cloud Retrieval

Build production Retrieval-Augmented Generation for React Native with one engine that runs both on-device and behind a Claude Opus 4.8 cloud service.

50 min readRead more