Claude Agent that triages unsolicited sales email in Gmail.
Project description
use-agent
A Claude Agent that scans a Gmail inbox for unsolicited sales email, replies in the user's voice, and archives the thread.
Built on the Claude Agent SDK. Gmail API operations are
exposed to the agent as tools via an in-process MCP server. All
user-specific configuration lives in a single config.toml — the
repo itself contains no identifying information.
What it does
- Searches the inbox for unread messages from outside senders (the
query is built from the safelist in
config.toml). - Fetches each candidate and checks whether the thread already has a sent reply.
- Classifies each message using the rules in
use_agent/prompts/classifier.md. - For every
COLD_SALEShit, generates a reply using the rules inuse_agent/prompts/reply.md. - Sends the reply as a proper threaded reply (
In-Reply-ToandReferencesheaders set from the original message, samethreadIdpassed to the send call), marks the original as read, and archives the thread. - Emits a summary of everything examined.
Unlike the Claude Cowork Gmail connector, this agent sends actual replies rather than drafts and can archive threads.
Install
Requires Python 3.14.
Run without installing (recommended)
uvx runs the latest release in an ephemeral environment —
no virtualenv to manage:
uvx use-agent auth # one-time OAuth
uvx use-agent run --dry-run # classify only
uvx use-agent run # full run
Pin a version if you want reproducibility:
uvx use-agent@1.0.0b3 run
Install system-wide with uv
uv tool install use-agent
use-agent auth
use-agent run
Upgrade later with uv tool upgrade use-agent.
Install with pipx or pip
pipx install use-agent
# or
pip install --user use-agent
From source (for development)
git clone git@github.com:gmr/use-agent.git
cd use-agent
uv sync
uv run use-agent auth
uv run use-agent run
uv sync installs the runtime deps (claude-agent-sdk,
google-api-python-client, google-auth-oauthlib, jinja2,
rich) plus the dev tools (pytest, ruff, coverage). The
console script lands at .venv/bin/use-agent.
Configure
Copy config.example.toml to ~/.config/use-agent/config.toml (or
point USE_AGENT_CONFIG at any path) and fill in the sections
below. The real config.toml is gitignored; only the example is
committed.
[user]
name = "Your Name"
organization = "Your Company"
[safelist]
# Never classified as cold sales, and auto-appended as -from:<d>
# filters to the Gmail search query.
domains = ["example.com", "example.net"]
[vendors]
# Exempt from cold-sales classification when the message is about
# billing / renewal / account management. Vendor reps pitching
# upsells still get flagged.
names = ["AWS", "GitHub"]
[voice]
# Rendered as a bulleted list under "## Voice Guidelines" in the
# reply prompt.
guidelines = [
"1-2 sentences maximum, never more",
"No apology for declining",
"Blunt but not hostile",
"Always ends with a remove request (except `specific_decline`)",
]
# Footer appended to every reply. May reference Jinja variables.
# Set to "" to disable.
footer = """\
---
This email was flagged as unsolicited sales outreach and this reply
was sent on {{ user_name }}'s behalf by an automated assistant.\
"""
[agent]
# Claude model that drives the agent.
model = "claude-haiku-4-5-20251001"
[search]
max_results = 25
# Override the Gmail query. If omitted, the query is built from
# "in:inbox is:unread" plus a -from:<domain> filter for every
# safelist entry.
# query = "in:inbox is:unread"
Both classifier.md and reply.md are Jinja2 templates; their
contents are rendered at startup using the values above. Editing
them changes agent behavior without any Python change.
Environment variable overrides
| Variable | Purpose | Default |
|---|---|---|
USE_AGENT_CONFIG |
Path to config.toml |
~/.config/use-agent/config.toml |
USE_AGENT_CREDENTIALS |
Path to OAuth client secret | ~/.config/use-agent/credentials.json |
USE_AGENT_TOKEN |
Path to stored OAuth token | ~/.config/use-agent/token.json |
XDG_CONFIG_HOME |
Base for the above defaults | ~/.config |
ANTHROPIC_API_KEY |
Required by the Claude Agent SDK | — |
Gmail OAuth setup
-
In Google Cloud Console, pick a project that has the Gmail API enabled and create an OAuth 2.0 Client ID of type Desktop app.
-
Download the client secret as
credentials.json. Drop it at~/.config/use-agent/credentials.json(or pointUSE_AGENT_CREDENTIALSat wherever you saved it). -
Run the one-time authorization flow:
use-agent auth # or: uvx use-agent auth
This opens a browser, completes the OAuth consent, and stores the refresh token at
~/.config/use-agent/token.json(mode0600). Refreshes happen automatically on subsequent runs.
The only scope requested is
https://www.googleapis.com/auth/gmail.modify, which covers read,
label changes (archive, mark read), and send — nothing destructive.
Run
The examples below use bare use-agent (what you have after uvx,
uv tool install, pipx, or pip install). From a source checkout
prefix each command with uv run.
# One-shot
use-agent run # process the inbox
use-agent run --dry-run # classify but don't reply/archive
use-agent run --max 10 # examine at most 10 candidates
use-agent run --query 'is:unread label:followup' # custom query
# Output format (default is pretty)
use-agent run --plain # no ANSI, pipe-delimited table
use-agent run --json # stdout is a single JSON document
# Daemon mode
use-agent run --daemon # loop forever, every 15m
use-agent run --daemon --interval 30m # 30-minute cadence
use-agent run --daemon --interval 2h # every two hours
# Intervals accept s / m / h / d suffixes, or raw seconds.
# Logging
use-agent -v run # DEBUG level logs to stderr
use-agent --logfile run.log run --daemon # tee logs to a file
Output modes
--pretty(default): Rich table on stdout, colour-coded classification, auto-wrapping columns. Running commentary ("Now fetching message…", "Classifying…") streams on stderr via theuse_agent.narrationlogger.--plain: No ANSI. A pipe-delimited ASCII table hits stdout — safe to pipe intocolumn,less,tee, etc.--json: A single JSON document on stdout:{"results": [{...}, ...]}. All logs and narration go to stderr, souse-agent run --json | jq '.results[].classification'is clean.
Exit code is 0 on a successful run with a parsed summary, 1 if
the agent produced no recognizable summary block.
Daemon mode
--daemon loops until Ctrl-C. Each iteration spins up a fresh
reporter and agent run; exceptions inside an iteration are logged
and the loop continues. Pair with --logfile for long-running
deployments:
use-agent --logfile ~/logs/use-agent.log run --daemon --interval 30m
Logging
The CLI emits structured logs to stderr (and, with --logfile, to
the named file too). Loggers:
| Logger | Level | Content |
|---|---|---|
use_agent.* |
INFO | High-level lifecycle (agent run start/end, daemon tick) |
use_agent.narration |
INFO | Running commentary from the agent |
use_agent.tools |
DEBUG | Every Gmail tool call (search, get, reply, archive) |
claude_agent_sdk |
WARNING | Pinned to WARN; SDK's INFO is too chatty |
-v / --verbose bumps the root level to DEBUG, surfacing every
tool invocation.
Isolation from your Claude Code setup
The agent is deliberately hermetic. ClaudeAgentOptions is
constructed with:
setting_sources=[]— don't merge in~/.claude/settings.json, any.claude/settings.local.json, or project-level settingssettings=None— no specific settings fileskills=None— no auto-discovered skillsmcp_servers={'gmail': <our server>}— only our Gmail MCP serverallowed_tools=[...]— only the four Gmail tools we expose
Nothing from your Claude Code dev environment — custom agents, plugins, MCP servers, hooks, skills — can leak into this agent run.
How the agent decides
Classification is rule-based, not sentiment-based. Each message
accumulates points from STRONG (2pt) and WEAK (1pt) signals — meeting
CTA, intro formulas, outreach-tooling domains, flattery hooks, fake
Re: threads, false premises about the org, etc. A total score of
>= 3 means COLD_SALES. Vendor billing, newsletters, transactional
notifications, threads that already have a sent reply, and senders
from safelisted domains are hard-exempt regardless of score.
Replies are one of four modes:
hard_remove— the default. A curt "Not interested, please remove."hard_remove_with_correction— when the sender got a fact about the org wrong; lead with a brief factual correction, then remove.specific_decline— reserved for reps of current vendors.none— not cold sales; skip.
Every reply optionally ends with the footer from config.toml,
which is itself a Jinja string (so it can interpolate {{ user_name }} etc.).
How replies are threaded
Gmail treats a reply as part of the original thread only when the
send request is correctly chained. gmail.py:
- Pulls the original
Message-IDandReferencesheaders. - Builds a new
EmailMessagewithIn-Reply-To: <original-id>andReferences: <chain> <original-id>. - Subject is prefixed with
Re:unless it already starts withRe:. - Base64url-encodes the MIME message and calls
users.messages.sendwith the encoded body and the originalthreadId.
That combination is what Gmail's UI uses to collapse the reply into the same conversation view the original arrived in.
License
BSD-3-Clause.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file use_agent-1.0.0b3.tar.gz.
File metadata
- Download URL: use_agent-1.0.0b3.tar.gz
- Upload date:
- Size: 73.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3c63a53e6e84c1cfbdf2da191073c3d4e0a35b0e742107baa8de2a78bf9b74af
|
|
| MD5 |
d92c655367128d8bb7b18c17e0a17088
|
|
| BLAKE2b-256 |
48418be0f44711cc61debcc229979eef7dcbeba695bee1ff470f53816dfab224
|
Provenance
The following attestation bundles were made for use_agent-1.0.0b3.tar.gz:
Publisher:
deploy.yaml on gmr/use-agent
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
use_agent-1.0.0b3.tar.gz -
Subject digest:
3c63a53e6e84c1cfbdf2da191073c3d4e0a35b0e742107baa8de2a78bf9b74af - Sigstore transparency entry: 1354231660
- Sigstore integration time:
-
Permalink:
gmr/use-agent@9ca82bc4b95e0b542f0e3060ee56efdbc0a17ede -
Branch / Tag:
refs/tags/v1.0.0b3 - Owner: https://github.com/gmr
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
deploy.yaml@9ca82bc4b95e0b542f0e3060ee56efdbc0a17ede -
Trigger Event:
release
-
Statement type:
File details
Details for the file use_agent-1.0.0b3-py3-none-any.whl.
File metadata
- Download URL: use_agent-1.0.0b3-py3-none-any.whl
- Upload date:
- Size: 29.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ea7859e8c142d6b8de52417610c6710e0fac3a47517c82f3598728ce8a1bb978
|
|
| MD5 |
8232afed71ca8ad9d99a273cf90cf609
|
|
| BLAKE2b-256 |
f23918c329cd5baad997ef72b51bb3fe7282e6557d8f2792e142958e5d4e0c32
|
Provenance
The following attestation bundles were made for use_agent-1.0.0b3-py3-none-any.whl:
Publisher:
deploy.yaml on gmr/use-agent
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
use_agent-1.0.0b3-py3-none-any.whl -
Subject digest:
ea7859e8c142d6b8de52417610c6710e0fac3a47517c82f3598728ce8a1bb978 - Sigstore transparency entry: 1354231773
- Sigstore integration time:
-
Permalink:
gmr/use-agent@9ca82bc4b95e0b542f0e3060ee56efdbc0a17ede -
Branch / Tag:
refs/tags/v1.0.0b3 - Owner: https://github.com/gmr
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
deploy.yaml@9ca82bc4b95e0b542f0e3060ee56efdbc0a17ede -
Trigger Event:
release
-
Statement type: