For developers / future-AI sessions: read
docs/ARCHITECTURE.mdfirst. It maps every URL → file, explains how the reminder chain / OAuth / calendar integrations fit together, and lists every external service + env var. Skim that and you'll have enough context to navigate the codebase without grepping.
Type it. Snap it. We'll calendar it.
InviteReader turns natural language and screenshots into calendar events using Google Gemini, then writes them to your Google Calendar (or hands you a .ics file).
- Frontend: Next.js 14 (App Router) + TypeScript + Tailwind + shadcn-style primitives
- AI: Google Gemini (
gemini-2.0-flash-exp) for text + image extraction - Backend: Next.js API routes (Node runtime), Supabase (Postgres + Auth + Storage)
- Auth: Supabase Auth → Google OAuth (with
calendar.eventsscope). Apple is one config entry away.
pnpm install # or npm/yarn — package-lock generation is skipped here
pnpm dev # http://localhost:3000You'll need a .env.local (see .env.example):
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=... # used server-side to read screenshots
GEMINI_API_KEY=...
NEXT_PUBLIC_APP_URL=http://localhost:3000- Create a project at https://supabase.com.
- Run the migrations (Supabase SQL Editor → paste each file):
supabase/migrations/0001_init.sql—profiles,events, RLS, signup triggersupabase/migrations/0002_storage.sql—screenshotsbucket + RLS
- Enable the Google provider under Authentication → Providers → Google:
- Set the Google OAuth
client_idandclient_secretfrom a Google Cloud project (see step 3). - Under Additional scopes, add:
https://www.googleapis.com/auth/calendar.events.
- Set the Google OAuth
- Add redirect URLs under Authentication → URL Configuration:
http://localhost:3000/auth/callbackhttps://your-project.vercel.app/auth/callback
- Copy keys from Project Settings → API:
NEXT_PUBLIC_SUPABASE_URL← Project URLNEXT_PUBLIC_SUPABASE_ANON_KEY←anonpublic keySUPABASE_SERVICE_ROLE_KEY←service_roleJWT (server only, keep secret)
- Open Google Cloud Console → APIs & Services → Credentials.
- Enable the Google Calendar API under Library.
- Create an OAuth 2.0 Client ID of type Web application.
- Authorized redirect URIs must include the Supabase callback:
https://<your-project-ref>.supabase.co/auth/v1/callback
- OAuth consent screen → Scopes → add
.../auth/calendar.events. - Paste the client ID/secret into Supabase (step 2.3 above).
prompt=consent+access_type=offlineare forced inlib/auth-providers.ts, which is what makes Google return arefresh_tokenon first sign-in. Don't remove them or token expiry will end the session every hour.
- Go to https://entra.microsoft.com → Applications → App registrations → New registration.
- Name:
InviteReader(whatever). - Supported account types: "Accounts in any organizational directory and personal Microsoft accounts" (gives you both work and personal Outlook users).
- Redirect URI: Web →
https://<your-supabase-ref>.supabase.co/auth/v1/callback - Register.
- In the new app → API permissions → Add a permission → Microsoft Graph → Delegated.
- Add:
Calendars.ReadWrite,User.Read,offline_access,email,openid,profile. - (Optional) Click Grant admin consent for — only needed if you want a smoother first-run UX for users in that tenant.
- Certificates & secrets → Client secrets → New client secret. Pick max expiry (24 months).
- Copy the secret value immediately — you can't see it again.
- Open
https://supabase.com/dashboard/project/<your-ref>/auth/providers. - Find Azure → enable it.
- Paste:
- Application (client) ID from your Azure app's Overview page
- Secret value from step 3
- Azure tenant URL (or leave the default
commonfor personal + work accounts).
- Save.
Supabase auto-refreshes the session, but the Microsoft provider access token expires every ~1h and needs a manual refresh on the server. For that we need the client ID + secret available to our API route:
AZURE_CLIENT_ID=...
AZURE_CLIENT_SECRET=...
AZURE_TENANT_ID=common # or your tenant id if you locked it down
Add these to .env.local and to Vercel's project env vars (Production + Preview).
Auto-fires the SMS → call → SMS → call → email escalation when an event approaches. Builds on top of the Twilio Verify integration (4c) and vapi.ai (4b).
Twilio's Verify integration uses a shared sender; SMS reminders need your own number.
- Twilio Console → Phone Numbers → Buy a Number (US, ~$1.15/month).
- After purchase, configure the number's Messaging webhook under "A MESSAGE COMES IN":
- Webhook URL:
https://invitereader.com/api/sms/inbound - HTTP method: POST
- Webhook URL:
- Copy the number (E.164 format, e.g.
+18005551234) → that'sTWILIO_FROM_NUMBER.
For production, prefer a Messaging Service: Console → Messaging → Services → Create. Add your number to the service. The Service SID becomes TWILIO_MESSAGING_SERVICE_SID (overrides TWILIO_FROM_NUMBER). Messaging Services handle opt-out compliance, A2P 10DLC registration, and let you swap senders without code changes.
- Sign up at https://resend.com (free tier: 3,000 emails/month).
- Domains → Add Domain → enter
invitereader.com→ Resend gives you SPF, DKIM, and a DMARC record. Add them at AWS Route 53 (same place you added the Vercel records). Verification takes ~5 min. - API Keys → Create API Key with full access. Copy → that's
RESEND_API_KEY. - Until your domain is verified, send from
onboarding@resend.dev(works immediately for testing).
openssl rand -hex 32That string is your CRON_SECRET. Vercel automatically passes it as Authorization: Bearer <CRON_SECRET> to the cron endpoint, so requests without it return 401.
https://vercel.com/hirenfire/snapcal/settings/environment-variables (Production + Preview):
TWILIO_FROM_NUMBER=+1... # or TWILIO_MESSAGING_SERVICE_SID=MGxxx
RESEND_API_KEY=re_...
EMAIL_FROM=InviteReader Reminders <reminders@invitereader.com>
CRON_SECRET=<the random hex from step 3>
In the Supabase SQL editor, paste and run supabase/migrations/0008_reminder_chain.sql. Adds default_reminder_schedule to profiles, extends reminder_jobs.channel to allow email, and adds events.reminder_email_sent_at.
Vercel Hobby cron is limited to once per day, which isn't fast enough for the reminder chain. The fix: use a free external service to hit our endpoint every minute.
cron-job.org (free, no card, reliable):
- Sign up at https://console.cron-job.org/signup.
- Create cronjob with:
- Title:
InviteReader reminder fire - URL:
https://invitereader.com/api/cron/fire-reminders - Schedule: Every minute (
* * * * *) - Method: GET
- Header:
Authorization: Bearer <your CRON_SECRET>
- Title:
- Save. The job fires once a minute; the dashboard shows execution history.
Alternatives: Upstash QStash (500 messages/day free), EasyCron, GitHub Actions schedule: cron: '*/5 * * * *' (5-min minimum).
The vercel.json we ship registers a once-daily backup cron at 9 AM UTC, so even if the external pinger goes down for a day, any pending reminders get drained on the daily catch-up. Upgrade to Vercel Pro ($20/mo) to remove this dependency entirely — change "schedule" in vercel.json to "* * * * *" and skip the external cron.
Vercel reads vercel.json at build time to register the daily safety-net cron. Project → Logs → Runtime will show [cron] /api/cron/fire-reminders ✓ once a day from Vercel and (if you set up cron-job.org) once per minute from there.
When a user creates an event, extract-event schedules a chain of reminder_jobs rows:
| Step | Channel | Default fire_at |
|---|---|---|
| 1 | SMS | event ‒ 60 min |
| 2 | Voice (vapi.ai) | event ‒ 45 min (15 min after SMS-1, if no reply) |
| 3 | SMS | event ‒ 40 min (5 min after Call-1, if missed) |
| 4 | Voice | event ‒ 30 min (final voice attempt) |
| 5 | Email (Resend) | event ‒ 25 min (if everything else fails) |
Three ways the chain ends early:
- User taps the link in an SMS →
/r/{token}→ marks acknowledged → cancels remaining jobs. - User replies to the SMS →
/api/sms/inbound→ marks acknowledged. - User says "got it" on the call → vapi webhook detects → cancels remaining.
Past-due steps are skipped: if you create an event 20 minutes out (defaults), SMS-1 / Call-1 / SMS-2 are all in the past at scheduling time and get dropped — only Call-2 and the email fire.
- User defaults:
/settings → Reminder schedule. Change any of the 5 timings + toggle email fallback. - Per event: on the Review Event card, expand Reminder schedule and edit. Click "Use my defaults" to clear the override.
- SMS: ~$0.0083/segment (US)
- Voice: ~$0.05/minute (vapi.ai) — only fires if SMS unacknowledged
- Email: free under 3,000/month (Resend)
- Vercel Cron: free on Hobby tier (1 cron, every minute)
A typical event that's acknowledged on SMS-1: ~$0.01. A worst-case 4-step escalation that hits the email: ~$0.20.
- Create an event 7 minutes out with custom schedule
{lead: 6, sms_window: 1, call_to_sms2: 1, sms2_to_call2: 1, call2_to_email: 1, email_fallback_enabled: true}. - ~1 min: SMS arrives.
- Don't reply. ~1 min: phone rings. Decline.
- ~1 min: 2nd SMS. Tap link → "Got it!" page → DB shows
reminder_acknowledged_at, all chain jobscancelled.
To test the full miss path, repeat without acknowledging anywhere — the email lands in your inbox at minute 5.
Required so users can't trigger voice calls to phone numbers they don't own. Twilio sends a 6-digit SMS code; the user enters it; we mark phone_verified_at. Voice and demo calls refuse to fire to unverified numbers.
https://www.twilio.com/try-twilio. The free trial gives you ~$15 in credit, enough to verify ~150 phone numbers (each SMS is ~$0.05–$0.10 depending on country).
Console → Verify → Services → Create new Service.
- Friendly name:
InviteReader(this is what appears in the SMS — "Your InviteReader verification code is 123456"). - Default code length (6) and TTL (10 minutes) are fine.
Copy the Service SID (starts with VA…).
Console → home page (https://console.twilio.com). The two values are in the Account Info card on the right.
Locally and on Vercel (Production + Preview):
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Redeploy. The "Send verification code" button in /settings and the new verify-then-call flow on the public demo button now work.
While your Twilio account is in Trial mode, Verify will only send SMS to numbers you've explicitly added under Phone Numbers → Verified Caller IDs. To send to anyone, you have to upgrade (which costs ~$20 and removes the prefix "Sent from your Twilio trial account" from messages).
- SMS via Verify: ~$0.05/verification in the US, more elsewhere
- Twilio Verify enforces 5 sends per number per 10 minutes by default; we layer a tighter
phone_verificationstable check on top so abuse stops before reaching Twilio at all
vapi.ai handles the outbound voice calls. The flow: our /api/voice-reminder builds a system prompt with the event context, calls vapi.ai, vapi places the phone call and runs the conversation, then posts a summary to /api/vapi-webhook.
Sign up at https://dashboard.vapi.ai. Free tier includes test minutes; production pricing is ~$0.05/minute.
In Provider Keys, paste an OpenAI key (or Anthropic, Groq, etc.). vapi uses this to drive the conversation. The system prompt we send each call lives in lib/voice-prompt.ts.
Phone Numbers → Buy Number. Pick a US number ($1/month at the time of writing). Copy its Phone Number ID (shown in the URL or detail panel) — that's your VAPI_PHONE_NUMBER_ID.
Assistants → Create. Name it "InviteReader Reminder". Pick:
- Model:
gpt-4o-miniis fine and fast. - Voice: any natural-sounding voice (PlayHT, ElevenLabs, etc.).
- First message: leave blank — we override per call.
- System prompt: leave blank — we override per call.
- Server URL (under Advanced → Webhook):
https://invitereader.com/api/vapi-webhook. Subscribe at minimum toend-of-call-report.
Save and copy the assistant's ID → that's VAPI_ASSISTANT_ID.
API Keys → Private Key. Copy → VAPI_API_KEY.
Locally in .env.local and on Vercel (Production + Preview):
VAPI_API_KEY=...
VAPI_ASSISTANT_ID=...
VAPI_PHONE_NUMBER_ID=...
Redeploy. The "Send test call" button on /settings and the "Call me about this" button on event previews will now place real calls.
⚠️ Calls go to whatever phone number is in the user's profile. There's no verification yet (Phase 4 — Twilio Verify), so a typo in the phone field will dial whoever owns that number. For dev, only test against your own phone.
- Go to https://aistudio.google.com/app/apikey and create a key.
- Add it as
GEMINI_API_KEYin.env.localand on Vercel.
- Push the repo and import it at https://vercel.com/new.
- Add all env vars from
.env.exampleto Project → Settings → Environment Variables. - Set
NEXT_PUBLIC_APP_URLto your production URL (e.g.https://invitereader.com). - Add the production callback
https://<your-app>.vercel.app/auth/callbackto:- Supabase → Auth → URL Configuration
- Deploy.
app/
page.tsx Hybrid input flow (auth-guarded)
events/page.tsx My Events history
login/page.tsx Sign-in
auth/callback/route.ts OAuth callback → persists Google tokens
auth/error/page.tsx
api/
extract-event/ POST: text/image → structured event JSON
create-google-event/ POST: writes event to Google Calendar
generate-ics/ POST: returns .ics download
events/ GET/POST: list & save events (RLS-scoped)
components/ HybridInput, EventPreviewCard, SuccessState, EventList, AuthButton, …
lib/
gemini.ts Prompt + JSON validation (1 stricter retry)
ics.ts .ics generation with RRULE + alarms
google-calendar.ts googleapis client + GoogleAuthRequiredError
auth-providers.ts <-- single source of truth; add Apple by appending an entry
supabase/{client,server,middleware,types}.ts
middleware.ts Auth gate (everything except /login, /auth/*)
supabase/migrations/ SQL for tables, RLS, storage
- Configure Apple in Supabase → Auth → Providers → Apple.
- Append one entry to
AUTH_PROVIDERSinlib/auth-providers.ts:{ id: "apple", label: "Continue with Apple" }
That's it — AuthButtonList maps over the array, the callback is provider-agnostic, and there is nothing else to change.
- Timezone: detected via
Intl.DateTimeFormat().resolvedOptions().timeZoneon first render of the home page and synced toprofiles.timezone. The Gemini prompt always receives the current ISO date/time and the user's timezone. - Recurrence: stored as a bare RRULE (e.g.
FREQ=WEEKLY;BYDAY=WE). Google Calendar receives it prefixed withRRULE:in therecurrencearray; theicspackage takes the bare form. - Service-role usage:
SUPABASE_SERVICE_ROLE_KEYis only used server-side in/api/extract-eventto download screenshots through Storage. Never expose it to the client. - Edge runtime: API routes that touch Gemini, googleapis, or
icsuseruntime = "nodejs". Page routes are server components on the default runtime.