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.
@@ -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.