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 result carrying the canonical text 'Tool call was not approved by the user' and skips execute() 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:

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;
},

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.

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 result with the canonical text 'Tool call was not approved by the user' (the optional reason is not included in it).
  3. Appends that result 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 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:

  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}) and input. On the AI SDK UI stream it becomes tool-approval-request { approvalId, toolCallId }.
  4. Pending entry registered — SessionState.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 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 ​

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 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 ​

Released under the MIT License.