Makes an entire Notion workspace reachable through Gemini Live voice, which can only read/write Google Workspace. Notion is mirrored one-way into Google Drive as a rich, read-only set of Docs (fast retrieval), and changes are made through a Google Tasks command inbox that this service relays to an existing Notion API.
A Notion page body can't be faithfully represented in a Google Doc (callouts, toggles, tables, columns, nested blocks, databases/views have no clean equivalent). A two-way content mirror would eventually write a lossy Doc back and destroy the rich Notion structure. So:
- Reads: Notion → Google Docs (read-only reflection). Gemini Live reads Docs.
- Writes: Gemini Live writes a Google Task (the one surface it can write by
voice). This service reads the task, relays it as an HTTP call to the existing
Alistair Skills API (which holds the full Notion write toolset), completes the
task with a
✓/✗receipt, and re-reflects the affected page. Google can never silently corrupt Notion.
Gemini Live can't call custom HTTP APIs / MCP directly (that's Enterprise/CLI only), and Gems don't work in Live — only "Saved Info" loosely steers it. The Tasks inbox + receipt is the workable write path; confirmation is a follow-up turn ("did that go through?").
Google Tasks "Notion Commands" ← Gemini writes one JSON request per task
Drive: Notion Mirror/ ← read-only reflection
_Commands (Doc) how to write + allowed paths + name→id catalog
_Dashboard (Doc) compact Areas/Projects/Actions list with ids
_Intray (Microsoft To-Do) (Doc) read-only mirror of the MS To-Do in-tray
Areas/<Area>/<Project>/….gdoc rich bodies, recursed into nested blocks
References/ Briefing/ Horizons/ Library/
| Layer | Cadence | Purpose |
|---|---|---|
poll_commands |
~30 s | run pending command tasks (Tasks has no push) |
poll_incremental |
~2 min | reflect every page changed since the watermark (deep sub-pages incl.) via Notion /search; also regenerates _Dashboard/_Commands + drops deleted/archived spine items |
full_reconcile |
daily, 04:00 (cron) | backstop only: deep-page deletions, orphan pruning, drift healing, regenerate Docs |
| per-command re-reflect | instant | refresh the page a command just changed |
| Notion webhook | optional | near-instant reflection of hand edits (off by default) |
poll_incremental catches edits at any depth (each page bumps its own
last_edited_time), so the expensive full crawl no longer needs to run often — it
holds the mirror lock for minutes, so keeping it infrequent is what stops commands
from being starved.
Propagation is incremental — only content whose hash changed is rewritten.
Gemini puts one JSON request in a task's notes (see the generated _Commands
Doc for the live schema + ids):
{ "path": "/api/notion/create-pages",
"body": { "parent": {"database_id": "<Actions db id>"},
"properties": { "Name": "Email Bob", "Action Status": "Next",
"Due": "2026-06-25", "Project": ["<project id>"] } } }The relay is guarded: only RELAY_ALLOWED_PATHS are callable (so a task can't
reach github/push-file or deletes), and update-page replace_content is blocked
unless force:true.
Non-voice clients can call the synchronous endpoint instead:
curl -X POST "https://<host>/command?key=$ADMIN_API_KEY" \
-H 'content-type: application/json' \
-d '{"path":"/api/notion/update-page","body":{...}}'Two parts: a one-time laptop step to mint the Google refresh token, then a deploy from GitHub (Railway) that you can do entirely from mobile.
- Google Cloud project: enable the Drive, Docs, Sheets, and Tasks APIs;
configure the OAuth consent screen (User type External, add your own Gmail as
a Test user); create an OAuth Client ID of type "Desktop app" and download its
JSON (this becomes
GOOGLE_CREDENTIALS_JSON). The app requests these scopes:drive,documents,spreadsheets,tasks. - Notion: a read-only internal integration token (
NOTION_API_TOKEN), and the integration must be shared with the PARA databases + the loose pages it mirrors.
bootstrap auth opens a browser for consent and catches a localhost redirect, so it
can't run on mobile or in the container. Everything after this is mobile-friendly.
uv venv --python 3.11 && uv pip install -e ".[dev]"
uv run pytest
cp .env.example .env # fill GOOGLE_CREDENTIALS_JSON, NOTION_API_TOKEN, RELAY_API_KEY, ADMIN_API_KEY
uv run python -m scripts.bootstrap auth # browser consent → prints GOOGLE_OAUTH_REFRESH_TOKEN
uv run python -m scripts.bootstrap init # prints GOOGLE_DRIVE_MIRROR_FOLDER_ID + GOOGLE_INDEX_SHEET_IDKeep the three printed values — they go into Railway next. (You can also run
bootstrap mirror locally for the first reflection, or just let Railway do it via
/admin/full-sync below.)
- Railway → New Project → Deploy from GitHub repo → this repo + branch. It builds
from the provided
Dockerfile/railway.json(start command uses$PORT). - Add a Volume mounted at
/data(the SQLite ledger lives at/data/ledger.db). - Set environment variables (from
.env.example):NOTION_API_TOKEN(read-only),ENABLE_NOTION_WEBHOOK=falseGOOGLE_CREDENTIALS_JSON,GOOGLE_OAUTH_REFRESH_TOKEN(Part A),GOOGLE_DRIVE_MIRROR_FOLDER_ID,GOOGLE_INDEX_SHEET_ID(Part A)RELAY_API_BASE_URL=https://web-production-2144c.up.railway.app,RELAY_API_KEYADMIN_API_KEY(you choose),LEDGER_DB_PATH=/data/ledger.db- (
RELAY_ALLOWED_PATHS/RELAY_DEFAULT_PATH/ poll cadences keep their defaults)
- After deploy:
curl https://<host>/health, then trigger the first reflection:curl -X POST "https://<host>/admin/full-sync?key=$ADMIN_API_KEY".
Then paste docs/SAVED_INFO.md into Gemini → Settings → Saved info.
create-pages parent ids (validated live): use the database ids, not the internal
collection://data-source ids — Areascollection://dfa76d06-073b-4493-9f96-319a9f088a5e, Projectscollection://b9c0cd8c-fa6c-46d1-95ed-87d7ef97d971, Actionscollection://2ebc58c5-8617-4748-8021-fcc2a37d3a97. The generated_CommandsDoc embeds these plus the live name↔id catalog.
📖
docs/HOW_IT_WORKS.mdis the dense, glanceable reference for the whole system — flow direction & timing, the exact Drive structure + placement rules, identity/self-healing, and every limitation (e.g. actions have no body mirrored). Read that for the nitty-gritty; the Operating Guide below is the quick version.
The final, verified rundown of day-to-day behaviour (validated live, 2026-06).
Reads come from the Drive Docs mirror; writes go through the Google Tasks inbox → guarded relay → Alistair API. One JSON request per task.
| Action | How | Endpoint / command |
|---|---|---|
| Read anything | reads the mirror Docs | _Dashboard, _Commands, per-page .gdoc, _Intray |
| Create action / project / sub-page | create-pages |
/api/notion/create-pages |
| Set properties (status, due, relation) | update_properties |
/api/notion/update-page |
| Append a note to a body | insert_content |
/api/notion/update-page |
| Edit / rewrite page text | update_content (content_updates: [{old_str, new_str}]) |
/api/notion/update-page |
| Microsoft To-Do add / complete / clear | add / done / delete |
/api/intray |
Gemini reads the page's mirror Doc → summarises → sends update_content with a
content_updates array of {old_str, new_str} edits (old text → refined text).
This is a real content replacement and is safe (fail-safe: it errors on a
multi-match/cross-block/sub-page-delete rather than clobbering). It needs the old
text matched exactly, so it's reliable for short/medium notes; long messy pages may
need a couple of tries. A blind whole-body wipe (replace_content) is blocked
(see below).
- Delete or archive Notion pages — the Alistair API has no such endpoint;
these commands return
✗ unsupported. Deletion is manual (do it in Notion); the nextfull_reconcilethen trashes the mirror Doc/folder + clears the row. - Blind whole-body replace (
replace_content) — blocked by the relay guard (footgun: it can also delete nested sub-pages/DBs). Returns✗unlessforce:true. Useupdate_contentinstead. - Rename a sub-page by title — relay quirk clears the title; renaming database items (areas/projects/actions) is fine.
- Gemini Live itself can't write Sheet cells / Doc bodies or call HTTP/MCP directly —
only the Tasks inbox. Confirmation is a follow-up turn ("did that go
through?") that reads the
✓/✗receipt; Live can't block mid-turn on the result.
| Change | Reflects in | Mechanism |
|---|---|---|
| Voice write (any command) | ~30–60 s | poll_commands (30 s) → relay → receipt + instant re-reflect of that page |
| Gemini chaining edits on its own change | wait for the receipt first (~30–60 s) | new ids appear in _Dashboard/_Commands after re-reflect; then build on top |
| Manual edit — any page edit (incl. nested sub-pages) | ~2 min | poll_incremental (Notion /search by last_edited_time, any depth) |
| Manual rename / move | ~2 min | poll_incremental (rename-in-place / move via ledger id) |
| Manual delete/archive of a spine item (area/project/action) | ~2 min | poll_incremental spine sweep |
| Manual delete of a deep sub-page, new deep subtree, orphan cleanup | next 04:00 | daily full_reconcile backstop (search can't report deletions) |
| (optional Notion webhook, off) | near-instant | Phase 2 |
Drive objects are addressed by ledger id, not name:
- Two new same-named items → distinct folders/Docs (no collision).
- Rename → same folder/Doc renamed in place (no orphan); move → relocates in place; delete → detected, Doc/folder trashed + row cleared + pair tombstoned.
- Same-named items under different parents are kept separate (normal).
- Pre-existing already-merged collisions aren't retroactively split.
- Per-item isolation — one bad page is counted
failedand skipped, never aborting the run; an unreadable body degrades to a placeholder and continues. - Verify-before-delete — each vanished page is re-fetched before removal, so a partial/failed crawl can never false-delete a page that still exists.
- Google 429 / Sheets — row cache + exponential backoff/retry; mirror jobs are serialized (httplib2 isn't thread-safe) with a 60 s HTTP timeout. No clashes with rate limits by design.
- No push anywhere → polling only (Tasks 30 s; Notion 3 min / 30 min). Receipts
(
✓/✗, bad id →✗) are written back into the task notes.
POST /admin/full-sync (background) + GET /admin/sync-status · POST /admin/reset-ledger · GET /admin/drive-tree · GET /admin/read-doc?id= · GET /admin/read-tab?tab= · POST /admin/test-command · GET /admin/list-commands.