GeniffyDocs
Changelog Log In Get a key

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

Terminal
pip install httpx geniffy

Set GENIFFY_API_KEY from API keys in the Geniffy app.

The sync

notion_sync.py
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.

Last updated October 6, 2026