Picture a “helpful” agent that can refund any order or delete any database row the moment the model feels confident. Legal signs off on the demo deck; nobody reads the fine print in processRefund until finance asks why last Tuesday’s refunds doubled.
HazelJS treats dangerous tools as first-class policy objects: mark them with requiresApproval: true, listen for TOOL_APPROVAL_REQUESTED on the runtime, and call approveToolExecution only from a workflow you trust. The same runtime supports resume(executionId) when execution pauses for human input—so “wait for manager” is not a hack on top of raw chat completions.
The core idea: the model proposes; the runtime blocks; humans or systems grant; you can resume without losing state.
Why approvals are not “just another if statement”
| Approach | Risk |
|---|---|
| “Trust the prompt” | Model drifts; jailbreaks bypass intent |
| Approve in app code after tool returns | Side effect already happened |
requiresApproval in @hazeljs/agent |
Executor blocks until approval; audit events fire first |
Multi-agent setups make this worse: supervisors and delegates can route into agents whose tools have side effects. Central approval keeps one choke point regardless of how many workers the graph used.
Project setup
npx @hazeljs/cli g app agent-approvals --template=ai-native
cd agent-approvals
cp .env.example .env
Start Postgres + pgvector (same as the base AI-native template), run npm run db:push, set OPENAI_API_KEY, then npm run dev.
Mark tools that need a human
import { Agent, Tool } from '@hazeljs/agent';
@Agent({
name: 'BillingAgent',
description: 'Handles billing lookups and sensitive actions',
systemPrompt: 'You help with billing. Never refund without explicit user confirmation in the thread.',
maxSteps: 8,
})
export class BillingAgent {
@Tool({
description: 'Issue a refund to the customer wallet',
requiresApproval: true,
parameters: [
{ name: 'orderId', type: 'string', description: 'Customer order id', required: true },
{ name: 'amountCents', type: 'number', description: 'Refund amount in cents', required: true },
{ name: 'reason', type: 'string', description: 'Refund reason for audit', required: true },
],
})
async issueRefund(input: { orderId: string; amountCents: number; reason: string }) {
return { status: 'refunded', ...input };
}
@Tool({
description: 'Look up order status (read-only)',
parameters: [{ name: 'orderId', type: 'string', description: 'Order to look up', required: true }],
})
async getOrderStatus(input: { orderId: string }) {
return { orderId: input.orderId, status: 'shipped' };
}
}
Read-only tools stay fast; only mutations that need governance carry requiresApproval.
Expose HTTP with a thin controller (companion demo uses POST /billing):
import { Body, Controller, Post } from '@hazeljs/core';
import { AgentService } from '@hazeljs/agent';
@Controller('billing')
export class BillingController {
constructor(private readonly agentService: AgentService) {}
@Post()
async run(@Body() body: { message: string }) {
const result = await this.agentService.execute('BillingAgent', body.message);
return { response: result.response, executionId: result.executionId };
}
}
Listen and approve (development vs production)
Development: auto-approve for integration tests only
import { Service } from '@hazeljs/core';
import { AgentService, AgentEventType } from '@hazeljs/agent';
import type { AgentEvent, ToolApprovalEventData } from '@hazeljs/agent';
@Service()
export class ApprovalBootstrapService {
constructor(private readonly agentService: AgentService) {}
async onModuleInit() {
if (process.env.NODE_ENV === 'production') return;
const runtime = this.agentService.getRuntime();
runtime.on(AgentEventType.TOOL_APPROVAL_REQUESTED, (event: unknown) => {
const e = event as AgentEvent<ToolApprovalEventData>;
const requestId = e.data.requestId;
if (requestId) {
runtime.approveToolExecution(requestId, 'dev-bootstrap');
}
});
}
}
Never ship this listener to production unless you intentionally want zero human review.
Production: enqueue and approve from your admin API
- On
TOOL_APPROVAL_REQUESTED, push{ requestId, executionId, toolName, payload }to a queue or DB row. - Expose
POST /admin/approvals/:requestId/approvethat loads the runtime and callsapproveToolExecution(requestId, approvedByUserId). - Optionally expose deny API and map denied approvals to user-visible errors.
The event payload shape is documented in @hazeljs/agent
Resume after pause
When execution is built for pause/resume, AgentService.resume(executionId, input?) continues from the saved checkpoint instead of restarting the whole chain. Pair this with:
-
Slack / email approval — worker receives webhook, calls approve, then
resume. -
@hazeljs/flow— model WAIT nodes that wait for external approval signals, with an audit timeline alongside agent events.
Use executionId from the initial execute result in logs and support tickets so operators can correlate “which run waited for Jane?”
Scenarios you can verify
Windows (PowerShell): use curl.exe or -d @body.json.
0) Build — npm run build should pass once tool parameters include description and the approval listener uses an unknown → AgentEvent<ToolApprovalEventData> cast (current @hazeljs/agent typings).
1) Read-only path — status.json:
{ "message": "What is the status of order ORD-1001?" }
curl.exe -s -X POST http://localhost:3000/billing
-H "Content-Type: application/json"
-d "@status.json"
Expect HTTP 200 with { "response": "...", "executionId": "..." } and no stuck run (the model should call getOrderStatus without requiresApproval).
2) Refund path — refund.json (wording helps the model pick issueRefund):
{
"message": "Refund order ORD-1001 for 5000 cents. Reason: defective item — customer approved in ticket 4412."
}
curl.exe -s -X POST http://localhost:3000/billing
-H "Content-Type: application/json"
-d "@refund.json"
With the dev-only bootstrap listener, you should still see a completed response: the runtime emits tool.approval.requested, immediately approveToolExecution, then tool.approval.granted before the tool body runs. In production, remove auto-approve and drive approveToolExecution from your admin API instead.
3) Logs / metrics — subscribe to AgentEventType values or grep logs for tool.approval.requested / tool.approval.granted.
What to change before production
-
Remove unconditional auto-approve; gate behind
NODE_ENVor a feature flag. - Persist approval decisions with actor id, timestamp, and request id for audits.
- Timeout stuck approvals so runs do not hold workers forever; surface “expired” to the user.
-
Supervisor + approvals — remember every routed worker inherits tool policy; document which workers expose
requiresApprovaltools.
requiresApproval, runtime events, and resume are how HazelJS keeps multi-agent power compatible with enterprise controls: the LLM can still orchestrate, but irreversible work stays behind a door only your systems open. Read @hazeljs/agent and, for long-lived waits, @hazeljs/flow. You can find the full working code example on GitHub
Top comments (0)