DEV Community

Cover image for Building Production AI Agents with @hazeljs/agent
Muhammad Arslan
Muhammad Arslan

Posted on

Building Production AI Agents with @hazeljs/agent

A tutorial on building stateful, tool-using AI agents with human-in-the-loop workflows using HazelJS.

Note: Even HazelJS is still in beta, but it is running in Production already with quite a few agents, we will soon release the GA version.

Reading time: ~12 minutes


Introduction

AI agents are everywhere—customer support bots, coding assistants, sales automation. But building production-grade agents is hard. You need state management, tool execution with safety guards, conversation memory, observability, and the ability to pause for human approval. Most teams end up hand-rolling these concerns, leading to brittle, hard-to-debug systems.

@hazeljs/agent is an AI-native agent runtime for Node.js that gives you:

  • Stateful execution — Agents maintain context across multiple steps
  • Tool system — Declarative tools with automatic validation, timeouts, and approval workflows
  • Memory & RAG — Persistent conversation history and retrieval-augmented generation
  • Observability — Full event system for monitoring and debugging
  • Human-in-the-loop — Pause, approve, and resume for sensitive operations

This tutorial walks you through building a customer support agent from scratch, including tools, approval workflows, and integration with HazelJS modules.


Prerequisites

  • Node.js 20+
  • An OpenAI API key (or Anthropic, Gemini, Cohere, Ollama)
  • Basic familiarity with TypeScript and decorators

Installation

npm install @hazeljs/agent @hazeljs/core @hazeljs/rag
Enter fullscreen mode Exit fullscreen mode

For OpenAI:

npm install openai
Enter fullscreen mode Exit fullscreen mode

Part 1: Define Your Agent

Agents are classes decorated with @Agent. Each capability the agent can use is a method decorated with @Tool.

Step 1: Create the Agent Class

import { Agent, Tool } from '@hazeljs/agent';

@Agent({
  name: 'support-agent',
  description: 'Customer support agent that can look up orders and process refunds',
  systemPrompt: `You are a helpful customer support agent. 
    When users ask about orders, use the lookupOrder tool.
    For refunds, use processRefund - it requires approval.
    Be concise and professional.`,
  enableMemory: true,
  enableRAG: false,  // Set to true if you have a RAG pipeline
})
export class SupportAgent {
  @Tool({
    description: 'Look up order information by order ID',
    parameters: [
      {
        name: 'orderId',
        type: 'string',
        description: 'The order ID to lookup (e.g. ORD-12345)',
        required: true,
      },
    ],
  })
  async lookupOrder(input: { orderId: string }) {
    // In production, call your order service or database
    const order = await this.fetchOrder(input.orderId);
    return {
      orderId: input.orderId,
      status: order?.status ?? 'not_found',
      trackingNumber: order?.trackingNumber ?? null,
      items: order?.items ?? [],
    };
  }

  @Tool({
    description: 'Process a refund for an order. Requires manager approval.',
    requiresApproval: true,  // Human must approve before execution
    parameters: [
      { name: 'orderId', type: 'string', description: 'Order to refund', required: true },
      { name: 'amount', type: 'number', description: 'Refund amount in dollars', required: true },
      { name: 'reason', type: 'string', description: 'Reason for refund', required: false },
    ],
  })
  async processRefund(input: { orderId: string; amount: number; reason?: string }) {
    // This only runs after approval
    return {
      success: true,
      refundId: `REF-${Date.now()}`,
      orderId: input.orderId,
      amount: input.amount,
      processedAt: new Date().toISOString(),
    };
  }

  private async fetchOrder(orderId: string) {
    // Mock for demo; replace with real DB/API call
    return { status: 'shipped', trackingNumber: 'TRACK123', items: [] };
  }
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • @Agent defines the agent's identity, system prompt, and whether memory/RAG are enabled
  • @Tool defines each capability with a description and typed parameters
  • requiresApproval: true means the runtime will pause and emit an event before executing—you must call approveToolExecution or rejectToolExecution

Part 2: Set Up the Runtime

The AgentRuntime orchestrates execution. You pass it an LLM provider, optional memory/RAG, and configuration.

import { AgentRuntime } from '@hazeljs/agent';
import { MemoryManager, BufferMemory } from '@hazeljs/rag';
import OpenAI from 'openai';

// 1. Create LLM provider (OpenAI example)
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const llmProvider = {
  complete: async (messages: Array<{ role: string; content: string }>) => {
    const response = await openai.chat.completions.create({
      model: 'gpt-4',
      messages: messages.map((m) => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content })),
    });
    return response.choices[0]?.message?.content ?? '';
  },
};

// 2. Memory (optional - for conversation history)
const memoryStore = new BufferMemory({ maxSize: 100 });
const memoryManager = new MemoryManager(memoryStore, {
  maxConversationLength: 20,
});
await memoryManager.initialize();

// 3. Create runtime
const runtime = new AgentRuntime({
  llmProvider,
  memoryManager,
  defaultMaxSteps: 10,
  enableObservability: true,
  enableMetrics: true,
});

// 4. Register agent
const supportAgent = new SupportAgent();
runtime.registerAgent(SupportAgent);
runtime.registerAgentInstance('support-agent', supportAgent);
Enter fullscreen mode Exit fullscreen mode

Part 3: Execute the Agent

const result = await runtime.execute(
  'support-agent',
  'Can you check the status of my order ORD-12345?',
  {
    sessionId: 'user-session-abc',
    userId: 'user-456',
    enableMemory: true,
  }
);

console.log(result.response);
// "Your order ORD-12345 is shipped. Tracking number: TRACK123."

console.log(`Completed in ${result.steps?.length ?? 0} steps`);
Enter fullscreen mode Exit fullscreen mode

The runtime:

  1. Loads conversation memory (if enableMemory and sessionId are set)
  2. Sends the user message + context to the LLM
  3. If the LLM decides to call a tool, the runtime executes it (or pauses for approval)
  4. Repeats until the agent responds or hits defaultMaxSteps

Part 4: Human-in-the-Loop (Approval Workflows)

When a tool has requiresApproval: true, the runtime pauses and emits tool.approval.requested. You must approve or reject before execution continues.

import { AgentEventType } from '@hazeljs/agent';

// Subscribe to approval requests
runtime.on(AgentEventType.TOOL_APPROVAL_REQUESTED, async (event) => {
  const { requestId, toolName, input } = event.data;

  console.log(`Approval needed for ${toolName}:`, input);

  // Option A: Auto-approve in dev, or send to your approval UI
  if (process.env.NODE_ENV === 'development') {
    runtime.approveToolExecution(requestId, 'dev-auto-approve');
  } else {
    // Send to Slack, admin dashboard, etc.
    await sendApprovalRequestToAdmin({
      requestId,
      executionId: event.executionId,
      toolName,
      parameters: input,
      onApprove: () => runtime.approveToolExecution(requestId, 'admin-user'),
      onReject: () => runtime.rejectToolExecution(requestId),
    });
  }
});

// Execute - will pause when processRefund is requested
const result = await runtime.execute(
  'support-agent',
  'I need a full refund for order ORD-999, amount $50.',
  { sessionId: 's1', enableMemory: true }
);

// If waiting for approval, result.state === 'waiting_for_approval'
// After you call approveToolExecution, the run continues automatically
// Or call runtime.resume(executionId) if you need to pass additional context
Enter fullscreen mode Exit fullscreen mode

Part 5: Observability and Events

Subscribe to events for logging, metrics, and debugging:

// Execution lifecycle
runtime.on(AgentEventType.EXECUTION_STARTED, (e) => {
  console.log('Agent started', e.data);
});

runtime.on(AgentEventType.EXECUTION_COMPLETED, (e) => {
  console.log('Agent completed', e.data);
});

// Per-step
runtime.on(AgentEventType.STEP_STARTED, (e) => {
  console.log('Step', e.data.stepNumber, 'started');
});

// Tool usage
runtime.on(AgentEventType.TOOL_EXECUTION_STARTED, (e) => {
  console.log('Tool executing:', e.data.toolName, e.data.input);
});

runtime.on(AgentEventType.TOOL_EXECUTION_COMPLETED, (e) => {
  console.log('Tool finished:', e.data.toolName, e.data.output);
});

// Catch-all
runtime.onAny((event) => {
  // Send to your observability backend
  metrics.record('agent.event', { type: event.type, ...event.data });
});
Enter fullscreen mode Exit fullscreen mode

Part 6: Integrate with HazelJS Modules

Use AgentModule to wire agents into your HazelJS app:

import { HazelModule } from '@hazeljs/core';
import { AgentModule } from '@hazeljs/agent';

@HazelModule({
  imports: [
    AgentModule.forRoot({
      runtime: {
        defaultMaxSteps: 10,
        enableObservability: true,
      },
      agents: [SupportAgent],
    }),
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Then inject the runtime via AgentService:

import { Injectable } from '@hazeljs/core';
import { AgentService } from '@hazeljs/agent';

@Injectable()
export class ChatController {
  constructor(private agentService: AgentService) {}

  async handleMessage(sessionId: string, message: string) {
    const runtime = this.agentService.getRuntime();
    const result = await runtime.execute('support-agent', message, {
      sessionId,
      enableMemory: true,
    });
    return result.response;
  }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Use Approval for Destructive Actions

@Tool({ requiresApproval: true })
async deleteAccount(input: { userId: string }) {
  // Refunds, deletions, data exports - always require approval
}
Enter fullscreen mode Exit fullscreen mode

2. Design Idempotent Tools

@Tool()
async createOrder(input: { orderId: string; items: any[] }) {
  const existing = await this.findOrder(input.orderId);
  if (existing) return existing;
  return this.createNewOrder(input);
}
Enter fullscreen mode Exit fullscreen mode

3. Return Structured Errors from Tools

@Tool()
async externalAPICall(input: any) {
  try {
    return await this.api.call(input);
  } catch (error) {
    return { success: false, error: (error as Error).message };
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Keep Agents Declarative

Put business logic inside tool methods, not in decorators or callbacks. The agent class should be easy to read and test.


Summary

@hazeljs/agent gives you a production-ready agent runtime with:

Feature Description
Stateful execution Context persists across steps
Tools @Tool decorator with validation, timeout, retries
Approval workflows requiresApproval: true for human-in-the-loop
Memory Conversation history via MemoryManager
RAG Optional retrieval-augmented generation
Events Full observability via AgentEventType
HazelJS integration AgentModule for DI and routing

Start with a simple agent and a few tools, add approval for sensitive operations, and scale up with memory and RAG as needed.


Next Steps


Built with HazelJShazeljs.com | GitHub

Top comments (0)