A scheduled check — it runs a few times a day — that watches Gmail for the real Rivian R2 order invite, pushes a phone notification when it arrives, and then disarms itself so it stops consuming tokens once the job is done.
It is built for two hard parts of this specific problem:
- False positives. Rivian sends frequent marketing blasts with invite-ish subject lines ("Important update on R2 orders", "R2 arrives June 9"). A keyword match cries wolf. So candidates are run through an LLM classifier that judges intent — a personalized, actionable call to place/configure your order vs. a newsletter.
- Unknown sender. Marketing comes from
hello@em.rivian.com, but the transactional invite may come from a different domain/ESP. So the sender is never used as a hard filter — it's explicitly untrusted as a signal.
Just want it running? Skip straight to What you'll need and Setup. This next section is the how-and-why for the curious.
The monitor is split into a thinking half and a plumbing half:
| Part | Who runs it | What it does |
|---|---|---|
RUN.md |
the scheduled Claude session | pulls candidates from Gmail (MCP), classifies each as ACTIONABLE_INVITE / TIMELINE_UPDATE / MARKETING/NOISE, writes results.json |
r2_monitor.py |
plain Python (no deps) | de-dup, ntfy notifications, the DONE sentinel, the backstop, --reset, --dry-run |
Keeping the irreversible/stateful work (notifying, disarming) in plain, tested code — and letting the LLM do only the judgement call — is deliberate.
- Sentinel gate.
r2_monitor.py guardruns first. If aDONEsentinel exists, the session stops immediately (near-zero work / near-zero tokens). - Two-pass Gmail search (full message bodies,
FULL_CONTENT):- Primary:
from:rivian.com newer_than:2d— Gmail's domain match catches any Rivian subdomain, so we don't depend on one address. - Secondary:
"R2" newer_than:2d— catches an invite from a non-obvious / third-party vendor domain. Pulls in noise (e.g. TLDR/Wired); the classifier handles it. Candidates are de-duped across both passes by message ID.
- Primary:
- Classification. Each candidate → strict JSON with
classification,confidence,reason,sender,subject,received(+message_id). - Decide & notify (
r2_monitor.py process):- High-confidence hit (
ACTIONABLE_INVITEand confidence ≥ 0.7) → HIGH priority ntfy with subject/sender/time/reason and a direct Gmail link, then the monitor disarms (writesDONE). - Maybe (
ACTIONABLE_INVITEand 0.4–0.7) → LOW priority ntfy flagged "POSSIBLE R2 invite — check manually." Keeps you in the loop without false alarms. Does not disarm — monitoring continues. - News (
TIMELINE_UPDATE) → DEFAULT priority ntfy flagged "R2 timeline update — FYI." For substantive non-actionable updates on when/whether you can order — a concrete order window/date (e.g. "you'll be invited in September–October 2026"), an acceleration, or an eligibility change. A heads-up, not the invite, so it does not disarm. Generic hype with no timeline content stays silent. - No hit → silent.
- Two channels. Alerts go to ntfy and Slack. The script owns ntfy and
records each run's new alerts to
state/last_run.json; the scheduled session mirrors them to Slack via the Slack MCP (Slack needs no egress allowlist). Slack is optional — unsetSLACK_USER_IDto use ntfy only.
- High-confidence hit (
- De-duplication. Notified message IDs are persisted in
state/state.json, tracking high-confidence and maybe notifications separately so a maybe can be upgraded to a real hit if a later run reclassifies it ≥ 0.7. - Self-termination. On a confirmed, successfully-sent high-confidence hit,
DONEis written and the success notification confirms it disarmed: "✅ R2 invite detected — scheduled check disabled. Run --reset to re-arm." - Hard backstop. If the date passes 2027-07-18 with no hit, one final
LOW "window elapsed — disabling check" notice is sent and
DONEis written — so a silently-missed invite can never leave the job running for months.
No servers, no programming. If you can sign into a website and edit a few lines of text, you can run this.
- A Claude account with Claude Code on the web (claude.ai/code). The monitor runs there in the cloud as a scheduled "routine," so your own computer doesn't need to be on.
- A phone for push notifications (the free ntfy app), and/or Slack if you'd prefer a direct message.
- About 15 minutes for a one-time setup.
New to any of this? Plain-language definitions:
- Claude Code — Anthropic's AI assistant for technical tasks. The web version can run scheduled jobs in the cloud for you.
- Routine — a scheduled task in Claude Code (think "alarm clock for a prompt") that runs automatically on a timer.
- MCP connector — a secure, permission-based link that lets Claude read a service like your Gmail. You connect it once with a normal Google sign-in. Here it is read-only — the monitor never sends or deletes mail.
- ntfy (ntfy.sh) — a free push-notification app with no account required. You choose a secret "topic" name; anything sent to that topic pops up on your phone.
Do these once, in order. Steps 1–1d are one-time setup; step 2 creates the scheduled routine; then test it before going live.
- Pick an ntfy topic + install the app — where alerts go
- Allow the cloud to reach ntfy.sh — so sends aren't blocked
- (Optional) Add Slack — a second alert channel
- Connect your Gmail to Claude — the inbox it watches
- Create the scheduled routine — put it on a timer
- Test it — confirm before relying on it
Notifications use ntfy — install the ntfy app, subscribe to a topic of your choosing, then point the monitor at it. Pick a long, unguessable topic name (ntfy topics are public to anyone who knows the name).
The topic is not stored in the repo (so this is safe to open source). Provide
it either as an environment variable or via a git-ignored .env file, which the
script loads automatically:
cp .env.example .env
echo 'NTFY_TOPIC=your-private-topic-name' > .env # e.g. r2-watch-7f3a9cOther optional overrides (env vars or .env): NTFY_SERVER (default
https://ntfy.sh), R2_BACKSTOP_DATE (default 2027-07-18), R2_TIMEZONE
(default America/Chicago), R2_HIGH_CONF (0.7), R2_MAYBE_LOW (0.4).
ntfy.sh (or your NTFY_SERVER) is not on it, notifications fail with
HTTP 403: Host not in allowlist and the monitor cannot alert you. The allowlist
is set per cloud environment in the web UI (not in this repo, not in the
routine config), so add your ntfy host before going live.
Exact click-path (Claude Code on the web, as of this writing):
- Go to claude.ai/code.
- At the bottom of the screen, next to the "Describe a task…" box, click the ☁️ environment chip (e.g. Default — whichever environment your routine uses).
- In the popover, under Cloud, click the ⚙️ gear icon on your environment's row. The "Update cloud environment" dialog opens.
- Find the Network access dropdown (it defaults to Trusted) and change it to Custom. An Allowed domains field appears.
- In Allowed domains, add one host per line — domains, not URLs:
(or your own
ntfy.shNTFY_SERVERhost;*wildcards are supported). - Leave "Also include default list of common package managers" checked — the routine needs GitHub to clone this repo, plus pip/npm.
- Save. The change applies only to newly provisioned sessions, so it takes effect on the routine's next scheduled run (an already-running session won't be retrofitted).
⚠️ Do not putNTFY_TOPIC/SLACK_USER_IDin the dialog's Environment variables box — it warns that those values are visible to anyone using the environment. The routine writes its own git-ignored.envat run time instead.
Verify it took: trigger a manual run of the routine (or run
python3 r2_monitor.py process --input fixtures/sample_results.json in a fresh
cloud session) and confirm a push lands on your phone instead of a 403. A quick
local sanity check of the topic+app wiring (separate from the sandbox allowlist)
is curl -d "test" https://ntfy.sh/<your-topic>.
You only need to add ntfy.sh — MCP connector traffic (Gmail, Slack) is routed
through Anthropic's servers and works without being in the allowlist. See the
Claude Code on the web docs.
A send failure is non-destructive: the monitor will not disarm and will retry on the next run, so an allowlist mistake delays alerts but never drops the hit.
Alerts are delivered to both ntfy and Slack. Slack is sent by the scheduled
session through the Slack MCP, so it needs no egress allowlist change — handy as a
backstop while you sort out ntfy. To enable, set your Slack target in .env:
echo 'SLACK_USER_ID=C0XXXXXXX' >> .env # a channel id (C..., recommended) or your user id (U..., DMs you)The Slack MCP connector must be enabled on the session/routine. Leave
SLACK_USER_ID empty to use ntfy only. (Find your user id in Slack: profile →
More → Copy member ID.)
The monitor reads your inbox through the Gmail MCP connector in the Claude session — this is the one hard requirement. Connect it once on your account:
- Go to claude.ai/customize/connectors (or, in the Claude Code web app, Customize → Connectors).
- Find Gmail in the directory and click Connect.
- Complete Google's OAuth flow and grant read access to your mail. You'll be asked to pick the Google account whose inbox you want watched — use the one the R2 invite will land in.
- Back in Claude Code, confirm Gmail shows as connected. When you create the routine in step 2, attach this connector to it (see Connectors below).
Notes:
- The connector is tied to your Claude account, so the routine reads your Gmail — nothing is shared in this repo.
- MCP connector traffic (Gmail, Slack) is routed through Anthropic's servers, so it works without any egress-allowlist change (that's only for ntfy, §1b).
- Read access is enough; the monitor never sends, deletes, or modifies mail.
This runs as a cloud routine in Claude Code (it needs the Gmail MCP server,
which lives in the Claude session). The easiest way to create it is the
/schedule skill — just describe the routine and it provisions the cloud
cron for you. In a Claude Code session, run /schedule and ask for:
- Name:
rivian-r2-monitor - Cadence: three times a day at 07:00, 13:00, 19:00 America/Chicago
(one routine, cron
0 0,12,18 * * *in UTC during CDT —/scheduledoes the timezone conversion for you). - Prompt: the contents of
RUN.md(the block between the---lines). Important: the cloud agent gets a fresh git clone where.envis absent (it's git-ignored), so prepend a step that writes.envwith yourNTFY_TOPICandSLACK_USER_IDbefore the pipeline runs — or set them another way the script can read. (Don't use the environment's Environment variables box for these — it's visible to anyone using the environment.) - Connectors: attach the Gmail and Slack MCP connectors.
- Model: any; a stronger model classifies marketing-vs-invite more reliably.
Prefer the web UI? You can instead create the routine manually at claude.ai/code → Routines → New routine, with the same cadence, prompt, and connectors. Either way, complete the egress allowlist in §1b first, or the cloud sends will 403.
Why 3×/day: an order invite isn't minute-critical, so this caps worst-case
detection latency at ~6–8h without continuous polling. Extra runs are safe and
cheap — de-dup means you're never pinged twice for the same email, and once a
high-confidence hit fires, the DONE sentinel makes every later run exit at the
guard (near-zero tokens). Keep the query at newer_than:2d so a skipped run is
covered by the next one. Bump to 4×/day (add 22:00) or every 3h if you want
tighter latency; drop to 1×/day to minimize cost.
Self-hosting instead? A cron entry that drives a headless Claude run works too — the only hard requirement is that the Gmail MCP server is available to the session. Example (conceptual):
# set CRON_TZ or use a system tz of America/Chicago CRON_TZ=America/Chicago 0 7,13,19 * * * cd /path/to/rivian-routine && claude -p "$(cat RUN.md)"
--dry-run runs the full pipeline against the last 7 days but suppresses
real notifications and never writes the sentinel or state — so you can
confirm it tags the June 9 "Important update" blast and other existing marketing
as NOT an invite.
End-to-end (real Gmail), in a Claude session: paste RUN.md into a normal
session and put the word DRY-RUN at the top. It will search the last 7 days,
classify everything, and run process --dry-run, printing each email's tier and
the notifications it would have sent — without touching your inbox state, ntfy,
or the sentinel.
Plumbing only (offline, no Gmail/LLM needed): a fixture set demonstrates the decision logic deterministically:
python3 r2_monitor.py process --input fixtures/sample_results.json --dry-runExpected: the June 9 marketing blast and the TLDR newsletter are silent, the ambiguous "reservation: next steps" is a MAYBE (low), and a personalized "it's your turn to configure your R2 order" is a HIGH hit that would disarm the monitor. Nothing is written to disk.
Regression suite (stdlib only, no deps):
python3 tests/test_fixtures.pyThis runs every fixture through process --dry-run and asserts the exit code and
HIT/MAYBE/NEWS routing, then verifies the dry-run wrote no state/. It includes
fixtures/real_inbox_results.json — modelled on a real inbox that actually held
the R2 invite, alongside the two false-positive traps a keyword/sender filter
would miss: a transactional order confirmation (invite-looking but a receipt)
and a "Keep an eye out for your invite" pre-invite teaser — plus a concrete
"you'll be invited in September–October 2026" timeline email. Only the genuine,
personalized invite fires a HIGH hit and disarms; the timeline email fires a
NEWS heads-up (no disarm); the traps and hype stay silent.
python3 r2_monitor.py --reset # clear the DONE sentinel; the scheduled check runs againUse this if a "maybe" turned out to be wrong, after a real hit if you want to keep watching, or to reuse the monitor next time.
r2_monitor.py # deterministic CLI: guard / process / reset / dry-run
RUN.md # scheduled-session prompt (Gmail search + classify + Slack mirror)
CLAUDE.md # guidance for Claude working in this repo
fixtures/ # offline test fixtures (sample + real-inbox regression cases)
tests/test_fixtures.py # stdlib regression suite: tier routing + exit codes per fixture
.env.example # copy to .env (git-ignored): NTFY_TOPIC, SLACK_USER_ID
state/ # runtime state: state.json, DONE, last_run.json (git-ignored)
This repo is safe to open source: it contains no inbox contents, no email
address, and no real ntfy topic — the topic is supplied at runtime via
NTFY_TOPIC or a git-ignored .env, and runtime state (state/, results.json,
.env) is git-ignored. Keep it that way: never commit .env or
state/state.json (it holds notified message IDs), and never hard-code your ntfy
topic.
Built by Brian Leach in Austin, TX — LinkedIn
MIT © Brian Leach