appscript.dev
Automation Intermediate Gmail

Predict email intent and route it

Classify inbound Northwind mail and apply the right Gmail label automatically.

Published Oct 22, 2025

Northwind runs a single shared inbox, and everything lands in it — sales enquiries, support tickets, invoice questions, partnership pitches, and the usual spam. Sorting that by hand every morning is a chore, and a misrouted sales lead can sit unread for a day.

This script reads each new, unread thread and asks Claude which of a fixed set of intents it belongs to — sales, support, billing, partnership or spam — then applies a matching Gmail label. Once mail is labelled, Gmail filters and team members can take over: each person watches the labels they own instead of wading through the whole inbox.

What you’ll need

  • A Gmail account whose inbox you want sorted. The script runs as that account.
  • An Anthropic API key saved as ANTHROPIC_API_KEY in Script Properties — see Store API keys and secrets securely.
  • Nothing else to set up. The script creates the intent/... labels and the intent-routed marker label the first time it needs them.

The script

// The set of intents Claude must choose from. Edit this list to match
// how your team actually splits its inbox.
const INTENTS = ['sales', 'support', 'billing', 'partnership', 'spam'];

// How many characters of the body to send. Enough to classify on,
// short enough to keep the request cheap and fast.
const BODY_CHARS = 1000;

/**
 * Finds new unread inbox threads, asks Claude to classify each one,
 * and applies an "intent/<label>" label plus a routed marker.
 */
function routeByIntent() {
  // 1. Only pick up threads not already handled by this script.
  const threads = GmailApp.search('in:inbox is:unread -label:intent-routed');
  if (!threads.length) return;

  // 2. The marker label keeps already-routed threads out of future runs.
  const routed = GmailApp.getUserLabelByName('intent-routed')
    || GmailApp.createLabel('intent-routed');

  for (const t of threads) {
    // 3. Classify on the first message — the one that started the thread.
    const m = t.getMessages()[0];
    const intent = callClaude(
      'Classify this Northwind email as one of: ' + INTENTS.join(', ') + '. ' +
      'Return only the label.\n\n' +
      'Subject: ' + m.getSubject() + '\n' +
      'Body: ' + m.getPlainBody().slice(0, BODY_CHARS)
    );

    // 4. Guard against an unexpected reply — only act on known intents.
    const clean = intent.toLowerCase().trim();
    if (!INTENTS.includes(clean)) continue;

    // 5. Apply the intent label, creating it on first use.
    const label = GmailApp.getUserLabelByName('intent/' + clean)
      || GmailApp.createLabel('intent/' + clean);
    t.addLabel(label);

    // 6. Mark the thread routed so it is skipped next time.
    t.addLabel(routed);
  }
}

/**
 * Minimal Anthropic API call. The key lives in Script Properties — it
 * is never pasted into the code. Returns Claude's plain-text reply.
 */
function callClaude(prompt) {
  const key = PropertiesService.getScriptProperties()
    .getProperty('ANTHROPIC_API_KEY');
  const res = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
    method: 'post',
    contentType: 'application/json',
    headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
    payload: JSON.stringify({
      model: 'claude-haiku-4-5-20251001',
      max_tokens: 20,
      messages: [{ role: 'user', content: prompt }],
    }),
  });
  return JSON.parse(res.getContentText()).content[0].text.trim();
}

How it works

  1. routeByIntent searches the inbox for unread threads that do not yet carry the intent-routed label, so each run only sees fresh mail. If there is nothing new, it stops immediately.
  2. It looks up — or creates — the intent-routed marker label, which is how the script remembers what it has already handled.
  3. For each thread it takes the first message, the one that opened the thread, and builds a prompt with the subject and the first 1,000 characters of the body.
  4. callClaude sends that prompt to Claude Haiku, a small fast model that is ideal for a short classification, and asks for just the label word back.
  5. The reply is lower-cased and checked against the INTENTS list. If Claude returns anything unexpected, the thread is skipped rather than mislabelled.
  6. The script applies an intent/<label> label — creating it the first time — and then adds the intent-routed marker so the thread is excluded from every later run.

Example run

Three unread threads are sitting in the inbox:

SubjectClassified asLabel applied
”Quote for a new marketing site?“salesintent/sales
”Login page throwing a 500 error”supportintent/support
”Your October invoice — wrong VAT?“billingintent/billing

After the run, each thread carries both its intent/... label and the intent-routed marker. A salesperson watching intent/sales sees the first thread straight away; the next run skips all three because they are now routed.

Trigger it

Run it on a schedule so mail is sorted without anyone thinking about it:

  1. In the Apps Script editor, open Triggers (the clock icon).
  2. Click Add Trigger.
  3. Choose routeByIntent, event source Time-driven, type Minutes timer, interval Every 15 minutes.
  4. Save and approve the authorisation prompt the first time.

A 15-minute cycle keeps the inbox tidy without burning quota. You can also run routeByIntent by hand from the editor to test it first.

Watch out for

  • Classification is a judgement call, not a fact. Mail that genuinely spans two intents will be forced into one. Skim each label occasionally so a misrouted thread does not go unseen.
  • The marker label is the memory. If you remove intent-routed from a thread, the next run will reclassify it. That is a handy way to redo a bad call, but do not strip it in bulk by accident.
  • A label is not a delete. Threads tagged intent/spam still sit in the inbox — pair the label with a Gmail filter if you want them archived.
  • Each thread costs one API call. A busy inbox of a few hundred new threads a day is cheap on Haiku, but the cost scales with volume — watch it if traffic grows.
  • Long threads are classified on the first message only. If a conversation changes topic, the original intent label sticks.

Related