Finish the Gmail integration¶
This runbook completes the owner-operated Gmail integration after the Milestone 18 code has reached production. It covers the Google Auth Platform setup, the three-grant bootstrap ceremony, secure production installation, activation, the required real-mailbox smoke, rollback, and milestone-close evidence.
The governing design remains Email Integration and ADR-0071. This runbook does not widen that contract: the three designed Google scopes for each connected account, no attachment download, no permanent deletion, and explicit approval for every write and send. The Milestone 26 Email mode and the Milestone 31 unsubscribe assistance build on the same credentials and are activated in their own sections below.
Finish line¶
Milestone 18 is operationally complete only when all of these are true:
- the Google project is External and In production;
- one Desktop app OAuth client requests exactly the three designed scopes;
- bootstrap has produced separate read, write, and send credential documents;
- the three owner-only documents are installed on the production host and
AGENT_EMAIL_ENABLED=1composes the Gmail tools; - a fresh interactive session proves read, approved draft/write, and exactly one approved send against the owner's real mailbox;
- a scoped daily schedule reads mail, parks its write for approval, sends a content-free approval notification to the phone, and completes after approval; and
- the smoke evidence is recorded without credentials or private mail content.
Verified starting point¶
This was checked on 2026-09-03 and must be rechecked before activation:
- The Gmail implementation reached
mainin GitHub pull request 73 (Glen review). Its final head0adf2d07524f6b4489e8c0cdd70abe56a3d7a1e1passed CodeRabbit, GitGuardian, and the CircleCI static, contract, integration, sandbox, and Apple jobs; all nine review threads are resolved. The merge commit isc46595f4741c482eafaeb2fbc5974818c76cdcf2. - Production release
20260903-043405-b6f003fcontains that merge. The API, documentation, and public website report the same release identity. https://www.veetbot.com/,https://www.veetbot.com/privacy, andhttps://www.veetbot.com/tosreturn HTTP 200.- The schedule API, schedule worker, notification API, notification dispatcher, and APNs provider are enabled. All application and notification units are active.
- Gmail remains disabled and
/etc/veetbot/gmaildoes not exist. - On the current Droplet, the active release owner and application service user
are both
veetbot. Recheck this identity assumption in phase 3.
These commands refresh the public part of that checkpoint:
curl --fail --show-error --dump-header - --output /dev/null \
https://api.veetbot.com/health/ready
curl --fail --show-error https://docs.veetbot.com/release.txt
curl --fail --show-error https://www.veetbot.com/release.txt
curl --fail --show-error --output /dev/null https://www.veetbot.com/
curl --fail --show-error --output /dev/null https://www.veetbot.com/privacy
curl --fail --show-error --output /dev/null https://www.veetbot.com/tos
Safety rules¶
- Never paste an OAuth client JSON, refresh token, access token, API bearer token, or raw credential document into chat, a ticket, Git, shell history, logs, or milestone evidence.
- Keep the downloaded client JSON and all generated credential documents in
owner-only directories. Every JSON file must be a regular, non-symlink file
with mode
0600. - Do not add
https://mail.google.com/, identity scopes, Calendar scopes, or any scope not listed below. - Use a fresh Veetbot session after activation. Existing sessions retain their pinned agent/tool configuration.
- Never repeat a Gmail write or send after
gmail.outcome_unknownor another uncertain post-dispatch result. Reconcile the draft, labels, trash, or recipient mailbox first.
Phase 1 — Finish Google Auth Platform setup¶
Use the Google Cloud project owned by the intended personal Google account. Google's current console groups the settings under Google Auth Platform. The official references are Create access credentials, Manage App Audience, OAuth scope catalog, and App Homepage requirements.
- Select the Veetbot project and enable Gmail API under APIs & Services > Library.
- Open Google Auth Platform > Branding and use:
| Field | Value |
|---|---|
| App name | Veetbot |
| User support email | the owner's monitored email address |
| Application home page | https://www.veetbot.com/ |
| Privacy policy | https://www.veetbot.com/privacy |
| Terms of service | https://www.veetbot.com/tos |
| Authorized domain, if requested | veetbot.com |
| Developer contact | the owner's monitored email address |
Include https://; do not enter a bare www.veetbot.com value. The
homepage must remain public, must not redirect to another domain, and must
retain its visible privacy-policy link.
3. Under Audience, choose External. While the app is in Testing, add
the intended Gmail account as a test user.
4. Under Data Access, add exactly these scopes:
https://www.googleapis.com/auth/gmail.readonly
https://www.googleapis.com/auth/gmail.modify
https://www.googleapis.com/auth/gmail.send
- Under Clients, select Create client, choose Desktop app, name it
Veetbot owner bootstrap, create it, and download the JSON. Do not create a Web application client and do not configure a redirect URI manually; the bootstrap command uses installed-app loopback callbacks on127.0.0.1:8765,:8766, and:8767. - Under Audience, select Publish app so the publishing status is In production before generating the three refresh tokens. Google documents that Testing authorizations expire after seven days; the repository contract deliberately accepts the possible one-time unverified-app warning for this owner-only client rather than operating on that seven-day clock.
- If Google offers a verification workflow, keep the configured scopes and URLs exact. Do not widen scopes to get past a console prompt. An owner-only, unverified production client may show Google's warning and remains subject to Google's user cap. If Google blocks authorization instead of offering the warning, stop and record only the console status/error text; complete Google's verification workflow before continuing.
Checkpoint:
- [ ] Gmail API enabled
- [ ] Branding URLs saved with
https:// - [ ] Audience is External
- [ ] Publishing status is In production
- [ ] Exactly three Gmail scopes configured
- [ ] Desktop app client JSON downloaded privately
Phase 2 — Run the three-grant bootstrap ceremony locally¶
Run this on the owner's Mac from a current repository checkout. The browser must run on the same machine because Google returns to loopback ports. The command refuses to overwrite existing output files.
git switch main
git pull --ff-only
uv sync --all-groups
export VEETBOT_GMAIL_CLIENT="/absolute/path/to/client-secret.json"
export VEETBOT_GMAIL_OUTPUT="$HOME/.config/veetbot/gmail-bootstrap-$(date +%Y%m%d-%H%M%S)"
chmod 0600 "$VEETBOT_GMAIL_CLIENT"
install -d -m 0700 "$VEETBOT_GMAIL_OUTPUT"
lsof -nP -iTCP:8765 -iTCP:8766 -iTCP:8767 -sTCP:LISTEN
uv run python -m gmail_mcp bootstrap \
--client-file "$VEETBOT_GMAIL_CLIENT" \
--output-directory "$VEETBOT_GMAIL_OUTPUT"
lsof should print nothing. During bootstrap, complete three separate Google
consents while signed in to the intended Gmail account:
- read —
gmail.readonly; - write —
gmail.modify; and - send —
gmail.send.
The command may print only each output path and granted scope. It must create:
Validate names, modes, keys, and scopes without printing credential values:
stat -f '%Lp %N' "$VEETBOT_GMAIL_OUTPUT"/*.json
uv run python - "$VEETBOT_GMAIL_OUTPUT" <<'PY'
import json
import pathlib
import sys
root = pathlib.Path(sys.argv[1])
expected = {
"gmail-read.json": "https://www.googleapis.com/auth/gmail.readonly",
"gmail-write.json": "https://www.googleapis.com/auth/gmail.modify",
"gmail-send.json": "https://www.googleapis.com/auth/gmail.send",
}
for name, scope in expected.items():
data = json.loads((root / name).read_text())
assert set(data) == {"client_id", "client_secret", "refresh_token", "scope"}
assert data["scope"] == scope
assert all(data[key] for key in ("client_id", "client_secret", "refresh_token"))
print(f"OK {name}: {scope}")
PY
Checkpoint:
- [ ] Three consents completed under the intended account
- [ ] Three files exist and report mode
600 - [ ] Validation prints three
OKlines and no credential values
Bootstrap named accounts¶
The command above remains the backward-compatible single-account ceremony.
For concurrent accounts, create a separate empty output directory for each
stable routing id and add --account-id. The id is lowercase configuration,
not necessarily the mailbox address:
export VEETBOT_GMAIL_ACCOUNT_ID="personal"
export VEETBOT_GMAIL_OUTPUT="$HOME/.config/veetbot/gmail-$VEETBOT_GMAIL_ACCOUNT_ID-$(date +%Y%m%d-%H%M%S)"
install -d -m 0700 "$VEETBOT_GMAIL_OUTPUT"
uv run python -m gmail_mcp bootstrap \
--client-file "$VEETBOT_GMAIL_CLIENT" \
--output-directory "$VEETBOT_GMAIL_OUTPUT" \
--account-id "$VEETBOT_GMAIL_ACCOUNT_ID"
Repeat with VEETBOT_GMAIL_ACCOUNT_ID=work, selecting the matching Google
account for all three browser consents. Tagged credential documents are not
accepted under a different id, but the label cannot independently prove which
Google identity was selected; the work-only draft smoke below is the identity
check. Do not edit an existing credential JSON to add the label.
Phase 3 — Install and activate production credentials¶
The current Droplet uses veetbot for both release ownership and application
services. Verify rather than assuming that remains true:
ssh root@api.veetbot.com '
set -eu
printf "release owner: "
stat -c %U /opt/veetbot/shared/deploy.lock
printf "api user: "
systemctl show --property User --value veetbot-api
printf "worker user: "
systemctl show --property User --value veetbot-worker
'
All three values must be veetbot. If the release owner and service user
differ, stop: an owner-only 0600 source cannot be read by both identities.
Add a reviewed systemd LoadCredential handoff and a deploy-readable source
before activation; do not loosen the files to 0640 or 0644.
Create a root-only upload directory, transfer the three files, and install them for the service identity:
ssh root@api.veetbot.com \
'install -d -o root -g root -m 0700 /root/veetbot-gmail-upload'
scp "$VEETBOT_GMAIL_OUTPUT"/gmail-{read,write,send}.json \
root@api.veetbot.com:/root/veetbot-gmail-upload/
ssh root@api.veetbot.com '
set -eu
install -d -o veetbot -g veetbot -m 0700 /etc/veetbot/gmail
for mode in read write send; do
install -o veetbot -g veetbot -m 0600 \
"/root/veetbot-gmail-upload/gmail-$mode.json" \
"/etc/veetbot/gmail/gmail-$mode.json"
done
stat -c "%U:%G %a %n" /etc/veetbot/gmail /etc/veetbot/gmail/*.json
rm -f /root/veetbot-gmail-upload/gmail-read.json \
/root/veetbot-gmail-upload/gmail-write.json \
/root/veetbot-gmail-upload/gmail-send.json
rmdir /root/veetbot-gmail-upload
'
The directory must report veetbot:veetbot 700; each file must report
veetbot:veetbot 600.
Back up /etc/veetbot/veetbot.env, then edit it in one save. Store paths only,
never JSON or token material:
AGENT_EMAIL_ENABLED=1
GMAIL_READ_CREDENTIAL_FILE=/etc/veetbot/gmail/gmail-read.json
GMAIL_WRITE_CREDENTIAL_FILE=/etc/veetbot/gmail/gmail-write.json
GMAIL_SEND_CREDENTIAL_FILE=/etc/veetbot/gmail/gmail-send.json
The flag and all three paths are atomic configuration: the application rejects partial enablement, and it also rejects configured Gmail paths while the flag is off.
For concurrent named accounts, install each tagged triplet in its own owner-only directory and use a non-secret manifest instead of the three legacy variables:
{
"version": 1,
"default_account": "personal",
"accounts": [
{
"account_id": "personal",
"read_credential_file": "/etc/veetbot/gmail/personal/gmail-read.json",
"write_credential_file": "/etc/veetbot/gmail/personal/gmail-write.json",
"send_credential_file": "/etc/veetbot/gmail/personal/gmail-send.json"
},
{
"account_id": "work",
"read_credential_file": "/etc/veetbot/gmail/work/gmail-read.json",
"write_credential_file": "/etc/veetbot/gmail/work/gmail-write.json",
"send_credential_file": "/etc/veetbot/gmail/work/gmail-send.json"
}
]
}
Install the manifest as /etc/veetbot/gmail/accounts.json, owned by veetbot
and not group- or world-writable. Then configure paths only:
AGENT_EMAIL_ENABLED=1
GMAIL_ACCOUNTS_FILE=/etc/veetbot/gmail/accounts.json
GMAIL_READ_CREDENTIAL_FILE=
GMAIL_WRITE_CREDENTIAL_FILE=
GMAIL_SEND_CREDENTIAL_FILE=
The default account retains mcp.gmail_read.*, mcp.gmail_write.*, and
mcp.gmail_send.*. The work account is advertised as
mcp.gmail_work_read.*, mcp.gmail_work_write.*, and
mcp.gmail_work_send.*. The two configuration forms are mutually exclusive.
Mirror the non-secret activation metadata into the scheduler's separate environment so firing-time authorization sees the same accounts:
For legacy single-account configuration, set AGENT_EMAIL_ENABLED=1 there and
leave GMAIL_ACCOUNTS_FILE empty. Never copy any
GMAIL_*_CREDENTIAL_FILE variable into the schedule environment. The lean
scheduler reads the manifest's account ids and derives their server-use scopes;
it does not open an OAuth credential document.
Run the production preflight as the same identity that owns the credentials, then restart the units that compose application tools and current schedule authority:
ssh root@api.veetbot.com '
set -eu
sudo -u veetbot bash --noprofile --norc -c '\''
set -a
. /etc/veetbot/veetbot.env
. /opt/veetbot/current/.release.env
set +a
cd /opt/veetbot/current
.venv/bin/python scripts/check_production_deployment.py
'\''
systemctl restart \
veetbot-maintenance veetbot-worker veetbot-async-worker veetbot-api \
veetbot-schedule
systemctl is-active \
veetbot-maintenance veetbot-worker veetbot-async-worker veetbot-api \
veetbot-schedule veetbot-notify
'
curl --fail --show-error --dump-header - --output /dev/null \
https://api.veetbot.com/health/ready
Do not continue unless preflight reports OK, every listed unit reports
active, and readiness returns the expected release header.
Phase 4 — Real-mailbox interactive smoke¶
Send a harmless message from a second mailbox to the connected Gmail account
with a unique subject such as VEETBOT-GMAIL-SMOKE-20260903-A. Do not use real
customer mail or confidential content. Open a new Veetbot conversation
after the service restart.
Run these checks in order:
For each non-default named account, first create an unsent draft manually in
that mailbox with a unique subject and ask the matching read tool to search
in:drafts subject:"<unique subject>" while reporting only the match count.
For work, require mcp.gmail_work_read.search_threads. A match through the
default mcp.gmail_read.* tools does not prove the work credential is active.
- Read without approval. Ask: “Use the Gmail tools to list my labels and
find the message with subject
VEETBOT-GMAIL-SMOKE-20260903-A.” Confirm the correct message is found and no approval is requested. - Write with approval. Ask: “Create a draft reply to that smoke-test
message with body
Veetbot Gmail draft smoke passed.Do not send it.” Confirm the run parks, the phone receives an approval notification that contains no recipient, subject, or body, approve once, and verify the draft exists in Gmail but was not sent. - Send with approval. Ask: “Send exactly one plain-text email to the test
recipient with subject
VEETBOT-GMAIL-SEND-20260903-Aand bodyVeetbot Gmail send smoke passed.” Inspect the complete proposal, approve once on the phone, and verify exactly one message arrives.
If the write or send returns an uncertain outcome, do not repeat it. Inspect Gmail drafts, thread labels/trash, Sent, and the recipient mailbox, then record the reconciled outcome.
Checkpoint:
- [ ] Read completed without approval
- [ ] Draft/write parked and completed only after phone approval
- [ ] Phone notification contained no mail content
- [ ] Exactly one approved message was received
Google OAuth verification replacement video¶
Google's demo-video guidance
requires a reviewer-facing end-to-end proof, not only a product tour. Record a
replacement whenever Google reports that the consent flow or Gmail
functionality is missing. The recording must use the same Veetbot OAuth
application submitted for verification, show Google in English, and make every
requested scope and its corresponding Veetbot action legible.
Use a dedicated non-production demo mailbox and harmless demo recipient whose addresses can remain visible. Do not revoke the production mailbox's grant just to make the consent screen reappear: that invalidates its installed refresh tokens. Instead, run the bootstrap ceremony with the submitted OAuth client and a demo Google account that has not granted Veetbot access. If no such account is available, stop and plan a controlled credential replacement before revoking an installed grant.
Record the following scenes in order. Prefer one continuous recording; if waits are removed, use visible cut markers and never cut between an action and its result.
| Scene | Evidence that must remain visible | Suggested narration |
|---|---|---|
| Identity and purpose | https://www.veetbot.com/, the Veetbot name, and the Gmail capability description |
“Veetbot is an AI agent that reads Gmail at the user's request and requires explicit approval before it changes a mailbox or sends mail.” |
| Start the grant | A terminal running python -m gmail_mcp bootstrap, followed by Veetbot's data-use disclosure and the operator typing CONTINUE; keep the client file and output location obscured |
“Before Google opens, Veetbot names the exact scope, data accessed, AI-provider transfer, retention and deletion behavior, and requires a separate affirmative action.” |
| Read consent | The complete Google consent flow for gmail.readonly, including Veetbot, account selection, every permission panel expanded, and the English language selector |
“This separate least-privilege grant lets Veetbot search messages, retrieve message bodies, and list labels.” |
| Modify consent | The complete Google consent flow for gmail.modify, with the same identity and all permission text expanded |
“Google's gmail.modify grant permits reading, composing, and sending messages. Veetbot restricts this separate write integration to drafts and reversible mailbox changes after approval; sending uses the separate gmail.send integration and approval path.” |
| Send consent | The complete Google consent flow for gmail.send, with the same identity and all permission text expanded |
“This separate grant lets Veetbot send the exact message the user approves.” |
| Grant result | The terminal's three success lines naming gmail.readonly, gmail.modify, and gmail.send; do not show credential contents |
“Bootstrap stored three separate owner-only credentials. Each credential contains exactly one of the requested scopes.” |
| Read functionality | A new Veetbot conversation asks to find and summarize VEETBOT-OAUTH-DEMO-<date>; tool activity identifies mcp.gmail_read.*, the matching message is returned, and no approval appears |
“Veetbot is now using gmail.readonly; this network read does not change the mailbox.” |
| Modify functionality | Veetbot is asked to create a draft reply; the mcp.gmail_write.create_draft approval shows the full demo recipient, subject, and body; one approval is given; Gmail then shows the draft and shows that it was not sent |
“Veetbot is now using gmail.modify. The operation cannot run until I approve the complete proposed draft.” |
| Send functionality | Veetbot is asked to send one message; the mcp.gmail_send.send_message approval shows the full demo recipient, subject, and body; one approval is given; Gmail Sent and the recipient inbox show exactly one message |
“Veetbot is now using gmail.send. It sends only the exact content I approve.” |
| Scope recap | A final card lists the three exact scope URIs beside the demonstrated read, draft, and send actions | “These are the only Google scopes requested, and the recording has shown the consent and app functionality for each one.” |
Do not cover the Veetbot identity, Google permission text, tool name, approval
arguments, or resulting Gmail state with a crop or blur. Blur only unrelated
personal data. The demo mailbox and recipient should contain no private mail so
their test addresses and the complete harmless message can remain visible.
Show the submitted OAuth client id only while the Google-hosted consent page is
visible. An OAuth client id is public application identity, not a credential.
Before the browser redirects to the local loopback callback, mask the browser
frame and keep it masked until the callback URL is gone. Never reveal the OAuth
client secret, authorization code, refresh token, access token, credential
JSON, browser developer tools, or shell history.
Add voice narration or large on-screen captions with the exact scope URI at the start of each consent and functionality pair. Do not accelerate the consent screens so much that a reviewer cannot read them. Before submitting, watch the uploaded video from beginning to end in a signed-out private browser window and confirm that it is available without an access request, is set to Unlisted, preferably uses at least 1080p, and has legible consent and approval text.
Reply directly to the open Google OAuth Verification email with the replacement link and real timestamps; do not open a second verification request:
Hello Google OAuth Verification Team,
We replaced the Veetbot demo video in response to your functionality findings:
<unlisted YouTube URL>
The video now shows the complete OAuth consent flow and the app functionality
for every requested scope:
- <timestamp>: complete gmail.readonly consent
- <timestamp>: gmail.readonly search and message summary
- <timestamp>: complete gmail.modify consent
- <timestamp>: approval-gated draft creation using gmail.modify
- <timestamp>: complete gmail.send consent
- <timestamp>: approval-gated send using gmail.send and one-message receipt
The consent UI is in English, uses the same Veetbot OAuth application submitted
for verification, and all requested permissions are expanded and readable.
Thank you.
Checkpoint:
- [ ] Same submitted
VeetbotOAuth application shown - [ ] Complete English consent flow shown for all three separate grants
- [ ] Every requested permission is expanded and readable
- [ ] Read, approved draft, and approved send are each tied to their scope
- [ ] Gmail confirms the draft state and exactly one delivered message
- [ ] No credential or private mailbox data appears
- [ ] Unlisted upload plays while signed out and timestamps are accurate
- [ ] Replacement link sent by replying to Google's open verification email
Phase 5 — Scheduled triage smoke¶
Do not create this schedule through the conversational schedule.create tool.
That tool intentionally fixes requested_scopes to an empty set, so its runs
cannot use Gmail. Create the smoke schedule through POST /v1/schedules with
the exact read and write scopes.
First create a fresh session through the API and copy its returned agent_id
and agent_version. Protect the bearer token from process arguments by passing
the authorization header through a private file or standard input, as the
release script does. Then submit this definition with a unique
Idempotency-Key; choose a local_time three to five minutes in the future:
{
"title": "Gmail triage smoke",
"instruction": "Search Gmail for the message whose subject is VEETBOT-GMAIL-SMOKE-20260903-A. Summarize it, then create one draft reply with body 'Scheduled Gmail triage smoke passed.' Never send the draft.",
"agent_id": "<agent_id from the fresh session>",
"agent_version": "<agent_version from the fresh session>",
"policy_profile": "default",
"requested_scopes": [
"mcp.gmail_read.use",
"mcp.gmail_write.use"
],
"limits": {
"max_steps": 8,
"max_model_calls": 8,
"max_tool_calls": 8,
"max_cost": "1"
},
"run_timeout_seconds": 300,
"cadence": {
"kind": "DAILY",
"local_time": "<HH:MM:SS>",
"timezone": "America/Los_Angeles"
},
"misfire_grace_seconds": 60,
"max_consecutive_failures": 3
}
The following production-host sequence keeps the bearer token out of command arguments. Set the smoke subject and triage time before running it:
ssh root@api.veetbot.com
set -eu
umask 077
set -a
. /etc/veetbot/veetbot.env
set +a
export VEETBOT_SMOKE_SUBJECT="VEETBOT-GMAIL-SMOKE-20260903-A"
export VEETBOT_TRIAGE_TIME="<HH:MM:SS three to five minutes ahead>"
export VEETBOT_API_BASE="http://127.0.0.1:8000"
export VEETBOT_PYTHON="/opt/veetbot/current/.venv/bin/python"
VEETBOT_AUTH_HEADER="$(mktemp)"
VEETBOT_SESSION_RESPONSE="$(mktemp)"
VEETBOT_SCHEDULE_REQUEST="$(mktemp)"
VEETBOT_SCHEDULE_RESPONSE="$(mktemp)"
trap 'rm -f -- "$VEETBOT_AUTH_HEADER" "$VEETBOT_SESSION_RESPONSE" \
"$VEETBOT_SCHEDULE_REQUEST" "$VEETBOT_SCHEDULE_RESPONSE"' EXIT
printf 'Authorization: Bearer %s\n' "$AUTH_TOKEN" >"$VEETBOT_AUTH_HEADER"
curl --fail-with-body --silent --show-error \
--header @"$VEETBOT_AUTH_HEADER" \
--header 'Content-Type: application/json' \
--data '{"agent_id":"general","metadata":{"purpose":"gmail-triage-smoke"}}' \
--output "$VEETBOT_SESSION_RESPONSE" \
"$VEETBOT_API_BASE/v1/sessions"
"$VEETBOT_PYTHON" - \
"$VEETBOT_SESSION_RESPONSE" \
"$VEETBOT_SCHEDULE_REQUEST" \
"$VEETBOT_TRIAGE_TIME" \
"$VEETBOT_SMOKE_SUBJECT" <<'PY'
import json
import pathlib
import sys
session_path, request_path, local_time, subject = sys.argv[1:]
session = json.loads(pathlib.Path(session_path).read_text())
request = {
"title": "Gmail triage smoke",
"instruction": (
f"Search Gmail for the message whose subject is {subject}. Summarize it, "
"then create one draft reply with body 'Scheduled Gmail triage smoke "
"passed.' Never send the draft."
),
"agent_id": session["agent_id"],
"agent_version": session["agent_version"],
"policy_profile": "default",
"requested_scopes": ["mcp.gmail_read.use", "mcp.gmail_write.use"],
"limits": {
"max_steps": 8,
"max_model_calls": 8,
"max_tool_calls": 8,
"max_cost": "1",
},
"run_timeout_seconds": 300,
"cadence": {
"kind": "DAILY",
"local_time": local_time,
"timezone": "America/Los_Angeles",
},
"misfire_grace_seconds": 60,
"max_consecutive_failures": 3,
}
pathlib.Path(request_path).write_text(json.dumps(request))
PY
curl --fail-with-body --silent --show-error \
--header @"$VEETBOT_AUTH_HEADER" \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: gmail-triage-smoke-20260903-a' \
--data-binary @"$VEETBOT_SCHEDULE_REQUEST" \
--output "$VEETBOT_SCHEDULE_RESPONSE" \
"$VEETBOT_API_BASE/v1/schedules"
VEETBOT_SCHEDULE_ID="$(
"$VEETBOT_PYTHON" -c \
'import json,sys; print(json.load(open(sys.argv[1]))["schedule"]["id"])' \
"$VEETBOT_SCHEDULE_RESPONSE"
)"
printf 'schedule_id=%s\n' "$VEETBOT_SCHEDULE_ID"
Observe the occurrence through
GET /v1/schedules/{schedule_id}/occurrences. It must materialize a run, read
the message without approval, park before create_draft, send a content-free
approval notification to the phone, and complete after one approval. Verify
the draft in Gmail and the schedule-outcome notification. Then cancel the
recurring smoke schedule with
DELETE /v1/schedules/{schedule_id}?expected_revision=1 so it does not run the
next day.
From the same protected shell, inspect and cancel with:
curl --fail-with-body --silent --show-error \
--header @"$VEETBOT_AUTH_HEADER" \
"$VEETBOT_API_BASE/v1/schedules/$VEETBOT_SCHEDULE_ID/occurrences" \
| "$VEETBOT_PYTHON" -m json.tool
curl --fail-with-body --silent --show-error --request DELETE \
--header @"$VEETBOT_AUTH_HEADER" \
"$VEETBOT_API_BASE/v1/schedules/$VEETBOT_SCHEDULE_ID?expected_revision=1" \
| "$VEETBOT_PYTHON" -m json.tool
exit
Checkpoint:
- [ ] Daily schedule created with only read and write Gmail scopes
- [ ] Occurrence materialized and linked to a run
- [ ] Read ran without approval
- [ ] Draft waited for approval and notification was content-free
- [ ] Run completed after approval and outcome notification arrived
- [ ] Smoke schedule cancelled after the successful occurrence
Phase 6 — Record evidence and close Milestone 18¶
Record only non-secret evidence:
- date and operator;
- Google project ID, External audience, and In production status;
- OAuth client type and name, never its ID secret or downloaded JSON;
- the three granted scope names;
- production credential paths, owner, and modes, never hashes or contents;
- production release identity and active unit names;
- Veetbot session, run, approval, schedule, and occurrence IDs;
- the harmless smoke subject tokens and pass/fail outcomes; and
- confirmation that the approval notification exposed no mail content and the recipient received exactly one message.
Update docs/status/project-state.yaml and docs/status/milestones.md only
after every owner-smoke checkpoint passes. Mark Milestone 18 complete, set its
completion date, and move the completed verification record to
docs/status/verification-history.yaml as required by repository governance.
Run:
make citations-fix
make docs-check
make check
uv run python -m scripts.check_reading_lane --base origin/dev
The closing commit is a full-contract change and must carry a
Reading-Lane: A trailer. Deliver it through the normal dev-to-main review
path; do not treat credential installation or a local smoke alone as milestone
completion.
Milestone 26 — Activate Email mode¶
Email mode is the app's Email presentation: the priority inbox, reading pane, drafts, feedback and learning of the email experience. It uses the accounts installed above and needs no new Google permission.
- Confirm the Phase 4 smoke passed for every connected account.
- In
/etc/veetbot/veetbot.env, setAGENT_EMAIL_MODE_ENABLED=1and appendemail.read,email.writetoAUTH_SCOPES. The flag grants no authority; the scopes and the existing account authority do. - Automatic email work spends at most USD 20 per UTC day and USD 200 per
rolling 30 days. A reviewed
runtime/limits.yamloverlay underAGENT_CONFIG_DIRcan set other finiteemail.daily_costandemail.monthly_costallowances. - Restart
veetbot-api,veetbot-worker,veetbot-async-worker, andveetbot-maintenance, confirm each is active, then open Email in the app and confirm each account reports a completed update.
Set the flag back to 0 and restart the same services to turn it off.
Milestone 31 — Activate and smoke unsubscribe assistance¶
Unsubscribe assistance is off until AGENT_EMAIL_UNSUBSCRIBE_ENABLED=1. It
needs both existing email flags, no new Google permission, and no credential.
It sends a request to a sender only on the owner's tap or approval. The
contract is Email unsubscribe assistance; the
egress decision is ADR-0112 decision 5.
Activate¶
- Confirm Email mode already works for both accounts and the installed client build includes the Subscriptions view.
- Append the flag with
tee -aso/etc/veetbot/veetbot.envkeeps its0640 root:veetbotmode:
- Restart
veetbot-api,veetbot-worker,veetbot-async-worker, andveetbot-maintenance, and confirm each is active. The API mounts the routes, the async worker builds the census during refresh, and the interactive worker owns the process-local public-HTTPS transport that sends the request. - Probe the mount without credentials from the host.
401means the routes exist;404means the flag did not take effect:
Smoke (owner-authorized; each step is a real, irreversible-or-visible act)¶
Choose low-stakes senders. Record outcome codes and counts only — never a sender name, an address, or an unsubscribe address.
- Open Email mode and leave it open for a few refreshes. The Subscriptions list fills from headers; rows read Checking… until their evidence is verified. Confirm both accounts appear under their badges.
- One-click. Pick one newsletter showing One-click request to …, confirm the sheet, and expect Unsubscribed. An unsubscribe cannot be undone. The interactive worker's journal shows one audit line with the destination host, port 443 and an allowed decision, and no path or query.
- mailto. Pick one sender showing Sends an email to …. Afterwards, find that exact message in Gmail's Sent mail with the header's recipient, subject and body and nothing else.
- Report spam, then Not spam. Report one junk sender; confirm its threads carry Gmail's Spam label and left the Inbox. Use Not spam and confirm exactly those threads returned.
- Cleanup. Unsubscribe from one sender with Also archive existing mail on; confirm its Inbox threads moved to All Mail.
- Chat. Ask Veetbot to list subscriptions, then to unsubscribe from one named sender. The run must wait for approval; the approval names the sender and destination host and never a path. Approve it once.
- Over the following two weeks, a sender that keeps mailing after ten days shows Still sending and offers Report spam. Nothing is sent on its own.
A failed row with unsubscribe.redirect_refused means that sender answered
with a redirect, which is never followed. Note the count: the design's
open question on redirects is decided from this evidence.
Deactivate¶
Set AGENT_EMAIL_UNSUBSCRIBE_ENABLED=0 and restart the same four services.
The routes, both Chat tools, the transport and the census stop existing.
Stored decisions remain, so re-enabling never re-suggests a kept or
unsubscribed sender.
Rollback and recovery¶
If activation fails before any successful write or send:
- Set
AGENT_EMAIL_ENABLED=0, blank all threeGMAIL_*_CREDENTIAL_FILEvalues, and blankGMAIL_ACCOUNTS_FILEin the same edit. - Restart
veetbot-maintenance,veetbot-worker,veetbot-async-worker, andveetbot-api. - Confirm readiness and a fresh non-email conversation.
- Keep the
0600credential documents in place while diagnosing; removing a file before disabling the feature makes configuration fail closed.
If a refresh token is revoked or Google rejects it, disable the integration, revoke Veetbot under the Google Account's third-party connections, run the bootstrap ceremony into a new empty directory, replace all three production files together, rerun preflight, and reactivate. If the OAuth client itself is compromised, delete or rotate that client in Google Cloud before bootstrapping new grants.
Common failures:
| Failure | Action |
|---|---|
OAuth client file must be owner-only |
Use an absolute, non-symlink regular file and chmod 0600 it. |
credential output already exists |
Use a new empty owner-only output directory; do not overwrite a partial grant set. |
authorization did not complete |
Free loopback ports 8765–8767, use a browser on the same Mac, and finish within five minutes. |
token exchange omitted the exact grant |
Recheck the three exact scopes, revoke the partial Veetbot grant, and rerun all three consents into a new directory. |
gmail.credential_rejected |
Check publishing status and revocation, then disable and re-bootstrap; do not edit a credential JSON. |
| configured account id rejected | Confirm the bootstrap --account-id matches the manifest account_id; re-bootstrap rather than editing the credential document. |
gmail.outcome_unknown |
Never retry the write/send. Reconcile directly in Gmail and the recipient mailbox. |
| schedule authorization failure | Confirm the schedule was created over HTTP with the exact mcp.gmail_read.use and mcp.gmail_write.use scopes. |
Email junk first pass (ADR-0203)¶
The first pass is off by default. With the existing TypeSafe selector/key set,
AGENT_EMAIL_JUNK_FILTER_MODE=shadow screens newly eligible mail while preserving
normal assessment. It does not reprocess already-current assessments. Every
request and its charge appear as content-free email.junk.started and
email.junk.completed events. Unknown usage retains conservative email charging.
For a bounded diagnostic outside normal refresh, use
uv run python -m scripts.email_junk_calibration replay --input PRIVATE_SNAPSHOT --output PRIVATE_RESULTS --max-threads 100 --max-cost-usd 0.05.
The input is an object with items, each containing an EmailThread-shaped
thread and its current protection/feedback learning context. Obtain them
under the owner's read authority; do not use stale or incomplete protection
context. Keep both files outside the repository. The command requires the
existing TYPESAFE_API_KEY, never reads Gmail or a database, and writes private,
exclusive-create results plus an attempt journal before dispatch. A fresh
invocation is a separately budgeted experiment; preserve every prior journal.
Baseline-model agreement is diagnostic, not independently measured precision.
Independently review the sampled source mail. Build a private calibration packet
with policy, model and observations. Each observation contains its unique
fingerprint, observed category (spam, bulk, keep), probability, the
owner's label (junk, keep), source: owner_review, and stratum (random,
personal_outreach, transactional). Do not include subjects, bodies or sender
addresses in this activation packet. Replay leaves labels empty deliberately.
uv run python -m scripts.email_junk_calibration check PRIVATE_PACKET recomputes
readiness; fabricated tests and model labels are not production evidence.
A bounded filter pilot requires at least 100 unique reviewed cases, at least 80
random cases, ten legitimate personal-outreach and ten transactional cases,
at least twenty predicted junk cases, and zero false positives at the fixed
0.99 combined spam-plus-bulk threshold. Protected-topic relevance must be at
most 0.05. These vendor scores are not measured error rates. This pilot floor
is not a guarantee of zero errors. Set
AGENT_EMAIL_JUNK_FILTER_EVIDENCE to the reviewed packet and select filter
only after checking it. Each run rechecks the packet; missing, invalid or
mismatched evidence uses shadow mode. The classifier pins jev-1.13.0.
Filtered mail remains in Other/All mail with an explanation. Important restores
priority immediately and sends it through normal assessment on the next refresh.
Known correspondents, kept subscriptions, Important/Needs reply, Not spam,
unknown attachments, incomplete sources and uncertain decisions are protected.
Bulk is not marked as spam. No Gmail labels change. Setting the mode to off
stops new judgments and restores ordinary assessment of previously bypassed
threads on subsequent bounded refreshes. No budget or milestone gate changes.
Approved work-account pilot (ADR-0204)¶
pilot uses a separate packet, never the general filter calibration shape. Bind
policy to JUNK_PILOT_POLICY, model to JUNK_MODEL, tenant_id and
principal_id to the approved owner, account_id to work, sample to
selected_live_inbox, and aware issued_at/expires_at no more than seven days
apart. Each observation contains only fingerprint, the observed raw
category, independent owner label, and source: owner_review. Require at
least 43 unique classifications, twenty junk nominations, seven legitimate keep
cases and no false-positive nominations. Protected skips are not classifications
and must not be invented as keep labels. Validate with JunkPilot.model_validate_json
and admitted(principal, "work", now) from the deployed source. Keep the real
packet outside the repository, readable only by the operator and service identity.
The October 14 selected 50-conversation sample contained 43 classifications, 30 correct junk nominations, six missed junk items and seven correct keeps; seven more conversations were protected before egress. It is not random or population-level accuracy evidence. The approved exception uses valid raw spam/bulk choices and retains keep/uncertain outcomes, all pre-egress protections, and the unchanged questions/model. It does not relax the general filter.
After reviewed deployment, set AGENT_EMAIL_JUNK_FILTER_MODE=pilot and point
AGENT_EMAIL_JUNK_FILTER_EVIDENCE at the validated private packet. Restart the
API, interactive worker, async worker and maintenance roles using the normal
configuration procedure. Verify their actual process configuration and release,
then a bounded foreground refresh: content-free judgment receipts/costs, the
junk_pilot_day counter and junk_pilot_admission records, ordinary assessment
for protected/keep mail, and unchanged Gmail labels. At most fifty new
conversations per UTC day may bypass; refresh/recovery of already admitted
conversations consumes no additional slot. Changing the packet cannot reset
that day's counter. Losing the final slot while Jev runs uses normal assessment.
Expiry and invalid evidence stop new pilot calls. The existing cached-mail
rotation restores full assessments on subsequent refreshes. Set mode off and
restart the same roles for immediate operator revocation. Do not renew or widen
the pilot without new owner-reviewed evidence and authorization. This pilot
neither completes Milestone 26 nor establishes general-rollout readiness.