Skip to main content

Gmail agent for job auto-replies

· 10 min read
Guy Klages
Technical Writer, Co-founder FindFit (Ex-Microsoft, Ex-Apple, Ex-Netflix, Ex-Google)
Gmail agent for job auto-replies

This article describes how I built a privacy-first agent (with Mistral and Ollama) that drafts customized
replies to recruiter emails without sending my inbox to the cloud.

The problem​

Like many people job hunting, I get a steady stream of recruiter emails. Many are missing one or more key components: Job ID, pay range (required in California), or complete job description. And they want a quick reply with a resume attached.

I wanted an agent that would:

  1. Watch my Gmail inbox for job-related messages
  2. Detect whether an email is actually a job description
  3. Draft a reply using exact wording I specified
  4. Attach my latest resume
  5. Run entirely locally for security (no LLM API calls with email content)

My target account: guy.klages@gmail.com

Prerequisites​

Before writing an agent, you need to have a clear list of which emails you want to reply to. I used Gmail's built-in Filters to distill which emails to target and then the built-in Labels feature to tag those filtered emails with a "Job" label.

Stack:

  • Python 3.12 + Gmail API
  • Ollama + Mistral 7B (local)
  • Gmail filters for the "Job" label (upstream of the agent)

Important caveats​

As you write your prompt, these are good things to keep in mind:

AdviceDescription
Output to draftYou will likely need to modify this email agent many times to perfect it, so you don't want this agent to auto-reply until after you've worked out all the kinks.
Structured loggingLog the subject, sender, classification result, and the conditional paragraphs to make debugging reply content easy.

The overall process is:

Gmail Filters route → local LLM classifies → code templates reply → human approves send.

What I asked for (the initial prompt)​

I asked Cursor to build an agent to write a customized email reply to any email tagged as "Job" with the following reply rules:

Always start with:

Hi, thank you for your email, I have attached my latest resume.

If the email lacks "Job ID" (subject or body):

To prevent duplicate submission, please tell me the Job ID.

If there's no numeric pay range:

California state law requires Job Descriptions to include the pay range. Please tell me their budgeted W2 pay range for this role.

Always end with:

To answer common questions:

  • I'm a US Citizen
  • My legal name is Guy Klages
  • My birth month and day are 0231
  • I don't have any vacations planned
  • I just started initial rounds of interviews with three other companies.

Please email me for any other questions or send me the RTR.

Thank you

And one hard constraint: use Mistral locally—no cloud LLM processing email content.

Architecture​

The biggest design decision was not letting the LLM write replies.

TaskToolWhy
"Is this a job description?"Local Mistral via OllamaNeeds judgment; must stay on-device
Job ID present?String searchExact, deterministic
Pay range present?RegexExact, deterministic
Reply bodyPython templateZero wording drift

The following is a flow diagram:

Lesson for builders Use the LLM for classification only. Put legal/compliance/personal boilerplate in code.


Project structure​

gmail-job-agent/
├── agent.py # Main poll loop
├── classifier.py # Ollama/Mistral YES-NO classification
├── reply_builder.py # Deterministic reply rules
├── gmail_client.py # Gmail API: read, draft, label
├── setup_auth.py # One-time OAuth
├── config.yaml # Account, resume path, mode, labels
├── credentials.json # Google OAuth client (gitignored)
├── token.json # Refresh token (gitignored)
└── com.guyklages.gmail-agent.plist # Optional macOS background job

Step 1: Google Cloud and Gmail API​

Setup​

  1. Google Cloud Console → new project
  2. Enable Gmail API
  3. OAuth consent screen → External, Testing mode
  4. Credentials → OAuth 2.0 Desktop app
  5. Download JSON → save as credentials.json

Scopes needed​

gmail.readonly
gmail.send
gmail.modify

Notes:

  • gmail.send is required for drafts.
  • modify is required for labels.

Error 403: access_denied​

After running setup_auth.py, I hit:

Access blocked: Gmail-autoreply-to-job-agent has not completed the Google verification process

Cause: OAuth apps in Testing mode only work for explicitly listed test users.

Fix:

  1. APIs & Services → OAuth consent screen
  2. Test users → Add guy.klages@gmail.com
  3. Save, wait ~1 minute, rerun setup_auth.py

Notes:

  • You do not need Google verification for a personal single-user agent.
  • Testing mode + your email address as a test user is enough.

Step 2: Local Mistral with Ollama​

brew install ollama

brew services start ollama

ollama pull mistral

Health check from Python:

requests.get("http://localhost:11434/api/tags")

Mistral ignores YES/NO​

Early on, /api/generate returned " Job Opening" instead of YES or NO.

Fix: Switch to the chat API with a strict system prompt, plus fallback parsing:

messages = [
{
"role": "system",
"content": (
"You classify incoming emails. Respond with exactly one word: "
"YES if the email is about a job opening or job description, "
"otherwise NO. Do not explain."
),
},
{"role": "user", "content": f"Subject: {subject}\n\nBody:\n{body}"},
]

If the model still wanders, parse markers like JOB OPENING, HIRING, NOT A JOB, etc.

Lesson Never assume instruction-following models follow instructions. Always validate and parse defensively.


Step 3: OAuth authorization​

"I don't have a python directory"​

In .venv/bin/ you see:

python -> python3.12
python3 -> python3.12
python3.12 -> /Library/Frameworks/.../python3.12

That's normal. python is a symlink, not a folder.

Run:

cd ~/Projects/gmail-job-agent

.venv/bin/python setup_auth.py

All of .venv/bin/python, python3, and python3.12 work the same.

Success looks like:

Authenticated successfully as guy.klages@gmail.com
Token saved to .../token.json

Step 4: Reply rules in code​

Pay-range detection uses regex:

PAY_PATTERNS = [
r'\$[\d,]+(?:\.\d{2})?\s*[-–—to]+\s*\$[\d,]+',
r'\$[\d,]+k\s*[-–—to]+\s*\$[\d,]+k',
r'salary[:\s]+\$?[\d,]+',
# ...
]

Job ID check is a simple case-insensitive search for "job id".

The full reply is assembled from fixed strings.


Step 5: Evolving requirements​

My first version automatically sent replies and scanned all unread mail. After testing, I changed three things:

Auto-send → drafts​

mode: draft # was: send

Safer default: review every reply before it goes out.

Gmail filter first, agent second​

My Gmail filter already applies a Job label. The agent should only process those:

gmail:
source_label: Job
processed_label: job-agent/processed

Gmail search query:

label:"Job" -label:job-agent/processed

Flow:

  1. Email arrives
  2. Gmail filter → Job label
  3. Agent polls → classifies → creates draft
  4. Agent adds job-agent/processed (won't run twice)

The agent never adds the Job label.

Don't mark messages as read​

Originally, mark_processed removed the UNREAD label. I removed that:

body = {"addLabelIds": [self.processed_label_id]}
# No removeLabelIds: ["UNREAD"]

The inbox stays visually "unread" until I actually open the message.


Step 6: Running the agent​

You can run your agent three ways:

A) One-shot test​

.venv/bin/python agent.py --once --verbose

B) Continuous (foreground)​

.venv/bin/python agent.py

C) Background (macOS launchd)​

cp com.guyklages.gmail-job-agent.plist ~/Library/LaunchAgents/

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.guyklages.gmail-job-agent.plist

Logs: agent.log in the project directory.

launchctl unload​

I tried to restart a service that was never installed:

  • Plist wasn't in ~/Library/LaunchAgents/
  • Service wasn't loaded

Fix: On modern macOS, use bootstrap / bootout instead of load / unload:

# Install
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.guyklages.gmail-job-agent.plist

# Remove
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.guyklages.gmail-job-agent.plist

Note: Error 5 on unload usually means nothing to unload. (not a broken agent)


Advice for others building Gmail agents​

Start with drafts, not send​

Auto-reply agents can embarrass you. Draft mode + human review is the right first ship.

Use Gmail filters as the first gate​

Let Gmail's fast, free rules handle obvious routing. Use your agent for judgment calls (classification) and structured output (templated replies).

Keep a processed label​

Without the job-agent/processed label, every poll recreates drafts for the same thread. Idempotency matters.

Don't send PII to cloud LLMs​

Recruiter emails contain names, companies, sometimes compensation. Local inference (Ollama, llama.cpp, MLX) keeps that on your machine. Gmail API traffic to Google is unavoidable; LLM inference doesn't have to leave your Mac.

Put personal details in config​

Legal name, birth month/day, citizenship lines belong in config.yaml (gitignored), not hardcoded in your source repo.

Test classification with real emails​

Mistral correctly classified "Backend Engineer opening" vs "Your Amazon order shipped" after the chat API fix, but your recruiter corpus may differ. Keep a --once --verbose loop handy.

OAuth Desktop app, not Web​

Web OAuth clients need redirect URI gymnastics. Desktop app + run_local_server() is simpler for CLI agents.

Regex pay-range detection​

Regex pay-range detection will miss edge cases such as "$140K DOE" and "€120k", so tune patterns from real emails. When in doubt, include the California pay-range ask since false positives are safer than false negatives.

Resume path: One canonical file​

Point config at a single PDF. Update that file when you revise your resume; the agent will then always attach the latest.


Reference​

Final config file​

gmail:
account: guy.klages@gmail.com
source_label: Job
processed_label: job-agent/processed

resume:
path: /Users/guyklages/Downloads/Guy_Klages_resume.pdf

ollama:
base_url: http://localhost:11434
model: mistral

mode: draft

poll_interval_seconds: 300

reply:
legal_name: Guy Klages
birth_month_day: "0231"

Stop your agent​

The methods below turn off everything, depending on how you started your agent.

Running in a terminal​

If you ran .venv/bin/python agent.py then go to that terminal window and press CTRL + C. That stops the poll loop immediately.

Running as a backgroup service​

If you used launchd to run your agent in the background, check if it's loaded:

launchctl print gui/$(id -u)/com.guyklages.gmail-job-agent

If that prints service info, then stop and remove it by entering:

launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.guyklages.gmail-job-agent.plist

(Optional) Remove the plist so it doesn't come back:

rm ~/Library/LaunchAgents/com.guyklages.gmail-job-agent.plist

Kill any leftover agent process​

If you're not sure, you can kill any other leftover agent process by entering the following:

pkill -f "gmail-job-agent/agent.py"

You can verify nothing is left with pgrep -fl agent.py. No output means it stopped.

Stop Ollama​

Ollama runs in the background and loads Mistral when needed. To stop it, enter:

brew services stop ollama

To prevent it from starting at login, run that command a second time. Then Ollama won't auto-start again until you run brew services start ollama.