Agent Package
The @hazeljs/agent package provides a production-grade AI agent runtime for HazelJS. Build stateful, tool-using, memory-enabled agents with approval workflows, RAG integration, and human-in-the-loop support — in a fully decorator-based TypeScript API.
What Is an AI Agent?
Traditional APIs respond to a single request and return. An AI agent is fundamentally different: it thinks, decides, acts, observes results, thinks again, and repeats until the task is done. Agents can:
- Break a complex goal into multiple steps automatically
- Call tools (functions) to interact with real systems
- Remember previous conversations and facts
- Retrieve relevant documents before reasoning
- Pause for human approval before taking sensitive actions
- Resume where they left off after waiting for input
This makes agents the right model for tasks like customer support automation, data research pipelines, DevOps assistants, and any multi-step workflow where a single LLM call is not enough.
Why @hazeljs/agent?
| Challenge | Without @hazeljs/agent | With @hazeljs/agent |
|---|---|---|
| Building execution loops | Manual loops, ad-hoc state | Deterministic state machine, built-in |
| Tool safety | Direct function calls, no audit | Validated, logged, retriable, approvable |
| Memory across turns | Rolling string in every prompt | Persistent sessionId-based memory |
| Human oversight | Custom webhook logic | requiresApproval: true + events |
| Debugging | No visibility into steps | Full event system per step |
| Integration | Separate LLM + memory + RAG wiring | One AgentRuntime wires all |
Architecture
The agent runtime coordinates registry, state, execution, tools, and memory — all within a single orchestration layer:
graph TD A["Your Code<br/>(@Agent, @Tool classes)"] --> B["AgentRuntime<br/>(Orchestrator)"] B --> C["Registry<br/>(Agents & Tools)"] B --> D["State Manager<br/>(Persist & Restore)"] B --> E["Executor<br/>(Think → Act → Persist loop)"] E --> F["Tool Executor<br/>(Validate, Run, Approve)"] B --> G["Event Bus<br/>(Observability)"] H["AIService<br/>(@hazeljs/ai)"] --> E I["Memory<br/>(@hazeljs/rag)"] --> E J["RAG / Docs<br/>(@hazeljs/rag)"] --> E style A fill:#3b82f6,stroke:#60a5fa,stroke-width:2px,color:#fff style B fill:#6366f1,stroke:#818cf8,stroke-width:2px,color:#fff style C fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff style D fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff style E fill:#f59e0b,stroke:#fbbf24,stroke-width:2px,color:#fff style F fill:#f59e0b,stroke:#fbbf24,stroke-width:2px,color:#fff style G fill:#8b5cf6,stroke:#a78bfa,stroke-width:2px,color:#fff style H fill:#ec4899,stroke:#f472b6,stroke-width:2px,color:#fff style I fill:#ec4899,stroke:#f472b6,stroke-width:2px,color:#fff style J fill:#ec4899,stroke:#f472b6,stroke-width:2px,color:#fff
Key Components
- AgentRuntime — The central orchestrator: registers agents and tools, runs the execution loop, manages events.
- Registry — Stores agent classes and tool metadata discovered from decorators.
- State Manager — Persists and restores execution state so agents can pause and resume.
- Executor — Drives the
think → act → persistloop, calling the LLM and dispatching actions. - Tool Executor — Validates parameters, handles timeouts and retries, enforces approval workflows.
- Event Bus — Emits events at every step for full observability.
- AIService — LLM integration (via
@hazeljs/ai) for decision-making. - Memory / RAG — Conversation history and document retrieval (via
@hazeljs/rag).
Advantages
1. Stateful by Design
Agents maintain context across multiple tool calls and turns — without you managing state manually. The runtime persists execution state on every step, enabling pause, resume, and recovery.
2. Safe Tool Execution
Tools are not raw function calls. Every tool is registered, validated, logged, timed out, retriable, and optionally gated behind human approval. You get a full audit trail of what the agent did and why.
3. Memory and RAG Built In
Use enableMemory: true to automatically persist conversation history per session. Use enableRAG: true to pull in relevant documents before the LLM reasons — no extra wiring.
4. Human-in-the-Loop
Mark any tool requiresApproval: true. The agent pauses, emits a tool.approval.requested event, and waits for your code to approve or reject — before the action runs. No changes to your approval logic needed.
5. Full Observability
Subscribe to any agent event — execution started/completed, step by step, tool calls, approvals, errors. Wire into your logging, alerting, or audit infrastructure without touching agent code.
6. Declarative API
Define an entire agent — name, prompt, tools, memory, RAG config — as a plain TypeScript class with decorators. No runtime configuration files. No boilerplate state machines to write.
7. Production Ready
Built-in execution timeout and per-tool timeout, cancellation via AbortSignal or cancel(executionId), retry on every tool, structured AgentError codes for handling and observability, deterministic state transitions, and TypeScript types throughout.
Installation
npm install @hazeljs/agent @hazeljs/core @hazeljs/rag
Install the AI provider (OpenAI is the most common):
npm install @hazeljs/ai openai
Quick Start
1. Define an Agent with Tools
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 for an e-commerce store.
You have access to order lookup and refund processing tools.
Always verify the order exists before processing a refund.`,
enableMemory: true,
enableRAG: true,
ragTopK: 5,
maxSteps: 10,
})
export class SupportAgent {
@Tool({
description: 'Look up order information by order ID. Returns status, items, and tracking.',
parameters: [
{ name: 'orderId', type: 'string', description: 'The order ID to look up', required: true },
],
})
async lookupOrder(input: { orderId: string }) {
// Call your actual order service
return {
orderId: input.orderId,
status: 'shipped',
items: [{ name: 'Blue T-Shirt', quantity: 2, price: 29.99 }],
trackingNumber: 'TRACK123456',
estimatedDelivery: '2024-12-10',
};
}
@Tool({
description: 'Process a refund for an order. Requires human approval before executing.',
requiresApproval: true,
timeout: 30000,
retries: 2,
parameters: [
{ name: 'orderId', type: 'string', description: 'Order ID to refund', required: true },
{ name: 'amount', type: 'number', description: 'Refund amount in USD', required: true },
{ name: 'reason', type: 'string', description: 'Reason for the refund', required: true },
],
})
async processRefund(input: { orderId: string; amount: number; reason: string }) {
// Call your payment service
return {
success: true,
refundId: `REF-${Date.now()}`,
amount: input.amount,
estimatedCredit: '3-5 business days',
};
}
@Tool({
description: 'Send a follow-up email to the customer with case details.',
parameters: [
{ name: 'email', type: 'string', description: 'Customer email address', required: true },
{ name: 'subject', type: 'string', description: 'Email subject line', required: true },
{ name: 'message', type: 'string', description: 'Email body', required: true },
],
})
async sendFollowUpEmail(input: { email: string; subject: string; message: string }) {
// Call your email service
return { sent: true, messageId: `MSG-${Date.now()}` };
}
}
2. Set Up the Runtime
import { AgentRuntime } from '@hazeljs/agent';
import { MemoryManager } from '@hazeljs/rag';
import { AIService } from '@hazeljs/ai';
// Create the LLM provider
const aiService = new AIService({
provider: 'openai',
model: 'gpt-4-turbo-preview',
apiKey: process.env.OPENAI_API_KEY,
});
// Create the memory manager for conversation history
const memoryManager = new MemoryManager({ /* vector store config */ });
// Create the runtime
const runtime = new AgentRuntime({
memoryManager,
llmProvider: aiService,
defaultMaxSteps: 10,
enableObservability: true,
});
// Register agents
const supportAgent = new SupportAgent();
runtime.registerAgent(SupportAgent);
runtime.registerAgentInstance('support-agent', supportAgent);
3. Execute the Agent
const result = await runtime.execute(
'support-agent',
'I ordered a blue t-shirt last week (order #A12345) but it arrived damaged. I want a refund.',
{
sessionId: 'user-session-abc',
userId: 'user-123',
enableMemory: true,
enableRAG: true,
timeout: 120_000, // optional: max execution time in ms
signal: abortController.signal, // optional: cancel via AbortSignal
streaming: true, // optional: stream tokens when LLM supports it
}
);
console.log(result.response);
// "I've looked up your order #A12345 and can see it was shipped with tracking TRACK123456.
// I've initiated a refund request for $29.99 which is now pending approval from our team.
// You'll receive a confirmation email once processed — credit takes 3-5 business days."
console.log(`Completed in ${result.steps.length} steps`);
// "Completed in 4 steps"
4. Handle Human-in-the-Loop Approvals
Approval is event-driven: when you call approveToolExecution or rejectToolExecution with the requestId from the event, the waiting execution resumes immediately (no polling). Pending requests from getPendingApprovals() include requestId, toolName, input, status (pending | approved | rejected | expired), and optional approvedBy / rejectedAt.
// Subscribe before executing — set up your approval handler
runtime.on('tool.approval.requested', async (event) => {
const { requestId, toolName, input } = event.data;
console.log(`Approval needed for tool: ${toolName}`);
console.log('Arguments:', JSON.stringify(input, null, 2));
// In production: send to an admin dashboard, Slack, PagerDuty, etc.
const approved = await askAdminForApproval(requestId, toolName, input);
if (approved) {
runtime.approveToolExecution(requestId, 'admin@company.com');
} else {
runtime.rejectToolExecution(requestId);
}
});
// Execute — agent will pause at the refund tool and wait
const result = await runtime.execute('support-agent', userMessage, { sessionId });
// If the agent paused waiting for approval
if (result.state === 'waiting_for_approval') {
// Resume after the approval event fires and is handled
const resumed = await runtime.resume(result.executionId);
console.log('Final response:', resumed.response);
}
Core Concepts
Agent State Machine
Every execution follows a deterministic state machine — no hidden state, fully observable:
stateDiagram-v2 [*] --> idle idle --> thinking : execute() thinking --> using_tool : LLM decides to call a tool thinking --> completed : LLM decides to respond thinking --> waiting_for_input : LLM asks for more info using_tool --> thinking : tool result returned using_tool --> waiting_for_approval : tool requires approval waiting_for_approval --> using_tool : approved waiting_for_approval --> thinking : rejected waiting_for_input --> thinking : resume() called thinking --> failed : error or maxSteps reached completed --> [*] failed --> [*]
Execution Loop
On every execute() or resume() call, the runtime runs this loop until completion, max steps, or a wait state:
- Load state — Restore agent context from the state manager
- Load memory — Retrieve conversation history for this session
- Retrieve RAG — Fetch relevant documents if
enableRAG: true - Ask LLM — Send context + tools + history to the model; model decides next action
- Execute action — Call the tool, ask the user, or emit the final response
- Persist state — Save state and memory after every step
- Repeat or finish — Continue if more steps are needed, or return the result
The @Agent Decorator
The @Agent decorator declares a class as an agent and stores configuration in metadata:
interface AgentConfig {
name: string; // Unique agent identifier — used in runtime.execute('name', ...)
description?: string; // Human-readable description
systemPrompt: string; // Instructions to the LLM — defines personality and behavior
enableMemory?: boolean; // Persist conversation history per sessionId (default: false)
enableRAG?: boolean; // Retrieve relevant docs before reasoning (default: false)
ragTopK?: number; // Number of RAG results to include (default: 5)
maxSteps?: number; // Max execution steps before stopping (default: runtime setting)
}
The @Tool Decorator
The @Tool decorator marks a method as a callable tool, with full metadata for the LLM:
interface ToolConfig {
description: string; // Shown to the LLM — be specific and accurate
requiresApproval?: boolean; // Pause execution and emit 'tool.approval.requested'
timeout?: number; // Ms before the tool call times out (default: 10000)
retries?: number; // Retry attempts on failure (default: 0)
policy?: string; // Custom policy tag for your approval logic
parameters: Array<{
name: string;
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
description: string; // Shown to the LLM — be specific about valid values
required: boolean;
}>;
}
What happens when a tool runs:
- The LLM decides to call the tool and provides arguments
- Parameters are validated against the schema
- If
requiresApproval: true, the runtime emits the approval event and waits - The tool method is called; result is logged and returned to the LLM
- If it fails: retried up to
retriestimes, then the error is returned to the LLM
Memory and RAG
Memory (conversation history) — Use the same sessionId across multiple execute() calls. The agent automatically loads and appends history so it remembers what was discussed:
// First turn
await runtime.execute('support-agent', 'My order is #A12345', {
sessionId: 'session-abc',
enableMemory: true,
});
// Second turn — agent remembers order #A12345
await runtime.execute('support-agent', 'Can I get a refund for it?', {
sessionId: 'session-abc', // same session
enableMemory: true,
});
RAG (document retrieval) — Set enableRAG: true to automatically retrieve relevant documents from your vector store before the LLM reasons. Useful for help center articles, runbooks, product catalogs, etc.:
@Agent({
name: 'knowledge-agent',
enableRAG: true,
ragTopK: 8, // retrieve top 8 chunks
systemPrompt: 'Answer questions using the retrieved knowledge base articles.',
})
export class KnowledgeAgent {}
Complete Real-World Example: E-Commerce Support Agent
This example shows a production-ready support agent that handles the most common customer scenarios end-to-end, including order lookup, refunds, shipping changes, and FAQ answers via RAG.
import { Agent, Tool, AgentRuntime, AgentEventType } from '@hazeljs/agent';
import { AIService } from '@hazeljs/ai';
import { MemoryManager, RagService } from '@hazeljs/rag';
import { HazelModule, Service, Controller, Post, Body } from '@hazeljs/core';
// ─── Agent Definition ─────────────────────────────────────────────────────────
@Agent({
name: 'ecommerce-support',
description: 'Full-service e-commerce customer support agent',
systemPrompt: `You are a friendly and efficient customer support agent for ShopCo.
You have tools to look up orders, process refunds, update shipping addresses,
and search the knowledge base for FAQs and policies.
Always look up order details first before taking any action.
Be concise, empathetic, and professional.`,
enableMemory: true,
enableRAG: true,
ragTopK: 5,
maxSteps: 15,
})
@Service()
export class EcommerceSupportAgent {
constructor(
private readonly orderService: OrderService,
private readonly paymentService: PaymentService,
private readonly shippingService: ShippingService,
private readonly emailService: EmailService,
) {}
@Tool({
description: 'Look up order by order ID. Returns items, status, shipping, and payment info.',
parameters: [
{ name: 'orderId', type: 'string', description: 'Order ID starting with #', required: true },
],
})
async getOrder(input: { orderId: string }) {
const order = await this.orderService.findById(input.orderId);
if (!order) return { error: 'Order not found', orderId: input.orderId };
return {
orderId: order.id,
status: order.status,
items: order.items,
total: order.total,
shippingAddress: order.shippingAddress,
tracking: order.trackingNumber,
estimatedDelivery: order.estimatedDelivery,
canRefund: order.status !== 'refunded' && order.status !== 'processing',
};
}
@Tool({
description: 'Search FAQs, return policies, and shipping information from the knowledge base.',
parameters: [
{ name: 'query', type: 'string', description: 'Search query for FAQs or policies', required: true },
],
})
async searchKnowledgeBase(input: { query: string }) {
// RAG search over your knowledge base
const results = await this.ragService.search(input.query, { topK: 5 });
return { articles: results.map(r => ({ title: r.title, content: r.content })) };
}
@Tool({
description: 'Check the current status of a delivery from the shipping carrier.',
parameters: [
{ name: 'trackingNumber', type: 'string', description: 'Carrier tracking number', required: true },
],
})
async trackShipment(input: { trackingNumber: string }) {
const tracking = await this.shippingService.track(input.trackingNumber);
return {
status: tracking.status,
location: tracking.currentLocation,
events: tracking.events.slice(0, 5),
estimatedDelivery: tracking.eta,
};
}
@Tool({
description: 'Update the shipping address for an order that has not yet shipped.',
requiresApproval: true,
parameters: [
{ name: 'orderId', type: 'string', description: 'Order ID', required: true },
{ name: 'newAddress', type: 'string', description: 'New shipping address', required: true },
],
})
async updateShippingAddress(input: { orderId: string; newAddress: string }) {
const result = await this.shippingService.updateAddress(input.orderId, input.newAddress);
return { updated: result.success, confirmationNumber: result.confirmationId };
}
@Tool({
description: 'Process a full or partial refund. Always verify order eligibility first.',
requiresApproval: true,
timeout: 30000,
parameters: [
{ name: 'orderId', type: 'string', description: 'Order ID to refund', required: true },
{ name: 'amount', type: 'number', description: 'Refund amount in USD', required: true },
{ name: 'reason', type: 'string', description: 'Refund reason', required: true },
{ name: 'itemIds', type: 'array', description: 'Specific item IDs to refund (empty = full refund)', required: false },
],
})
async processRefund(input: { orderId: string; amount: number; reason: string; itemIds?: string[] }) {
const refund = await this.paymentService.refund({
orderId: input.orderId,
amount: input.amount,
reason: input.reason,
itemIds: input.itemIds,
});
// Send confirmation email automatically
await this.emailService.send({
template: 'refund-confirmation',
data: { refundId: refund.id, amount: refund.amount },
});
return {
success: true,
refundId: refund.id,
amount: refund.amount,
estimatedCredit: '3-5 business days',
emailSent: true,
};
}
@Tool({
description: 'Send a summary email to the customer about the support case resolution.',
parameters: [
{ name: 'customerId', type: 'string', description: 'Customer ID', required: true },
{ name: 'summary', type: 'string', description: 'Summary of what was done', required: true },
{ name: 'nextSteps', type: 'string', description: 'What the customer should expect', required: false },
],
})
async sendResolutionEmail(input: { customerId: string; summary: string; nextSteps?: string }) {
const customer = await this.orderService.getCustomer(input.customerId);
await this.emailService.send({
to: customer.email,
template: 'support-resolution',
data: { name: customer.name, summary: input.summary, nextSteps: input.nextSteps },
});
return { sent: true };
}
}
// ─── Runtime Setup ────────────────────────────────────────────────────────────
function createSupportRuntime(): AgentRuntime {
const aiService = new AIService({
provider: 'openai',
model: 'gpt-4-turbo-preview',
apiKey: process.env.OPENAI_API_KEY!,
});
const memoryManager = new MemoryManager({
vectorStore: { type: 'memory' }, // use Redis/Pinecone in production
});
const runtime = new AgentRuntime({
memoryManager,
llmProvider: aiService,
defaultMaxSteps: 15,
enableObservability: true,
});
// Observability: log every step and tool call
runtime.on(AgentEventType.STEP_STARTED, (e) =>
console.log(`[agent] step ${e.data.stepNumber} — thinking`));
runtime.on(AgentEventType.TOOL_EXECUTION_STARTED, (e) =>
console.log(`[agent] calling tool: ${e.data.tool}`, e.data.args));
runtime.on(AgentEventType.EXECUTION_COMPLETED, (e) =>
console.log(`[agent] done in ${e.data.steps} steps`));
// Approval handler: send to Slack, respond async (event-driven — approve/reject resolves immediately)
runtime.on(AgentEventType.TOOL_APPROVAL_REQUESTED, async (event) => {
const { requestId, toolName, input } = event.data;
console.log(`[approval] ${toolName} needs approval`, input);
// Auto-approve in development; use real approval flow in production
if (process.env.NODE_ENV === 'development') {
runtime.approveToolExecution(requestId, 'auto-dev');
} else {
// Send to approval queue — approve/reject from your dashboard
await approvalQueue.push({ requestId, toolName, input });
}
});
// Register the agent
const agent = new EcommerceSupportAgent(
new OrderService(),
new PaymentService(),
new ShippingService(),
new EmailService(),
);
runtime.registerAgent(EcommerceSupportAgent);
runtime.registerAgentInstance('ecommerce-support', agent);
return runtime;
}
// ─── HTTP Controller ──────────────────────────────────────────────────────────
@Controller('/support')
@Service()
export class SupportController {
private runtime = createSupportRuntime();
@Post('/chat')
async chat(@Body() body: { message: string; sessionId: string; userId: string }) {
const result = await this.runtime.execute(
'ecommerce-support',
body.message,
{
sessionId: body.sessionId,
userId: body.userId,
enableMemory: true,
enableRAG: true,
}
);
return {
response: result.response,
sessionId: body.sessionId,
steps: result.steps.length,
state: result.state,
executionId: result.executionId,
};
}
@Post('/resume')
async resume(@Body() body: { executionId: string; input?: string }) {
const result = await this.runtime.resume(body.executionId, body.input);
return { response: result.response, state: result.state };
}
}
What this example demonstrates:
- Multiple tools with different safety levels (read-only vs.
requiresApproval) - Dependency injection inside an
@Agentclass (order, payment, shipping services) - Automatic email on refund — the tool handles side effects
- Full observability setup (logging, approval handler)
- HTTP controller exposing chat and resume endpoints
- Session-based memory so the agent remembers context across turns
Event System
Subscribe to any combination of events for observability, audit logging, and integrations:
import { AgentEventType } from '@hazeljs/agent';
// Execution lifecycle
runtime.on(AgentEventType.EXECUTION_STARTED, (e) => {
metrics.increment('agent.executions');
logger.info('Agent started', { agent: e.data.agentName, session: e.data.sessionId });
});
runtime.on(AgentEventType.EXECUTION_COMPLETED, (e) => {
metrics.histogram('agent.steps', e.data.steps);
logger.info('Agent completed', { response: e.data.response.slice(0, 100) });
});
// Individual steps
runtime.on(AgentEventType.STEP_STARTED, (e) =>
logger.debug(`Step ${e.data.stepNumber} started`));
// Tool calls
runtime.on(AgentEventType.TOOL_EXECUTION_STARTED, (e) =>
auditLog.write({ action: e.data.tool, args: e.data.args, session: e.data.sessionId }));
runtime.on(AgentEventType.TOOL_EXECUTION_COMPLETED, (e) =>
logger.debug('Tool done', { tool: e.data.tool, duration: e.data.duration }));
// Approval workflow
runtime.on(AgentEventType.TOOL_APPROVAL_REQUESTED, async (e) =>
slack.post('#approvals', `Agent wants to call ${e.data.tool}: ${JSON.stringify(e.data.args)}`));
// Catch all
runtime.onAny((e) => console.log(e.type, e.data));
HazelJS Module Integration
Register agents at the module level for full dependency injection:
import { HazelModule } from '@hazeljs/core';
import { AgentModule } from '@hazeljs/agent';
import { RagModule } from '@hazeljs/rag';
import { AIModule } from '@hazeljs/ai';
@HazelModule({
imports: [
AIModule.register({
provider: 'openai',
model: 'gpt-4-turbo-preview',
apiKey: process.env.OPENAI_API_KEY,
}),
RagModule.forRoot({
vectorStore: { type: 'pinecone', apiKey: process.env.PINECONE_KEY, index: 'support-docs' },
embeddings: { provider: 'openai', apiKey: process.env.OPENAI_API_KEY },
}),
AgentModule.forRoot({
runtime: {
defaultMaxSteps: 15,
enableObservability: true,
},
agents: [EcommerceSupportAgent, KnowledgeAgent],
}),
],
controllers: [SupportController],
})
export class AppModule {}
Execution Control: Timeout, Cancellation, and Streaming
Timeout
Single-agent runs respect an execution timeout so long-running or stuck executions fail cleanly. Set it per run or globally:
// Per execution
const result = await runtime.execute('support-agent', userMessage, {
timeout: 60_000, // 60 seconds
});
// Global default (AgentRuntimeConfig)
const runtime = new AgentRuntime({
defaultTimeout: 120_000, // 2 minutes
});
When the timeout is exceeded, execution throws an AgentError with code AGENT_TIMEOUT.
Cancellation
Cancel an in-flight execution using an AbortSignal or by ID:
// Option 1: Pass an AbortSignal when starting
const controller = new AbortController();
const resultPromise = runtime.execute('support-agent', userMessage, {
signal: controller.signal,
});
// Later: cancel from UI or another flow
controller.abort();
// Option 2: Cancel by execution ID (e.g. from a "Stop" button)
const result = await runtime.execute('support-agent', longQuery, { sessionId });
// User clicks Stop — you have the executionId from events or the initial result
runtime.cancel(result.executionId);
When cancelled, the execution fails with an AgentError with code AGENT_CANCELLED.
Streaming
When your LLM provider implements optional streamChat(), you can stream step and token chunks for the final response:
// Execution options
const result = await runtime.execute('support-agent', userMessage, {
streaming: true, // use streamChat when available
});
// Or use the dedicated async generator for full control
for await (const chunk of runtime.executeStream('support-agent', userMessage, {
sessionId: 'user-123',
streaming: true,
timeout: 60_000,
signal: abortController.signal,
})) {
switch (chunk.type) {
case 'step':
console.log('Step completed', chunk.step);
break;
case 'token':
process.stdout.write(chunk.content);
break;
case 'done':
console.log('Final result', chunk.result);
break;
}
}
Chunk types: { type: 'step', step }, { type: 'token', content }, { type: 'done', result }.
Structured Errors
The agent runtime uses AgentError with stable codes for programmatic handling and observability:
import { AgentError, AgentErrorCode } from '@hazeljs/agent';
try {
const result = await runtime.execute('support-agent', input, options);
} catch (err) {
if (err instanceof AgentError) {
switch (err.code) {
case AgentErrorCode.TIMEOUT:
// Execution exceeded timeout
break;
case AgentErrorCode.CANCELLED:
// User or system cancelled
break;
case AgentErrorCode.MAX_STEPS_EXCEEDED:
// Hit max steps limit
break;
case AgentErrorCode.LLM_ERROR:
// LLM call failed (err.cause has details)
break;
case AgentErrorCode.TOOL_NOT_FOUND:
case AgentErrorCode.INVALID_TOOL_INPUT:
// Tool invocation problem
break;
case AgentErrorCode.EXECUTION_NOT_FOUND:
// resume() with invalid executionId
break;
case AgentErrorCode.RATE_LIMIT_EXCEEDED:
// Rate limiter rejected the request
break;
}
}
throw err;
}
Use err.cause for the underlying error when available (e.g. for LLM_ERROR, INVALID_TOOL_INPUT).
Advanced Usage
Custom Initial Context
Pass structured data to the agent at execution time — useful for injecting user profile, order data, or tenant context:
const result = await runtime.execute('support-agent', 'I want a refund', {
sessionId: 'session-abc',
initialContext: {
customerId: 'cust-123',
customerTier: 'premium',
recentOrders: ['#A001', '#A002'],
preferredLanguage: 'en-US',
},
});
Pause and Resume on User Input
Agents can pause mid-execution waiting for user input:
// First message — agent starts working and may ask a clarifying question
const result = await runtime.execute('support-agent', 'I have a problem with my order', {
sessionId: 'session-abc',
});
if (result.state === 'waiting_for_input') {
// result.response contains the question the agent asked
console.log(result.response); // "Could you provide your order number?"
// User responds — resume with their answer
const continued = await runtime.resume(result.executionId, '#A12345');
console.log(continued.response);
}
Tool Policies
Tag tools with custom policy identifiers so your approval handler can apply different logic:
@Tool({
description: 'Delete all customer data (GDPR erasure)',
requiresApproval: true,
policy: 'gdpr-erasure', // your handler checks this
parameters: [
{ name: 'customerId', type: 'string', required: true },
],
})
async deleteCustomerData(input: { customerId: string }) { /* ... */ }
In your approval handler (use requestId from the event to call approveToolExecution or rejectToolExecution):
runtime.on(AgentEventType.TOOL_APPROVAL_REQUESTED, async (event) => {
const { requestId, toolName, input } = event.data;
const pending = runtime.getPendingApprovals().find((r) => r.requestId === requestId);
const policy = pending?.metadata?.policy;
if (policy === 'gdpr-erasure') {
await gdpoApprovalQueue.push({ requestId, toolName, input });
} else {
await standardApprovalQueue.push({ requestId, toolName, input });
}
});
Best Practices
Write Clear Tool Descriptions
The LLM reads tool descriptions to decide which to call and when. Be specific: describe what the tool does, what the parameters mean, and what it returns.
// ✅ Good — specific and informative
@Tool({
description: 'Look up order status, items, and shipping tracking. Returns canRefund flag.',
parameters: [
{ name: 'orderId', type: 'string', description: 'Order ID starting with #, e.g. #A12345', required: true },
],
})
// ❌ Bad — vague, LLM may misuse it
@Tool({ description: 'Get order', parameters: [{ name: 'id', type: 'string', required: true }] })
Return Structured Errors (Not Exceptions)
When a tool fails gracefully (item not found, API unavailable), return a structured object the LLM can reason about. Reserve exceptions for bugs.
async lookupOrder(input: { orderId: string }) {
const order = await this.db.find(input.orderId);
if (!order) return { error: 'Order not found', suggestion: 'Double-check the order ID' };
return order;
}
Design Idempotent Tools
The LLM may decide to call a tool twice. Check before creating or modifying.
async createTicket(input: { orderId: string; issue: string }) {
const existing = await this.ticketDb.findByOrderId(input.orderId);
if (existing) return { ticketId: existing.id, alreadyExists: true };
return await this.ticketDb.create(input);
}
Use Approval for All Mutations
Read-only tools (lookup, search, track) never need approval. Anything that writes, sends, charges, or deletes should use requiresApproval: true in production.
Keep System Prompts Focused
The system prompt is the agent's personality and constraints. Be specific about what the agent should and should not do, what tone to use, and which tools to prefer in which situations.
Multi-Agent Patterns
Single agents are powerful, but real workflows often need multiple specialized agents working together. @hazeljs/agent ships three first-class multi-agent patterns: peer-to-peer delegation with @Delegate, DAG pipelines with AgentGraph, and LLM-driven routing with SupervisorAgent.
@Delegate — Peer-to-Peer Agent Calls
The @Delegate decorator marks a method as a transparent call to another registered agent. The LLM sees it as a regular tool; at runtime the AgentRuntime replaces the method body with runtime.execute(targetAgent, input).
import { Agent, Tool, Delegate, AgentRuntime } from '@hazeljs/agent';
@Agent({
name: 'research-agent',
systemPrompt: 'You are a research specialist. Find detailed information on any topic.',
})
export class ResearchAgent {
@Tool({
description: 'Research a topic in depth and return a structured summary.',
parameters: [
{ name: 'topic', type: 'string', description: 'Topic to research', required: true },
],
})
async research(input: { topic: string }) {
// Calls real research logic — web search, RAG, etc.
return { topic: input.topic, findings: '...' };
}
}
@Agent({
name: 'writer-agent',
systemPrompt: 'You write polished blog posts. Delegate research tasks to the research agent.',
})
export class WriterAgent {
// This method is replaced at runtime by AgentRuntime.execute('research-agent', ...)
@Delegate({
agent: 'research-agent',
description: 'Research a topic and return detailed findings. Use before writing.',
inputField: 'query', // maps the string argument to { query: '...' }
})
async researchTopic(query: string): Promise<string> {
return ''; // body is never called — runtime replaces it
}
@Tool({
description: 'Write a blog post on a topic (research is done automatically).',
parameters: [
{ name: 'topic', type: 'string', description: 'Blog post topic', required: true },
],
})
async writeBlogPost(input: { topic: string }) {
// Agent will call researchTopic() which delegates to ResearchAgent
return { title: `All about ${input.topic}`, body: '...' };
}
}
When to use @Delegate: When you want one agent to transparently call another agent as if it were a local tool, with no extra orchestration code.
AgentGraph — DAG Pipelines
AgentGraph lets you wire agents and functions into a directed acyclic graph with sequential edges, conditional routing, or parallel fan-out. Each node runs in sequence (or parallel), and the output of one node becomes the input of the next.
graph TD A["Entry: researcher"] --> B["writer"] B --> C["reviewer"] C -->|"approved"| D["END"] C -->|"needs revision"| B style A fill:#3b82f6,stroke:#60a5fa,stroke-width:2px,color:#fff style B fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff style C fill:#f59e0b,stroke:#fbbf24,stroke-width:2px,color:#fff style D fill:#8b5cf6,stroke:#a78bfa,stroke-width:2px,color:#fff
Building a Graph
import { AgentRuntime, END } from '@hazeljs/agent';
const runtime = new AgentRuntime({ /* ... */ });
// Register agents
runtime.registerAgent(ResearchAgent);
runtime.registerAgent(WriterAgent);
// Build the pipeline
const graph = runtime
.createGraph('blog-pipeline')
.addNode('researcher', { type: 'agent', agentName: 'research-agent' })
.addNode('writer', { type: 'agent', agentName: 'writer-agent' })
.addNode('publisher', {
type: 'function',
fn: async (input) => {
// Custom function node — publish to CMS, send email, etc.
await cms.publish(input.body);
return { published: true, url: `https://blog.com/${input.title}` };
},
})
.addEdge('researcher', 'writer')
.addEdge('writer', 'publisher')
.addEdge('publisher', END)
.setEntryPoint('researcher')
.compile();
// Run the graph
const result = await graph.run('Write a post about GraphRAG', { sessionId: 'blog-001' });
console.log(result.url);
Conditional Routing
Add a router function on an edge to route dynamically based on the previous node's output:
const graph = runtime
.createGraph('review-pipeline')
.addNode('writer', { type: 'agent', agentName: 'writer-agent' })
.addNode('reviewer', { type: 'agent', agentName: 'reviewer-agent' })
.addNode('publisher',{ type: 'function', fn: publishFn })
.addEdge('writer', 'reviewer')
.addConditionalEdge('reviewer', (output) => {
// Route based on reviewer's output
if (output.approved) return 'publisher';
return 'writer'; // loop back for revision
})
.addEdge('publisher', END)
.setEntryPoint('writer')
.compile();
Parallel Fan-Out
Run multiple agents simultaneously and merge their outputs before continuing:
const graph = runtime
.createGraph('research-pipeline')
.addNode('coordinator', { type: 'agent', agentName: 'coordinator-agent' })
.addNode('parallel-research', {
type: 'parallel',
branches: ['web-researcher', 'academic-researcher', 'news-researcher'],
})
.addNode('synthesizer', { type: 'agent', agentName: 'synthesizer-agent' })
.addEdge('coordinator', 'parallel-research')
.addEdge('parallel-research', 'synthesizer')
.addEdge('synthesizer', END)
.setEntryPoint('coordinator')
.compile();
Streaming Node-by-Node
Use .stream() to get results from each node as it completes — useful for showing progress in a UI:
for await (const event of graph.stream('Write a post about AI', { sessionId: 'abc' })) {
console.log(`[${event.node}] completed:`, event.output);
}
Visualizing the Graph
const mermaid = graph.visualize();
console.log(mermaid);
// graph TD
// researcher --> writer
// writer --> publisher
// publisher --> END
SupervisorAgent — LLM-Driven Routing
SupervisorAgent uses an LLM to decompose a complex task into subtasks, routes each to the best available worker agent, accumulates results, and loops until the task is complete.
graph TD A["User Task"] --> B["Supervisor<br/>(LLM Router)"] B -->|"Subtask 1"| C["ResearchAgent"] B -->|"Subtask 2"| D["CoderAgent"] B -->|"Subtask 3"| E["WriterAgent"] C --> F["Results Accumulator"] D --> F E --> F F --> B B -->|"Done"| G["Final Response"] style A fill:#3b82f6,stroke:#60a5fa,stroke-width:2px,color:#fff style B fill:#6366f1,stroke:#818cf8,stroke-width:2px,color:#fff style C fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff style D fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff style E fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff style F fill:#f59e0b,stroke:#fbbf24,stroke-width:2px,color:#fff style G fill:#8b5cf6,stroke:#a78bfa,stroke-width:2px,color:#fff
import { AgentRuntime } from '@hazeljs/agent';
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const runtime = new AgentRuntime({ /* ... */ });
// Register worker agents
runtime.registerAgent(ResearchAgent);
runtime.registerAgent(CoderAgent);
runtime.registerAgent(WriterAgent);
// Create the supervisor
const supervisor = runtime.createSupervisor({
name: 'project-manager',
workers: ['research-agent', 'coder-agent', 'writer-agent'],
maxRounds: 6, // Maximum supervisor-worker loops before stopping
llm: async (prompt: string) => {
const res = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
});
return res.choices[0].message.content ?? '';
},
});
// Run — the supervisor decomposes, routes, and synthesizes automatically
const result = await supervisor.run(
'Build a REST API for a todo app: research best practices, write the code, and document it.',
{ sessionId: 'project-001' },
);
console.log(result.response);
console.log(`Completed in ${result.rounds} supervisor rounds`);
How the supervisor works:
- The LLM receives the task and the list of available workers with their descriptions
- It decomposes the task into subtasks and assigns each to the best worker
- Each worker executes its subtask using its own tools and memory
- Results are accumulated and fed back to the supervisor LLM
- The LLM decides whether to do another round or return the final response
- Loops until the task is done or
maxRoundsis reached
When to use SupervisorAgent: When the task is too complex and open-ended to predetermine the execution order — you want the LLM to figure out the plan dynamically.
Choosing the Right Pattern
| Pattern | When to use | Predictability | Flexibility |
|---|---|---|---|
@Delegate | One agent calls another agent as a tool | High — explicit call site | Low — wired at code level |
AgentGraph | Known workflow with defined steps | High — you define the DAG | Medium — conditional routing |
SupervisorAgent | Open-ended task, dynamic decomposition | Low — LLM decides the plan | High — adapts to any task |
API Reference (high level)
| API | Description |
|---|---|
runtime.execute(agentName, input, options?) | Run an agent. Options: sessionId, userId, maxSteps, timeout, signal, streaming, enableMemory, enableRAG, initialContext, metadata. |
runtime.executeStream(agentName, input, options?) | Run an agent and stream chunks (step, token, done). Same options as execute; use streaming: true when LLM supports streamChat. |
runtime.resume(executionId, input?) | Resume a paused execution (e.g. after user input or approval). |
runtime.getContext(executionId) | Async. Returns Promise<AgentContext | undefined> for the execution. |
runtime.cancel(executionId) | Cancel an in-flight execution (next check throws AgentError with CANCELLED). |
runtime.approveToolExecution(requestId, approvedBy) | Approve a pending tool execution (event-driven; use requestId from TOOL_APPROVAL_REQUESTED). |
runtime.rejectToolExecution(requestId) | Reject a pending tool execution. |
runtime.getPendingApprovals() | List pending approval requests (each has requestId, status, toolName, input, etc.). |
AgentService (module) | Same as above; getContext is async and returns Promise<AgentContext | undefined>. executeStream and cancel are available. |
AgentError, AgentErrorCode | Structured errors: TIMEOUT, CANCELLED, MAX_STEPS_EXCEEDED, TOOL_NOT_FOUND, INVALID_TOOL_INPUT, LLM_ERROR, EXECUTION_NOT_FOUND, RATE_LIMIT_EXCEEDED. |
For the full API reference (AgentRuntime, AgentModule, @Agent, @Tool, @Delegate, AgentGraph, SupervisorAgent, event types, execution result shape), see the Agent package on GitHub.
Related
- AI Package — LLM providers (OpenAI, Anthropic, Gemini) that power the agent's reasoning
- RAG Package — Vector stores, memory, and document retrieval used by agents
- MCP Package — Expose agent tools as a Model Context Protocol server
- Prompts Package — Manage and version system prompts used by agents
- Guardrails Package — Content safety and validation on agent inputs/outputs
- Ops Agent Package — Pre-built ops agent for Jira and Slack workflows