← All work

Meeting Prep Bot

Verified pre-meeting briefs from calendar bookings: every claim cited, namesakes kept out.

Aug 2026Personal

Next.js 16 (App Router, after())React 19TypeScriptTailwind v4Vercel AI SDK v7OpenRouter (Gemini Flash Lite)Exa search + contentsSEC EDGAR full-text searchGoogle Calendar API (push channels)Google Drive APIZodVercel Fluid Compute

Problem

Someone books fifteen minutes through a calendar link and the host walks in cold. Generic AI research fills the gap with confident prose that cannot be traced to a page, and because names collide it is often about a different person entirely; a fully cited brief about the wrong human is worse than no brief. The output has to be safe to read out loud in the meeting, including the parts it refuses to write.

What I built

Google Calendar pushes a notification the moment someone books; a Next.js webhook returns 200 immediately and hands the work to after(), which sweeps the next two weeks of events, identifies the one external booker per event (a single guest attendee, else a booked-by address in the description, else a name in the title, declining on ambiguity) and generates a brief per booker under a per-invocation cap. Each brief runs deterministic seed searches over Exa, then a tool-using model (search, fetch, SEC EDGAR full-text) that may only read URLs a search surfaced this run, then a structured-output pass in which every affiliation and fact must carry a source URL and a verbatim quote. A code gate then applies two tests: the quote appears on a page fetched this run, and that page carries a token tying it to this person (their email or company domain, never the name alone); failures are dropped and counted by which gate fired. LinkedIn, data-broker, obituary and arrest sites are refused before they can enter the evidence store and are listed by hostname, and with nothing verified the brief withholds all prose and shows only orienting questions. Briefs are served as logged-out, mobile-first pages and can be exported to a Google Doc under a per-file Drive scope, while the calendar grant itself is read-only.

Highlights

  • Two gates, both in code: SUPPORTED (the quoted snippet is really on a page fetched this run) and CORROBORATED (that page carries the booker's email or company domain). A name match satisfies neither, which is what keeps a namesake out; the second gate was added after a quote-only gate passed a confident, fully cited brief about a different person with the same name. Drops are reported per gate, because a missing quote is a model failure and a wrong-person page is the failure the product exists to prevent.
  • The schema is the first guard: the model has no field for company, confidence or a LinkedIn URL. Company is derived from the email domain and null for freemail (anything webmail-shaped counts as freemail, since inventing an employer is the costlier mistake), confidence is computed in code from what survived the gates, and LinkedIn is only ever a search link for a human to click: excluded at the search provider and again locally, never fetched.
  • Honest empty state instead of plausible padding: with no verified affiliation, direction, decisions and follow-ups are withheld, and questions are filtered so no unverified proper noun can be presupposed. 'What are you working on at X?' asserts X, in the one part of the brief the host reads out loud.
  • Scope refusals happen before a page becomes evidence: booking links, LinkedIn and people-search hosts are blocked pre-fetch; personal-life and public-record pages are refused on content, with two independent markers required; the model may only fetch URLs a search surfaced this run, so page text cannot steer the crawl. Refused hosts are shown by name and never linked.
  • Least privilege by scope rather than by promise: calendar.events.owned.readonly is enough for push notifications, so nothing can write to the calendar; Doc export uses drive.file and shares only on a strict share === true; webhook and sweep secrets are length-checked and compared timing-safe, so an unset secret can never authorise; the evidence ledger is built per run because Fluid Compute reuses instances across requests; rendered claims pass one escaping boundary and only http(s) URLs become links.

Numbers

  • 149 assertion call sites across three check scripts (identity 40, evidence 69, render 40) using only node:assert, all passing
  • One brief takes 40-70 seconds; a sweep is capped at 5 briefs per invocation by default inside a 300-second function budget
  • Per-run research budget: four accepted pages from seed searches, at most 8 model tool steps, at most 10 fetched pages become evidence
  • Default model chosen on a benchmark of this workload: ~0.7s to decide a tool call vs ~4.4s for deepseek-v4-flash, and ~187 tok/s vs ~54, the difference between a ~30s and a ~75s scan

Screenshots

Meeting Prep Bot: A finished brief: each claim shows its verbatim quote and full source URL, and the box at the bottom counts what was thrown away by which gate and names refused hosts without linking them. Rendered locally from a fixture with a fictional subject.
A finished brief: each claim shows its verbatim quote and full source URL, and the box at the bottom counts what was thrown away by which gate and names refused hosts without linking them. Rendered locally from a fixture with a fictional subject.
Meeting Prep Bot: The manual-entry form, the fallback when there is no calendar event to work from. An email is required because a name alone cannot be corroborated.
The manual-entry form, the fallback when there is no calendar event to work from. An email is required because a name alone cannot be corroborated.