A connector reads one source and yields Document rows in the shared schema.
That’s the whole extension surface; everything downstream (embedding, hybrid
retrieval, permissions, the agent, MCP) works the moment your rows land.
One connector per source, never per person. A teammate is not a connector. Identity is a property of the database session, and a row policy on the table filters every read by it, so everyone queries the same brain and sees their own permitted slice of it. See PERSONAL_VS_TEAM.md.
1. Copy the template and implement fetch().
cp team_brain/connectors/TEMPLATE.py team_brain/connectors/notion.py
from collections.abc import Iterable
from datetime import UTC, datetime
from team_brain.schema import Document
class NotionConnector:
source = "notion"
def __init__(self, workspace: str) -> None:
self.workspace = workspace
def fetch(self) -> Iterable[Document]:
for page in read_my_pages(self.workspace): # your API calls
yield Document(
source=self.source,
external_id=page.id, # stable → upsert key
title=page.title,
body=page.text,
created_at=page.updated_at.astimezone(UTC), # tz-aware!
url=page.url,
author=page.author,
# For private content:
# visibility="restricted", acl=["alice", "bob"],
)
2. Register a builder in team_brain/connectors/__init__.py:
def _build_notion(args: list[str]) -> Connector:
from team_brain.connectors.notion import NotionConnector
return NotionConnector(args[0])
register("notion", _build_notion)
3. Ingest.
uv run team-brain ingest notion my-workspace
uv run team-brain ask "what did we decide about pricing?" --user alice
fetch() must guarantee)| Field | Rule |
|---|---|
source |
matches your connector’s source |
external_id |
stable within the source, re-ingesting the same item updates in place, and anything you stop emitting gets tombstoned |
created_at |
timezone-aware (use datetime.now(UTC) if the source has no date) |
visibility / acl |
"public" for everyone; "restricted" + acl=[members] for private content. Restricted + empty acl = visible to nobody (fail-closed) |
domains |
group-based access labels (e.g. ["ops"], ["ops","marketing"]). Empty = company-wide. A caller sees a labeled doc only if their groups grant one of these domains. Stamp it from the source (a channel, a repo, a --domain arg), never from a person. Enforced at query time; see PERSONAL_VS_TEAM.md and team_brain/access.py |
metadata |
free-form; set {"kind": "code"} to make items show up in search_code |
fetch() returns a full snapshot of the current source state. Ingestion
upserts everything you yield and tombstones anything for your source that you
no longer yield, so deletes and now-private items drop out automatically.
If your source is noisy (chat, comments), distill before embedding; see how
team_brain/connectors/slack.py calls enrich_thread() and stores the result
in metadata["_enriched"]. The ingest path embeds the distilled text when
present and falls back to title + body otherwise.
The parametrized contract test (tests/test_connector_contract.py) automatically
holds every registered connector to the contract; add an offline build entry and
you get coverage for free.