Skip to content

tutorials

Build an AI email agent with typed tools over your own inbox

June 10, 2026

#ai#agent#vercel-ai-sdk#inbox#cloudflare
Build an AI email agent with typed tools over your own inbox
Visual: Greta Impressa on okslop

An LLM that can only chat about your inbox is a toy. One with typed tools over your own database is an operator: it searches, reads a thread, summarizes it, drafts a reply, and, if you let it, sends.

What is an AI email agent and how is it different from a chatbot?

An AI email agent is a language model wired to a set of typed tools over your mail store, so it can take actions (search, read, summarize, draft, send) instead of only answering questions about email in the abstract. The difference from a chatbot is the tool loop: when you ask "what needs my attention?", a chatbot guesses from training data, while an agent calls a search_emails tool, gets real rows back from your inbox, and reasons over them.

This works cleanly only when the inbox is data you control. If your mail lives behind a vendor API, your "agent" is really a brittle integration: rate-limited endpoints, partial fields, no transactions, and a third party between the model and your data. When the inbox is your own SQLite database on Cloudflare D1, the agent calls typed functions that run ordinary queries. That is the whole pattern, and the rest of this guide builds it.

We will use the Vercel AI SDK because it is model-agnostic and its tool-calling loop is the cleanest abstraction for this. If you want to expose the same capability to external assistants like Claude, that is the same tools served over MCP rather than a separate build.

Why does an owned inbox make a good agent target?

Because the agent calls typed tools against your own database, not a screenshot, a scrape, or someone else's rate-limited API. Three properties fall out of ownership:

  • Typed, transactional access. A tool handler runs a real query and returns rows with stable fields (threadId, subject, from, a body preview). No pagination guesswork, no missing fields, no surprise 429s mid-conversation.
  • The same authorization the UI uses. If every tool dispatches the same procedures your webmail UI calls, the agent inherits the existing access checks for free. You are not building a second, weaker data path for the model to walk through.
  • Your data stays put. Reads never leave your infrastructure. Only the text you choose to send (a thread body you ask it to summarize) reaches the model provider, and even that is your choice of provider. See why an owned inbox is the prerequisite for the longer argument.

The agent is the action layer. Underneath it, the interpreters that produce the triage signal run on inbound mail and the FTS5 search the agent's tools call indexes it. The agent does not re-derive any of that; it reads it.

What tools should an email agent have?

A small, sharp set covering the read-reason-act loop. In Tinbox these are declared once in a shared Zod manifest and reused across the chat agent, the MCP server, and the CLI. For a wider menu of candidates to prioritize, see the agent tools worth building for your inbox first and the email tasks most worth automating with an agent. The core set:

ToolReads or writesWhat it does
search_emailsreadFull-text search by subject, sender, or body
list_threadsreadRecent threads, most recent first
get_threadreadOne thread with its full message history
summarize_threadreadReads a thread, then makes an LLM sub-call to summarize it
create_draftwrite (safe)Creates a reply draft a human reviews
update_draftwrite (safe)Edits a draft (compare-and-swap on version)
send_emailwrite (destructive)Actually dispatches mail, behind an approval step

summarize_thread is the interesting one: it is a tool that itself calls a model. The handler reads the thread with a normal query, then invokes an injected summarizer built from your configured model. The trpc layer stays provider-agnostic; the provider SDK lives in the worker that wires the route. That keeps the tool manifest importable by the CLI and the MCP server without dragging a provider dependency along.

How do you define a tool with a Zod schema?

You declare the tool once: a name the model uses, a description it reads, a Zod input schema (which both validates arguments and generates the input type), and a handler that runs an authorized query and returns a structured result. Here is search_emails, close to the real definition:

import { z } from "zod";
import { defineManifestTool } from "./manifest";
import type { ToolResult } from "./result";

export const searchEmails = defineManifestTool({
  name: "search_emails",
  title: "Search email",
  description:
    "Full-text search the user's mail by subject, sender, or body. " +
    "Call this to find relevant emails BEFORE answering. Never answer " +
    "from memory about specific mail. Returns matching messages with " +
    "threadId, inboxId, subject, sender, and a body preview.",
  inputSchema: z.object({
    query: z.string().describe("Search terms, e.g. 'refund', 'invoice March'."),
    inboxId: z.string().optional().describe("Restrict to one inbox; omit for all."),
  }),
  // MCP-style hints: this is safe to auto-run.
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
  execute: async ({ query, inboxId }, ctx): Promise<ToolResult> => {
    // A pinned conversation scope is a HARD override of the model's inboxId.
    const out = await ctx.caller.emails.search({
      query,
      inboxId: ctx.scopeInboxId ?? inboxId,
    });
    return {
      kind: "email_list",
      query,
      emails: out.results.map(toEmail),
      truncated: out.total > out.results.length,
    };
  },
  // What the MODEL sees: a trimmed text view, not the full payload.
  toModelText: (r) =>
    r.kind === "email_list"
      ? `Found ${r.emails.length} email(s) for "${r.query}":\n` +
        r.emails.map((e) => `- [${e.threadId}] ${e.subject} (${e.from}): ${e.preview}`).join("\n")
      : "",
});

Three things are load-bearing here. First, ctx.caller is the same authorized tRPC caller the UI uses, so the search inherits its access checks. Second, ctx.scopeInboxId is a hard override: when a conversation is scoped to one inbox, the model's inboxId argument is ignored and the search is forced. Third, execute returns a structured object, and toModelText trims it. The model never sees the full result; it sees a compact line per hit. That split is how you give the model dozens of results without blowing the context window.

How do structured results become rich cards?

The handler returns a discriminated union keyed on kind, and the UI switches on that to render a card instead of plain text. The result type is the one stable interface between the agent and the chat UI:

export type ToolResult =
  | { kind: "email_list"; emails: ToolEmail[]; query?: string; truncated?: boolean }
  | { kind: "thread"; threadId: string; subject: string; participants: string[];
      messages: { from: string; preview: string; date: number }[]; messageCount: number }
  | { kind: "draft"; draftId: string; version: number; subject?: string;
      action: "created" | "updated" }
  | { kind: "summary"; threadId: string; summary: string; messageCount: number }
  | { kind: "sent_email"; threadId: string; to: string[]; subject: string }
  | { kind: "error"; message: string };

One object, multiple consumers: the chat UI persists it verbatim and renders a card by switching on kind, the model receives the trimmed toModelText view, and an MCP client gets it as structuredContent. An error kind is the uniform failure shape (an authorization denial, a stale-draft conflict, a missing model). It is surfaced to the user, never swallowed.

How do you wire the tools into the Vercel AI SDK?

You pass a model instance and the tool set to streamText, and the SDK runs the tool-calling loop for you: it sends the tool schemas to the model, validates any tool call against the matching Zod schema, invokes your handler, feeds the trimmed result back, and continues until the model produces a final answer. The adapter from a manifest tool to an SDK tool is small:

import { streamText, tool, zodSchema, stepCountIs } from "ai";

function toAiSdkTool(entry, ctx) {
  return tool({
    description: entry.description,
    inputSchema: zodSchema(entry.inputSchema),
    // A destructive tool makes the SDK insert a human approval step.
    needsApproval: entry.annotations.destructiveHint,
    execute: async (input, { toolCallId }) => {
      const result = await entry.execute(input, ctx, toolCallId);
      // Persist the FULL structured result (the card reads it)...
      ctx.fullResults.set(toolCallId, JSON.stringify(result));
      return result;
    },
    // ...but feed the MODEL a trimmed text view (the token-budget guard).
    toModelOutput: ({ output }) => ({ type: "text", value: entry.toModelText(output) }),
  });
}

const result = streamText({
  model,                    // any Vercel AI SDK provider, your choice
  system: "You are an email assistant. Search before you answer about mail.",
  messages,
  tools: Object.fromEntries(
    enabledTools.map((t) => [t.name, toAiSdkTool(t, ctx)]),
  ),
  // Let the model chain several tool calls before answering.
  stopWhen: stepCountIs(8),
});

return result.toUIMessageStreamResponse();

model is the only line that knows which provider you use. Swap it for a different provider and nothing else changes: same tools, same handlers, same cards. That is the point of building on the AI SDK rather than a single vendor's agent framework. stopWhen: stepCountIs(8) lets the model search, then read, then summarize in one turn instead of stopping after the first tool call.

What does a real triage session look like?

Here is an actual flow: the user asks a vague question, the agent chains read tools, then drafts a reply and waits for approval before sending.

you ▸ what needs my attention today?

  ⚙ list_threads { limit: 20 }
    → email_list card: 20 threads

agent ▸ Three threads look time-sensitive:
        1. "Refund request, order 4471" (customer, 2 replies, no response yet)
        2. "Invoice March overdue" (vendor)
        3. "Re: contract redline" (legal, waiting on you)

you ▸ summarize the refund one and draft a reply

  ⚙ search_emails { query: "refund order 4471" }
    → email_list card: 1 thread [thr_9f2a]
  ⚙ summarize_thread { threadId: "thr_9f2a" }
    → summary card: "A customer requests a refund for order 4471 (damaged
      on arrival, photos attached). They have replied twice without a
      response. They want a refund, not a replacement. Needs a reply today."
  ⚙ create_draft { inboxId: "inb_support", threadId: "thr_9f2a",
                   subject: "Re: Refund request, order 4471",
                   bodyText: "Hi Dana, I'm sorry the order arrived damaged…" }
    → draft card: draft_7c10 (version 1) · created

agent ▸ Drafted a reply apologizing and confirming a full refund. Review it
        in the thread, or tell me to send it.

you ▸ send it

  ⚙ send_email { … }   ⛔ approval required
    [ Approve ]  [ Deny ]

The agent never sent anything on its own. It drafted, and the send is gated behind an explicit approval click. That gate is not a UI nicety; it is the safety boundary, and it is wired into the tool itself.

How do you stop an agent from sending email it should not?

Put send_email behind three independent gates, so any one of them blocks a bad send:

  1. A deploy-time allowlist. In Tinbox, app/agent.config.ts lists which tools exist on this deployment. A draft-only deployment omits send_email entirely; the model can create drafts but has no send verb to call. This is git-gated, not a runtime toggle, so it is reviewable in a pull request.
  2. A token capability check. The send runs the same authorized messages.send procedure, which checks for a reply capability. A read-only token cannot send even if the tool is registered. The model cannot escalate its own permissions.
  3. A human approval step. The tool's destructiveHint annotation makes the Vercel AI SDK mark the call needsApproval, so the chat surfaces an approve/deny step (and MCP clients confirm) before the handler runs.

Here is the failure-then-fix. With send not on the allowlist, the model tries anyway:

you ▸ just send Dana a refund confirmation, don't ask me

  ⚙ send_email { … }
    → error card: "Tool 'send_email' is not enabled on this deployment."

agent ▸ I can't send on this deployment; sending is disabled. I created a
        draft (draft_7c10) for you to review and send manually instead.

The model wanted to fire-and-forget. The allowlist refused, the tool returned a uniform error result, and the agent fell back to a draft. Before you flip send on, walk the AI email agent safety checklist so the guardrails are deliberate and not an afterthought. To actually enable sending, you add the tool to the config and the approval step still stands:

// app/agent.config.ts
export default {
  tools: [
    "search_emails", "list_threads", "get_thread",
    "summarize_thread", "create_draft", "update_draft",
    "send_email", // ← now enabled; approval step still required at call time
  ],
};

Note one honest limit: Tinbox has narrow runtime tool toggles stored in D1 (agent_settings) for flipping a few tools without a redeploy, but that is a small switch, not a full role-based permissions UI. The durable boundary is the git-gated allowlist plus the per-call approval. Treat send as guarded, never as the default. For the broader pattern, see the same guarded tools exposed to external assistants.

How do interpreters make the agent smarter?

Interpreters classify inbound mail before the agent ever runs, so the agent reads precomputed signals instead of re-deriving them on every turn. When a message arrives, the interpreters that produce the triage signal run (algorithmic rules first, then LLM rules for ambiguous cases) and write structured verdicts: a category, a priority flag, an extracted OTP, a complaint score. Those verdicts are just fields the agent's read tools return.

So when you ask "what's urgent?", the agent does not have to read and judge every thread from scratch and spend tokens doing it. It filters on the priority verdict the interpreter already wrote. The division of labor is clean: interpreters classify on ingest, the agent reasons and acts on demand, and the MCP server exposes the same act-on-demand tools to outside assistants. Each layer does one job and feeds the next.

Same tools, external assistants

The tool manifest you just built is not chat-only. Because it imports only Zod and your result types (never the AI SDK, the MCP SDK, or a provider SDK), the same manifest is served over an MCP endpoint, so Claude or any MCP client can run search_emails, summarize_thread, and (if allowed) send_email against your inbox with the same authorization and the same approval gate. You do not re-teach the model your inbox twice. Define the tool once; wire the surfaces. The MCP launch covers that setup in expose the same tools to Claude over MCP.

What are the common failure modes?

Three you will hit, and how each is handled:

  • A tool returns empty. The model passed a bad query and search_emails finds nothing. The handler returns an empty email_list, the model sees "Found 0 email(s)", and a well-prompted agent broadens the query or asks you to clarify rather than inventing a result.
  • The model tries to send without approval or scope. Covered above: the allowlist or the capability check returns an error result, and the agent falls back to a draft. This is exactly why send is destructive-gated; a confident hallucination cannot dispatch mail.
  • Token or scope errors. A read-only token hitting a write tool, or a scoped conversation where the model passes a foreign inboxId. The authorization layer denies it and the scope override silently corrects it. The model gets a clear error text, not a stack trace, and recovers in the conversation.

The pattern across all three: tools return a uniform error result that both the user (as a card) and the model (as trimmed text) can see. Failures are surfaced, not swallowed, and the agent reasons about them like any other tool output.

Deploy it

You now have the whole pattern: typed tools with Zod schemas over your own D1 inbox, wired into the model-agnostic Vercel AI SDK, returning structured results that render as cards, with send guarded behind an allowlist, a capability check, and a human approval step. One manifest powers the chat agent, the MCP server, and the CLI.

Deploy this yourself: npx tinbox init <domain> scaffolds the inbox, the interpreters, the tool manifest, and the streaming chat agent onto your own Cloudflare account. The agent config (app/agent.config.ts) and tool definitions are yours to read and edit. The docs walk through enabling send and adding a custom tool.

Frequently asked questions

Which model does an AI email agent use?

Any of them. The agent is built on the Vercel AI SDK, which is model-agnostic: you pass a model instance (an Anthropic, OpenAI, Google, or local provider) to streamText, and the tool-calling loop works the same. Tinbox ships a default provider but the choice is a config value, not a hard dependency. Your tool definitions and handlers never change when you switch models.

Can an AI email agent send email on my behalf?

Yes, but it is guarded. The send_email tool is marked destructive, which makes the Vercel AI SDK require an explicit approval step before the call executes, and the underlying messages.send still runs an authorization check that a read-only token fails. In Tinbox, send_email is also on a deploy-time allowlist (app/agent.config.ts): a draft-only deployment omits it, and the agent can only create drafts a human reviews.

How is a built-in email agent different from an MCP server?

They share the exact same tools. The built-in agent is an in-app chat (Vercel AI SDK) that runs the tool manifest server-side over your D1 inbox. An MCP server exposes that same manifest to external assistants like Claude over a JSON-RPC endpoint. One Zod manifest feeds both surfaces, so a tool you add appears in the chat agent and over MCP without rewriting it.

Does the agent see my whole inbox or just one folder?

It sees what its tools are allowed to reach. Every tool dispatches the same authorized tRPC procedures the UI uses, so the same access checks apply. A conversation can also pin an inbox scope: when set, search and list tools are hard-forced to that one inbox and the agent cannot reach others even if it passes a different id.

How does a tool call actually reach my data?

The model emits a tool call with JSON arguments, the Vercel AI SDK validates them against the tool's Zod schema, and the handler runs a normal authorized query against your D1 inbox. The handler returns a structured result object that renders as a rich card in the UI, while the model receives a trimmed text view of the same result to stay within the token budget.