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 and bulk marketing, then takes the right action for each — a terse reply for cold sales pitches, an RFC 8058 one-click unsubscribe for newsletters.
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.mdas one ofCOLD_SALES,BULK_MARKETING, orNOT_COLD_SALES. - Takes the action matching the classification:
COLD_SALES— generate a reply using the rules inuse_agent/prompts/reply.md, send it as a threaded reply, mark the original read, archive.BULK_MARKETINGwith aList-Unsubscribeheader — honor it (RFC 8058 one-click HTTPS POST preferred, falling back to HTTPS GET or a mailto unsubscribe) and Trash the thread.BULK_MARKETINGwithout aList-Unsubscribeheader — Trash the thread without clicking any in-body unsubscribe link (clicking validates the address to a sender that already ignored the spec).NOT_COLD_SALES— skip.
- Emits a summary of everything examined.
Unlike the Claude Cowork Gmail connector, this agent sends actual replies rather than drafts and can act on the mailbox.
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.0b4 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"]
[newsletters]
# Bulk senders to LEAVE ALONE — community mailing lists, vendor
# product updates, or anything else you opted into. Matched first
# by List-Id (precise), then sender domain (coarse). Leave both
# empty to unsubscribe from every bulk list not otherwise
# safelisted.
keep_domains = []
keep_list_ids = ["pgsql-general.lists.postgresql.org"]
[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 six Gmail tools we expose (search,get_message,reply,archive_and_mark_read,unsubscribe_and_trash,trash)
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.
COLD_SALES — personalized sales outreach. 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, senders
from safelisted domains, and threads that already have a sent
reply are hard-exempt regardless of score.
BULK_MARKETING — unsolicited mass broadcast (newsletters,
product promos, demand-gen nurture). The decisive signal is the
presence of both List-Unsubscribe and
List-Unsubscribe-Post: One-Click — real personal email never
carries those. ESP infrastructure (HubSpot, Marketo, Sendinblue,
emBlue, SendGrid, Mailchimp, etc.) and opaque List-Id values
raise confidence. Matching the [newsletters] keep_domains or
keep_list_ids in config.toml exempts the message, as do
community mailing lists with organization-owned List-Ids.
Reply modes for COLD_SALES:
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.
Action modes for BULK_MARKETING:
unsubscribe_and_delete—List-Unsubscribeis present; honor it (see "How unsubscribes work" below), then Trash the thread.delete— noList-Unsubscribeheader; Trash without clicking any in-body unsubscribe link.
Every reply optionally ends with the footer from config.toml,
which is itself a Jinja string (so it can interpolate {{ user_name }} etc.).
How unsubscribes work
For BULK_MARKETING with a List-Unsubscribe header, the
unsubscribe_and_trash tool picks the best available endpoint in
this order:
- RFC 8058 one-click HTTPS POST — when
List-Unsubscribe-Post: List-Unsubscribe=One-Clickis set and an HTTPS URI is present. A body-less POST to the URI withList-Unsubscribe=One-Clickas a form field. This is how Gmail and Apple Mail's built-in "Unsubscribe" button works. - HTTPS GET — when only an HTTPS URI is present, no one-click declaration.
- Mailto unsubscribe — a standalone email to the
mailto:URI with the subject/body it requests.
Only after the chosen action completes does the thread move to
Trash (recoverable for ~30 days). --dry-run skips both the HTTP
call and the Trash move but still logs the method that would have
been used.
In-body HTML unsubscribe links are NEVER clicked. Clicking a shady sender's in-body link validates the address and typically increases spam rather than stopping it.
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.0b5.tar.gz.
File metadata
- Download URL: use_agent-1.0.0b5.tar.gz
- Upload date:
- Size: 82.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8ec2a56a39af4238bd99a658228948bdcc81612f5ca51ec5c946a1562bb50922
|
|
| MD5 |
44f2e0f50a6c7eaf9f5961139804290e
|
|
| BLAKE2b-256 |
ad0506d37a61081dc2fa31000d4396275d421c3b1a1166ab76a804a0e764435e
|
Provenance
The following attestation bundles were made for use_agent-1.0.0b5.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.0b5.tar.gz -
Subject digest:
8ec2a56a39af4238bd99a658228948bdcc81612f5ca51ec5c946a1562bb50922 - Sigstore transparency entry: 1419319821
- Sigstore integration time:
-
Permalink:
gmr/use-agent@87793a093431efea595d0e1ae7ab2d63db0b92c4 -
Branch / Tag:
refs/tags/v1.0.0b5 - Owner: https://github.com/gmr
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
deploy.yaml@87793a093431efea595d0e1ae7ab2d63db0b92c4 -
Trigger Event:
release
-
Statement type:
File details
Details for the file use_agent-1.0.0b5-py3-none-any.whl.
File metadata
- Download URL: use_agent-1.0.0b5-py3-none-any.whl
- Upload date:
- Size: 37.0 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 |
06faced8d35b5749ebc4e917947303510bbd1a9aa099017deb04331af86a38f4
|
|
| MD5 |
1fbf5feaf2afa5091172ab3c4bd85f8b
|
|
| BLAKE2b-256 |
2c1e1209f58b8ee12787bf89b724a1ab58ebe57a9e1f8950ebb9d4bfea8d81ea
|
Provenance
The following attestation bundles were made for use_agent-1.0.0b5-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.0b5-py3-none-any.whl -
Subject digest:
06faced8d35b5749ebc4e917947303510bbd1a9aa099017deb04331af86a38f4 - Sigstore transparency entry: 1419319959
- Sigstore integration time:
-
Permalink:
gmr/use-agent@87793a093431efea595d0e1ae7ab2d63db0b92c4 -
Branch / Tag:
refs/tags/v1.0.0b5 - Owner: https://github.com/gmr
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
deploy.yaml@87793a093431efea595d0e1ae7ab2d63db0b92c4 -
Trigger Event:
release
-
Statement type: