Notion
Keep each of your users' Notion in their memory. Your app already holds a Notion token for each user, from
their sign-in to your Notion integration; this sync reads the pages that token can see and adds each one under
its own id with the label channel: notion. Run it on a schedule. A page that hasn't changed costs nothing,
a changed page teaches only what changed, a page that is gone takes what it taught with it, and when the user
disconnects Notion, one call forgets everything that came from it.
Install
pip install httpx geniffyuv add httpx geniffySet GENIFFY_API_KEY from API keys in the Geniffy app.
The sync
import httpx
from geniffy import Geniffy
geniffy = Geniffy() # reads GENIFFY_API_KEY
LABELS = {"channel": "notion"}
def notion(token: str) -> httpx.Client:
"""One user's Notion, with the token their sign-in to your integration gave you."""
return httpx.Client(base_url="https://api.notion.com/v1", timeout=30,
headers={"Authorization": f"Bearer {token}", "Notion-Version": "2026-03-11"})
def pages(api: httpx.Client):
"""Every page the user shared with your integration."""
body: dict = {"filter": {"property": "object", "value": "page"}, "page_size": 100}
while True:
out = api.post("/search", json=body).raise_for_status().json()
yield from out["results"]
if not out.get("has_more"):
return
body["start_cursor"] = out["next_cursor"]
def title_of(page: dict) -> str:
for prop in page.get("properties", {}).values():
if prop.get("type") == "title":
return "".join(t["plain_text"] for t in prop["title"]) or "Untitled"
return "Untitled"
def text_of(api: httpx.Client, block_id: str, depth: int = 0) -> list[str]:
"""A page's words, block by block, with blocks inside blocks two levels down."""
lines: list[str] = []
params: dict = {"page_size": 100}
while True:
out = api.get(f"/blocks/{block_id}/children", params=params).raise_for_status().json()
for block in out["results"]:
line = "".join(t["plain_text"] for t in block.get(block["type"], {}).get("rich_text", [])).strip()
if line:
lines.append(line)
if block.get("has_children") and depth < 2:
lines += text_of(api, block["id"], depth + 1)
if not out.get("has_more"):
return lines
params["start_cursor"] = out["next_cursor"]
def sync(user_id: str, token: str) -> int:
"""Bring one user's Notion into their memory. Returns how many pages it holds."""
mem = geniffy.space(f"user_{user_id}")
seen = set()
with notion(token) as api:
for page in pages(api):
text = "\n\n".join(text_of(api, page["id"]))
if text:
mem.memories.add(text, title=title_of(page), external_id=f"notion:{page['id']}", labels=LABELS)
seen.add(f"notion:{page['id']}")
mem.sources.delete_labelled(LABELS, keep=seen) # the rest: deleted in Notion, or no longer shared with you
return len(seen)
def disconnect(user_id: str) -> int:
"""The user disconnected Notion: everything that came from it goes. Returns how many pages."""
return geniffy.space(f"user_{user_id}").sources.delete_labelled(LABELS)How it behaves
- Each page is one source, under its Notion id. Sent again unchanged, it costs nothing; changed, only the paragraphs that changed are learned, and what was removed is taken back. See Your own ids.
- A page that is gone goes from memory too, with what it taught, whether it was deleted in Notion or the user stopped sharing it with your integration.
- Recall can keep to Notion, or leave it out:
mem.context(question, labels={"channel": "notion"}). See Labels. - Disconnecting forgets it all in one call, and nothing the user added another way.
The same shape works for any source your users connect: list what the token can see, add each item under its
own id with a label for the source, delete what is gone with delete_labelled(labels, keep=...), and delete by
the label when they disconnect.