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. 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, one or more concise draft bodies
- When an event is detected, a Google Calendar event is created and the Gmail message is labeled
event - When a reply is needed, a Gmail draft is created on the original thread. If the email could reasonably be answered in two distinct ways (e.g., accept vs. decline an invitation), both drafts are created so you can pick the one you want to send and discard the other
- 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"}.
Each email triggers up to two Gemini calls:
| Call | Model | Purpose | Max output |
|---|---|---|---|
analyze_email |
gemini-3.5-flash |
Classify event/reply/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) |
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.
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: email analysis uses the full-quality model; context extraction (a simpler task) uses
gemini-2.0-flash-lite(~10× cheaper per token). - 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 Run (Flask)
├── Gmail API (read emails, apply labels, create drafts)
├── Gemini API (extract event details, draft replies, compose standup)
├── Calendar API (create events, read today's schedule)
├── Tasks API (create and read action items)
└── 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/In the Cloud Build console, go to Triggers → Connect Repository and authorize krMaynard/automation. Create a trigger pointing at the main branch using cloudbuild.yaml.
The Cloud Build service account needs two IAM roles:
- Cloud Run Admin on the project
- Service Account User on
[PROJECT_NUMBER]-compute@developer.gserviceaccount.com
Push to main to trigger the first build and deploy. The Cloud Run service is named assistant (see cloudbuild.yaml).
gcloud run services update assistant \
--region us-central1 \
--set-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"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| 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) |
GEMINI_MODEL |
Gemini model for all AI tasks (default gemini-3.5-flash) |
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) |
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 |