Meeting prep briefs: Calendar → Notion + Linear → Slack
Poll Calendar for meetings starting soon, match Notion docs and Linear issues, DM the pre-read.
Thirty minutes before a meeting, most people haven't opened the doc or checked the tracker. This polls Google Calendar for meetings starting soon, matches the title against Notion docs and Linear issues, and DMs each attendee a pre-read — doc links, issue status, or an explicit "checked, found nothing" instead of silence. It's for internal team meetings, not a CRM; a sales-call brief is separately scoped. Notion Calendar overlays items next to Calendar, but that's a view you open — this pushes the brief to DMs unasked.
How it flows
- 01
Poll for meetings starting soon
Every 5–10 minutes, events.list on the primary calendar with timeMin=now, timeMax=now+30min, singleEvents=true, orderBy=startTime. Both bounds are exclusive and must carry a timezone offset — a bare timestamp gets rejected.
- 02
Dedupe by event id
The same meeting shows up across several poll windows before it starts; track which event ids were already processed so the pre-read is not sent twice. Unlike the Notion-status or Gmail-label write-backs other polling workflows in this collection use as their idempotency marker, this cannot be written back onto the Calendar event itself — that would need a wider scope than the read-only one Setup step 3 deliberately keeps. Keep the processed-id set in whatever runs the poll job (a local table or key-value store), with entries expiring after a day — well past the 30-minute window any single event can appear in.
- 03
Search Notion for related docs
Title-substring search on the meeting title. Zero hits fall back to any Notion or Linear URL already pasted into the meeting description — a stronger signal than retrying with a wider, noisier title.
- 04
Search Linear for related issues
searchIssues against the meeting title (full-text + vector search); if the title reads as a project name, searchProjects first and pull that project's issues instead. Throttled to Linear's 30-requests/minute searchIssues limit when several meetings land in the same poll window.
- 05
Assemble the pre-read
Notion doc links, Linear issue identifiers and status, and the meeting's own time and attendee list, combined into one message.
- 06
Resolve attendees to Slack user ids
Each attendee email goes through users.lookupByEmail. External guests and any email that does not match a workspace member come back users_not_found — skip that attendee's DM rather than failing the whole run, and keep a tally of who was not matched.
- 07
DM the pre-read to each resolved attendee
chat.postMessage with channel set to the resolved Slack user id — no conversations.open first.
- 08
Degrade instead of going silent when nothing matched
If both Notion and Linear came back empty, the DM still goes out — with a line saying the workflow checked and found nothing, so the attendee knows to look manually instead of assuming it did not run.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
Google Calendar
Trigger — spots meetings starting soon and who is invited
- 01
Enable the Calendar API and configure OAuth consent
Google Cloud Console → APIs & Services → Library → search "Google Calendar API" → Enable. Then APIs & Services → OAuth consent screen → set App name and User type (Internal only works inside a Workspace domain; External needs a Google review to distribute widely, but External + Testing mode is enough for a team running this for itself).
- 02
Create a Web application OAuth client
Credentials → Create Credentials → OAuth client ID → Web application — not Desktop app. The official Desktop quickstart only walks through a one-off local interactive authorization; it does not cover persisting a refresh token for an unattended poller, which is what this needs. Build the authorization URL with access_type=offline — omit it and the access token has no way to renew itself silently once it expires.
- 03
Scope to read-only events, nothing wider
Use calendar.events.readonly (full value in the snippet) — Google's own scope list describes it as "View events on all your calendars", narrower than the calendar.readonly scope most tutorials default to, which also grants calendar metadata and settings this workflow never touches.
The scope valuehttps://www.googleapis.com/auth/calendar.events.readonly - 04
Poll a narrow time window — this is not the anti-pattern Google warns about
Google's own quota guide tells you not to repeatedly poll a calendar and to use push notifications instead — but that advice is about watching for content changes (events added or edited). This workflow needs something Calendar has no push mechanism for at all: a "meeting starts in 30 minutes" alert. reminders.overrides only fires to email or popup, never a webhook, so there is no native way to be told when wall-clock time crosses a threshold. Polling a 30-minute window every 5–10 minutes is the only path, not a shortcut around the documented best practice. Both timeMin and timeMax are exclusive bounds and both must carry a timezone offset — a bare timestamp is rejected. singleEvents=true is required before orderBy=startTime is accepted; without it, a recurring meeting comes back as one recurrence rule instead of the specific instance starting soon.
GET /calendar/v3/calendars/primary/eventsGET https://www.googleapis.com/calendar/v3/calendars/primary/events ?timeMin=2026-08-08T09:30:00-04:00 &timeMax=2026-08-08T10:00:00-04:00 &singleEvents=true &orderBy=startTime &maxResults=10 Authorization: Bearer {access_token}
Notion
One of two document/issue sources matched against the meeting
- 01
Create the integration at the current URL
The old notion.so/my-integrations now 301-redirects — create the connection at app.notion.com/developers/connections instead (Developer portal → Connections → New connection; requires workspace-owner permission). Under Capabilities, check only Read content; this workflow never writes to Notion.
- 02
Add the connection to every page or database it needs
A new connection starts with access to nothing. Open each project page or database to be matched against → "•••" menu → Add connections → search for and select the connection by name. There is no workspace-wide grant — repeat per page or database.
- 03
(Optional) Record the project database's data source id
If the team keeps a structured "Projects" database with a Relation or Select property per project, retrieve it once and keep its data_source_id — querying that by property filter is more reliable than the title search in the flow below, since it is not limited to substring matching.
- 04
Know the shape of the title search
query matches only page and data_source titles, not page body content, and it is a substring match with no fuzzy tolerance. Take the meeting title (or its core phrase, stripped of dates and meeting-series numbers) as query — a wider search only pulls back noise, it does not increase the chance of finding the right doc.
POST https://api.notion.com/v1/search · Notion-Version: 2026-03-11{ "query": "Q3 Roadmap Sync", "filter": { "value": "page", "property": "object" }, "sort": { "direction": "descending", "timestamp": "last_edited_time" }, "page_size": 5 }
Linear
The other document/issue source matched against the meeting
- 01
Create a personal API key
Linear → Settings → Security & access → Personal API keys → New API key. Send it as the raw Authorization header value — no "Bearer " prefix, the most common source of a 400 here.
- 02
Look up team ids once
Run a one-off teams { nodes { id key name } } query and store the ids the polling job needs — every Linear GraphQL call is addressed by id, not by name.
- 03
Use searchIssues, not the deprecated issueSearch
The schema still defines issueSearch but flags it "[DEPRECATED] ... use searchIssues instead" — a pre-2025 tutorial using it is pointing at an endpoint on its way out. searchIssues runs full-text and vector search, optionally weighted with teamId, and carries its own rate limit — 30 requests/minute — separate from the account's general 5,000-requests/hour key limit. When several meetings land in the same poll window, queue their searchIssues calls rather than firing them concurrently; the 30/min ceiling bites well before the general limit does. If the meeting title is itself a project name, searchProjects first and pull that project's issues instead — it is more precise than a full-text search over every issue title.
searchIssuesquery MeetingRelatedIssues($term: String!) { searchIssues(term: $term, first: 10) { nodes { identifier title state { name } url } totalCount } } # variables { "term": "Q3 Roadmap Sync" }
Slack
Delivers the pre-read as a direct message
- 01
Create the app with two Bot Token Scopes
api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes: chat:write and users:read.email. Skip im:write and channels:manage — chat.postMessage's channel parameter accepts a Slack user id directly and opens the DM itself, so there is no separate conversations.open call to make first.
- 02
Install to workspace
OAuth & Permissions → Install to Workspace → copy the xoxb- Bot User OAuth Token.
- 03
Know the DM payload shape
channel takes the Slack user id returned by users.lookupByEmail, not a channel id — Slack opens the DM conversation on the first post if one is not already open.
POST https://slack.com/api/chat.postMessage{ "channel": "U024BE7LH", "text": "Pre-read for Q3 Roadmap Sync (starts in 25 min)", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Q3 Roadmap Sync* — starts in 25 min\n\n*Notion docs:*\n• <https://notion.so/...|Q3 Roadmap Draft>\n\n*Linear issues:*\n• <https://linear.app/acme/issue/ROAD-88|ROAD-88> In Progress" } } ] }