Skip to content

tutorials

Let an AI agent label and tag incoming email by intent

June 5, 2026

#ai#inbox#labels#interpreters#cloudflare
Let an AI agent label and tag incoming email by intent
Visual: Desk Cartography on okslop

Every message that lands needs a quick judgment: is this a sales pitch, a support request, a bill, a recruiter, or just an automated notification? Doing that by hand does not scale, and brittle keyword filters mislabel half of it.

Labeling email by intent means assigning each inbound message one tag from a small fixed set, so your inbox can filter, route, and badge on it. The classification itself is the easy part. The work is doing it cheaply where the answer is obvious, reaching for a model only where intent is genuinely ambiguous, and writing the label back to your own store without standing up a separate tagging service.

This tutorial builds the whole pipeline the manual way first, with runnable code and the real failure modes, then shows the version where the engine classifies and persists the label for you. If you only want the shortcut, skip to the Tinbox section.

What does it mean to label email by intent?

Labeling email by intent is per-message classification into a closed set of categories that describe what the sender wants, not what the message contains. A receipt and a shipping notice both contain order details, but their intent is the same: automated notification. A two-line message asking about pricing and a long forwarded thread can share the intent sales even though they look nothing alike.

The first decision is the label set. Keep it small and closed. A good starting taxonomy for a product inbox:

LabelExample sender intent
salesWants to buy, asks about pricing or a demo
supportHas a problem with something they already use
billingInvoices, payment failures, refund requests
recruitingJob applications, recruiter outreach
automatedNotifications, receipts, no-reply system mail
personalA human writing directly, none of the above

A closed enum matters because it keeps the classifier honest (it cannot invent a label) and keeps the inbox filterable (a finite set of tabs). The moment you allow free-form tags, you get sales, Sales, sales-inquiry, and pricing question for the same thing.

The second decision is where the label lives. It belongs next to the message, as a tag field or a structured JSON column on the message row, so the inbox can filter without calling out to a separate system. We will come back to this when we write the persistence step.

How do I label the obvious cases without an LLM?

Use keyword and header rules for any intent that a fixed pattern can settle reliably, because they are free, instant, and deterministic. A huge share of inbound mail is automated, and you can recognize most of it without a model call at all.

// rules.ts: deterministic intent rules, no model involved
export type Intent =
  | "sales" | "support" | "billing"
  | "recruiting" | "automated" | "personal";

export interface Verdict {
  label: Intent;
  flags: string[];
  by: "rule" | "llm";
}

const NOREPLY = /\b(no-?reply|do-?not-?reply|notifications?|mailer-daemon)@/i;
const BILLING = /\b(invoice|receipt|payment (failed|declined)|refund)\b/i;
const RECRUIT = /\b(resume|cv attached|applying for|job (opening|posting))\b/i;

export function ruleLabel(input: {
  from: string;
  subject: string;
  text: string;
}): Verdict | null {
  const hay = `${input.subject}\n${input.text}`;

  if (NOREPLY.test(input.from)) {
    return { label: "automated", flags: ["no-reply"], by: "rule" };
  }
  if (BILLING.test(hay)) {
    return { label: "billing", flags: [], by: "rule" };
  }
  if (RECRUIT.test(hay)) {
    return { label: "recruiting", flags: [], by: "rule" };
  }
  // Not confident enough, let the LLM decide.
  return null;
}

Returning null is the important part. These rules only fire when they are sure. Everything else falls through to the model, instead of forcing a wrong guess. This is the difference between a rule that helps and a keyword filter that fights you: a confident rule abstains rather than mislabel.

The failure mode to watch for here is greedy patterns. An early version of the billing rule used /payment/, which then tagged a sales email reading "happy to walk you through payment options" as billing. Tighten the pattern (payment (failed|declined)) and let ambiguous wording fall through to the model rather than widening the regex until it catches the wrong mail.

How do I classify the ambiguous mail with an LLM?

Call a model with a strict enum output schema, so the result is always one of your labels and never free text. The Vercel AI SDK's generateObject pins the output to a Zod schema, which is exactly what keeps an intent classifier from drifting into new categories.

// classify.ts: LLM intent labeling with a closed enum
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import type { Verdict } from "./rules";

const schema = z.object({
  label: z.enum([
    "sales", "support", "billing",
    "recruiting", "automated", "personal",
  ]),
  urgent: z.boolean(),
});

export async function llmLabel(input: {
  from: string;
  subject: string;
  text: string;
}): Promise<Verdict> {
  const { object } = await generateObject({
    model: openai("gpt-4o-mini"),
    schema,
    prompt:
      "Classify the SENDER'S INTENT of this email into exactly one label. " +
      "Judge what the sender wants, not the surface topic. " +
      "Mark urgent only if the sender needs a same-day reply.\n\n" +
      `From: ${input.from}\nSubject: ${input.subject}\n\n${input.text.slice(0, 4000)}`,
  });

  return {
    label: object.label,
    flags: object.urgent ? ["urgent"] : [],
    by: "llm",
  };
}

Two details earn their keep. First, the prompt tells the model to judge intent, not topic, which is the whole point and the thing a naive prompt gets wrong. Second, the body is truncated to 4000 characters: a long forwarded chain is mostly quoted history, and you pay per token for content that rarely changes the label.

Now chain the cheap path in front of the billed one:

// label.ts: rules first, model only when needed
import { ruleLabel, llmLabel, type Verdict } from "./rules";

export async function labelEmail(input: {
  from: string;
  subject: string;
  text: string;
}): Promise<Verdict> {
  return ruleLabel(input) ?? (await llmLabel(input));
}

If your automated and billing rules catch 60% of inbound mail, you just removed 60% of your model bill. Order is the cheapest optimization you have. For the deeper version of this cost argument, see the AI inbox triage walkthrough, which is the sibling problem of sorting and prioritizing rather than labeling.

Where do I write the label so the inbox can use it?

Write the label and flags onto the message record itself, so filtering is a local query and not a call to an external tagging service. If your inbox is a database (and it should be), the label is just another column or a key in a JSON field.

// persist.ts: write the verdict back to the message store
import type { Verdict } from "./rules";

// `db` is your D1 binding; `messages` has an `extra` JSON column.
export async function applyLabel(
  db: D1Database,
  messageId: string,
  v: Verdict,
) {
  const row = await db
    .prepare("SELECT extra FROM messages WHERE id = ?")
    .bind(messageId)
    .first<{ extra: string | null }>();

  const extra = row?.extra ? JSON.parse(row.extra) : {};
  extra.intent = v.label;
  extra.flags = Array.from(new Set([...(extra.flags ?? []), ...v.flags]));

  await db
    .prepare("UPDATE messages SET extra = ? WHERE id = ?")
    .bind(JSON.stringify(extra), messageId)
    .run();
}

Two things will bite you here if you run this on the ingest hot path. First, a slow generateObject call should never delay accepting the message. Move classification into an async consumer (a Cloudflare Queue) so the receiver only stores raw mail and a separate invocation labels it. Second, retries are inevitable, and an LLM is non-deterministic, so the same message labeled twice can land in two categories. Cache the verdict by a hash of the normalized body, keyed on the prompt version, so a redeploy or a duplicate delivery reuses the prior label instead of re-billing and re-rolling the dice.

// cache key that invalidates when the prompt changes
import { createHash } from "node:crypto";

const PROMPT_VERSION = "intent-v3";
export function cacheKey(body: string): string {
  const norm = body.trim().replace(/\s+/g, " ").toLowerCase();
  const hash = createHash("sha256").update(norm).digest("hex").slice(0, 16);
  return `label:${PROMPT_VERSION}:${hash}`;
}

How do I stop a prompt edit from mislabeling everything?

Keep an eval suite of fixture emails with their expected labels and run it on every prompt or schema change, because an intent classifier regresses silently. The day you reword the prompt to fix support versus sales, you can quietly start tagging every recruiter as personal, and nothing will tell you unless you check.

// label.eval.ts: run before every deploy
import { expect, test } from "vitest";
import { labelEmail } from "./label";

const fixtures = [
  { from: "j@acme.com", subject: "pricing for 50 seats?",
    text: "We're evaluating tools, what's enterprise pricing?", want: "sales" },
  { from: "no-reply@stripe.com", subject: "Your invoice",
    text: "Invoice 4821 is attached.", want: "automated" },
  { from: "p@studio.io", subject: "login broken",
    text: "I can't sign in since this morning, getting a 500.", want: "support" },
  { from: "recruiter@bigco.com", subject: "Senior role",
    text: "Saw your work, would you consider applying for...", want: "recruiting" },
];

for (const f of fixtures) {
  test(`labels: ${f.subject}`, async () => {
    const v = await labelEmail(f);
    expect(v.label).toBe(f.want);
  });
}

Run this against the real model (or a stubbed one for determinism in CI), and treat a red eval the same as a failing unit test. Include negative fixtures: a "payment options" sales email that must NOT become billing, a notification that must NOT become personal. The negatives are where regressions hide. For the broader discipline of testing LLM classifiers, see writing eval suites for LLM email classifiers.

How do I let the chat agent act on those labels?

Once labels are written to the message store, an AI agent over your inbox reads them like any other field and can act on them: "draft replies to everything tagged support from today" or "summarize this week's sales intent." The label is structured data the agent can filter on, so you get intent-aware automation without the agent having to re-classify anything. For a menu of label-driven jobs worth wiring up, see email tasks worth automating with an AI agent and the broader ways an AI agent can actually run your inbox.

The pattern for giving an agent a new capability is a single typed tool definition: a name, a Zod input schema, and a function that runs a procedure against your data. You register it once and it is available to the chat loop. If you want an agent that can re-label on request ("move this to billing"), that is the same shape as any other write tool, gated the way you gate sending. The honest boundary worth keeping: let the agent propose and read freely, but make any write reversible and auditable. For the guardrail design behind that, see letting an AI agent send email without going rogue.

How do I label email by intent without building the pipeline?

Declare an interpreter and the engine classifies, caches, and persists the label on your own Cloudflare account, so you skip the consumer, the cache, the schema, and the write path. Tinbox runs the entire stack (D1, R2, Queues, Workers) on the tenant's own Cloudflare account, and email interpreters are the productized version of everything above.

An interpreter is a declared rule that reads each inbound message and writes a structured verdict: a category, flags, custom extra props, and a named UI card. It comes in two flavors that map exactly to the two paths in this tutorial. Algorithmic interpreters are the keyword rules (an otp and a receipt rule ship built in). LLM interpreters are the model path: you give instructions and a Zod output schema, and the engine runs the model, caches the verdict by content hash, and writes it to the message's extra field for you.

// app/interpreters.config.ts
import { defineInterpreters, builtinInterpreters } from "tinbox/cf/interpreters";
import { z } from "zod";

export default defineInterpreters([
  ...builtinInterpreters,
  {
    id: "intent",
    kind: "llm",
    instructions:
      "Classify the sender's intent into exactly one label. " +
      "Judge what the sender wants, not the topic.",
    output: z.object({
      label: z.enum([
        "sales", "support", "billing",
        "recruiting", "automated", "personal",
      ]),
      urgent: z.boolean(),
    }),
    toVerdict: (out) => ({
      category: out.label,
      flags: out.urgent ? ["urgent"] : [],
    }),
  },
]);

The git-owned config is the ceiling: a runtime settings table can only narrow what is enabled, never add a rule that is not in code. Algorithmic interpreters always run before LLM ones, and the LLM rule's prompt and cache key include what the cheap rules already extracted, so ordering and cost control are handled. Each built-in ships its own eval suite, and you can attach evals to your own rule the same way you would write the Vitest fixtures above. The verdict lands in extra as a category plus flags, exactly the field your inbox already filters on, and the chat agent reads.

What is deliberately not here: interpreters write labels and propose reply drafts, but they do not auto-send. Labeling is reversible by design. Multi-domain-per-instance is designed but not built, so today this runs one brand per deploy (you run many tenants by deploying more than once on the same Cloudflare account). And the software is never gated: it nags softly rather than blocking, so nothing in this pipeline stops working, licensed or not.

The point is not that Tinbox classifies better than the hand-rolled version above. The point is that the consumer, the content-hash cache, the schema column, the eval harness, and the write path are the parts you do not want to maintain, and they come wired on infrastructure you own.

Deploy this yourself

Run the whole stack, interpreters included, on your own Cloudflare account:

npx tinbox init yourdomain.com

That scaffolds the Worker, the D1 schema, and an app/interpreters.config.ts you can drop the intent rule into. From there the inbox triage guide and the OTP detection tutorial cover the sibling interpreter patterns, the triage rules that tame a busy inbox gives you ready-made label-and-route examples, and the interpreter author's guide in the repo documents all three flavors, the UI card registry, and the eval runner.

Deploy this yourself: npx tinbox init <domain>, then read the interpreters docs to wire your own intent labels.

Frequently asked questions

How do I get an AI agent to label my email by intent?

Define a closed set of intent labels, then run a classifier over every inbound message that returns one label plus optional flags against a strict schema. Persist the label to the message record (a tags column or a JSON field) so the inbox can filter and badge on it. Use keyword rules for unambiguous mail and an LLM only for genuinely ambiguous intent like distinguishing a sales pitch from a support request.

Should I use keyword rules or an LLM to tag email?

Use keyword rules for deterministic, free cases: notifications, no-reply addresses, calendar invites, password resets. Use an LLM for ambiguous intent where wording varies, such as separating sales outreach from a partnership inquiry or a complaint from a feature request. Run the cheap rules first so most mail is labeled without a billed model call.

Where should the intent label be stored?

Store it next to the message, not in a separate service. A tags array or a structured JSON field on the message row lets your inbox UI filter, sort, and badge without a join to an external system. In Tinbox the label lands in the message's extra field as a category and flags, so it travels with the data you already own.

Will labeling email by intent leak my mail to a third party?

Only if the classifier runs on someone else's service. Keyword rules never leave your infrastructure. An LLM rule sends the bytes you choose to a model provider using your own API key, and the resulting label is written to your own database. There is no labeling SaaS sitting in the middle holding your archive.

How do I keep an intent classifier from drifting when I edit the prompt?

Pin the output to a fixed enum of labels with a Zod schema, cache verdicts by a content hash so the same message always gets the same label, and version the cache key on the prompt and schema so an edit invalidates stale results. Then keep an eval suite of labeled fixture emails and run it on every prompt change to catch regressions before deploy.