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 result carrying the canonical text
'Tool call was not approved by the user'and skipsexecute()entirely. Its persisted shape (failed vs. successful tool result) currently differs by runtime — see Deny semantics.
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;
},The core tool_approval_request / tool_approval_response chunks declare an optional isAutomatic flag for audit visibility of automatic decisions. The runtime does not set it today, and it is not forwarded to the AI SDK UI stream: ai's strict tool-approval-request chunk allows only { approvalId, toolCallId } (see UI-chunk wire contract). Read it from the Helix stream if you need it.
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 result with the canonical text
'Tool call was not approved by the user'(the optionalreasonis not included in it). - Appends that result to conversation history so the LLM sees it on the next step.
- Resumes the run;
execute()never fires for the denied call.
The LLM sees the denial text and can react — apologize, propose an alternative, give up.
Known divergence (CP-66)
The persisted SHAPE of the deny result currently differs by runtime. Cloudflare Workflows and DBOS persist it as a failed tool result ({"error": ...}, metadata.helixToolFailed: true); runtime-js, the Cloudflare DO runtime and Temporal persist the text as a successful tool result (a JSON string, helixToolFailed: false), so a UI reading helixToolFailed renders it as a success there. Which shape is correct is an open contract decision — see CP-66 in the conformance findings.
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}) andinput. On the AI SDK UI stream it becomestool-approval-request { approvalId, toolCallId }.- 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 the synthetic deny result (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 result for the call whose content includes 'Tool call was not approved by the user', and that execute() was never called. Do not assert metadata.helixToolFailed or an {"error": ...} wrapper unless you pin the runtime: that shape currently differs by runtime (see Deny semantics).
See also
- Client-executed tools — the sibling HITL primitive for tools whose entire body runs on the client.
@helix-agents/ai-sdkREADME — composability ladder —useHelixChat,useHelixSendAutomaticallyWhen, andcreateHelixSendAutomaticallyWhenfor wiring approval-response flushes correctly.- v6 to v7 migration guide — full details on the stateless-suspension redesign.
- Interrupt and resume — how
RunOutcome.suspended_*statuses fit into the broader lifecycle.