DEV Community

Shehriyar Malik
Shehriyar Malik

Posted on

Sticky Hands Off the Red Button: Approvals, Events, and Resumable Agents in HazelJS

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
Enter fullscreen mode Exit fullscreen mode

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' };
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Never ship this listener to production unless you intentionally want zero human review.

Production: enqueue and approve from your admin API

  1. On TOOL_APPROVAL_REQUESTED, push { requestId, executionId, toolName, payload } to a queue or DB row.
  2. Expose POST /admin/approvals/:requestId/approve that loads the runtime and calls approveToolExecution(requestId, approvedByUserId).
  3. 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) Buildnpm run build should pass once tool parameters include description and the approval listener uses an unknownAgentEvent<ToolApprovalEventData> cast (current @hazeljs/agent typings).

1) Read-only pathstatus.json:

{ "message": "What is the status of order ORD-1001?" }
Enter fullscreen mode Exit fullscreen mode
curl.exe -s -X POST http://localhost:3000/billing 
-H "Content-Type: application/json" 
-d "@status.json"
Enter fullscreen mode Exit fullscreen mode

Expect HTTP 200 with { "response": "...", "executionId": "..." } and no stuck run (the model should call getOrderStatus without requiresApproval).

2) Refund pathrefund.json (wording helps the model pick issueRefund):

{
  "message": "Refund order ORD-1001 for 5000 cents. Reason: defective item — customer approved in ticket 4412."
}
Enter fullscreen mode Exit fullscreen mode
curl.exe -s -X POST http://localhost:3000/billing 
-H "Content-Type: application/json" 
-d "@refund.json"
Enter fullscreen mode Exit fullscreen mode

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_ENV or 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 requiresApproval tools.

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


Package links

HazelJS site

Top comments (0)