use-agent 1.0.0b1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- use_agent-1.0.0b1/.github/workflows/deploy.yaml +45 -0
- use_agent-1.0.0b1/.github/workflows/testing.yaml +36 -0
- use_agent-1.0.0b1/.gitignore +20 -0
- use_agent-1.0.0b1/.pre-commit-config.yaml +23 -0
- use_agent-1.0.0b1/CLAUDE.md +175 -0
- use_agent-1.0.0b1/PKG-INFO +283 -0
- use_agent-1.0.0b1/README.md +257 -0
- use_agent-1.0.0b1/config.example.toml +90 -0
- use_agent-1.0.0b1/pyproject.toml +103 -0
- use_agent-1.0.0b1/tests/__init__.py +0 -0
- use_agent-1.0.0b1/tests/test_cli.py +56 -0
- use_agent-1.0.0b1/tests/test_config.py +63 -0
- use_agent-1.0.0b1/tests/test_prompt_rendering.py +126 -0
- use_agent-1.0.0b1/tests/test_settings.py +196 -0
- use_agent-1.0.0b1/use_agent/__init__.py +8 -0
- use_agent-1.0.0b1/use_agent/__main__.py +4 -0
- use_agent-1.0.0b1/use_agent/agent.py +281 -0
- use_agent-1.0.0b1/use_agent/auth.py +60 -0
- use_agent-1.0.0b1/use_agent/cli.py +202 -0
- use_agent-1.0.0b1/use_agent/config.py +44 -0
- use_agent-1.0.0b1/use_agent/gmail.py +246 -0
- use_agent-1.0.0b1/use_agent/prompts/classifier.md +95 -0
- use_agent-1.0.0b1/use_agent/prompts/reply.md +95 -0
- use_agent-1.0.0b1/use_agent/reporter.py +186 -0
- use_agent-1.0.0b1/use_agent/settings.py +161 -0
- use_agent-1.0.0b1/use_agent/tools.py +106 -0
- use_agent-1.0.0b1/uv.lock +994 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
on:
|
|
3
|
+
release:
|
|
4
|
+
types: [published]
|
|
5
|
+
|
|
6
|
+
permissions:
|
|
7
|
+
id-token: write
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- name: Checkout repository
|
|
14
|
+
uses: actions/checkout@v6
|
|
15
|
+
with:
|
|
16
|
+
fetch-depth: 0
|
|
17
|
+
|
|
18
|
+
- name: Install uv
|
|
19
|
+
uses: astral-sh/setup-uv@v5
|
|
20
|
+
|
|
21
|
+
- name: Set up Python
|
|
22
|
+
run: uv python install 3.14
|
|
23
|
+
|
|
24
|
+
- name: Build
|
|
25
|
+
run: uv build
|
|
26
|
+
|
|
27
|
+
- name: Upload dist
|
|
28
|
+
uses: actions/upload-artifact@v4
|
|
29
|
+
with:
|
|
30
|
+
name: dist
|
|
31
|
+
path: dist/
|
|
32
|
+
|
|
33
|
+
publish:
|
|
34
|
+
needs: build
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
environment: pypi
|
|
37
|
+
steps:
|
|
38
|
+
- name: Download dist
|
|
39
|
+
uses: actions/download-artifact@v4
|
|
40
|
+
with:
|
|
41
|
+
name: dist
|
|
42
|
+
path: dist/
|
|
43
|
+
|
|
44
|
+
- name: Publish package
|
|
45
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: Testing
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: ["**"]
|
|
5
|
+
paths-ignore:
|
|
6
|
+
- 'docs/**'
|
|
7
|
+
- '*.md'
|
|
8
|
+
tags-ignore: ["*"]
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python: ["3.14"]
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout repository
|
|
17
|
+
uses: actions/checkout@v6
|
|
18
|
+
with:
|
|
19
|
+
fetch-depth: 0
|
|
20
|
+
|
|
21
|
+
- name: Install uv
|
|
22
|
+
uses: astral-sh/setup-uv@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python }}
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: uv sync
|
|
28
|
+
|
|
29
|
+
- name: Run ruff check
|
|
30
|
+
run: uv run ruff check .
|
|
31
|
+
|
|
32
|
+
- name: Run ruff format check
|
|
33
|
+
run: uv run ruff format --check .
|
|
34
|
+
|
|
35
|
+
- name: Run tests
|
|
36
|
+
run: uv run coverage run && uv run coverage report
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.venv/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
.coverage
|
|
8
|
+
.coverage.*
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
|
|
12
|
+
# Local secrets & private config — NEVER commit
|
|
13
|
+
credentials.json
|
|
14
|
+
token.json
|
|
15
|
+
config.toml
|
|
16
|
+
*.pem
|
|
17
|
+
.env
|
|
18
|
+
.env.*
|
|
19
|
+
!.env.example
|
|
20
|
+
!config.example.toml
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v5.0.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: trailing-whitespace
|
|
6
|
+
- id: end-of-file-fixer
|
|
7
|
+
- id: check-yaml
|
|
8
|
+
- id: check-added-large-files
|
|
9
|
+
- id: check-merge-conflict
|
|
10
|
+
- id: check-toml
|
|
11
|
+
- id: debug-statements
|
|
12
|
+
|
|
13
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
14
|
+
rev: v0.14.6
|
|
15
|
+
hooks:
|
|
16
|
+
- id: ruff-check
|
|
17
|
+
args: [--fix]
|
|
18
|
+
- id: ruff-format
|
|
19
|
+
|
|
20
|
+
- repo: https://github.com/tombi-toml/tombi-pre-commit
|
|
21
|
+
rev: v0.7.25
|
|
22
|
+
hooks:
|
|
23
|
+
- id: tombi-format
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project
|
|
6
|
+
|
|
7
|
+
`use-agent` is a Claude Agent that triages a Gmail inbox for
|
|
8
|
+
unsolicited sales email, replies in the user's voice, and archives
|
|
9
|
+
the thread. It's built on the Claude Agent SDK; Gmail API operations
|
|
10
|
+
are exposed as tools via an in-process SDK MCP server.
|
|
11
|
+
|
|
12
|
+
Python 3.14, uv, hatchling + hatch-vcs, ruff. Runtime deps:
|
|
13
|
+
`claude-agent-sdk`, `google-api-python-client`,
|
|
14
|
+
`google-auth-oauthlib`, `jinja2`, `rich`.
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv sync # install runtime + dev deps
|
|
20
|
+
uv run use-agent auth # one-time Gmail OAuth
|
|
21
|
+
uv run use-agent run --dry-run # classify only
|
|
22
|
+
uv run use-agent run # full run: reply + archive
|
|
23
|
+
uv run use-agent run --daemon --interval 30m --logfile run.log
|
|
24
|
+
uv run use-agent run --json | jq . # stdout is pure JSON
|
|
25
|
+
|
|
26
|
+
uv run ruff check .
|
|
27
|
+
uv run ruff format --check .
|
|
28
|
+
uv run coverage run # pytest under coverage
|
|
29
|
+
uv run coverage report
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`ANTHROPIC_API_KEY` is required for any `run`.
|
|
33
|
+
|
|
34
|
+
## Architecture
|
|
35
|
+
|
|
36
|
+
The data flow is deliberately narrow. All user-specific context enters
|
|
37
|
+
through `config.toml`; everything else is derivable from the code.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
cli.main → Settings.load() → agent.run(settings, reporter)
|
|
41
|
+
↓
|
|
42
|
+
_render_system_prompt(settings)
|
|
43
|
+
↓
|
|
44
|
+
claude_agent_sdk.query(
|
|
45
|
+
prompt=<user-prompt>,
|
|
46
|
+
options=ClaudeAgentOptions(
|
|
47
|
+
system_prompt=<rendered>,
|
|
48
|
+
mcp_servers={'gmail': <built from GmailClient>},
|
|
49
|
+
allowed_tools=[mcp__gmail__search, ...],
|
|
50
|
+
setting_sources=[], # hermetic
|
|
51
|
+
model=<from config>,
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
↓
|
|
55
|
+
streaming AssistantMessage/TextBlock
|
|
56
|
+
↓
|
|
57
|
+
reporter.on_text(...) (→ narration logger)
|
|
58
|
+
↓
|
|
59
|
+
reporter.finish() (→ stdout: table/JSON)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Key module responsibilities:
|
|
63
|
+
|
|
64
|
+
- `gmail.GmailClient` is the only module that touches the Gmail REST
|
|
65
|
+
API. It returns frozen `Message` dataclasses so tool output stays
|
|
66
|
+
JSON-friendly.
|
|
67
|
+
- `tools.build_mcp_server(client)` wraps four `@claude_agent_sdk.tool`
|
|
68
|
+
closures (`search`, `get_message`, `reply`, `archive_and_mark_read`)
|
|
69
|
+
over a `GmailClient` and returns an SDK MCP server. The closure
|
|
70
|
+
approach lets the tools share state without a module-global client.
|
|
71
|
+
- `agent.run()` is the orchestration entry point: renders the system
|
|
72
|
+
prompt, builds the tool server, configures `ClaudeAgentOptions`,
|
|
73
|
+
streams responses into the `Reporter`, and returns an exit code.
|
|
74
|
+
Python does no per-message logic — the agent drives the loop.
|
|
75
|
+
- `reporter.Reporter` owns all output. **`on_text` never writes to
|
|
76
|
+
stdout**; narration goes to the `use_agent.narration` logger
|
|
77
|
+
instead. `finish()` parses the last ` ```json ` fenced block from
|
|
78
|
+
the buffered text and renders it as a Rich table (`pretty`),
|
|
79
|
+
pipe-delimited ASCII (`plain`), or a JSON document (`json`).
|
|
80
|
+
- `settings.Settings` is a frozen dataclass loaded from
|
|
81
|
+
`config.toml` (path via `USE_AGENT_CONFIG` env var or
|
|
82
|
+
`~/.config/use-agent/config.toml`). If `[search] query` is omitted,
|
|
83
|
+
a default `in:inbox is:unread` query is built from the safelist
|
|
84
|
+
domains as `-from:<domain>` filters.
|
|
85
|
+
|
|
86
|
+
### Prompts are Jinja2 templates
|
|
87
|
+
|
|
88
|
+
`use_agent/prompts/classifier.md` and `reply.md` are Jinja2 templates
|
|
89
|
+
rendered at startup. Context injected by `agent._render_context`:
|
|
90
|
+
`user_name`, `organization`, `safelist_domains`, `vendor_names`,
|
|
91
|
+
`voice_guidelines`, `reply_footer`, plus two pre-rendered strings
|
|
92
|
+
(`voice_block`, `footer_block`, `footer_instruction`). The
|
|
93
|
+
pre-rendered strings exist because the repo's pre-commit Markdown
|
|
94
|
+
formatter strips blank lines inside `{% if %}` blocks — avoid Jinja
|
|
95
|
+
control flow in prompt Markdown; pre-render composite blocks in
|
|
96
|
+
Python and inject them as single `{{ ... }}` substitutions.
|
|
97
|
+
|
|
98
|
+
`reply_footer` is itself a Jinja string (may reference `{{ user_name
|
|
99
|
+
}}` etc.) and is rendered once in `_render_context` before being
|
|
100
|
+
injected into the reply template.
|
|
101
|
+
|
|
102
|
+
### Editing agent behavior without touching Python
|
|
103
|
+
|
|
104
|
+
Most behavior tweaks belong in `config.toml` or the prompt Markdown:
|
|
105
|
+
|
|
106
|
+
- New classification signal → edit `prompts/classifier.md`
|
|
107
|
+
- Change reply voice / add a template → edit `prompts/reply.md`
|
|
108
|
+
- Add a vendor exemption → `[vendors] names` in `config.toml`
|
|
109
|
+
- Add a safelisted domain → `[safelist] domains` in `config.toml`
|
|
110
|
+
- Change footer text / disable footer → `[voice] footer` (or `""`)
|
|
111
|
+
- Change model → `[agent] model`
|
|
112
|
+
|
|
113
|
+
Python changes are only needed when the set of tools, output modes,
|
|
114
|
+
CLI flags, or Gmail operations needs to change.
|
|
115
|
+
|
|
116
|
+
### Hermetic SDK configuration
|
|
117
|
+
|
|
118
|
+
`agent.run()` passes `setting_sources=[]`, `settings=None`,
|
|
119
|
+
`skills=None` to `ClaudeAgentOptions` so the agent never merges in
|
|
120
|
+
the developer's `~/.claude/settings.json`, project-level Claude Code
|
|
121
|
+
config, plugins, or auto-discovered skills. The only MCP server the
|
|
122
|
+
agent sees is our Gmail one; the only tools it can call are the four
|
|
123
|
+
in `tools.ALLOWED_TOOLS`. Keep it that way — preserving isolation is
|
|
124
|
+
part of the trust model.
|
|
125
|
+
|
|
126
|
+
### Threaded replies
|
|
127
|
+
|
|
128
|
+
Gmail only collapses a reply into the original conversation when all
|
|
129
|
+
three are correct: `In-Reply-To: <original Message-ID>`,
|
|
130
|
+
`References: <chain> <original Message-ID>`, and `threadId` in the
|
|
131
|
+
`users.messages.send` request. `gmail.GmailClient.reply` assembles
|
|
132
|
+
all three from the fetched original. Don't regress this — dropping
|
|
133
|
+
`threadId` or the headers silently demotes replies into new threads.
|
|
134
|
+
|
|
135
|
+
### Output contract
|
|
136
|
+
|
|
137
|
+
The agent is prompted to emit exactly one fenced ` ```json ` block
|
|
138
|
+
containing `{"results": [...]}`. The reporter's `_extract_summary`
|
|
139
|
+
walks the buffered text from the end backwards looking for the last
|
|
140
|
+
parseable JSON fence with a `results` array (or a bare list of rows
|
|
141
|
+
with a `classification` key). If the agent forgets to emit the
|
|
142
|
+
block, `finish()` returns exit code 1 — treat this as a real error.
|
|
143
|
+
|
|
144
|
+
### Logging
|
|
145
|
+
|
|
146
|
+
| Logger | Level | Purpose |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `use_agent.*` | INFO | Lifecycle (run start, daemon tick) |
|
|
149
|
+
| `use_agent.narration` | INFO | Agent's running commentary (buffered text) |
|
|
150
|
+
| `use_agent.tools` | DEBUG | Per-tool-call Gmail operations |
|
|
151
|
+
| `claude_agent_sdk` | WARNING | Pinned; SDK INFO is too chatty |
|
|
152
|
+
|
|
153
|
+
All handlers write to stderr (plus the `--logfile` target when set)
|
|
154
|
+
so stdout stays pure in `--json` mode.
|
|
155
|
+
|
|
156
|
+
## Conventions
|
|
157
|
+
|
|
158
|
+
- **Module-level imports only.** A pre-commit hook rejects `from
|
|
159
|
+
pathlib import Path` / `from typing import Any` style imports —
|
|
160
|
+
use `import pathlib` / `import typing`, then `pathlib.Path(...)`,
|
|
161
|
+
`typing.Any`.
|
|
162
|
+
- Ruff: 79-char lines, single quotes, `py314` target. Config lives
|
|
163
|
+
in `pyproject.toml`.
|
|
164
|
+
- No compound shell commands: each `Bash` call is one command
|
|
165
|
+
(repo-wide preference, not project-specific, but still applies).
|
|
166
|
+
- `credentials.json`, `token.json`, and `config.toml` are all
|
|
167
|
+
gitignored. The repo itself contains no identifying information;
|
|
168
|
+
`config.example.toml` uses placeholder values only.
|
|
169
|
+
|
|
170
|
+
## Version
|
|
171
|
+
|
|
172
|
+
`use_agent.__version__` is read at runtime via
|
|
173
|
+
`importlib.metadata.version('use-agent')`, with a `0.0.0+unknown`
|
|
174
|
+
fallback. `[project] version` is pinned in `pyproject.toml`; hatch-vcs
|
|
175
|
+
is configured but not currently generating a version file.
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: use-agent
|
|
3
|
+
Version: 1.0.0b1
|
|
4
|
+
Summary: Claude Agent that triages unsolicited sales email in Gmail.
|
|
5
|
+
Project-URL: Homepage, https://github.com/gmr/use-agent
|
|
6
|
+
Author-email: "Gavin M. Roy" <gavinmroy@gmail.com>
|
|
7
|
+
License-Expression: BSD-3-Clause
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
17
|
+
Classifier: Topic :: Communications :: Email
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Requires-Dist: claude-agent-sdk>=0.1.0
|
|
20
|
+
Requires-Dist: google-api-python-client>=2.150
|
|
21
|
+
Requires-Dist: google-auth-httplib2>=0.2
|
|
22
|
+
Requires-Dist: google-auth-oauthlib>=1.2
|
|
23
|
+
Requires-Dist: jinja2>=3.1
|
|
24
|
+
Requires-Dist: rich>=13.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# use-agent
|
|
28
|
+
|
|
29
|
+
A Claude Agent that scans a Gmail inbox for unsolicited sales email,
|
|
30
|
+
replies in the user's voice, and archives the thread.
|
|
31
|
+
|
|
32
|
+
Built on the [Claude Agent SDK][sdk]. Gmail API operations are
|
|
33
|
+
exposed to the agent as tools via an in-process MCP server. All
|
|
34
|
+
user-specific configuration lives in a single `config.toml` — the
|
|
35
|
+
repo itself contains no identifying information.
|
|
36
|
+
|
|
37
|
+
[sdk]: https://docs.claude.com/en/api/agent-sdk/overview
|
|
38
|
+
|
|
39
|
+
## What it does
|
|
40
|
+
|
|
41
|
+
1. Searches the inbox for unread messages from outside senders (the
|
|
42
|
+
query is built from the safelist in `config.toml`).
|
|
43
|
+
2. Fetches each candidate and checks whether the thread already has
|
|
44
|
+
a sent reply.
|
|
45
|
+
3. Classifies each message using the rules in
|
|
46
|
+
[`use_agent/prompts/classifier.md`](use_agent/prompts/classifier.md).
|
|
47
|
+
4. For every `COLD_SALES` hit, generates a reply using the rules in
|
|
48
|
+
[`use_agent/prompts/reply.md`](use_agent/prompts/reply.md).
|
|
49
|
+
5. Sends the reply as a proper threaded reply (`In-Reply-To` and
|
|
50
|
+
`References` headers set from the original message, same
|
|
51
|
+
`threadId` passed to the send call), marks the original as read,
|
|
52
|
+
and archives the thread.
|
|
53
|
+
6. Emits a summary of everything examined.
|
|
54
|
+
|
|
55
|
+
Unlike the Claude Cowork Gmail connector, this agent sends actual
|
|
56
|
+
replies rather than drafts and can archive threads.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
Requires Python 3.14 and [`uv`][uv].
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv sync
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This installs the runtime deps (`claude-agent-sdk`,
|
|
67
|
+
`google-api-python-client`, `google-auth-oauthlib`, `jinja2`,
|
|
68
|
+
`rich`) and the dev tools (`pytest`, `ruff`, `coverage`). The
|
|
69
|
+
console script `use-agent` lands at `.venv/bin/use-agent`.
|
|
70
|
+
|
|
71
|
+
[uv]: https://docs.astral.sh/uv/
|
|
72
|
+
|
|
73
|
+
## Configure
|
|
74
|
+
|
|
75
|
+
Copy `config.example.toml` to `~/.config/use-agent/config.toml` (or
|
|
76
|
+
point `USE_AGENT_CONFIG` at any path) and fill in the sections
|
|
77
|
+
below. The real `config.toml` is gitignored; only the example is
|
|
78
|
+
committed.
|
|
79
|
+
|
|
80
|
+
```toml
|
|
81
|
+
[user]
|
|
82
|
+
name = "Your Name"
|
|
83
|
+
organization = "Your Company"
|
|
84
|
+
|
|
85
|
+
[safelist]
|
|
86
|
+
# Never classified as cold sales, and auto-appended as -from:<d>
|
|
87
|
+
# filters to the Gmail search query.
|
|
88
|
+
domains = ["example.com", "example.net"]
|
|
89
|
+
|
|
90
|
+
[vendors]
|
|
91
|
+
# Exempt from cold-sales classification when the message is about
|
|
92
|
+
# billing / renewal / account management. Vendor reps pitching
|
|
93
|
+
# upsells still get flagged.
|
|
94
|
+
names = ["AWS", "GitHub"]
|
|
95
|
+
|
|
96
|
+
[voice]
|
|
97
|
+
# Rendered as a bulleted list under "## Voice Guidelines" in the
|
|
98
|
+
# reply prompt.
|
|
99
|
+
guidelines = [
|
|
100
|
+
"1-2 sentences maximum, never more",
|
|
101
|
+
"No apology for declining",
|
|
102
|
+
"Blunt but not hostile",
|
|
103
|
+
"Always ends with a remove request (except `specific_decline`)",
|
|
104
|
+
]
|
|
105
|
+
# Footer appended to every reply. May reference Jinja variables.
|
|
106
|
+
# Set to "" to disable.
|
|
107
|
+
footer = """\
|
|
108
|
+
---
|
|
109
|
+
This email was flagged as unsolicited sales outreach and this reply
|
|
110
|
+
was sent on {{ user_name }}'s behalf by an automated assistant.\
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
[agent]
|
|
114
|
+
# Claude model that drives the agent.
|
|
115
|
+
model = "claude-haiku-4-5-20251001"
|
|
116
|
+
|
|
117
|
+
[search]
|
|
118
|
+
max_results = 25
|
|
119
|
+
# Override the Gmail query. If omitted, the query is built from
|
|
120
|
+
# "in:inbox is:unread" plus a -from:<domain> filter for every
|
|
121
|
+
# safelist entry.
|
|
122
|
+
# query = "in:inbox is:unread"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Both `classifier.md` and `reply.md` are Jinja2 templates; their
|
|
126
|
+
contents are rendered at startup using the values above. Editing
|
|
127
|
+
them changes agent behavior without any Python change.
|
|
128
|
+
|
|
129
|
+
### Environment variable overrides
|
|
130
|
+
|
|
131
|
+
| Variable | Purpose | Default |
|
|
132
|
+
| ----------------------- | -------------------------------- | -------------------------------------- |
|
|
133
|
+
| `USE_AGENT_CONFIG` | Path to `config.toml` | `~/.config/use-agent/config.toml` |
|
|
134
|
+
| `USE_AGENT_CREDENTIALS` | Path to OAuth client secret | `~/.config/use-agent/credentials.json` |
|
|
135
|
+
| `USE_AGENT_TOKEN` | Path to stored OAuth token | `~/.config/use-agent/token.json` |
|
|
136
|
+
| `XDG_CONFIG_HOME` | Base for the above defaults | `~/.config` |
|
|
137
|
+
| `ANTHROPIC_API_KEY` | Required by the Claude Agent SDK | — |
|
|
138
|
+
|
|
139
|
+
## Gmail OAuth setup
|
|
140
|
+
|
|
141
|
+
1. In Google Cloud Console, pick a project that has the Gmail API
|
|
142
|
+
enabled and create an **OAuth 2.0 Client ID** of type _Desktop
|
|
143
|
+
app_.
|
|
144
|
+
2. Download the client secret as `credentials.json`. Drop it at
|
|
145
|
+
`~/.config/use-agent/credentials.json` (or point
|
|
146
|
+
`USE_AGENT_CREDENTIALS` at wherever you saved it).
|
|
147
|
+
3. Run the one-time authorization flow:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
uv run use-agent auth
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
This opens a browser, completes the OAuth consent, and stores
|
|
154
|
+
the refresh token at `~/.config/use-agent/token.json` (mode
|
|
155
|
+
`0600`). Refreshes happen automatically on subsequent runs.
|
|
156
|
+
|
|
157
|
+
The only scope requested is
|
|
158
|
+
`https://www.googleapis.com/auth/gmail.modify`, which covers read,
|
|
159
|
+
label changes (archive, mark read), and send — nothing destructive.
|
|
160
|
+
|
|
161
|
+
## Run
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# One-shot
|
|
165
|
+
uv run use-agent run # process the inbox
|
|
166
|
+
uv run use-agent run --dry-run # classify but don't reply/archive
|
|
167
|
+
uv run use-agent run --max 10 # examine at most 10 candidates
|
|
168
|
+
uv run use-agent run --query 'is:unread label:followup' # custom query
|
|
169
|
+
|
|
170
|
+
# Output format (default is pretty)
|
|
171
|
+
uv run use-agent run --plain # no ANSI, pipe-delimited table
|
|
172
|
+
uv run use-agent run --json # stdout is a single JSON document
|
|
173
|
+
|
|
174
|
+
# Daemon mode
|
|
175
|
+
uv run use-agent run --daemon # loop forever, every 15m
|
|
176
|
+
uv run use-agent run --daemon --interval 30m # 30-minute cadence
|
|
177
|
+
uv run use-agent run --daemon --interval 2h # every two hours
|
|
178
|
+
# Intervals accept s / m / h / d suffixes, or raw seconds.
|
|
179
|
+
|
|
180
|
+
# Logging
|
|
181
|
+
uv run use-agent -v run # DEBUG level logs to stderr
|
|
182
|
+
uv run use-agent --logfile run.log run --daemon # tee logs to a file
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Output modes
|
|
186
|
+
|
|
187
|
+
- **`--pretty` (default):** Rich table on stdout, colour-coded
|
|
188
|
+
classification, auto-wrapping columns. Running commentary ("Now
|
|
189
|
+
fetching message…", "Classifying…") streams on stderr via the
|
|
190
|
+
`use_agent.narration` logger.
|
|
191
|
+
- **`--plain`:** No ANSI. A pipe-delimited ASCII table hits stdout —
|
|
192
|
+
safe to pipe into `column`, `less`, `tee`, etc.
|
|
193
|
+
- **`--json`:** A single JSON document on stdout:
|
|
194
|
+
`{"results": [{...}, ...]}`. All logs and narration go to stderr,
|
|
195
|
+
so `use-agent run --json | jq '.results[].classification'` is
|
|
196
|
+
clean.
|
|
197
|
+
|
|
198
|
+
Exit code is `0` on a successful run with a parsed summary, `1` if
|
|
199
|
+
the agent produced no recognizable summary block.
|
|
200
|
+
|
|
201
|
+
### Daemon mode
|
|
202
|
+
|
|
203
|
+
`--daemon` loops until Ctrl-C. Each iteration spins up a fresh
|
|
204
|
+
reporter and agent run; exceptions inside an iteration are logged
|
|
205
|
+
and the loop continues. Pair with `--logfile` for long-running
|
|
206
|
+
deployments:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
use-agent --logfile ~/logs/use-agent.log run --daemon --interval 30m
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Logging
|
|
213
|
+
|
|
214
|
+
The CLI emits structured logs to stderr (and, with `--logfile`, to
|
|
215
|
+
the named file too). Loggers:
|
|
216
|
+
|
|
217
|
+
| Logger | Level | Content |
|
|
218
|
+
| --------------------- | ------- | ------------------------------------------------------- |
|
|
219
|
+
| `use_agent.*` | INFO | High-level lifecycle (agent run start/end, daemon tick) |
|
|
220
|
+
| `use_agent.narration` | INFO | Running commentary from the agent |
|
|
221
|
+
| `use_agent.tools` | DEBUG | Every Gmail tool call (search, get, reply, archive) |
|
|
222
|
+
| `claude_agent_sdk` | WARNING | Pinned to WARN; SDK's INFO is too chatty |
|
|
223
|
+
|
|
224
|
+
`-v` / `--verbose` bumps the root level to DEBUG, surfacing every
|
|
225
|
+
tool invocation.
|
|
226
|
+
|
|
227
|
+
## Isolation from your Claude Code setup
|
|
228
|
+
|
|
229
|
+
The agent is deliberately hermetic. `ClaudeAgentOptions` is
|
|
230
|
+
constructed with:
|
|
231
|
+
|
|
232
|
+
- `setting_sources=[]` — don't merge in `~/.claude/settings.json`,
|
|
233
|
+
any `.claude/settings.local.json`, or project-level settings
|
|
234
|
+
- `settings=None` — no specific settings file
|
|
235
|
+
- `skills=None` — no auto-discovered skills
|
|
236
|
+
- `mcp_servers={'gmail': <our server>}` — only our Gmail MCP server
|
|
237
|
+
- `allowed_tools=[...]` — only the four Gmail tools we expose
|
|
238
|
+
|
|
239
|
+
Nothing from your Claude Code dev environment — custom agents,
|
|
240
|
+
plugins, MCP servers, hooks, skills — can leak into this agent run.
|
|
241
|
+
|
|
242
|
+
## How the agent decides
|
|
243
|
+
|
|
244
|
+
Classification is rule-based, not sentiment-based. Each message
|
|
245
|
+
accumulates points from STRONG (2pt) and WEAK (1pt) signals — meeting
|
|
246
|
+
CTA, intro formulas, outreach-tooling domains, flattery hooks, fake
|
|
247
|
+
`Re:` threads, false premises about the org, etc. A total score of
|
|
248
|
+
`>= 3` means `COLD_SALES`. Vendor billing, newsletters, transactional
|
|
249
|
+
notifications, threads that already have a sent reply, and senders
|
|
250
|
+
from safelisted domains are hard-exempt regardless of score.
|
|
251
|
+
|
|
252
|
+
Replies are one of four modes:
|
|
253
|
+
|
|
254
|
+
- `hard_remove` — the default. A curt "Not interested, please remove."
|
|
255
|
+
- `hard_remove_with_correction` — when the sender got a fact about
|
|
256
|
+
the org wrong; lead with a brief factual correction, then remove.
|
|
257
|
+
- `specific_decline` — reserved for reps of current vendors.
|
|
258
|
+
- `none` — not cold sales; skip.
|
|
259
|
+
|
|
260
|
+
Every reply optionally ends with the `footer` from `config.toml`,
|
|
261
|
+
which is itself a Jinja string (so it can interpolate `{{
|
|
262
|
+
user_name }}` etc.).
|
|
263
|
+
|
|
264
|
+
## How replies are threaded
|
|
265
|
+
|
|
266
|
+
Gmail treats a reply as part of the original thread only when the
|
|
267
|
+
send request is correctly chained. `gmail.py`:
|
|
268
|
+
|
|
269
|
+
1. Pulls the original `Message-ID` and `References` headers.
|
|
270
|
+
2. Builds a new `EmailMessage` with `In-Reply-To: <original-id>` and
|
|
271
|
+
`References: <chain> <original-id>`.
|
|
272
|
+
3. Subject is prefixed with `Re: ` unless it already starts with
|
|
273
|
+
`Re:`.
|
|
274
|
+
4. Base64url-encodes the MIME message and calls
|
|
275
|
+
`users.messages.send` with the encoded body **and** the original
|
|
276
|
+
`threadId`.
|
|
277
|
+
|
|
278
|
+
That combination is what Gmail's UI uses to collapse the reply into
|
|
279
|
+
the same conversation view the original arrived in.
|
|
280
|
+
|
|
281
|
+
## License
|
|
282
|
+
|
|
283
|
+
BSD-3-Clause.
|