Crux
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

lib/assistant.ts
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

app/api/chat/route.ts
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

app/chat.tsx
'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.

On this page