An intelligent middleware for capturing thoughts and organizing them in Notion.
Synapse eliminates the friction of manual data entry in Notion. It accepts unstructured, natural-language text, uses a multi-step AI chain (parse → classify → extract) to understand and structure the content, and then routes it to the correct database or page in a Notion workspace. The entire project is written in Python and deployed on Modal.
- Modal hosts everything.
app.pyis the single deployment shim — image, secrets, endpoints. All business logic lives insrc/core/as plain Python with no Modal imports. webhook— a proxy-authedfastapi_endpoint. Callers sendModal-Key+Modal-Secretheaders; unauthorized requests are rejected at Modal's edge for free. It validates the payload andspawn()s the worker — spawn IS the queue (no Pub/Sub).process— the background worker (timeout=600,memory=512,max_containers=1to serialize runs since Notion dedupe is query-then-create, retries with backoff). Runscore.pipeline.run.- Gemini (
gemini-3-flash-preview, env-overridable viaGEMINI_MODEL, with automatic fallback toGEMINI_FALLBACK_MODELon a 404) does parsing, classification, and extraction with structured JSON output. - App secrets are env vars only: the Modal secret
synapsein the cloud,op runlocally..env.tplis the canonical manifest (op:// refs, committed) — the app's own provider keys only. - Workspaces hold everything that belongs to one user: their Notion ids, allowlists and wording, place tags, property-id map, and their Notion + life-data credentials. They live in the
synapse-stateVolume, edited withjust workspace ...; each device token files its captures into one workspace. The repo carries only the generic template.
flowchart LR
R["Receptor<br/>(iOS/macOS Shortcut)"] -->|"POST {raw_text}<br/>Modal-Key / Modal-Secret"| W["webhook<br/>(Modal fastapi_endpoint,<br/>proxy auth)"]
W -->|"process.spawn()"| P["process worker<br/>(core.pipeline.run)"]
P -->|"parse / classify / extract"| G["Gemini"]
P -->|"enrichment"| X["Spotify · YouTube ·<br/>web scrape"]
P -->|"create / update pages"| N["Notion databases"]
P -->|"outcome logs"| L["Notion Logs DB"]
uv sync # install everything
just test # unit tests (no network)
just check # ruff lint + format check
just dev # live-reload deploy against real Modal infra (modal serve)
just test-integration # real Gemini calls (key via 1Password)
just deploy # test + sync-secrets + modal deploy
just recept "Buy eggs $ groceries" # send one thought to the deployed webhookLocal runs against real services use 1Password injection — never plaintext on disk:
op run --env-file=.env.tpl -- uv run <cmd>Everything else is code; these are one-time console/dashboard actions:
- Modal auth (local):
uv run modal token new. - Modal Proxy Auth Token: Modal dashboard → Settings → Proxy Auth Tokens → mint a token. Give the token ID/secret to the Receptor client (and store them on a 1Password item of your choosing). The webhook rejects requests without
Modal-Key/Modal-Secretheaders. - Google API key: mint a YouTube Data API v3 key in the Google Cloud console (APIs & Services → Credentials) and put them on the 1Password item that
.env.tplreferences. - CI secret:
gh secret set OP_SERVICE_ACCOUNT_TOKENwith a 1Password service-account token that can read the project's vault (the one.env.tplreferences). - Push secrets to Modal:
just sync-secrets(reads.env.tpl, injects viaop, creates/updates thesynapseModal secret). - Notion select options: every
allowlistvalue indatabases.yamlmust exist as an option on the live Notion select/multi_select/status property (add missing ones in the Notion UI). Hydration intersects allowlists with live options and prints a⚠️ ... allowlist options missing from Notion selectwarning for any value it had to drop; the AI can never pick a dropped value. The Fun ActivitiesLocationallowlist is personal config: the committed yaml carries generic example cities — setNOTION_FUN_ACTIVITIES_LOCATIONS(comma-separated, in the env item.env.tplreferences) to your real city list. - Executions DB
Tagsproperty: aTagsmulti_select with theproject-appendoption must exist on the Executions DB.
The whole pipeline is YAML-driven. The template defines every category and its rules; a workspace's overlay supplies what is specific to it (db_id per Notion-backed category, the top-level db_ids for logs/trips/projects/notes, its own allowlists or wording, tasks.place_tags). just workspace pull <id> <dir> / push <id> <dir> round-trip an overlay.
To add a new Notion database category:
- Add the category definition to
template/databases.yaml(no ids), then add itsdb_idto each workspace's overlay andjust sync-prop-ids <id>.
| Field | Required | Usage |
|---|---|---|
description |
✅ Yes | The Classifier Prompt. Used by the AI to decide if an incoming item belongs to this category. |
helper |
No | true marks a helper DB (trips, logs, youtube-channels) that is only related to, never a classification target. |
properties |
✅ Yes | Maps exact Notion column names to their rules. |
type:title,rich_text,rich_text_list,url,date,select,multi_select,status,relation,boolean.required:trueforces the AI to produce a value.instruction: the extraction prompt for this field. Placeholders:{current_date}(Eastern time),{raw_text}. Fordatefields the instruction MUST demand ISO 8601 —notion_utils._notion_dateraises on anything else.virtual:truehides the field from the AI; Python fills it.allowlist: strict enum for select/multi_select/status (intersected with live Notion options at runtime — see manual setup step 6: the options must also exist in Notion).create_new:truelets the AI invent new values beyond the allowlist.
- Core Syntax
@splitter: separate multiple distinct items in one message.$context: define the Project, Date, Status, or category hint.
- Defaults (if not specified)
- Tasks: Status
To Do| TagChore| PriorityHigh| DateToday(Eastern) - Movies/TV:
Not Started· YouTube:Watched· Podcasts:Not Started· Fun Activities:To Do· Groceries:On List
- Tasks: Status
- Category cheatsheet
- Tasks (default):
Update dating profile - Projects:
Refactor code $ Synapse(strict: must name the project in context; use "note"-flavored phrasing for project notes) - URLs: auto-route to YouTube, Podcasts (Spotify/TAL), or Bookmarks
- People:
Will Barlow Theo's Friend - Dates/status:
Cancel Uber One $ Jan 1·The Matrix $ movie priority
- Tasks (default):
- Batch example:
Arun Vantage Senior Associate @ https://youtu.be/xyz @ Buy eggs $ groceries
app.py # the ONLY file that imports modal
src/core/ # business logic (pipeline, ai_engine, handlers, notion_utils, ...)
src/core/template/ # generic Notion schemas, extraction rules and prompts
src/core/workspace.py # a user's overlay + credentials, the active workspace
store.py # durable key -> JSON store on the synapse-state Volume
tests/ # pytest suite (unit + test_integration.py for real Gemini)
scripts/ # one-off clients for the deployed webhook
.env.tpl # secrets manifest (op:// refs)
Push to main → GitHub Actions runs pytest -m "not integration" and modal deploy app.py (Modal tokens loaded from 1Password). Manual: just deploy.
Synapse uses a dedicated Google Cloud project and a dedicated API key restricted
to generativelanguage.googleapis.com. Enable that API, attach the project
to the intended billing account, and store the key as GEMINI_API_KEY in
this project's environment. Never copy an API key from another application.
Gemini request quotas are per project, so separate keys in one project do
not isolate quota (Gemini rate limits).
Verify a small generateContent request with the configured model before
syncing a replacement key; listing models does not verify billing credits.