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
For OpenAI:
npm install openai
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: [] };
}
}
Key points:
-
@Agentdefines the agent's identity, system prompt, and whether memory/RAG are enabled -
@Tooldefines each capability with a description and typed parameters -
requiresApproval: truemeans the runtime will pause and emit an event before executing—you must callapproveToolExecutionorrejectToolExecution
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);
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`);
The runtime:
- Loads conversation memory (if
enableMemoryandsessionIdare set) - Sends the user message + context to the LLM
- If the LLM decides to call a tool, the runtime executes it (or pauses for approval)
- 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
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 });
});
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 {}
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;
}
}
Best Practices
1. Use Approval for Destructive Actions
@Tool({ requiresApproval: true })
async deleteAccount(input: { userId: string }) {
// Refunds, deletions, data exports - always require approval
}
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);
}
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 };
}
}
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
- Agent README — Full API reference
- QUICKSTART — 5-minute setup
- Customer Support Assistant — Real-world agent app with Customer Support
Built with HazelJS — hazeljs.com | GitHub
Top comments (0)