Watches your Gmail inbox, uses Gemini to detect schedulable events and to draft replies, then creates Google Calendar events and Gmail drafts automatically. Also generates a daily morning standup email draft addressed to your key stakeholders, and keeps a shareable availability calendar in sync with your busy time. Runs on Google Cloud Run, triggered by Cloud Scheduler.
- Cloud Scheduler sends a
POST /request to the Cloud Run service every 5 minutes - The service fetches unread Gmail messages that haven't been processed yet
- Each email is analyzed by Gemini, which returns two things:
- Whether the email contains a schedulable event (meeting, appointment, deadline, booking confirmation, etc.) and, if so, the event details
- Whether the email needs a reply and, if so, a single concise draft body
- When an event is detected, a Google Calendar event is created and the Gmail message is labeled
event - When a reply is needed, exactly one Gmail draft is created on the original thread. The draft is a reply-all (the original sender in
To, everyone else on the message inCc, minus your own address) and quotes the original message beneath your reply so it keeps its conversational context. Consulting requests (inbound messages soliciting your professional consulting, advisory, or paid engagement services) never generate a reply draft - Processed message IDs are saved to GCS so emails are never analyzed twice
The endpoint responds with a JSON summary like {"processed": 2, "events_created": 1, "drafts_created": 1}.
- Cloud Scheduler sends a
POST /standuprequest once each weekday morning - The service scans 30 days of sent/received mail to discover key stakeholders, using Gemini to rank them by importance
- It fetches today's calendar events, open tasks, recently completed tasks, and recent email thread context per stakeholder
- Gemini composes a four-section standup email (Today's Schedule, Open Tasks, Completed Yesterday, Stakeholder Updates)
- The draft is saved to Gmail — addressed to all discovered stakeholders — for you to review and send
- A GCS record (
last_standup_date.json) prevents duplicate drafts on the same day
The endpoint responds with {"draft_url": "https://mail.google.com/mail/#drafts/...", "date": "YYYY-MM-DD"}.
- Cloud Scheduler sends a
POST /availability-syncrequest on a schedule of your choosing - The service reads every busy event within the next
AVAILABILITY_SYNC_DAYSdays from each source calendar listed inAVAILABILITY_SOURCE_CALENDAR_IDS(your personal calendars) - Calendars shared with you as "free/busy only" — e.g. a corporate work calendar whose admin blocks external detail sharing — go in
AVAILABILITY_FREEBUSY_CALENDAR_IDSinstead. Those are read through Google's FreeBusy API, which returns only busy time spans (never titles, attendees, or any detail) and already excludes free/declined/cancelled time server-side - Each busy block from either source is mirrored onto the target
AVAILABILITY_CALENDAR_IDas a detail-free block — the summary is just "Busy" (configurable viaAVAILABILITY_BUSY_TITLE), with no description, location, or attendees, marked private and opaque - For
events.listsources, events explicitly marked Free (transparency=transparent), cancelled events, and events you have declined are skipped — they don't block your availability - The sync is reconciling and idempotent: mirror events are tagged with a private extended property (
availabilitySync=true) linking each back to its source event, so every run creates newly-added blocks, updates rescheduled ones, and deletes blocks whose source was cancelled or moved out of the window. Events you created by hand on the availability calendar (anything without the tag) are never touched
Share the availability calendar with consulting clients (read-only, free/busy) so they can see when you're open without seeing what you're doing. The endpoint responds with a summary like {"busy_blocks": 14, "created": 3, "updated": 1, "deleted": 2, "skipped_free": 5, "window_days": 60}.
Cloud Run + Cloud Scheduler + GCS are all optional. main.py uses file-based
OAuth whenever GOOGLE_CREDENTIALS_FILE is set, and local-file state
(STATE_DIR) whenever GCS_BUCKET_NAME is unset — so any always-on machine can
be the server with no GCP at all. Infra cost is then $0; the only spend is the
Gemini API for email processing (per new email, not per poll), and
deploy/selfhost/env.example ships the cheapest defaults. The availability sync
costs nothing (Calendar API only).
Ready-to-use systemd units (a service = the server; timers = Cloud
Scheduler, every 5 min for email and 30 min for availability) and a one-time
token minter (mint_token.py) live in
deploy/selfhost/. Install them, drop in an OAuth
credentials.json, and enable whichever automations you want.
Each email triggers up to two Gemini calls:
| Call | Model | Purpose | Max output |
|---|---|---|---|
analyze_email |
gemini-2.0-flash-lite |
Classify event/reply (and, optionally, action items), signal context-worthiness | 1,024 tokens |
update_user_context |
gemini-2.0-flash-lite |
Extract personal facts into persistent profile | dynamic (estimated tokens + 512, range 1,024–4,096) |
Both calls default to gemini-2.0-flash-lite — the lightest tier — because classification and fact-extraction don't need a premium model. analyze_email's model is overridable via GEMINI_MODEL if you want more accuracy at higher cost (the premium gemini-3.5-flash was the previous default, but its input-token pricing dominated the bill).
The context update call only fires when analyze_email returns has_context_update=true — a signal the first call sets when the email contains facts worth remembering (new contact details, family news, stated preferences) versus newsletters, receipts, or automated notifications. This keeps the second call from running on ~50–80% of inbox traffic.
Action item extraction is on by default and gated by ENABLE_ACTION_ITEMS. Set ENABLE_ACTION_ITEMS=false to disable it as a cost-saving measure: the ACTION ITEMS section is then dropped from the analyze_email prompt entirely, so the model never generates action items — cutting both prompt and output tokens on every email — and no Google Tasks are created.
Reply drafting is on by default and gated by ENABLE_DRAFT_REPLIES. Set ENABLE_DRAFT_REPLIES=false to disable it: the (large) REPLY section is dropped from the analyze_email prompt entirely, so the model is never asked to classify replies or draft one — cutting both prompt (input) and output tokens on every email — and no Gmail drafts are created. Calendar events, action items, and context updates still work.
user_context.json in GCS stores a freeform JSON profile that grows automatically as facts are extracted from emails. It is injected into the analyze_email prompt so drafts are personalized. The profile survives Cloud Run restarts and can be manually edited at any time:
gsutil cat gs://your-bucket-name/user_context.json # inspect
gsutil cp user_context.json gs://your-bucket-name/ # overwrite/seedInitial seed example:
{
"name": "Kyle Maynard",
"preferred_tone": "friendly but professional",
"preferred_sign_off": "Thanks, Kyle",
"family": {},
"important_contacts": {},
"notes": []
}Several layers prevent runaway billing:
- Output caps:
analyze_emailcapped at 1,024 output tokens;update_user_contextuses a dynamic cap (max(1024, profile_chars / 3 + 512)) bounded at 4,096. Without caps, unconstrained generation was the root cause of a 15× billing spike. - Conditional context updates:
has_context_updatefield onEmailAnalysislets the first LLM call gate the second. Junk email never reachesupdate_user_context. - Input token reduction: quoted reply chains are stripped from email bodies before they reach the model (threaded emails can have 2–3× more quoted history than new content). User context is serialized as compact JSON (no indentation). The context update prompt truncates the email body to 1,500 chars — fact extraction doesn't need the full body.
- Model tiering: both analysis and context extraction default to
gemini-2.0-flash-lite(~10× cheaper per token than the premiumgemini-3.5-flash), since classification and fact-extraction don't need a premium model. OverrideGEMINI_MODELto trade cost for accuracy. - Optional feature gating: reply drafting (
ENABLE_DRAFT_REPLIES) and action-item extraction (ENABLE_ACTION_ITEMS) can each be turned off. When off, their prompt section is dropped entirely, cutting input and output tokens on every email. - Token logging: every Gemini response logs prompt/output/total token counts tagged by call type. Query in Cloud Logging:
Output looks like:
resource.type="cloud_run_revision" textPayload=~"tokens \["tokens [analyze_email] 'Lunch Thursday' — prompt: 847, output: 312, total: 1159 tokens [update_user_context] 'Lunch Thursday' — prompt: 423, output: 198, total: 621
Cloud Scheduler (*/5 * * * *) → POST / → email processing pipeline
Cloud Scheduler (0 8 * * 1-5) → POST /standup → daily standup draft
Cloud Scheduler (*/30 * * * *) → POST /availability-sync → mirror busy time to availability calendar
Cloud Run (Flask)
├── Gmail API (read emails, apply labels, create drafts)
├── Gemini API (extract event details, draft replies, compose standup)
├── Calendar API (create events, read schedule, mirror busy blocks)
├── Tasks API (read tasks for standup; create action items when ENABLE_ACTION_ITEMS=true)
└── GCS (token.json, processed_ids.json, user_context.json,
last_standup_date.json)
Create a project at console.cloud.google.com signed in with the Gmail account you want to monitor.
Enable the required APIs:
gcloud services enable run.googleapis.com cloudbuild.googleapis.com \
cloudscheduler.googleapis.com storage.googleapis.com gmail.googleapis.com \
calendar-json.googleapis.com tasks.googleapis.com- Go to APIs & Services → OAuth consent screen — set up an external app and add your Gmail as a test user
- Go to APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID
- Choose Desktop app, download the JSON, save it as
credentials.json
The OAuth scopes used are Gmail modify (read, label, create drafts) and Calendar events.
gsutil mb gs://your-bucket-namepython -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # fill in GEMINI_API_KEY, GCS_BUCKET_NAME, GOOGLE_CREDENTIALS_FILE
python main.py # browser opens for Google consentUpload the generated token to GCS:
gsutil cp token.json gs://your-bucket-name/Deployment runs from the GitHub Actions workflow in .github/workflows/deploy.yml: on every push to main it runs the deterministic unit tests and, when the two required GCP identity variables are configured, builds the container from the Dockerfile and deploys to Cloud Run. Repositories using only the self-hosted service skip the Cloud Run job cleanly. Cloud deployment authenticates to GCP keylessly via Workload Identity Federation (no service-account key stored in GitHub), and injects the availability calendar IDs from GitHub repository Variables so that config lives in GitHub rather than being set by hand.
Test jobs. The deploy is gated only on the unit-tests job (the deterministic suite, everything except tests/test_email_analysis.py) — these call no external service, so the gate is free and reliable. The live-LLM eval suite (tests/test_email_analysis.py) runs in a separate, non-blocking evals job that executes only when a GEMINI_API_KEY secret is configured and otherwise no-ops. This keeps deploys from stalling on a paid/flaky external dependency; add the secret if you want the evals to actually run.
One-time GCP setup — create a Workload Identity pool + provider for GitHub's OIDC and a deploy service account, then let the repo impersonate it:
PROJECT_ID=your-project
PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')
GITHUB_REPO=krMaynard/automation # change to your-username/your-repo if you fork
# Deploy service account
gcloud iam service-accounts create github-deployer --project "$PROJECT_ID"
SA=github-deployer@$PROJECT_ID.iam.gserviceaccount.com
# Roles the deploy needs: build the image, deploy the service, act as the runtime SA
for ROLE in roles/run.admin roles/cloudbuild.builds.editor \
roles/artifactregistry.writer roles/storage.admin \
roles/iam.serviceAccountUser; do
gcloud projects add-iam-policy-binding "$PROJECT_ID" --member="serviceAccount:$SA" --role="$ROLE"
done
# Workload Identity pool + GitHub OIDC provider
gcloud iam workload-identity-pools create github --project="$PROJECT_ID" --location=global
gcloud iam workload-identity-pools providers create-oidc github \
--project="$PROJECT_ID" --location=global --workload-identity-pool=github \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
--attribute-condition="assertion.repository=='$GITHUB_REPO'"
# Let this repo impersonate the deploy SA
POOL=$(gcloud iam workload-identity-pools describe github --project="$PROJECT_ID" --location=global --format='value(name)')
gcloud iam service-accounts add-iam-policy-binding "$SA" --project="$PROJECT_ID" \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/$POOL/attribute.repository/$GITHUB_REPO"GitHub configuration (repo → Settings → Secrets and variables → Actions):
| Kind | Name | Value |
|---|---|---|
| Variable | GCP_WORKLOAD_IDENTITY_PROVIDER |
projects/<PROJECT_NUMBER>/locations/global/workloadIdentityPools/github/providers/github (fill in the real number/ID from the setup above) |
| Variable | GCP_SERVICE_ACCOUNT |
github-deployer@<PROJECT_ID>.iam.gserviceaccount.com |
| Variable | AVAILABILITY_CALENDAR_ID |
your availability calendar's ID |
| Variable | AVAILABILITY_SOURCE_CALENDAR_IDS |
e.g. kieranmaynard@gmail.com,family…@group.calendar.google.com |
| Variable | AVAILABILITY_FREEBUSY_CALENDAR_IDS |
e.g. you@roblox.com (or leave unset) |
| Secret | GEMINI_API_KEY |
your Gemini key (optional — only the non-blocking evals job uses it; without it, evals are skipped and the deploy still proceeds) |
The deploy uses --update-env-vars, so it only writes the availability calendar IDs and leaves the service's other env (GEMINI_API_KEY, GCS_BUCKET_NAME, CALENDAR_ID, …) untouched. To change which calendars feed availability, just edit the repository Variable and re-run the workflow — no code change or gcloud needed.
If you previously wired up the Cloud Build trigger: disable it (Cloud Build console → Triggers) so pushes to
maindon't deploy twice.cloudbuild.yamlis kept in the repo only for reference; the GitHub Actions workflow is the deploy path.
The availability calendar IDs come from GitHub Variables (above). Set the rest once directly on the service:
gcloud run services update assistant \
--region us-central1 \
--update-env-vars GEMINI_API_KEY=AIza...,GCS_BUCKET_NAME=your-bucket-name,CALENDAR_ID=primary# Create a service account for the scheduler
gcloud iam service-accounts create cloud-scheduler-invoker
gcloud run services add-iam-policy-binding assistant \
--region=us-central1 \
--member="serviceAccount:cloud-scheduler-invoker@YOUR_PROJECT.iam.gserviceaccount.com" \
--role="roles/run.invoker"
SERVICE_URL=$(gcloud run services describe assistant --region us-central1 --format='value(status.url)')
# Email processing: runs every 5 minutes
gcloud scheduler jobs create http email-assistant-poll \
--schedule="*/5 * * * *" \
--uri="$SERVICE_URL" \
--http-method=POST \
--oidc-service-account-email=cloud-scheduler-invoker@YOUR_PROJECT.iam.gserviceaccount.com \
--location=us-central1
# Daily standup: runs at 8 am Monday–Friday
gcloud scheduler jobs create http daily-standup \
--schedule="0 8 * * 1-5" \
--uri="$SERVICE_URL/standup" \
--http-method=POST \
--oidc-service-account-email=cloud-scheduler-invoker@YOUR_PROJECT.iam.gserviceaccount.com \
--location=us-central1 \
--time-zone="America/Los_Angeles"
# Availability sync: mirror busy time every 30 minutes
gcloud scheduler jobs create http availability-sync \
--schedule="*/30 * * * *" \
--uri="$SERVICE_URL/availability-sync" \
--http-method=POST \
--oidc-service-account-email=cloud-scheduler-invoker@YOUR_PROJECT.iam.gserviceaccount.com \
--location=us-central1For the availability sync, first create a dedicated Google Calendar (in Google
Calendar: + → Create new calendar), grab its Calendar ID from that
calendar's settings, and set AVAILABILITY_CALENDAR_ID (plus
AVAILABILITY_SOURCE_CALENDAR_IDS to your personal calendar IDs) on the Cloud
Run service. Then share that calendar with clients — set its access permission
to See only free/busy (hide details) for extra safety, though the mirrored
blocks carry no details to begin with.
Including a corporate work calendar (e.g. a Google Workspace account at
another company): most Workspace domains block sharing full event details
outside the org, but allow sharing free/busy. Share your work calendar as
"See only free/busy" with the account the sync authenticates as
(kieranmaynard@gmail.com), then add the work calendar's ID to
AVAILABILITY_FREEBUSY_CALENDAR_IDS (not AVAILABILITY_SOURCE_CALENDAR_IDS).
The sync reads it through the FreeBusy API, so it only ever sees busy time
spans — never what the meetings are.
In Cloud Run → Logs tab, a successful run looks like:
[INFO] Loaded 12 previously processed message ID(s).
[INFO] Processing 2 new message(s)...
[INFO] Analyzing: 'Lunch meeting Thursday'
[INFO] Event detected: 'Lunch with Sarah' at 2026-05-15T12:30:00
[INFO] Calendar event created: https://www.google.com/calendar/event?eid=...
[INFO] Draft reply created: https://mail.google.com/mail/u/0/#drafts/...
To test end-to-end, send yourself an email like:
Subject: Lunch meeting Thursday Body: Let's catch up for lunch this Thursday at 12:30pm at The Market on Main. Should wrap up by 2pm. Does that work for you?
Then click Run now in Cloud Scheduler and check Google Calendar and your Gmail drafts.
source .venv/bin/activate
python main.py # runs the Flask server on port 8080Trigger a run manually:
curl -X POST http://localhost:8080/ # email processing
curl -X POST http://localhost:8080/standup # standup draft
curl -X POST http://localhost:8080/availability-sync # mirror busy time| Variable | Description |
|---|---|
GEMINI_API_KEY |
API key from aistudio.google.com |
GCS_BUCKET_NAME |
GCS bucket for token.json, processed_ids.json, user_context.json, last_standup_date.json |
CALENDAR_ID |
Calendar to read/write events (primary = default) |
TASKS_LIST_ID |
Google Tasks list for action items (@default = My Tasks). Only used when ENABLE_ACTION_ITEMS is enabled |
ENABLE_ACTION_ITEMS |
Extract action items and create Google Tasks (default true). Set to false to disable and reduce Gemini API cost |
ENABLE_DRAFT_REPLIES |
Draft reply emails and create Gmail drafts (default true). Set to false to disable and reduce Gemini API cost |
GEMINI_MODEL |
Gemini model for all AI tasks (default gemini-2.0-flash-lite; set gemini-3.5-flash for higher accuracy at higher cost) |
STANDUP_LOOKBACK_DAYS |
Days of mail history to scan for stakeholder discovery (default 30) |
STANDUP_THREAD_DAYS |
Days of thread context per stakeholder included in standup (default 7) |
AVAILABILITY_CALENDAR_ID |
Target calendar for detail-free "Busy" blocks (required for /availability-sync) |
AVAILABILITY_SOURCE_CALENDAR_IDS |
Comma-separated source calendars read via events.list — your personal calendars (defaults to CALENDAR_ID) |
AVAILABILITY_FREEBUSY_CALENDAR_IDS |
Comma-separated calendars shared as free/busy-only (e.g. a corporate work calendar), read via the FreeBusy API — busy time only, never details (default empty) |
AVAILABILITY_SYNC_DAYS |
How many days ahead to keep mirrored (default 60) |
AVAILABILITY_BUSY_TITLE |
Title used for every mirrored block (default Busy) |
GOOGLE_CREDENTIALS_FILE |
Path to OAuth client secrets (local only) |
GOOGLE_TOKEN_FILE |
Path to OAuth token file (local only) |
BROWSER_PATH |
Optional path to a specific browser for OAuth consent |