How to Implement Human-in-the-Loop Action Approval in Agent-Native
Set needsApproval: true in your defineAction configuration to pause execution, emit an approval_required event to connected clients, and resume processing only after a human reviewer submits a valid approvalKey via the /api/approval endpoint.
Agent-native provides a built-in human-in-the-loop (HITL) mechanism that gates sensitive operations behind explicit approval steps. By configuring the needsApproval flag in any action definition, you trigger a managed pause-resume cycle handled entirely by the core runtime—requiring no custom middleware for Slack, Telegram, or web UI integrations.
How the Approval Flow Works
The human-in-the-loop implementation follows a five-step execution model managed by the core libraries:
- Declaration: An action is marked with
needsApproval: truein its metadata. - Interception: When invoked, the runtime emits an
approval_requiredevent instead of executing therunfunction. - Propagation: The event streams to clients via SSE, displaying an approval prompt in the active UI (Slack, Telegram, or web).
- Resolution: A reviewer submits an
approvalKeythrough the standard/api/approvalendpoint. - Completion: The agent re-issues the original tool call with the key attached, and the action executes.
This flow is orchestrated across packages/core/src/action.ts (lines 383–524), where the runtime detects the approval flag; packages/core/src/agent/production-agent.ts (lines 582, 2484, 3150), where execution pauses; and packages/core/src/agent/types.ts (lines 200–230), where pending calls are stored.
Defining Actions That Require Approval
To enable HITL for any action, import defineAction from @agent-native/core and set the needsApproval property:
import { defineAction } from "@agent-native/core";
import { z } from "zod";
export default defineAction({
needsApproval: true, // Triggers the HITL flow
schema: z.object({
emailId: z.string(),
body: z.string(),
}),
async run({ emailId, body }) {
// Executes only after human approval
await db.insert(replies).values({ emailId, body });
},
});
When needsApproval is detected in action.ts (line 383), the runtime aborts immediate execution and generates an approval_required event containing an approvalKey and action summary. The actual run function remains uninvoked until the approval arrives.
Customizing Approval Policies
For role-based or contextual gating, attach an approvalPolicy object to restrict who may approve the action:
export default defineAction({
needsApproval: true,
approvalPolicy: {
requiredRoles: ["admin", "security"],
prompt: "Deploy to production – approve?",
},
schema: z.object({ /* ... */ }),
run: async (input) => { /* ... */ },
});
The policy is enforced server-side in packages/dispatch/src/server/plugins/auth.ts (line 42) before the approval UI allows submission.
Runtime Event Handling
When the agent encounters an action requiring approval, the runtime performs three critical operations:
First, it emits a structured event defined in packages/core/src/agent/types.ts (lines 200–230) with the shape { type: "approval_required", approvalKey: string, summary: string }.
Second, it stores the pending tool call in AgentChatRequest.approvedToolCalls so the original context is preserved for resumption.
Third, the main execution loop in packages/core/src/agent/production-agent.ts (line 582) pauses the current turn, yielding control back to the client layer until the approvalKey is received.
Client-Side Integration
The SSE client layer handles approval events transparently across all front-end implementations. In packages/core/src/client/sse-event-processor.ts (lines 25, 51, 461), incoming events are parsed:
if (ev.type === "approval_required") {
// Forward to UI layer with approvalKey attached
}
The chat runtime in packages/core/src/client/chat/runtime.ts (line 1304) routes this event to render an approval card. Once a reviewer clicks Approve, the client POSTs the approvalKey to /api/approval (defined in templates/dispatch/shared/api.ts), triggering the agent to re-issue the original tool call with the key included in the payload.
Practical Example: Gated Email Sending
Here is a complete implementation for an email action that requires security team approval:
// src/actions/send-email.ts
import { defineAction } from "@agent-native/core";
import { z } from "zod";
export default defineAction({
needsApproval: true,
approvalPolicy: {
requiredRoles: ["security", "admin"],
prompt: "Approve external email transmission?"
},
schema: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
async run({ to, subject, body }) {
// Only executes post-approval
await sendMail({ to, subject, body });
},
});
When invoked through any interface—CLI, web UI, or A2A protocol—the action automatically surfaces an approval request in the connected client. After a valid reviewer submits the approval through the generic dispatch API, the mail function executes with the original arguments preserved.
Summary
- Declarative Configuration: Adding
needsApproval: truetodefineActionautomatically enables HITL without custom middleware. - Managed State: The runtime stores pending calls in
AgentChatRequest.approvedToolCallsand pauses execution viaproduction-agent.tsuntil resolution. - Universal Transport: Approval events flow through SSE to all connected clients, with UI rendering handled by
sse-event-processor.tsandchat/runtime.ts. - Policy Enforcement: Role-based restrictions are validated server-side before approval acceptance.
- Seamless Resumption: After the
approvalKeyis submitted to/api/approval, the original tool call re-executes transparently with full context preserved.
Frequently Asked Questions
How does the agent resume execution after receiving an approval?
When a reviewer submits the approvalKey to the /api/approval endpoint, the agent runtime retrieves the stored pending call from AgentChatRequest.approvedToolCalls (as defined in packages/core/src/agent/types.ts) and re-issues the original tool call with the key attached. The run function then executes normally, returning results to the original caller without requiring client-side state management.
Can I restrict which users are allowed to approve specific actions?
Yes, by attaching an approvalPolicy object to your action definition with a requiredRoles array. The dispatch server validates these roles in packages/dispatch/src/server/plugins/auth.ts (line 42) before processing the approval submission. Only users with matching role assignments will see active approval buttons in the UI.
What information is included in the approval_required event?
The event object includes an approvalKey (a unique identifier for the pending operation), a summary string describing the action, and the full context needed to resume execution. These fields are defined in packages/core/src/agent/types.ts (lines 200–230) and consumed by the SSE processor in packages/core/src/client/sse-event-processor.ts.
Is human-in-the-loop supported across all dispatch templates?
Yes, the HITL mechanism is implemented in the core libraries used by all dispatch templates. The generic API in templates/dispatch/shared/api.ts includes the /api/approval endpoint, and the event processing logic in packages/core/src/client/ is shared across Slack, Telegram, and web UI runtimes, ensuring consistent behavior regardless of front-end implementation.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →