Unified tool calling in modern Next.js 15 applications
The Vercel AI SDK provides a standard interface for connecting LLMs to external APIs across React Server Components, Server Actions, and Route Handlers.
When giving a chat assistant the ability to send emails, using tool() with a Zod schema guarantees type safety and enables client-side tool call rendering with interactive confirmation states.
| Feature / Requirement | Server Route Handler (/api/chat) | Server Action Direct Call |
|---|---|---|
| Streaming Model | SSE via toDataStreamResponse() | Action response return |
| Client Rendering | Automatic toolInvocation streaming | Manual hook state tracking |
| Cold Start | None — hosted HTTP endpoint | None — hosted HTTP endpoint |
| Human Confirmation | Client intercepts invocation before dispatch | Requires multi-step form token |
Defining the email tool with Zod schema validation
In your Next.js route handler (app/api/chat/route.ts), define the email tool using the tool helper from the ai package:
import { openai } from '@ai-sdk/openai';
import { streamText, tool } from 'ai';
import { z } from 'zod';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
messages,
tools: {
sendEmail: tool({
description: 'Send a transactional email notification to a team member or customer.',
parameters: z.object({
to: z.string().email().describe('Destination email address'),
subject: z.string().min(3).describe('Subject line of the email'),
text: z.string().min(5).describe('Plain-text body content'),
}),
execute: async ({ to, subject, text }) => {
const res = await fetch('https://api.sadasend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SADASEND_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({ to, subject, text }),
});
const data = await res.json();
return { success: res.ok, messageId: data.id, status: data.status };
},
}),
},
});
return result.toDataStreamResponse();
}Handling confirmation UI on the frontend with useChat
Because sending an email is a state-changing side effect, the frontend can inspect toolInvocations to display a rich preview card with a Confirm Send button before triggering the final API call:
'use client';
import { useChat } from 'ai/react';
export function EmailAssistant() {
const { messages, input, handleInputChange, handleSubmit, addToolResult } = useChat();
return (
<div className="chat-container">
{messages.map((m) => (
<div key={m.id} className="message-bubble">
<p>{m.content}</p>
{m.toolInvocations?.map((inv) => {
if (inv.toolName === 'sendEmail' && !('result' in inv)) {
return (
<div key={inv.toolCallId} className="confirmation-card">
<h4>Confirm Outbound Email</h4>
<p><strong>To:</strong> {inv.args.to}</p>
<p><strong>Subject:</strong> {inv.args.subject}</p>
<button onClick={() => addToolResult({ toolCallId: inv.toolCallId, result: { approved: true } })}>
Approve & Dispatch
</button>
</div>
);
}
return null;
})}
</div>
))}
</div>
);
}Edge runtime isolation and credential scoping
Configure your API route with export const runtime = "edge" to ensure sub-millisecond cold starts across global points of presence. Use a scoped SadaSend API key restricted to your verified sender domains.
Building AI agents that send email?
Scoped API keys, per-key recipient allowlists, approval mode and a hosted MCP server with ten tools — on the free plan, without a card.