Approval Gates
Approval gates are the v7 first-class human-in-the-loop primitive for tools that need explicit user consent before running. Set requireApproval: true (or a function form) on defineTool and the runtime suspends the agent at the LLM's tool-call boundary, emits a tool_approval_request stream chunk, and waits for the human to approve or deny the action before invoking execute().
This replaces the v6 pattern of rolling your own approval state machine (emit a custom chunk, wait on a "confirmation" tool, branch on the result) with a single declarative flag.
When to use approval gates
- Sending email / messages — the LLM proposes content; the user signs off before delivery.
- Destructive operations — deletes, deprovisioning, refunds.
- High-spend or high-trust calls — paid API calls, third-party integrations on behalf of a user.
- Compliance gates — operations that need an explicit audit trail of who approved what.
For tools where the entire body executes on the client (no server-side execute() at all), use execute: 'client' instead — see client-executed tools. The two primitives are mutually exclusive: requireApproval runs execute() on the server after approval, while execute: 'client' hands the entire call to the browser.
Static approval (always require)
Set requireApproval: true to require approval on every call:
import { defineTool } from '@helix-agents/core';
import { z } from 'zod/v4';
const sendEmail = defineTool({
name: 'sendEmail',
description: 'Send an email on behalf of the user.',
inputSchema: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
outputSchema: z.object({ messageId: z.string() }),
requireApproval: true,
execute: async (input) => {
// Only runs after the user approved.
return await emailService.send(input);
},
});When the LLM calls this tool, the runtime:
- Validates
inputagainstinputSchema. - Emits a
tool_approval_requeststream chunk with{ toolCallId, toolName, approvalId, input }. - Suspends the run with status
suspended_client_tooland persists the pending entry inSessionState.pendingClientToolCalls(the approval path reuses the client-tool suspension primitive). - On approval submission, resumes the loop and runs
execute(). - On denial, synthesizes a tool-error result (
'Tool call ... was not approved by the user') and skipsexecute()entirely.
Dynamic approval (function form)
Pass a function to require approval only when a runtime predicate holds:
const deleteRecord = defineTool({
name: 'deleteRecord',
inputSchema: z.object({
recordId: z.string(),
force: z.boolean().optional(),
}),
// Require approval only for force-deletes.
requireApproval: (input, ctx) => input.force === true,
execute: async (input) => {
return await db.delete(input.recordId, { force: input.force });
},
});The function receives:
input— the parsed, schema-validated tool input.ctx.requestContext— per-request context (auth principal, tenant, feature flags) wired by theAgentExecutor.ctx.workspace— the agent's workspace handle, when the tool runs in a workspace-aware agent.
Fail-closed semantics. If the function throws, the runtime treats the result as requireApproval = true (require approval), not false. This matches the Mastra precedent and is the safer default — a buggy gate function suspends rather than silently bypasses the human check.
requireApproval: (input, ctx) => {
// If `featureFlags()` throws, the gate fail-closes to "require approval".
return ctx.requestContext?.featureFlags?.('approve-deletes') ?? false;
},When the dynamic gate decides automatically (returns false → run through; returns true → require user approval), the runtime sets isAutomatic: true on the tool_approval_request chunk for audit visibility. The UI can choose not to render an approve/deny dialog for automatic decisions.
Client-side approval UX
The AI SDK v6 React integration exposes approval requests as tool-approval-request UI message parts and gives the chat instance an addToolApprovalResponse(...) method.
'use client';
import { useChat } from '@ai-sdk/react';
function ApprovalUI({ chat }: { chat: ReturnType<typeof useChat> }) {
const last = chat.messages[chat.messages.length - 1];
if (!last) return null;
const requests = last.parts.filter(
(p): p is Extract<typeof p, { type: 'tool-approval-request' }> =>
p.type === 'tool-approval-request'
);
if (requests.length === 0) return null;
return (
<div className="approval-stack">
{requests.map((req) => (
<div key={req.approvalId} className="approval-card">
<h3>Approve: {req.toolName}?</h3>
<pre>{JSON.stringify(req.input, null, 2)}</pre>
<button
onClick={() =>
chat.addToolApprovalResponse({
approvalId: req.approvalId,
approved: true,
})
}
>
Approve
</button>
<button
onClick={() =>
chat.addToolApprovalResponse({
approvalId: req.approvalId,
approved: false,
reason: 'Not authorized',
})
}
>
Deny
</button>
</div>
))}
</div>
);
}addToolApprovalResponse POSTs to /chat/{id}/submit-tool-result with { kind: 'approval-response', approvalId, approved, reason? }, the agent loop resumes, and the SDK's stream-close-and-reopen lifecycle reattaches to the new run.
useResumeClientTools (the hook for client-executed tools) skips approval-request parts intentionally — approvals route through addToolApprovalResponse, not the addToolOutput path.
Deny semantics
When the user denies an approval (approved: false), the runtime:
- Records the response in
SessionState.suspensionContext. - Synthesizes a tool-error message with text
'Tool call <toolCallId> was not approved by the user'(plus the optionalreasonif provided). - Appends the error message to conversation history so the LLM sees it on the next step.
- Resumes the run;
execute()never fires for the denied call.
The LLM observes a normal failed tool result and can react — apologize, propose an alternative, give up. From the model's perspective, denial looks identical to any other tool failure.
Server-side flow
The full server-side lifecycle for a requireApproval tool call:
- LLM emits a tool call — the iterator validates input.
- Approval gate evaluated —
_approvalGate.kind === 'always'short- circuits totrue;_approvalGate.kind === 'fn'invokes the function with(input, { requestContext, workspace }). Errors fail-closed totrue. tool_approval_requestchunk emitted — carriestoolCallId,toolName,approvalId(=${runId}::${toolCallId}),input, andisAutomaticwhen applicable.- Pending entry registered —
SessionState.pendingClientToolCallsgets akind: 'approval'entry with the deadline. StepOutcomereturnssuspended_client_tool— the step iterator yields, the run loop persists state, and the run resolves withRunOutcome.suspended_client_tool.handle.result()resolves with status'suspended_client_tool'— the chat handler closes the SSE stream.- Client submits via
chat.addToolApprovalResponse, which POSTs to/chat/{id}/submit-tool-resultwith the approval-response shape. executor.resume({ sessionId })is called by the chat handler — the loop readssuspensionContext, processes the response, and runsexecute()(on approve) or appends a synthetic tool-error (on deny).
The full sequence shows the symmetry with client-executed tools: both primitives use the same pendingClientToolCalls + suspensionContext machinery, with kind discriminating routing in submitToolResult.
Constraints
| Constraint | Detail |
|---|---|
Mutually exclusive with execute: 'client' | defineTool throws at config time. |
Mutually exclusive with finishWith: true | defineTool throws at config time. |
| Function gate fail-closes | Errors → requireApproval = true. |
| One approval per tool call | The approvalId is ${runId}::${toolCallId}; resubmitting a previously-resolved approval returns already_completed. |
| Runtime support | All four runtimes (JS, Cloudflare Durable Object, Temporal, Cloudflare Workflows) ship full v7 stateless HITL in v7.0. runtime-dbos supports approval flows via the DBOS-native messaging primitives. |
Testing approval gates
Use MockLLMAdapter to script the LLM's tool-call request and assert the runtime suspends without invoking execute(). The packages/core/src/__tests__/phase1-integration.test.ts test illustrates the canonical pattern:
import { MockLLMAdapter } from '@helix-agents/core/testing';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
const mockExecute = vi.fn();
const sendEmail = defineTool({
name: 'sendEmail',
inputSchema: z.object({ to: z.string(), body: z.string() }),
outputSchema: z.object({ messageId: z.string() }),
requireApproval: true,
execute: mockExecute,
});
const llm = new MockLLMAdapter([
// Step 1: LLM proposes the call.
{ toolCalls: [{ toolName: 'sendEmail', args: { to: 'a@b.com', body: 'hi' } }] },
]);
const executor = new JSAgentExecutor(stateStore, streamManager, llm);
const handle = await executor.execute(agent, { message: 'send a hi email' });
const result = await handle.result();
expect(result.status).toBe('suspended_client_tool');
expect(mockExecute).not.toHaveBeenCalled();
// Drive the approval response through and verify execute now runs.
await executor.submitToolResult({
kind: 'approval-response',
sessionId: handle.sessionId,
toolCallId: result.suspended.toolCallIds[0],
approvalId: `${handle.runId}::${result.suspended.toolCallIds[0]}`,
approved: true,
});
const resumed = await executor.resume({ sessionId: handle.sessionId });
const final = await resumed.result();
expect(final.status).toBe('completed');
expect(mockExecute).toHaveBeenCalledOnce();For deny-path coverage, submit with approved: false and assert that the conversation history contains a tool-error message and execute() was never called.
See also
- Client-executed tools — the sibling HITL primitive for tools whose entire body runs on the client.
- v6 to v7 migration guide — full details on the stateless-suspension redesign.
- Interrupt and resume — how
RunOutcome.suspended_*statuses fit into the broader lifecycle.