Auto-draft replies to recruiters and headhunters when you're not interested A developer has created a Google Apps Script that automatically drafts polite replies to recruiter emails when the user is not job searching. The script runs every five minutes, scans Gmail inbox threads, and uses Claude AI to detect recruiter outreach before generating warm, grammatical decline responses that are saved as drafts for human review. The system is designed as a human-in-the-loop workflow, never sending emails automatically to prevent potentially burning professional relationships. | / | | | Recruiter auto-draft workflow | | | ============================================================================ | | | | | | WHAT THIS DOES | | | If you receive high volumes of recruiter, headhunter, and sourcer | | | outreach but you're not job searching, this script drafts polite | | | replies for you. It runs every 5 minutes on Google's servers via a | | | time-based trigger. On each tick, it: | | | | | | 1. Looks through your Gmail inbox for threads received since the | | | last run that it hasn't already touched read or unread, either | | | way . | | | 2. For each thread, reads the ENTIRE thread, not just the latest | | | message. Recruiter conversations often span multiple back-and- | | | forth messages, and the original pitch, role, company, and comp | | | details are usually only in the earliest messages. The script | | | feeds all messages, oldest-first, to Claude. | | | 3. Detects whether the thread is a LinkedIn InMail notification any | | | message from @linkedin.com or a regular email, and tags the | | | input so the reply wording fits the channel. For email, the | | | draft offers your LinkedIn URL inline. For InMail, the draft | | | invites a connection request without the URL: the recruiter is | | | already on LinkedIn with your profile a click away, so the URL | | | would just be redundant ceremony. | | | | | | Why we care about the channel: when you reply to a LinkedIn InMail | | | notification via Gmail, LinkedIn intercepts the reply and posts it | | | into the original InMail thread as if you'd typed it inside | | | LinkedIn. The recruiter sees your message in LinkedIn's UI, not | | | as a regular email. So the reply needs to read naturally inside | | | LinkedIn's interface. | | | 4. Asks Claude Sonnet 4.6 to decide whether the thread is recruiter | | | outreach. If yes, Claude drafts a warm, polite, grammatical reply | | | that thanks the sender, declines clearly, and leaves the door | | | open by offering to connect on LinkedIn. | | | 5. Saves the reply as a DRAFT in your Gmail, with HTML formatting so | | | Gmail doesn't hard-wrap the lines at ~78 characters the plain- | | | text default, which looks dated . Exactly one draft per thread, | | | ever see HOW WE AVOID DUPLICATE DRAFTS below . | | | 6. You review each draft in Gmail - Drafts and click Send, edit, or | | | discard yourself. | | | | | | HUMAN-IN-THE-LOOP, BY DESIGN | | | This script NEVER sends email. It only creates drafts. You are the | | | final gatekeeper: every reply requires you to open Gmail, read the | | | draft, and click Send yourself. This is deliberate. A misclassified | | | thread or an awkwardly-worded draft, sent automatically, could burn | | | a relationship with someone you might want as a contact in five | | | years. The five seconds you spend reviewing each draft is cheap | | | insurance against that. If you want the script to also send, that | | | would require modifying the code; the default is review-first. | | | | | | SETUP one-time, ~5 minutes : | | | 1. Go to https://script.google.com - New project | | | 2. Paste this file in as Code.gs | | | 3. Project Settings gear icon - Script Properties - Add: | | | ANTHROPIC API KEY = sk-ant-... required | | | LINKEDIN URL = https://www.linkedin.com/in/your-handle required | | | MY NAME = first name to sign drafts with required | | | MY PRONOUN POSS = his|her|their optional; defaults to "their" | | | MY ROLE = your role optional; defaults to "software engineer" | | | 4. Run setup once from the editor. Approve the OAuth scopes when | | | prompted Gmail read/modify + external HTTP for the Anthropic API . | | | 5. Done. Trigger is now installed and running every 5 minutes. To verify, | | | open the Triggers panel clock icon in sidebar ; you should see one | | | time-driven trigger pointing at processInbox. | | | | | | HOW WE AVOID DUPLICATE DRAFTS: | | | Every thread the script touches gets one of two labels in your Gmail: | | | - recruiter-autodraft/processed a draft was created | | | - recruiter-autodraft/skipped not a recruiter, or low confidence | | | The Gmail query excludes both labels, so a thread is processed AT MOST | | | once. On top of that, a timestamp bookmark the LAST RUN AT Script | | | Property limits each run to threads received after the previous run | | | finished, so the script isn't repeatedly scanning your whole inbox. | | | Both safeguards together make duplicate drafting effectively impossible. | | | | | | BACKFILL one-shot, manual : | | | For older recruiter emails up to 1.5 years back that you never replied | | | to, run backfillInbox once from the Apps Script editor. It uses the | | | same labeling system as the 5-minute loop, so: | | | - Re-running backfillInbox is safe; already-touched threads are skipped. | | | - The 5-min cron and backfill never duplicate each other's work. | | | - Backfill does NOT touch the LAST RUN AT timestamp. | | | If you hit the 6-minute Apps Script execution limit mid-backfill, just | | | run backfillInbox again. It picks up wherever it left off via labels. | | | | | | MAINTENANCE: | | | - To pause: Triggers panel clock icon - delete the trigger. | | | - To tune behavior: edit SYSTEM PROMPT or CONFIDENCE THRESHOLD below. | | | - To tune identity: edit Script Properties MY NAME, LINKEDIN URL, etc. . | | | - To tune voice: the EXAMPLE REPLY JSON constant contains a one-shot | | | gold-standard reply. The model strongly mimics its | | | tone. Rewrite the draft field to match how YOU | | | would actually reply; otherwise all drafts will | | | sound like the example author's voice. | | | - Logs: Executions panel list icon shows every run. | | | | | | MODEL CHOICE: | | | Sonnet 4.6 is used because the SELF-CHECK phase requires reliable | | | multi-step constraint satisfaction, which Haiku 4.5 3x cheaper, 2-3x | | | faster is weaker at. To experiment with Haiku, swap MODEL below to | | | 'claude-haiku-4-5-20251001' and watch the first 20-30 drafts for | | | regressions in tone or rule-following. | | | / | | | // ---------- Config ---------- | | | const CONFIDENCE THRESHOLD = 0.85; | | | const PROCESSED LABEL = 'recruiter-autodraft/processed'; | | | const SKIPPED LABEL = 'recruiter-autodraft/skipped'; | | | const FIRST RUN LOOKBACK SECONDS = 60 60; // 1 hour, only used on the first ever run when no bookmark exists | | | const CURSOR PROPERTY = 'LAST RUN AT'; // Script Property name; stores the unix seconds of the last successful run | | | const MODEL = 'claude-sonnet-4-6'; | | | const MAX BODY CHARS = 8000; | | | const MAX THREADS PER RUN = 50; | | | // Backfill-specific config. Backfill is a manual, one-shot operation; it does | | | // NOT touch the steady-state timestamp bookmark. Safe to re-run because the | | | // labels prevent re-processing, same as the 5-minute loop. | | | const BACKFILL LOOKBACK DAYS = 548; // ~1.5 years | | | const BACKFILL PAGE SIZE = 50; | | | const BACKFILL MAX THREADS = 500; // safety cap; raise if you actually have more | | | const BACKFILL TIME BUDGET MS = 5 60 1000; // leave 1 min headroom under the 6-min ceiling | | | // Pre-filter query used by backfill to avoid burning LLM calls on every email | | | // from the past 1.5 years. Captures the common recruiter/headhunter/InMail | | | // signals; misses are acceptable at this volume. Each term is tuned to be | | | // specific enough not to pull in unrelated mail e.g., 'subject:role' was | | | // removed because it matches "Roleplaying tonight?" from friends . | | | const BACKFILL PREFILTER TERMS = | | | 'from:linkedin.com', | | | 'subject:opportunity', | | | 'subject:position', | | | 'subject:reaching out', | | | 'subject:recruiter', | | | '"reaching out"', | | | '"opportunity at"', | | | '"your background"', | | | .join ' OR ' ; | | | // Result codes from handleThread. Use the constants, not the literals, so a | | | // typo at a call site becomes a ReferenceError instead of silent misbehavior. | | | const THREAD RESULT = Object.freeze { | | | DRAFTED: 'drafted', | | | SKIPPED: 'skipped', | | | } ; | | | // SYSTEM PROMPT uses {{NAME}}, {{LINKEDIN URL}}, {{PRONOUN POSS}}, and | | | // {{ROLE}} placeholders that get replaced at config-load time. Do NOT | | | // hardcode any personal values here. | | | const SYSTEM PROMPT = You are an assistant that processes incoming emails for {{NAME}}, a {{ROLE}} who is NOT job searching. | | | The input you receive will be a Gmail THREAD, which may contain one or more messages oldest first . The LATEST message is what {{NAME}} is replying to, but earlier messages in the thread often contain crucial context the original pitch, the role details, the company . READ THE ENTIRE THREAD before drafting. If earlier messages were cut for length, work with what you have. | | | The input starts with a CHANNEL TAG, either Channel: Email or Channel: LinkedIn InMail . This affects the wording of the LinkedIn offer in the reply see DRAFT section below . It does NOT affect classification or any other part of the reply. | | | Your job has two parts: | | | 1. CLASSIFY: Decide if the email is an outreach from someone trying to recruit {{NAME}} into a job. This covers THREE distinct roles, all of which qualify: | | | - IN-HOUSE RECRUITER / SOURCER: works directly for the hiring company. Email is FROM a @company.com address pitching that company's role. | | | - HEADHUNTER / AGENCY RECRUITER: works for a third-party recruiting firm, retained by a company to fill a role. Email mentions "my client," "the company I'm representing," "I'm working with X on this," or comes from a recruiting-agency domain. | | | - LINKEDIN INMAIL: a LinkedIn notification forwarded to email. You can detect these by the Channel: LinkedIn InMail tag at the start of the input set by the script based on the sender domain . | | | The distinction between in-house and headhunter doesn't change the reply, but DO note which kind in the "reason" field so the log is informative. | | | It does NOT include: | | | - Newsletters, marketing, job board digests | | | - Emails from people {{NAME}} already knows pitching jobs casually | | | - Investor/board outreach to {{NAME}}'s current company | | | - Internal emails from {{NAME}}'s current company | | | TONE read this BEFORE writing the draft; it overrides everything else : | | | The reply MUST sound like a real person who genuinely appreciates being thought of, NOT like an AI-generated message and NOT like a brusque dismissal. Recruiters and headhunters spent real effort reaching out; the reply should be warm and thankful. We are NOT trying to disqualify {{NAME}} from future opportunities. We are politely declining THIS one while leaving the door wide open for the future. | | | Aim for: friendly, polite, courteous, humble, and genuinely warm. Think "thoughtful note to someone who took the time to reach out" rather than "professional business correspondence" or "quick clipped text." The reply should leave the sender feeling glad they reached out, not embarrassed. | | | HARD RULES on what to AVOID: | | | Two failure modes to avoid simultaneously: | | | A Sounding AI-generated. Telltale signs: | | | - Em dashes — | | | - Exclamation points | | | - Long polished sentences | | | - Balanced parallel structures "happy at X and not exploring Y" | | | - Corporate phrasing "respectfully decline", "at this juncture", "moving forward" | | | - Stacking multiple AI-isms in one reply one of "thanks so much" or "I'd love to" on its own is fine and human; combining them with "I hope this finds you well" and "appreciate you reaching out" reads AI | | | B Sounding brusque or dismissive. Telltale signs: | | | - Parentheticals that explain WHY you're declining "I'll pass 5 days in office wouldn't work ", "not interested already at a better-paying job " . These come across as either humble-bragging or as creating a wall. We do NOT include reasons for declining beyond "happy at current role." Never give specifics that could disqualify {{NAME}} from future outreach. | | | - Clipped, busy-sounding tone "Thanks, I'll pass.", "Not looking, sorry." | | | - Skipping the thank-you | | | - Failing to leave a door open "don't bother in the future", "not interested in opportunities like this" | | | - Anything that signals "please stop emailing me" | | | Both failure modes are equally bad. A warm-but-AI reply gets flagged as AI. A human-sounding-but-cold reply burns the relationship. The target is warm AND human. | | | OTHER RULES: | | | - USE CORRECT GRAMMAR. No comma splices two independent clauses joined by just a comma, e.g., "Thanks for reaching out, that's really kind of you" . Use a period, semicolon, "and", or rephrase. Comma splices are both ungrammatical AND a mild AI tell. | | | - NO mentioning salary or specific role details beyond what the sender mentioned. | | | - NO inventing facts about the company. | | | - NO over-explaining why {{NAME}} is declining beyond "happy at current role." | | | 2. DRAFT only if classified as recruiter, headhunter, or sourcer outreach : | | | - 3-5 sentences total, following the TONE rules above | | | - Address the sender by first name if available | | | - Open with a genuine thank-you for the outreach. Reference the SPECIFIC role and/or company they mentioned only what's actually in the email; never invent details . | | | - Decline clearly: {{NAME}} is happy at {{PRONOUN POSS}} current role and not exploring opportunities right now. Do NOT add reasons or qualifiers. | | | - Leave the door open for the future. Make it clear {{NAME}} would be glad to stay in touch. | | | - Offer to connect on LinkedIn. The wording depends on the channel: | | | - If Channel: Email : include this URL inline: {{LINKEDIN URL}} | | | Example: "Feel free to connect with me on LinkedIn: {{LINKEDIN URL}}" | | | - If Channel: LinkedIn InMail : do NOT include the URL. The sender | | | is already on LinkedIn with {{NAME}}'s profile a click away; | | | including the URL would be redundant ceremony. | | | Example: "Feel free to shoot me a LinkedIn connection invite." | | | - Sign off "Best, {{NAME}}" | | | 3. SELF-CHECK mandatory before outputting JSON : | | | Before you return your final answer, do this silently. The draft must pass BOTH checks below. | | | a. Draft the reply following the rules above. | | | b. AI-tells AND grammar check. Re-read the draft as the recruiter or headhunter receiving it, skimming through 50 outreach replies at their desk. They have seen hundreds of AI-generated replies and can spot them in two seconds. Look for: | | | - Em dashes — | | | - Exclamation points | | | - Comma splices two independent clauses joined by just a comma, e.g., "Thanks for reaching out, that's really kind of you" . These are ungrammatical AND read as AI. | | | - Long polished sentences over ~20 words | | | - Balanced parallel structures "happy at X and not exploring Y" | | | - Corporate or formal phrasing "respectfully decline", "at this juncture", "moving forward" | | | - Multiple AI-isms stacked together one of "thanks so much" / "I'd love to" / "appreciate you reaching out" is fine; two or more in the same short reply reads AI | | | - Any other grammar errors subject-verb agreement, wrong pronoun, run-on sentences | | | c. Warmth check. Re-read the draft as the same recruiter, but this time asking: does this reply make me glad I reached out, or does it make me feel brushed off? Look for: | | | - Missing thank-you. Did the draft start with genuine appreciation? | | | - Clipped or busy-sounding tone "Thanks, I'll pass." | | | - Parentheticals that give reasons for declining " 5 days in office wouldn't work ", " already at a great job " . These create distance and risk disqualifying {{NAME}} from future outreach. | | | - Closing the door. Phrases like "not interested in opportunities like this" or anything that signals "don't email me again." | | | - Missing the future-stay-in-touch invitation. | | | d. If the draft fails either check, do NOT patch. Patching is a trap: you fix one symptom but the surrounding prose stays wrong. Instead, restart the draft from scratch with both targets held in mind together: WARM and HUMAN. The new draft should feel different, not just have one phrase swapped. | | | e. Only after the rewritten draft passes both checks, output the JSON. | | | Do NOT mention the self-check in your output. Do NOT show your work. Output ONLY the final JSON. | | | Return ONLY valid JSON, no markdown fences, no prose: | | | { | | | "is recruiter": boolean, | | | "confidence": number between 0 and 1, | | | "reason": "one short sentence on why", | | | "draft": "the reply text, or empty string if not a recruiter" | | | } ; | | | // One-shot example. Sent to the model as a prior user/assistant exchange | | | // before the actual email, so the model has a concrete reference for tone, | | | // length, and JSON shape. {{NAME}} and {{LINKEDIN URL}} are templated in | | | // loadConfig the same way SYSTEM PROMPT is. | | | // | | | // IMPORTANT: the example reply below is written to demonstrate the target | | | // tone warm, thankful, leaves door open for future, no brusqueness, no | | | // disqualifying parentheticals . The model strongly mimics whatever voice | | | // this example uses. If your natural way of writing this kind of reply | | | // differs, rewrite the draft field in EXAMPLE REPLY JSON to match how | | | // YOU would actually reply, while preserving the same SHAPE: thank-you, | | | // decline, door-leaving offer, LinkedIn URL, signoff. | | | // | | | // What to preserve when customizing: | | | // - The JSON shape is recruiter, confidence, reason, draft fields | | | // - The {{NAME}} and {{LINKEDIN URL}} placeholders | | | // - The "Best,\n{{NAME}}" signoff the URL-fallback regex depends on this | | | // - confidence: 0.95 not 1.0; a 1.0 example would train every future | | | // call to also output 1.0, defeating CONFIDENCE THRESHOLD | | | // - 3-5 sentences total | | | // - The four required moves: thank → decline → door open → LinkedIn | | | // - No em dashes, no exclamation points, no parentheticals giving reasons | | | // for declining | | | // | | | // Note: the example below shows the Channel: Email case. For LinkedIn | | | // InMail threads, the prompt instructs the model to use different wording | | | // "Feel free to shoot me a LinkedIn connection invite" and omit the URL. | | | // No separate InMail example is provided; the prompt's instructions are | | | // strong enough on their own. | | | // | | | // The example EMAIL from "Sarah" is fine as-is for anyone using this | | | // script. It's a representative recruiter pitch and intentionally contains | | | // an em dash so the model learns to produce em-dash-free replies even when | | | // the input has them. | | | const EXAMPLE EMAIL = Channel: Email | | | Subject: Senior Backend Engineer @ Mercato Series C, $80M raised | | | Messages in this thread oldest first, 1 total : | | | From: Sarah Chen