appscript.dev
Automation Intermediate Gmail

Auto-draft email replies with AI

Stage contextual draft replies in Gmail for every incoming Northwind support thread.

Published Jul 18, 2025

Northwind’s support inbox is full of messages that need a reply but not a lot of thought — order chases, “how do I” questions, polite acknowledgements. Writing each one from a blank box is slow, and the slowness is what makes the queue back up. The team did not want AI sending mail unsupervised; they wanted a head start.

This script reads every unread support thread, asks Claude to draft a friendly, concise reply, and stages it as a Gmail draft on the thread. Nothing is sent — an agent opens the thread, reads the draft, tweaks it if needed, and hits send. The slow part is done; the human stays in control.

What you’ll need

  • A Gmail support label applied to incoming support threads, by filter or by hand. The script only looks at threads carrying that label.
  • An Anthropic API key saved as ANTHROPIC_API_KEY in Script Properties — see Store API keys and secrets securely.
  • Nothing else — the script creates the support/ai-drafted label itself.

The script

// Gmail search that selects threads needing a draft: unread support
// mail that has not already been drafted.
const SEARCH_QUERY = 'label:support is:unread -label:support/ai-drafted';

// Label applied once a draft has been staged, so threads are not
// drafted twice on the next run.
const DRAFTED_LABEL = 'support/ai-drafted';

/**
 * Finds unread support threads, asks Claude to draft a reply for each,
 * stages it as a Gmail draft, and labels the thread as drafted.
 */
function draftReplies() {
  const threads = GmailApp.search(SEARCH_QUERY);

  if (!threads.length) {
    Logger.log('No support threads need a draft — nothing to do.');
    return;
  }

  // Find or create the marker label once, outside the loop.
  const drafted = GmailApp.getUserLabelByName(DRAFTED_LABEL)
    || GmailApp.createLabel(DRAFTED_LABEL);

  for (const t of threads) {
    // 1. Use the first message in the thread as the customer's question.
    const msg = t.getMessages()[0];

    // 2. Ask Claude for a reply based on the subject and body.
    const reply = generateReply(msg.getSubject(), msg.getPlainBody());

    // 3. Stage the draft and label the thread so it is skipped next run.
    if (reply) {
      t.createDraftReply(reply);
      t.addLabel(drafted);
    }
  }
}

/**
 * Builds the prompt and asks Claude for a single support reply.
 */
function generateReply(subject, body) {
  const prompt =
    'Draft a friendly, concise Northwind support reply (≤120 words). ' +
    'Sign off "— Northwind support".\n\n' +
    'Subject: ' + subject + '\n\n' + body;
  return callClaude(prompt);
}

/**
 * Minimal Anthropic API call. The key lives in Script Properties — it
 * is never pasted into the code.
 */
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-sonnet-4-6',
      max_tokens: 400,
      messages: [{ role: 'user', content: prompt }],
    }),
    muteHttpExceptions: true,
  });
  return JSON.parse(res.getContentText()).content[0].text.trim();
}

How it works

  1. draftReplies runs a Gmail search for unread threads labelled support that do not already carry support/ai-drafted. If none match, it logs a message and stops — no wasted API calls.
  2. It resolves the support/ai-drafted label once, creating it the first time the script runs.
  3. For each thread it takes the first message as the customer’s enquiry and passes the subject and plain-text body to generateReply.
  4. generateReply wraps the message in a prompt that asks for a friendly reply under 120 words with a fixed sign-off, then calls Claude.
  5. callClaude sends the prompt to the Anthropic API with the key pulled from Script Properties, and returns the model’s text.
  6. Back in the loop, the returned reply is staged on the thread with createDraftReply — staged, never sent — and the thread is labelled so the next run skips it.

Example run

A customer sends a thread labelled support:

Subject: Where’s my order #4821?

Hi, I ordered the medium prints on Monday and haven’t had a dispatch email yet. Could you check?

After a run, a draft sits on the thread, ready for an agent to review:

Hi there,

Thanks for getting in touch about order #4821. I can see it’s with our print team now and dispatch confirmations usually follow within one working day — you should have yours shortly. If it hasn’t arrived by tomorrow, just reply here and I’ll chase it directly.

— Northwind support

The thread is now labelled support/ai-drafted, so it will not be drafted again.

Trigger it

Run this on a time-based trigger so drafts are waiting when an agent opens the queue:

  1. In the Apps Script editor open Triggers (the clock icon).
  2. Add a trigger for draftReplies, Time-driven, Minutes timer, every 15 minutes.

Fifteen minutes keeps drafts fresh without hammering the API. For a quieter inbox, an hourly trigger is plenty.

Watch out for

  • createDraftReply only stages a draft — it never sends. A human must open each thread and click send. Keep it that way: an AI reply that is wrong on facts is fine in a draft and harmful in a sent message.
  • The script reads only the first message in the thread. On a long back-and-forth it will miss later context — use t.getMessages().slice(-1)[0] if you want the most recent message instead.
  • Each thread is one API call. A burst of 200 unread threads is 200 calls and 200 lots of tokens; watch your Anthropic spend and Apps Script’s daily UrlFetchApp quota.
  • muteHttpExceptions means a failed call returns an error body, not an exception. If the API key is missing or rate-limited, JSON.parse will throw on the error JSON — check the logs if drafts stop appearing.
  • The model has no access to order systems or account data. It writes a plausible, friendly reply; the agent reviewing it is responsible for the facts.

Related