Skip to content

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=1 composes 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 main in GitHub pull request 73 (Glen review). Its final head 0adf2d07524f6b4489e8c0cdd70abe56a3d7a1e1 passed CodeRabbit, GitGuardian, and the CircleCI static, contract, integration, sandbox, and Apple jobs; all nine review threads are resolved. The merge commit is c46595f4741c482eafaeb2fbc5974818c76cdcf2.
  • Production release 20260903-043405-b6f003f contains that merge. The API, documentation, and public website report the same release identity.
  • https://www.veetbot.com/, https://www.veetbot.com/privacy, and https://www.veetbot.com/tos return 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/gmail does 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_unknown or 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.

  1. Select the Veetbot project and enable Gmail API under APIs & Services > Library.
  2. 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
  1. 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 on 127.0.0.1:8765, :8766, and :8767.
  2. 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.
  3. 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:

  1. read — gmail.readonly;
  2. write — gmail.modify; and
  3. send — gmail.send.

The command may print only each output path and granted scope. It must create:

gmail-read.json
gmail-write.json
gmail-send.json

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 OK lines 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:

AGENT_EMAIL_ENABLED=1
GMAIL_ACCOUNTS_FILE=/etc/veetbot/gmail/accounts.json

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.

  1. 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.
  2. 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.
  3. Send with approval. Ask: “Send exactly one plain-text email to the test recipient with subject VEETBOT-GMAIL-SEND-20260903-A and body Veetbot 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 Veetbot OAuth 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.

  1. Confirm the Phase 4 smoke passed for every connected account.
  2. In /etc/veetbot/veetbot.env, set AGENT_EMAIL_MODE_ENABLED=1 and append email.read,email.write to AUTH_SCOPES. The flag grants no authority; the scopes and the existing account authority do.
  3. Automatic email work spends at most USD 20 per UTC day and USD 200 per rolling 30 days. A reviewed runtime/limits.yaml overlay under AGENT_CONFIG_DIR can set other finite email.daily_cost and email.monthly_cost allowances.
  4. Restart veetbot-api, veetbot-worker, veetbot-async-worker, and veetbot-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

  1. Confirm Email mode already works for both accounts and the installed client build includes the Subscriptions view.
  2. Append the flag with tee -a so /etc/veetbot/veetbot.env keeps its 0640 root:veetbot mode:
echo 'AGENT_EMAIL_UNSUBSCRIBE_ENABLED=1' | sudo tee -a /etc/veetbot/veetbot.env >/dev/null
  1. Restart veetbot-api, veetbot-worker, veetbot-async-worker, and veetbot-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.
  2. Probe the mount without credentials from the host. 401 means the routes exist; 404 means the flag did not take effect:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/v1/email/subscriptions

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Cleanup. Unsubscribe from one sender with Also archive existing mail on; confirm its Inbox threads moved to All Mail.
  6. 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.
  7. 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:

  1. Set AGENT_EMAIL_ENABLED=0, blank all three GMAIL_*_CREDENTIAL_FILE values, and blank GMAIL_ACCOUNTS_FILE in the same edit.
  2. Restart veetbot-maintenance, veetbot-worker, veetbot-async-worker, and veetbot-api.
  3. Confirm readiness and a fresh non-email conversation.
  4. Keep the 0600 credential 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.