Sidekick listens to professional conversations and turns them into trustworthy, provenance-backed, long-horizon memory — then, once trusted, acts on it through your tools via MCP, behind a confirmation gate. 90% of the system is commodity glue; the memory layer is the entire company.
Six stages, but effort is deliberately lopsided. Capture and transcription are rented or thin; the defensible ground is Remember + Connect — the moat.
flowchart LR A["🎙 Capture
any sensor"]:::commodity --> B["📝 Understand
STT + diarise"]:::commodity B --> C["🧠 Remember
commitments + graph"]:::moat C --> D["🤝 Connect
people · time · contradictions"]:::moat D --> E["⚡ Suggest
stage behind the gate"]:::yours E --> F["✅ Act
MCP · gated + audited"]:::yours classDef commodity fill:#e8e4da,stroke:#9a968c,color:#232220; classDef moat fill:#0e7c76,stroke:#0a5d58,color:#ffffff; classDef yours fill:#5b4bd6,stroke:#4a3db0,color:#ffffff;
A TypeScript monorepo (npm workspaces) over Postgres + pgvector, with a native Swift capture client and thin local MCP tool-servers. Offline-first: whisper.cpp STT, sherpa-onnx diarisation, Ollama for the LLM.
flowchart TB
subgraph SENSORS["Sensors · P8 (interchangeable)"]
IOS["apps/capture-ios
Swift · record → encrypt → ship"]:::commodity
MIC["room mic · manual upload"]:::commodity
end
subgraph WEB["packages/web · Vite + React"]
DASH["Dashboard
Memory · Review · Capabilities · Activity"]:::yours
end
subgraph API["packages/api · Fastify (read layer · planner · gate · audit)"]
CAP["POST /v1/captures"]:::yours
AUTH["auth · per-request owner ctx"]:::scale
READ["/v1/meetings · /recall · /commitments · /audio"]:::yours
PAR["/v1/capabilities · /activity · /proposals"]:::act
end
subgraph MEM["packages/memory · THE MOAT"]
STT["STT seam
whisper · deepgram · assemblyai"]:::commodity
DIA["diarise · sherpa-onnx"]:::commodity
EXT["extract → commitments + questions"]:::moat
IDN["identity · temporal · 3-modality recall"]:::moat
EMB["embeddings · pgvector"]:::moat
PA["PA: capability switches · email triage/answer/trust · inbox loop"]:::act
GATE["executor · autonomy · MCP client"]:::act
end
subgraph DATA["Postgres 16 + pgvector · system of record"]
DB[("schema.sql + gate.sql
RLS per owner")]:::data
BLOB[("BlobStore · AES-256-GCM audio")]:::data
end
subgraph TOOLS["tools/ · maintained MCP servers"]
MAIL["mcp-email · drafts"]:::commodity
GMAIL["Gmail MCP · read + send (OAuth)"]:::commodity
GH["GitHub / Linear / Calendar / LinkedInPoster"]:::commodity
end
IOS --> CAP
MIC --> CAP
DASH -->|Bearer token| API
CAP --> STT --> DIA --> EXT --> IDN --> EMB
READ --> DB
EXT --> DB
PA --> GATE
GATE -->|@modelcontextprotocol/sdk| MAIL
GATE --> GMAIL
GATE --> GH
PA --> GMAIL
CAP --> BLOB
API --> DB
classDef commodity fill:#efece4,stroke:#9a968c,color:#232220;
classDef moat fill:#0e7c76,stroke:#0a5d58,color:#ffffff;
classDef yours fill:#1f6f68,stroke:#0a5d58,color:#ffffff;
classDef act fill:#5b4bd6,stroke:#4a3db0,color:#ffffff;
classDef scale fill:#8a6d1f,stroke:#6e561a,color:#ffffff;
classDef data fill:#2b2a27,stroke:#000,color:#ffffff;
The throwaway sensor funnels through one authenticated contract; STT + diarisation produce provenance-grade utterances; the LLM extracts commitments — under-asserting anything it can't ground.
sequenceDiagram autonumber participant S as Sensor (iOS / mic) participant API as API · POST /v1/captures participant B as BlobStore (AES-256-GCM) participant STT as STT + diarise participant DB as Postgres (RLS) participant LLM as LLM extract S->>API: multipart audio · capture_id · sensor · Bearer API->>B: encrypt + store (per-owner namespace) API->>STT: transcribe (word timestamps + speakers) STT-->>DB: utterances (ms offsets = provenance) Note over API,DB: P7 consent — a non-consenting speaker is RETAINED but never processed API->>LLM: extract over consenting utterances LLM-->>DB: commitments (facts) + commitment_questions Note over DB: P3 — every fact links its source utterance + audio offset
The single most important rule: a claim that can't render its provenance chain is surfaced as a question, not asserted as a fact. Facts and questions live in separate tables. This is what makes the recall trustworthy enough to act on.
flowchart TD U["utterance
(text + audio offset)"] --> EX{"extract · validate · gate"} EX -->|"grounded + confident (P4)"| F["commitment — FACT
debtor → creditor · modality · status · confidence"]:::fact EX -->|"ungrounded / low-confidence"| Q["commitment_question
surfaced, never asserted"]:::q EX -->|"small talk / vague"| X["dropped"]:::drop F --> P["▶ provenance — play the exact 8 seconds"]:::prov Q --> P classDef fact fill:#0e7c76,stroke:#0a5d58,color:#fff; classDef q fill:#b9791a,stroke:#8a5b14,color:#fff; classDef drop fill:#efece4,stroke:#9a968c,color:#232220; classDef prov fill:#fff,stroke:#0e7c76,color:#0e7c76;
One question, routed across three lookup paths. pgvector is one path of three — never the asserting one (P2).
flowchart LR Q["question
'what did Simon commit to?'"] --> R{route} R -->|who owes what| REL["relational
SQL over commitments + entities"]:::moat R -->|what changed| TMP["temporal
supersession chains"]:::moat R -->|sounds like this| FUZ["fuzzy
pgvector → rows"]:::idx REL --> A["answer + ▶ provenance"]:::prov TMP --> A FUZ --> A classDef moat fill:#0e7c76,stroke:#0a5d58,color:#fff; classDef idx fill:#1f6f68,stroke:#0a5d58,color:#fff; classDef prov fill:#fff,stroke:#0e7c76,color:#0e7c76;
Supporting subsystems: identity resolution (speakers → entities, human-in-the-loop, MEM-2), temporal (supersede + contradiction detection, MEM-3), embeddings (Ollama / OpenAI, MEM-6).
Every external mutation flows proposed_actions → executor → audit (P5). The executor is the
only thing that touches a real tool, and it refuses while SUGGEST_ONLY is on — the kill switch.
flowchart LR C["commitment"] --> PL["planner"] PL --> ST["proposed_actions
status = pending"]:::gate ST -->|reject| RJ["rejected"]:::drop ST -->|approve / auto-approve| AP["approved"]:::gate AP --> KS{"SUGGEST_ONLY ?"} KS -->|"true · kill switch"| HOLD["held — nothing dispatched"]:::drop KS -->|false| EX["executor → MCP server"]:::act EX --> TOOL[("Gmail · GitHub · Calendar · LinkedInPoster")]:::data EX --> AU["audit_log · before/after · reversible (undo)"]:::gate classDef gate fill:#b9791a,stroke:#8a5b14,color:#fff; classDef act fill:#5b4bd6,stroke:#4a3db0,color:#fff; classDef drop fill:#efece4,stroke:#9a968c,color:#232220; classDef data fill:#2b2a27,stroke:#000,color:#fff;
| Tier | Classes | Gate behaviour |
|---|---|---|
| 0 · read | check_email · read_calendar | context only — never asserts a commitment (P2) |
| 1 · artifact | draft_email · answer_email · sort_email · propose_meeting · create_reminder · draft_linkedin_post · stage_issue | reversible — auto-approve eligible once proven |
| 2 · outward | send_email · send_reply · send_meeting_invite · publish_linkedin_post | irreversible — always confirm; never auto without trust |
The autonomy ladder is a user-facing switch per capability: Off / Suggest / Auto. Two invariants keep "Auto" safe: Auto = auto-approve, never gate-bypass; and the switch is a ceiling — a per-item trust gate can only lower it.
flowchart TD SW["capability_settings
(per tenant)"] --> M{mode} M -->|off| SKIP["skip — not staged"]:::drop M -->|suggest| PEND["stage · pending"]:::gate M -->|auto| K{"SUGGEST_ONLY on?"} K -->|"yes (Inv-1)"| PEND K -->|no| RV{reversible?} RV -->|"yes (artifact)"| TD{"trust = deny?"} RV -->|"no (outward)"| TA{"trust = allow?"} TD -->|no| AA["auto_approve"]:::act TD -->|"yes (Inv-2)"| PEND TA -->|"yes · trusted + low-stakes"| AA TA -->|"no (Inv-2)"| PEND classDef act fill:#0e7c76,stroke:#0a5d58,color:#fff; classDef gate fill:#b9791a,stroke:#8a5b14,color:#fff; classDef drop fill:#efece4,stroke:#9a968c,color:#232220;
The capability that makes the switches do something. npm run pa -- loop reads the inbox
(Gmail MCP), triages it, drafts memory-grounded replies, and stages send_reply through the gate — where
the switch + the trust classifier decide. An ungroundable reply is never auto-sent.
flowchart TD IN["EmailSource.listInbox
Gmail MCP / fixture"]:::commodity --> TR["triageInbox · PA-2"]:::act TR -->|"needs_action"| TH["getThread"] TR -->|"newsletter / fyi / spam"| IGN["labelled, no reply"]:::drop TH --> DR["draftReply · PA-3.1
ground in memory (who · what's owed · what's settled)"]:::moat DR --> CF{confident?} CF -->|"no (P3/P4)"| DRAFT["force draft · pending
never auto-sent"]:::gate CF -->|yes| TG["emailTrustGate · PA-3.2
known contact? low-stakes?"]:::act TG --> DEC["decideForAction · send_reply switch"] DEC -->|"auto + trusted + routine"| SEND["auto-approve → queued to send"]:::sendc DEC -->|"new / sensitive / dense"| DRAFT DEC -->|"off"| SKIP["skip"]:::drop classDef commodity fill:#efece4,stroke:#9a968c,color:#232220; classDef moat fill:#0e7c76,stroke:#0a5d58,color:#fff; classDef act fill:#5b4bd6,stroke:#4a3db0,color:#fff; classDef gate fill:#b9791a,stroke:#8a5b14,color:#fff; classDef sendc fill:#1f6f68,stroke:#0a5d58,color:#fff; classDef drop fill:#efece4,stroke:#9a968c,color:#232220;
Isolation is enforced twice: the app scopes every query by owner_id, and Postgres
row-level security backstops it. The runtime drops to an unprivileged role per connection, so a missing
WHERE still can't leak across tenants. Unscoped ⇒ 0 rows (fails closed).
sequenceDiagram autonumber participant C as Client (Bearer) participant API as API participant ALS as runWithOwner (AsyncLocalStorage) participant PG as Postgres C->>API: request + session token API->>API: resolve token → user_id (sha256 at rest) API->>ALS: runWithOwner(user_id) ALS->>PG: SET app.user_id · SET ROLE sidekick_app PG-->>API: RLS-filtered rows (owner only) Note over PG: superuser/owner bypass RLS — so the runtime never connects as one Note over PG: unscoped connection → 0 rows (fails closed)
Postgres is the substrate (P2). Every table carries owner_id + RLS. Facts and questions are
distinct; embeddings are a secondary index keyed by subject_table/subject_id.
erDiagram
users ||--o{ meetings : owns
users ||--o{ capability_settings : configures
users ||--o{ retention_policy : sets
meetings ||--o{ utterances : has
utterances ||--o{ commitments : grounds
utterances ||--o{ commitment_questions : grounds
entities ||--o{ entity_aliases : "known as"
entities ||--o{ commitments : "debtor / creditor"
commitments ||--o{ proposed_actions : triggers
proposed_actions ||--o{ audit_log : audited
embeddings }o--|| utterances : "subject (polymorphic)"
users {
uuid id PK
text email
text role "member | admin"
}
commitments {
uuid id PK
text debtor
text creditor
text modality "firm|tentative|hypothetical"
text status "open|superseded|done"
float confidence
uuid owner_id
}
proposed_actions {
uuid id PK
text type
jsonb payload
text status "pending|approved|executed|failed"
uuid owner_id
}
capability_settings {
uuid owner_id PK
text capability PK
text mode "off|suggest|auto"
}
The phased build (0→4) is structurally complete and gate-green; the PA layer (autonomy switches + email brain + live connector) sits on top. What remains needs the real world, not more code.
| Layer | State | Notes |
|---|---|---|
| Phase 0 · Capture | built | iOS client (CAP-1) · backend ingest · whisper STT live; STT vendor verdict (DEC-1) open |
| Phase 1 · Memory (moat) | built | extraction · trust contract · identity · temporal · 3-modality recall · embeddings — precision gate (MEM-1.6) awaits a real labelled corpus |
| Phase 2 · Suggest | built | planner → gate → §8 demo runs (stages, never sends) |
| Phase 3 · Act | built | executor + real MCP draft_email (reversible) behind the kill switch · autonomy ladder · expanded action catalog |
| Phase 4 · Scale | built | multi-tenant RLS · consent enforcement · GDPR governance · repeatable onboarding · wedge profiles |
| PA · the assistant | built | PA-1 switches · PA-2/3 email triage/answer/trust · PA-4 dashboard · PA-5 Gmail connector + inbox→stage loop |
| Go-live | needs you | a Gmail MCP server + your OAuth · flip EMAIL_SOURCE=gmail-mcp + SUGGEST_ONLY=false |
| Quality | CI | vitest · eslint · GitHub Actions (INF-3) · per-layer verify gates |
Canonical sources: sidekick-architecture.md · MASTER-PLAN.md · TODO.md · schema.sql · gate.sql