Tool approvals
Add human approval to dangerous tool calls in serverless-safe apps.
Tool approval is middleware for human-in-the-loop execution. It is not an awaited modal on the server. Crux returns an approval request, your app renders the decision UI, and the next request resumes with the decision in message history.
That return-and-resume shape works in serverless, Convex actions, CLI programs, React, Expo, OpenAI, Google, Anthropic, and the AI SDK adapter because the approval state is portable message data.
Add an approval policy
import { approvalMiddleware, prompt } from '@crux/core'
const approvals = approvalMiddleware({
id: 'dangerous-actions',
match: ['sendEmail', 'createRefund'],
onRequest: ({ toolName, input }) => {
audit.write({ phase: 'requested', toolName, input })
},
onApproved: ({ approvalId, toolName }) => {
audit.write({ phase: 'approved', approvalId, toolName })
},
onDenied: ({ approvalId, toolName, reason }) => {
audit.write({ phase: 'denied', approvalId, toolName, reason })
},
})
export const assistant = prompt({
id: 'support-agent',
tools: { sendEmail, createRefund },
toolMiddleware: [approvals],
})Native adapters
Native adapters return Crux message history on result.messages. Use the message-shape helpers from @crux/core/adapter/tool (also exported from the @crux/core root) to find approval requests and append decisions.
import { appendToolApprovalResponse, findToolApprovalRequests } from '@crux/core/adapter/tool'
import { createOpenAI } from '@crux/openai'
const first = await openai.generate(assistant, {
model: 'gpt-4o',
input: { message: 'Refund this customer and email them.' },
})
const [request] = findToolApprovalRequests(first.messages)
if (request) {
const final = await openai.generate(assistant, {
model: 'gpt-4o',
messages: appendToolApprovalResponse(first.messages, {
approvalId: request.approvalId,
approved: true,
approvalToken: request.approvalToken,
}),
})
console.log(final.text)
}The same shape works with @crux/google and @crux/anthropic because approvals are handled by Crux before provider-specific message conversion.
approvalToken binds the decision to the exact server-issued request. Keep the original returned messages on the server or in trusted session storage, then append the user decision to that history. Do not accept arbitrary client-fabricated message history for tools that can mutate data or call external systems.
The approval helpers are message-shape utilities and types only — safe to import in browser components. Keep approvalMiddleware() itself on the server with your prompt/tool definitions.
AI SDK adapter
@crux/ai maps approval middleware to the AI SDK approval protocol. In React, render the tool UI part with state === 'approval-requested' and call addToolApprovalResponse().
'use client'
import { useChat } from '@ai-sdk/react'
import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
export function Chat() {
const { messages, sendMessage, addToolApprovalResponse } = useChat({
api: '/api/chat',
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
})
return messages.map((message) =>
message.parts.map((part) => {
if (part.type === 'tool-sendEmail' && part.state === 'approval-requested') {
return (
<div key={part.toolCallId}>
<p>Approve email to {part.input.to}?</p>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval.id,
approved: true,
})
}
>
Approve
</button>
</div>
)
}
return null
}),
)
}Approve once
Use a predicate matcher when approval depends on a user, session, or database grant.
const approvals = approvalMiddleware({
id: 'send-email-once',
match: [
async ({ toolName, input }) => {
if (toolName !== 'sendEmail') return false
const scope = typeof input === 'object' && input !== null && 'to' in input ? String(input.to) : 'unknown'
return !(await approvalsDb.hasApproval({
userId: session.user.id,
toolName,
scope,
}))
},
],
onApproved: async ({ toolName, input }) => {
const scope = typeof input === 'object' && input !== null && 'to' in input ? String(input.to) : 'unknown'
await approvalsDb.recordApproval({
userId: session.user.id,
toolName,
scope,
})
},
})The first matching call pauses. After the approval is recorded, the predicate returns false for the same scope, so future calls execute normally.