CookbookBasics
React tool approvals
Add human approval to a Next.js chat route without holding a server request open.
This recipe uses @crux/ai, approvalMiddleware(), and the AI SDK approval message flow. The server returns when the model asks for approval. The browser renders buttons. The next request includes the approval response and Crux continues the tool loop.
Prompt and policy
import { approvalMiddleware, prompt } from '@crux/core'
import { tool } from '@crux/ai'
import { z } from 'zod'
export const sendEmail = tool({
description: 'Send an email to a customer.',
inputSchema: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
execute: async ({ to, subject, body }) => {
await email.send({ to, subject, body })
return { sent: true }
},
})
export const emailApprovals = approvalMiddleware({
id: 'email-approval',
match: ['sendEmail'],
onApproved: ({ approvalId, input }) => {
audit.write({ approvalId, action: 'approved', input })
},
onDenied: ({ approvalId, reason }) => {
audit.write({ approvalId, action: 'denied', reason })
},
})
export const assistant = prompt({
id: 'support-assistant',
input: z.object({ message: z.string() }),
system: 'Help support agents, but never send email without approval.',
prompt: ({ input }) => input.message,
tools: { sendEmail },
toolMiddleware: [emailApprovals],
})Route
import { stream } from '@crux/ai'
import { openai } from '@ai-sdk/openai'
import { assistant } from '@/lib/assistant'
export async function POST(req: Request) {
const { messages } = await req.json()
const last = messages.at(-1)
const result = await stream(assistant, {
model: openai('gpt-4o'),
input: { message: last?.content ?? '' },
messages,
})
return result.toUIMessageStreamResponse()
}Client
'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 (
<main>
{messages.map((message) => (
<article key={message.id}>
{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>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval.id,
approved: false,
reason: 'Denied by user',
})
}
>
Deny
</button>
</div>
)
}
if (part.type === 'text') return <p key={part.text}>{part.text}</p>
return null
})}
</article>
))}
<button onClick={() => sendMessage({ text: 'Email Sam about the refund.' })}>
Ask
</button>
</main>
)
}The important part is that the server route is stateless. The approval id and response are in the message history, so this works in serverless runtimes.