First, a correction to the title of this post: the Vercel AI SDK has no useTools hook. Neither ai 7 nor @ai-sdk/react exports one. Tools are objects you build with the tool() helper on the server and pass to streamText or generateText. On the React side, useChat receives the tool calls as typed message parts, which is where your UI shows "waiting for code" or an approval button. This post builds three email tools that way, against the Lumbox REST API, and covers the parts that are specific to email: long waits inside a serverless function, what to hand back to the model, and keeping sends behind a human.

The code targets AI SDK 7 (ai@7), where a tool's schema field is inputSchema, the loop limit is stopWhen, and the system prompt field is instructions. On 4.x the first two were parameters and maxSteps, and versions before 7 use system (still accepted in 7 as a deprecated alias).

The tools

// lib/email-tools.ts
import { tool } from "ai";
import { z } from "zod";

const BASE = "https://api.lumbox.co/v1";

async function lumbox(path: string, init: { method?: string; body?: unknown; signal?: AbortSignal; key?: string } = {}) {
  const res = await fetch(BASE + path, {
    method: init.method ?? "GET",
    headers: {
      "X-API-Key": process.env.LUMBOX_API_KEY!,
      "Content-Type": "application/json",
      ...(init.key ? { "Idempotency-Key": init.key } : {}),
    },
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
    signal: init.signal,
  });
  return { status: res.status, body: await res.json().catch(() => null) };
}

export const emailTools = {
  createInbox: tool({
    description: "Create a fresh email address for this task. Call once per task.",
    inputSchema: z.object({}),
    execute: async (_input, { abortSignal }) => {
      const r = await lumbox("/inboxes", { method: "POST", body: {}, signal: abortSignal });
      if (r.status !== 201) return { error: r.body?.error ?? `HTTP ${r.status}` };
      return { inbox_id: r.body.id, address: r.body.address, created_at: r.body.created_at };
    },
  }),

  waitForCode: tool({
    description:
      "Wait up to 90 seconds for a verification code in the inbox. " +
      "Pass since = the created_at returned by createInbox.",
    inputSchema: z.object({
      inbox_id: z.string(),
      since: z.string().describe("ISO timestamp; only mail received after it counts"),
      from: z.string().optional().describe("Sender domain, for example github.com"),
    }),
    execute: async ({ inbox_id, since, from }, { abortSignal }) => {
      const qs = new URLSearchParams({ timeout: "90", since });
      if (from) qs.set("from", from);
      const r = await lumbox(`/inboxes/${inbox_id}/otp?${qs}`, { signal: abortSignal });
      if (r.status === 408) return { code: null, hint: "No code yet. Ask the site to resend, then wait again." };
      if (r.status !== 200) return { error: r.body?.error ?? `HTTP ${r.status}` };
      return { code: r.body.code, expires: r.body.expires };
    },
  }),

  sendEmail: tool({
    description: "Send a plain-text email from an inbox.",
    inputSchema: z.object({
      inbox_id: z.string(),
      to: z.string(),
      subject: z.string(),
      text: z.string(),
    }),
    execute: async ({ inbox_id, ...msg }, { abortSignal, toolCallId }) => {
      const r = await lumbox(`/inboxes/${inbox_id}/send`, {
        method: "POST", body: msg, signal: abortSignal, key: toolCallId,
      });
      if (r.status !== 201) return { error: r.body?.error ?? `HTTP ${r.status}` };
      return { sent: true, email_id: r.body.email_id };
    },
  }),
};

A few choices here are deliberate.

Tool results are small. waitForCode returns the code and its expiry, not the email. The model does not need the HTML of a verification email, and every token you return is one more token in every later step of the loop. Email bodies are also untrusted input: Lumbox wraps text_body in a fence labelled as untrusted content, but the safest body is the one the model never reads. If a task does need the body, return stripped_text from GET /v1/inboxes/:id/wait and keep the fence.

Expected outcomes are values, not exceptions. A 408 from the long-poll means no code arrived yet, which the model can act on (click "resend", wait again). Returning { code: null, hint } lets it do that. Throwing would surface as a tool error, and models tend to treat those as "the tool is broken" and give up.

The wait is anchored with since. The /otp endpoint looks back five minutes by default and returns the newest verification email in that window. If the model retries a signup in the same inbox, the previous code is still in that window. Passing the inbox's created_at (or the time the form was submitted, if your browser tool reports it) keeps an old code from coming back.

The abort signal is forwarded. The second argument to execute carries abortSignal. When the user closes the tab or the request is cancelled, the pending long-poll is cancelled with it instead of holding a connection open for another 90 seconds.

The send uses the tool call ID as its idempotency key. Lumbox keeps an Idempotency-Key for 24 hours and returns the stored response when the same key and body arrive again. The tool call ID is unique per call, so a re-run of the same call cannot send a second email.

The route handler

// app/api/agent/route.ts
import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { emailTools } from "@/lib/email-tools";

export const maxDuration = 300;

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: anthropic("claude-sonnet-4-6"),
    instructions:
      "You complete email verification for the user's tasks. " +
      "Create one inbox per task. Only send email when the user asked for it.",
    messages: await convertToModelMessages(messages),
    tools: emailTools,
    toolApproval: { sendEmail: "user-approval" },
    stopWhen: stepCountIs(10),
    abortSignal: req.signal,
  });

  return result.toUIMessageStreamResponse();
}

stopWhen matters more than it looks. Without it, streamText stops after the first step, so the model would create the inbox and end its turn before ever waiting for the code. Ten steps is enough for create, wait, a resend, a second wait, and a reply to the user.

maxDuration is the Next.js route setting for how long the function may run. A single waitForCode call can hold for 90 seconds, a retry doubles that, and the model's own generation time comes on top. The ceiling you can set depends on your Vercel plan, so size the timeout in the tool to fit inside it rather than the other way round. If a verification can take many minutes, a chat request is the wrong place to wait; hand it to a background job and let the chat poll for the result.

Approval before sending

In AI SDK 7, approval is configured on the streamText call through toolApproval (the older per-tool needsApproval flag still exists but is marked deprecated). With sendEmail: "user-approval", a send call does not execute. It streams to the client as a tool part in the approval-requested state, with the recipient, subject and text the model chose. Your UI renders those and calls addToolApprovalResponse({ id, approved }) from useChat; only an approved call reaches execute. createInbox and waitForCode only touch the agent's own inbox, so they run without asking.

The server-side guards still apply after approval. A new Free account without a verified domain can send 5 emails in its first hour and 25 in its first day, sends are rate limited per minute by plan, and recipients that hard-bounced or unsubscribed are dropped. The tool returns those responses as error values the model can report back.

What this does not do

These tools do not fill in the signup form; that is your browser tool's job, and the model passes the address from createInbox to it. waitForCode only finds numeric codes (4 to 8 digits near words like "code" or "verification") in emails the parser categorised as verification mail. For a site that sends a confirmation link instead, add a fourth tool around /wait that returns parsed.verification_links. And nothing here deletes the inbox: add a deleteInbox tool or clean up after the run, because inboxes stay until you delete them and the Free plan holds three.

The Vercel AI SDK integration page has a single-tool version that uses the lumbox package for the receive side, the Node.js guide covers the same endpoints outside a model loop, and how OTP extraction works explains which codes the parser catches.