Skip to content

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:

ts
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:

  1. Validates input against inputSchema.
  2. Emits a tool_approval_request stream chunk with { toolCallId, toolName, approvalId, input }.
  3. Suspends the run with status suspended_client_tool and persists the pending entry in SessionState.pendingClientToolCalls (the approval path reuses the client-tool suspension primitive).
  4. On approval submission, resumes the loop and runs execute().
  5. On denial, synthesizes a tool-error result ('Tool call ... was not approved by the user') and skips execute() entirely.

Dynamic approval (function form)

Pass a function to require approval only when a runtime predicate holds:

ts
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 the AgentExecutor.
  • 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.

ts
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.

tsx
'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:

  1. Records the response in SessionState.suspensionContext.
  2. Synthesizes a tool-error message with text 'Tool call <toolCallId> was not approved by the user' (plus the optional reason if provided).
  3. Appends the error message to conversation history so the LLM sees it on the next step.
  4. 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:

  1. LLM emits a tool call — the iterator validates input.
  2. Approval gate evaluated_approvalGate.kind === 'always' short- circuits to true; _approvalGate.kind === 'fn' invokes the function with (input, { requestContext, workspace }). Errors fail-closed to true.
  3. tool_approval_request chunk emitted — carries toolCallId, toolName, approvalId (= ${runId}::${toolCallId}), input, and isAutomatic when applicable.
  4. Pending entry registeredSessionState.pendingClientToolCalls gets a kind: 'approval' entry with the deadline.
  5. StepOutcome returns suspended_client_tool — the step iterator yields, the run loop persists state, and the run resolves with RunOutcome.suspended_client_tool.
  6. handle.result() resolves with status 'suspended_client_tool' — the chat handler closes the SSE stream.
  7. Client submits via chat.addToolApprovalResponse, which POSTs to /chat/{id}/submit-tool-result with the approval-response shape.
  8. executor.resume({ sessionId }) is called by the chat handler — the loop reads suspensionContext, processes the response, and runs execute() (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

ConstraintDetail
Mutually exclusive with execute: 'client'defineTool throws at config time.
Mutually exclusive with finishWith: truedefineTool throws at config time.
Function gate fail-closesErrors → requireApproval = true.
One approval per tool callThe approvalId is ${runId}::${toolCallId}; resubmitting a previously-resolved approval returns already_completed.
Runtime supportAll 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:

ts
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

Released under the MIT License.