Skip to documentation

eli workspace

End-to-end journeys

The three journeys below are code-traced walkthroughs, not aspirational flows. Every hop was verified against the repo source (file:line citations throughout), including the hops that turn out to be stubs or broken seams — those are called out honestly at the end of each journey.

Each journey gets a simplified overview (one flowchart plus a narrative naming the participating clusters) and a fully detailed reconstruction as sequence diagrams, so you can read it at whichever altitude you need. Together they prove the point of the whole architecture: the same cluster set composes into three very different products — a solo local tool, a hosted team product, and a machine-facing agent workspace.

Cluster legend used throughout (matching the per-cluster chapters):

IDCluster
C1Dual-mode data & storage core (lib/storage, lib/db, lib/index, lib/watch, lib/config)
C2Authoring & navigation UX shell (editor, note list, sidebar, palette, keybindings)
C3Hybrid search & embeddings pipeline (FTS5/tsvector, auto-embed queue, RRF)
C4AI platform (gateway, model registry, key resolver, chat/RAG endpoints, usage metering)
C5Agent-native surface (MCP stdio + HTTP, tools, PAT/local-token auth, /v1 API, rate limits)
C6SaaS platform & collaboration (Supabase auth, workspaces, invites, RLS, comments, realtime, shares)
C7Git, versioning & diff (commit loop, versions table, section diff/restore)
C8Capture & templating (clipper, bookmarklet, pairing, templates, daily notes)
C9Platform operations & delivery (cron digest, trash prune, SSE eventing, email, audit)

J1. The local-first knowledge loop

A solo user runs pnpm dev (next dev --turbopack -p 3737, package.json:15) in Local mode (ELI_MODE zod-defaults to local, env.ts:15). Markdown files on disk are the source of truth; SQLite is a rebuildable index.

Overview — simplified

J1 local-first knowledge loop — simplified overview

The loop starts in C1 + C2: boot registers the chokidar watcher (instrumentation.ts:13-28) and the vault picker runs the initial scan into the per-vault SQLite index (app/_actions/vault.ts:30-66, lib/index/scanner.ts:52-126). Authoring is C2 writing through C1's LocalAdapter (BlockNote → saveNoteAction → atomic .md write + versions row). Every save fans out to C3 (30s-debounced auto-embed → note_embeddingsBLOB) and C7 (debounced git auto-commit); external edits re-enter through C1's watcher and C9's SSE event stream. Search and chat are C3 + C4 (FTS + brute-force KNN fused by RRF; map-reduce RAG through the Vercel AI Gateway with optional function-calling tools). History and restore are C7 reading the SQLite versions table. The daily digest is C9 + C4 producing an ordinary vault note that re-enters the whole loop.

Stage 1 — First-run setup and vault selection

pnpm dev boots Next, and instrumentation.register() (nodejs runtime + local mode only) starts the chokidar watcher for the active vault — or silently returns when no vault exists yet (instrumentation.ts:13-28). The home page redirects to /v when a vault is active, otherwise renders the VaultPicker (app/(local)/page.tsx:15-17,39-50); the first readConfig() call mints ~/.eli/config.json with fresh localToken/mcpToken secrets (lib/config/index.ts:23-35,86-92). pickVaultAction validates the path, registers the vault (atomic tmp+rename write, app/_actions/vault.ts:30-54), then synchronously runs scanVault: walk all .md files, parse frontmatter/tags/wikilinks, content-hash skip, 500-row transactional batches into ~/.eli/index/<sha256-16>.db with FTS5 triggers (lib/index/scanner.ts:52-126,164-250; lib/db/client.sqlite.ts:31-83). LocalAdapter.db() also self-heals fresh vaults via a meta.initial_scan_at first-touch guard (lib/storage/local.ts:87-129).

Stage 2 — Create + edit a note in BlockNote → .md on disk

Cmd+Shift+N opens NewNoteDialog (path defaults to inbox/<slug>.md, lib/keybindings/registry.ts:40-42; components/new-note-dialog.tsx:38-46); createNoteAction LocalAdapter.createNote materialises the row and writes the file atomically with markEcho so the watcher ignores the self-write (lib/storage/local/writes.ts:196-229,35-49). The note page mounts BlockNote by default (components/editor/note-editor-root.tsx:27-36), which parses markdown→blocks on mount and debounce-saves 1.5s after every change (components/editor/blocknote-editor.tsx:80-104,133-153). saveNoteActionupdateNote does optimistic concurrency: a stale expectedVersionId parks the loser's body as an auto-conflict-snapshot version and surfaces a ConflictBanner (app/_actions/notes.ts:63-89; lib/storage/local/writes.ts:255-277). Each save appends a new versions row (parent chain), emits note:updated, and best-effort enqueues a git commit (lib/storage/local/writes.ts:308-337).

Stage 3 — Chokidar watch event → incremental index → FTS row updated

The watcher uses awaitWriteFinish(60ms) and a 750ms-TTL echo map to suppress self-writes (lib/watch/chokidar.ts:8-16,36-59). An external .md change runs updatePath: unlink deletes the row; add/change re-runs scanVault(prune:false) — a full hash-skip re-walk, explicitly flagged as fine for v1 (lib/index/incremental.ts:17-42). FTS5 stays in sync purely via the AFTER INSERT/UPDATE/DELETE triggers on notes (lib/db/migrations/sqlite/0001_fts.sql:23-38). The watcher then emits a ChangeEvent on the in-process events bus; /api/events streams it over SSE (25s heartbeats, no backlog replay) and VaultEventsBridge debounces 500ms then router.refresh() (app/api/events/route.ts:20-58; components/vault-events-bridge.tsx:26-48).

Stage 4 — Auto-embed queue → embedding → stored vector

scheduleEmbed keeps one timer per noteId in a globalThismap with a 30s re-armed debounce ("user is still typing", lib/ai/auto-embed-queue.ts:33,47-83). When it fires, runEmbed builds title + '\n\n' + body truncated to 30,000 chars (skipping <50-char notes) and calls embedTexts → AI SDK embedMany against the gateway with openai/text-embedding-3-large forced to 1536 dims (lib/ai/embeddings.ts:37-94; lib/ai/models.ts:31-40). Key resolution is local-BYOK first (~/.eli/config.json#aiKey) then AI_GATEWAY_API_KEY; no key → warn and drop (lib/ai/resolver.ts:35-74). The result is one Float32Array BLOB row per note in note_embeddingschunkText is deliberately not used here (lib/storage/local/embeddings.ts:104-142). Backfill surfaces: the /ai-key page's indexer card and pnpm embed:backfill (app/api/ai/embed/route.ts:22-68; scripts/embed-backfill.mjs:1-46).

Stage 5 — Search: UI → /api/search/hybrid → lexical + vector → RRF fusion

Three entry points: the note-list ?q= filter, the ⌘K palette (FTS-only), and the /search hybrid explorer with hybrid/fts/semantic tabs (app/(local)/search/page.tsx:20-33; components/command-palette.tsx:112-118). The route runs the FTS pass first (sanitised AND-joined prefix tokens, bm25 rank, <mark> snippets, trash excluded, lib/storage/local/search.ts:25-33,79-105), embeds the query, then brute-force cosine KNN over FTS candidates plus a global top-up pass (lib/search/hybrid.ts:89-116; lib/storage/local/embeddings.ts:212-257). Any vector-side failure degrades to FTS-only (lib/search/hybrid.ts:143-148). Fusion is weighted Reciprocal Rank Fusion, k=60, weights fts 0.7 / vector 0.3 (lib/search/rrf.ts:35,86-140 — verified: HYBRID_DEFAULT_WEIGHTS = { fts: 0.7, vector: 0.3 }).

Stage 6 — AI chat: tools → RAG over the vault → answer

/chat mounts VaultChat with threads persisted in vault SQLite (migration 0003_chat_threads) and two checkboxes: 'tools' and 'allow writes' (components/ai/vault-chat.tsx:262-311). The POST to /api/ai/chat hydrates thread history, hybrid-searches the last user turn (topK=8), chunks loaded bodies (chunkText 3500/250, cap 24), map-summarises each chunk with the fast tier (openai/gpt-5.4-mini) and reduces with the smart tier (anthropic/claude-opus-4.6) plus a cite-by-title system message (app/api/ai/chat/route.ts:170-300; tiers verified in lib/ai/models.ts:30-40). With tools enabled the model gets read_note/list_notes/search_notes (+create_note/update_note when writes allowed), looped with stopWhen: stepCountIs(6); agent writes land as versions with authorKind:'agent', agentName:'chat-tool' (lib/ai/tools.ts:61-237). The reply is a single JSON payload — the fully built streaming twin /api/ai/chat/stream has zero UI consumers (verified by grep; only the editor slash-menu streams, via /api/ai/draft).

Stage 7 — Git commit-loop auto-commit, then diff view + restore

enqueueCommit gates on ELI_DISABLE_GIT!=1, a real .git/ in the vault (eli never runs git init for you), and config git.auto (default true, debounce 30s, lib/git/commit-loop.ts:80-133). flushNow probes for system git (500ms git --version) → simple-git, else isomorphic-git; stages exactly the queued paths and commits eli: update <path> — agent batches authored as <agentName> <agentName@eli.local> (lib/git/detect.ts:19-92; lib/git/commit-loop.ts:139-182). Any flush failure permanently disables the loop in-process. Versions/diff/restore are SQLite-versions-table based, not git-based: the diff page computes diffVersions(a,b) → heading-anchored SectionDiff[] (app/(local)/v/diff/[...path]/page.tsx:36-63), and restoreSectionAction splices the source section into the current body as a NEW version that re-enters the whole loop (app/_actions/diff.ts:33-66). Whole-version restore exists only as the MCP restore_version tool (lib/mcp/tools/diff.ts:203-247) — no UI button.

Stage 8 — Daily digest over recent activity

Two triggers, one pipeline: Vercel Cron schedules GET /api/cron/digest at 13:00 UTC (vercel.json:3-7 — verified) with strict Bearer CRON_SECRET auth (unset secret → always 401, app/api/cron/digest/route.ts:46-51); locally the /v/digest empty-state literally prints the curl command, or the 'Generate now' button runs generateDigestNowAction (app/_actions/digest.ts:45-104). runDigestpaginates up to 5,000 notes, filters to yesterday's UTC window (?date=YYYY-MM-DD overrides it on both triggers), splits created/updated, counts top tags, and — when a key resolves — generates a Highlights narrative with the smart tier, metered via recordUsage (lib/ai/digest-runner.ts:42-100). The result is an idempotent upsert of digests/YYYY-MM-DD.md with type:digest frontmatter — a normal vault note that gets disk-written, FTS-indexed, embedded, and git-committed like any other (lib/ai/digest-runner.ts:114-135). No email is sent in either mode.

Full sequence — detailed

Part A — Boot, vault pick, first note (stages 1-2):

J1 detailed sequence, part A — boot, vault pick, first note (stages 1-2)

Part B — External edit, embed, hybrid search (stages 3-5):

J1 detailed sequence, part B — external edit, embed, hybrid search (stages 3-5)

Part C — Chat, git flush, diff/restore, digest (stages 6-8):

J1 detailed sequence, part C — chat, git flush, diff/restore, digest (stages 6-8)

Where the seams show

  • Watcher lazy-start is claimed but unimplemented. instrumentation.ts:8-11promises a lazy start "on the first request that actually touches storage", but startWatcher's only caller in the repo is instrumentation.register() (verified by grep). A first-run user who picks a vault gets no live FS watching until they restart pnpm dev; pickVaultAction never starts a watcher.
  • Vault picking blocks on the full initial scan pickVaultAction awaits scanVault synchronously inside the dialog action with no progress UI (scanVault's onProgress hook has no caller), and the picker is paste-a-path only — no native folder dialog, explicitly deferred (components/vault-picker.tsx:112-115).
  • Chat is not streamed to the UI. /api/ai/chat/stream (app/api/ai/chat/stream/route.ts:236-284) is fully built but no component fetches it — VaultChat uses the blocking JSON route. Only the editor slash-menu AI streams (via /api/ai/draft).
  • Auto-embed does not chunk. One whole-note vector from (title+body).slice(0,30000); lib/ai/chunking.ts is used only in chat map-reduce, so long notes lose tail content in semantic search.
  • Auto-embed covers only the UI save path scheduleEmbed's sole caller is saveNoteAction (app/_actions/notes.ts:96-105); createNoteAction, watcher-indexed external edits, and agent/chat write tools never schedule embeds, leaving those notes out of semantic search until the /ai-key indexer or pnpm embed:backfill runs.
  • createNote never enqueues a git commit — only updateNote calls enqueueCommit (lib/storage/local/writes.ts:330is the sole call site), so a brand-new note isn't committed until later edited; deletes/restores likewise.
  • Incremental indexing is a full re-walk per FS event (lib/index/incremental.ts:38-42) — hash-skip makes it cheap but it's O(vault) per event; flagged in-code as "fine for v1".
  • Commit-loop failure is sticky per process: one failed flush disables the loop; reenableCommitLoop exists with zero callers, and there is no git init affordance.
  • Local digest has no scheduler: vercel.jsoncrons only fire on Vercel; locally it's a manual curl with CRON_SECRET or the Generate-now button, and an unset CRON_SECRET makes the route always-401. No digest email exists in any mode.
  • No whole-version restore in the UI — only per-section; storage.restoreVersion is reachable only through the MCP tool. Git history is write-only bookkeeping in this journey (diff reads the SQLite versions table, never git log).
  • One shared rate-limit bucket for all local requests (rateKey='local') — bursts of hybrid searches can 429 the single local user.
  • BlockNote round-trip is lossy by design (blocksToMarkdownLossy, paragraph fallback on parse failure) and BlockNoteView is hard-pinned to theme="light" despite the app's dark mode. Some landing/chat copy is stale ("chokidar-driven live updates land in follow-on tasks" — they shipped).

J2. The SaaS collaboration loop

A team on the hosted product (ELI_MODE=saas; the (saas) route group 404s otherwise, app/(saas)/layout.tsx:6-8). Postgres + RLS replace disk + SQLite; the same StorageAdapter interface (C1) keeps everything above it identical.

Overview — simplified

J2 SaaS collaboration loop — simplified overview

Identity and tenancy are C6: Supabase magic-link/OAuth, cookie-refreshing middleware, RLS-gated workspace INSERT with a SECURITY DEFINER trigger bootstrapping the owner membership. Invites are C6 + C9 (Resend email; token hash in pending_invites; service-role acceptance that a DB trigger records in audit_log). Editing is C2 over C1's SupabaseAdapter: the notes_save()RPC does OCC with conflict snapshots (last-write-wins, loser parked), C6's Realtime watcher shows a non-destructive refresh toast, and every save still fans out to C3's embed queue via C4's gateway. Comments and share links are C6; clipping is C8 (broken in SaaS — see seams); the digest cron is C9 + C4 fanning out per workspace; audit and admin review are C6 + C9.

Stage 1 — Signup / login via Supabase auth

Signup and login are the same flow — "signup is just a sign-in against an email Supabase hasn't seen" (app/(saas)/(auth)/signup/page.tsx:17-22). Magic link uses supabase.auth.signInWithOtp from the browser; OAuth is hardcoded to Google + GitHub (components/auth/email-magic-link.tsx:42-49; components/auth/oauth-buttons.tsx:18-36). /api/auth/callback exchanges the code, honours a safe relative ?next=, and routes zero-membership users to /onboarding/new-workspace, else /w/<slug> preferring the eli_ws cookie (app/api/auth/callback/route.ts:37-77). A profiles row is auto-created by the auth.users INSERT trigger (lib/db/migrations/pg/0002_profiles_trigger.sql:22-51), and middleware.ts:34-67 refreshes the session cookie on every request while bouncing unauthenticated /w|/settings|/onboarding hits to /login?next=.

Stage 2 — Onboarding → create workspace

createWorkspaceAction validates the slug (regex + 20 reserved words) and INSERTs into workspaces with owner_id = auth.uid() under the workspaces_insert_self RLS policy (app/_actions/workspaces.ts:26-112; lib/db/migrations/pg/0001_rls.sql:37-40). The on_workspace_created SECURITY DEFINER trigger re-validates the reserved slug and idempotently inserts the owner's memberships row — no service-role needed (lib/db/migrations/pg/0003_workspace_bootstrap.sql:17-51). The action sets the eli_ws cookie (httpOnly, lax, 30d) and redirects to /w/<slug> (app/_actions/workspaces.ts:63-71,117-118).

Stage 3 — Invite → Resend email → acceptance → membership row

createInviteAction generates a 32-byte base64url token, stores only sha256(token) in pending_invites (RLS invites_insert_admin), and mails the plaintext accept URL via Resend — falling back to console logging when RESEND_API_KEY is unset (app/_actions/invites.ts:56-101; lib/email/resend.ts:39-67). The invitee is bounced through /login?next= if anonymous; acceptInviteAction hashes the token, looks it up via the service-roleclient (the invitee isn't yet a member so RLS would block), checks the 7-day TTL, inserts the memberships row, stamps accepted_at, and sets eli_ws (app/_actions/invites.ts:120-167). The membership insert fires the on_membership_changed trigger writing audit_log 'member.added' (lib/db/migrations/pg/0005_audit_log.sql:55-63). Email mismatch is deliberately not blocked — the unguessable token is the boundary.

Stage 4 — Concurrent editing: notes_save RPC, conflict snapshots, realtime refresh

The SaaS note page resolves SupabaseAdapter.forActiveWorkspace() (session + eli_ws cookie + RLS-visible workspace, lib/storage/supabase.ts:75-97). Saves post {id, bodyMd, expectedVersionId, workspaceId}; on version mismatch the adapter FIRST parks the loser as an auto-conflict-snapshot versions row (author_kind system), then proceeds — last write wins (lib/storage/supabase/writes.ts:85-109,195-227). The write itself is the notes_save() Postgres RPC (SECURITY INVOKER, so RLS notes_update_editor gates it): atomic UPDATE + versions INSERT; a 40001 race triggers snapshot + force-retry (lib/db/migrations/pg/0006_notes_save.sql:17-86; writes.ts:131-171). Member B's RealtimeNoteWatcher subscribes to postgres_changesUPDATEs on the note and shows a 'This note changed elsewhere' toast with a Refresh action — own saves suppressed via a 32-entry local-version set; there is no presence and no auto-merge, and SupabaseAdapter.subscribe() is a no-op stub (components/realtime/note-watcher.tsx:74-110; lib/storage/supabase.ts:295-299). Saves still fire-and-forget scheduleEmbed (app/_actions/notes.ts:95-105).

Stage 5 — Commenting

CommentsThread groups threads by section_anchor with replies (depth cap 3) and @-mention autocomplete from listWorkspaceMembers, doing optimistic insert with rollback (components/comments/comments-thread.tsx:44-119). addCommentAction inserts via the user-scoped client — RLS comments_insert_commenter requires commenter+ role AND author_id = auth.uid(); resolve/delete are self-or-admin (app/_actions/comments.ts:66-145; lib/db/migrations/pg/0001_rls.sql:287-319).

Stage 6 — Public share link → /s/[token] → revocation

createShareLinkAction inserts sha256(token) into share_links under RLS share_links_insert_editor and returns the plaintext exactly once (app/_actions/shares.ts:47-76; lib/storage/supabase/shares.ts:72-97). Anonymous visitors hit /s/<token> (public — middleware doesn't cover /s/): the page resolves the token via service-role, returning uniform null on every failure to resist enumeration (lib/storage/supabase/shares.ts:113-146). View mode renders read-only markdown; comment/edit modes require a session and re-validate token+mode before service-role writes (edit implies comment, not vice versa, app/_actions/shares-comment-edit.ts:65-243). Revocation is an RLS-gated DELETE; subsequent hits 404 (app/_actions/shares.ts:78-97).

Stage 7 — Clipping a web page via bookmarklet (broken in SaaS)

POST /api/clip takes a Bearer workspace PAT (eli_pat_<prefix>_<secret>): service-role prefix lookup, sha256 verify, revoked/expired checks (app/api/clip/route.ts:163-197). handleClip derives clipped/<slug>-<host>.md, ingests base64 attachments (25MB cap → 413), and dedupes identical (workspace, url) clips inside a 60s in-memory window (lib/clipper/handler.ts:40,162-312). Critical gap: after PAT auth the route calls getStorage(), which in SaaS needs the session + eli_ws cookies— a cross-origin bookmarklet fetch sends neither, so storage resolution throws and the clip 500s; the PAT's workspace never builds the adapter (app/api/clip/route.ts:77-87; lib/storage/supabase.ts:75-97). The bookmarklet installer page is also local-mode-only (app/(local)/bookmarklet/page.tsx:21-25).

Stage 8 — Workspace digest cron → (no) email delivery

Vercel Cron hits /api/cron/digest daily at 13:00 UTC with strict Bearer CRON_SECRET equality (the x-vercel-cron header is explicitly not trusted, app/api/cron/digest/route.ts:46-66). In SaaS the route lists ALL workspaces via service-role and, per workspace, builds an adapter pinned to the owner's identity, then runs runDigest → AI-summarised digest NOTE at digests/<date>.md (app/api/cron/digest/route.ts:103-134; lib/ai/digest-runner.ts:57-135). Members read digests at /w/<slug>/digest or trigger the same pipeline session-scoped. There is no digest email: sendEmail's only call site in the codebase is the invite action. A sibling cron, /api/cron/trash-prune (daily 04:00 UTC, vercel.json:8-11), purges 30-day-old trashed notes (?days= override) under the same CRON_SECRET auth + per-workspace fan-out pattern.

Stage 9 — Audit log

audit_log has exactly three producers: the on_membership_changed SECURITY DEFINER trigger (member.added / role_changed / removed / left, actor from request.jwt.claims.sub — null for service-role paths like invite acceptance), recordAiAuditfor the chat assistant's write tools (lib/db/migrations/pg/0005_audit_log.sql:46-95; lib/ai/audit.ts:32-49), and a best-effort comment.reattached insert from reattachComment — with actor_user_idleft null, attribution reconstructable only from the comment's author_id (lib/storage/supabase/comments.ts:150-168 — verified). Note edits, comment creation, shares, clips, and invite creation produce no audit rows — note edits are traceable only via the versions table. Reads are RLS-gated to owner/admin, and the /settings/audit page (last 200 rows, batched profile-name resolution) is not linked from the settings nav (app/(saas)/(app)/w/[workspace]/settings/audit/page.tsx:35-99).

Stage 10 — Admin reviewing usage and settings

/w/<slug>/settings covers General (rename owner/admin, slug owner-only with eli_ws cookie update, app/_actions/workspaces-settings.ts:38-80), Members (roles + pending invites, sole-owner invariants app-enforced on top of RLS, app/_actions/memberships.ts:47-173), AI (BYOK keys, embed coverage, gateway config, ai_usage charts with admin-or-self RLS, lib/ai/usage.ts:33-60), and Developers (PAT create/revoke/rotate — rotate explicitly non-atomic, app/_actions/pats.ts:93-121). The side-nav links to a Billing page that doesn't exist and omits the live Audit page (settings/layout.tsx:4-13).

Full sequence — detailed

Part A — Auth, workspace, invite (stages 1-3):

J2 detailed sequence, part A — auth, workspace, invite (stages 1-3)

Part B — Concurrent editing, realtime, comments (stages 4-5):

J2 detailed sequence, part B — concurrent editing, realtime, comments (stages 4-5)

Part C — Shares, clip, cron, audit, admin (stages 6-10):

J2 detailed sequence, part C — shares, clip, cron, audit, admin (stages 6-10)

Where the seams show

  • SaaS clipping is broken end-to-end: /api/clip authenticates the PAT but then resolves storage from session cookies a cross-origin bookmarklet never sends → 500. The PAT's workspace_id only feeds the idempotency key and viewUrl, never the adapter — so a same-browser clip where cookies ARE present silently lands in whatever workspace eli_wspoints at, not necessarily the PAT's. The bookmarklet installer page is local-mode-only; SaaS users are told (in a code comment) to hand-assemble one.
  • No digest email despite the digest cron sendEmail's sole call site is the invite action; the cron writes a note instead.
  • SupabaseAdapter.subscribe() is a no-op stub ("until Phase 4 wires Supabase Realtime"); realtime collaboration is only the per-note refresh toast — no presence, no auto-merge, and the watcher is silently inert if Realtime isn't enabled on the notes table.
  • Conflict snapshots are best-effort: a failed auto-conflict-snapshot INSERT is warn-and-continue (lib/storage/supabase/writes.ts:220-224— verified), so the loser's body can be silently lost; and nothing notifies the losing member beyond the generic realtime refresh toast.
  • Audit coverage is narrow: membership changes, AI write-tools, and comment re-attaches only. Note edits, comment creation, shares, PATs, invites, and clips produce no audit rows; service-role paths (invite acceptance) log member.added with a null actor, and comment.reattached rows carry a null actor too.
  • Settings nav links a Billing page that doesn't exist (404) and omits the live Audit page (direct URL only).
  • The notes_select_share RLS policy is dormant — the implemented share path is pure service-role; the short-lived JWT described in the policy comment is never minted. The /s/ comment thread also shows truncated user ids — the profiles join is an acknowledged presentation-only follow-up.
  • ShareEditClient never refreshes its versionId after save, so a recipient's second edit trips 40001 against their own prior save; they must reload between edits.
  • SaaS note view is single-pane (no sidebar parity, favourites hardcoded false); SupabaseAdapter throws NotImplementedError for semanticSearch ('Phase 5'), graph('Phase 7'), applyTemplate('Phase 6').
  • In-process state on serverless: the auto-embed queue and clip idempotency cache are globalThis maps — a recycled worker silently drops pending embeds and dedupe state.
  • Invite acceptance ignores email mismatch by design (token possession is the boundary); PAT rotation is non-atomic (revoke lands before create); sole-owner invariants are app-code only — RLS deliberately allows membership self-DELETE, so a direct API caller could orphan a workspace; trash pruning keys off updatedAt as a proxy for trashed_at, so any later update to a trashed row resets its prune clock; digest notes are attributed to the workspace owner regardless of who generated the activity.

J3. The agent-native loop

An AI agent (Claude Code, Cursor, Codex CLI, Gemini CLI, OpenClaw) wired to a user's eli knowledge base — from setup through tool execution, the /v1 OpenAI-compatible surface, and rate limiting + audit.

Overview — simplified

J3 agent-native loop — simplified overview

Setup and credentials are C5 + C2 (a command-palette dialog writing five agent config files; PATs minted under C6's RLS) — app/_actions/setup.ts:38-77, app/_actions/pats.ts:53-91. Both transports (stdio binary and streamable-HTTP route) are C5, resolving storage through C1's adapters — the same LocalAdapter/SQLite index the UI uses, which is exactly why an agent write shows up in an open browser tab. Tool execution fans out identically to the human path: C1 writes, C7 agent-attributed versions and git commits, C9 SSE events — but not C3's embed queue (a real gap). Search tools ride C3 + C4; the /v1 surface is C5 + C4 (RAG injection + gateway); digest consumption and the embed:backfill bridge are C9 + C3. AGENTS.md guidance generation (C5) exists as a complete library with zero production callers.

Stage 1 — Setup: register eli as an MCP server for external agents

Local mode only: the command palette's 'Set up external AI tools…' opens a multi-select dialog (all five agents pre-checked, components/command-palette.tsx:213-222; components/setup/external-tools-dialog.tsx:45-51). setupExternalToolsActionrejects SaaS ('saas uses PATs'), resolves the active vault and the absolute path to bin/eli-mcp.mjs, and runs five pure-FS writers sequentially (app/_actions/setup.ts:38-77; lib/setup/index.ts:33-57). Each merges an eli entry ({command:'node', args:[<abs bin>], env:{VAULT_PATH, MCP_LOCAL_TOKEN?, WS_UI_PORT?}}) into the agent's config, preserving foreign entries via atomic tmp+rename (lib/setup/json-config.ts:33-116); Codex gets hand-rolled TOML with a regex section splicer (lib/setup/codex.ts:32-118), and OpenClaw additionally attempts a best-effort openclaw mcp add whose failure is silently swallowed (lib/setup/openclaw.ts:24-74).

Stage 2 — Credential minting: SaaS PATs and local tokens/pairings

SaaS agents authenticate with workspace PATs minted at Settings → Developers: createPatAction (RLS-scoped) calls generatePat() — format eli_pat_<8-char prefix>_<43-char base64url secret>, only sha256(secret) stored, plaintext shown once; revoke is a soft revoked_at; rotate is an explicitly non-atomic revoke-then-create (app/_actions/pats.ts:53-160; lib/auth/pat.ts). Local mode has two credentials: ELI_LOCAL_TOKEN/MCP_LOCAL_TOKEN (env shared secret for HTTP MCP and /v1) and per-extension pairing tokens (eli_pair_*) minted via /pairPOST /api/pair into ~/.eli/config.json#pairings — note these authenticate the clipper endpoint, not MCP (lib/pair/extension.ts:44-103; app/api/clip/route.ts:203-204). The mcpToken minted into ~/.eli/config.json has no consumer anywhere — the setup action injects env.ELI_LOCAL_TOKEN into agent configs (app/_actions/setup.ts:73) and the MCP surfaces read only the MCP_LOCAL_TOKEN/ELI_LOCAL_TOKEN env vars.

Stage 3 — stdio transport boot

The agent spawns node bin/eli-mcp.mjs per its config; the shim requires VAULT_PATH, loads the esbuild bundle dist/lib/mcp/stdio.js (built by pnpm build:mcp) or exits 2 with a build hint (bin/eli-mcp.mjs:31-58). runStdioServer forces ELI_MODE=local, pins a cached LocalAdapter (first db() runs the gated initial vault scan into ~/.eli/index/<sha256-16>.dbwith WAL + FTS5 triggers), registers all 16 tools (3 read + 4 write + append + search + 2 diff + comment + daily + template + 2 memory — the HTTP route comment claiming "15 tools" at app/api/mcp/[transport]/route.ts:25 is stale) on an McpServer named eli v0.0.1, and connects a StdioServerTransport (lib/mcp/stdio.ts:49-96; lib/storage/local.ts:54-129). There is no per-call auth or rate limit on stdio — the OS process boundary is the isolation; MCP_LOCAL_TOKEN only labels version attribution (lib/mcp/auth.ts:22-24). The handshake instructions string points agents at AGENTS.md at the vault root (lib/mcp/server.ts:57-77).

Stage 4 — Remote streamable-HTTP transport with PAT auth + rate limit

/api/mcp/[transport] wraps Vercel's mcp-handler around the same tool surface (app/api/mcp/[transport]/route.ts:52-91). Auth is mode-dependent: SaaS → experimental_withMcpAuth(authForMcp, required:true); local with MCP_LOCAL_TOKEN → constant-time compare; local without → completely open (127.0.0.1 bind is a deployment concern, per route comment). authForMcp discriminates PAT vs JWT: PATs get a service-role prefix lookup, timing-safe sha256 verify, revoked/expiry checks, fire-and-forget last_used_at, and AuthInfo.extra carrying userId/workspaceId/role/agentName (lib/mcp/auth.ts:65-178). Rate limiting runs after auth, keyed on patId/userId (60/min, 10k/day in-memory). Critical gap (verified at lib/mcp/context.ts:79-91): defaultMcpContextProvider ignores the AuthInfo entirely and calls getStorage() — in SaaS that needs session + eli_wscookies a Bearer-only agent doesn't have, so remote SaaS tool calls fail at storage resolution and the PAT's workspace/agentName never reach the tools.

Stage 5 — Read tools: list_artifacts / read_artifact / list_versions

Each tool resolves the context once, returns dual output (human content[] + machine structuredContent), and converts storage errors into isError results. list_artifacts filters by type/tag/query (limit 50/500; its workspace arg is a documented no-op, lib/mcp/tools/read.ts:49-114). read_artifact resolves ULID-or-path in one indexed select, adds backlinks with path/title wikilink variants, and serialises round-trippable frontmatter+body markdown (read.ts:120-169; lib/storage/local/reads.ts:327-352). list_versions returns newest-first history including authorKind/agentName so agents can see prior agent edits (read.ts:175-209).

Stage 6 — Write tools and their side-effect fan-out

edit_artifact storage.updateNote(id, patch, {authorKind:'agent', agentName, message}) (lib/mcp/tools/write.ts:101-157) — note frontmatter is a full replacement, not a merge. Its sibling append_to_artifact runs smartAppend — end-of-document, or insertion into a section matched by heading anchor/slug — then the same agent-attributed updateNote (lib/mcp/tools/append.ts:82-140). Locally that runs the identical pipeline as a UI save: OCC conflict snapshot, materialise, atomic .md write with echo-marking, one SQLite transaction updating notes/tags/links and appending the agent-attributed versions row (FTS via triggers), an events-bus emit → SSE to open tabs, and a debounced git commit authored as the agent (lib/storage/local/writes.ts:238-337; lib/git/commit-loop.ts:139-182). SaaS goes through the transactional notes_save/notes_rename RPCs under RLS — a viewer-role PAT's write dies in Postgres (lib/storage/supabase/writes.ts:15-120). Cross-process nuance: when the write comes from the stdio process, the Next server's chokidar watcher sees the file change (echo suppression is per-process), re-indexes the shared SQLite file, and emits the SSE itself (lib/watch/chokidar.ts:99-124; lib/index/incremental.ts:17-40). Side-effect asymmetry: MCP writes never call scheduleEmbed — verified: the only scheduleEmbed caller is the UI saveNoteAction (app/_actions/notes.ts:96-105).

Stage 7 — Search tools: fts, semantic, hybrid

The search tool defaults to FTS5 (sanitised prefix MATCH, bm25, <mark> snippets, trash excluded; SaaS uses the tsvector RPC, lib/mcp/tools/search.ts:28-58,107-129). semantic/hybrid dynamically import hybridSearch— despite the tool description still telling the model they "land in Phase 5" — embedding the query via the gateway (1536-dim hard check) then KNN: local brute-force cosine over note_embeddings BLOBs with FTS-candidate filter + global top-up; SaaS pgvector knnNotes RPC; fused by RRF with per-list rank contributions (lib/search/hybrid.ts:89-196). Embed failures degrade gracefully to FTS-only. memory_search/memory_get apply the same engine over .openclaw/ files with a scope filter (lib/mcp/tools/memory.ts:53-208).

Stage 8 — Templates, daily note, comments, diff/restore

apply_template reads <vault>/.eli/templates/<id>.md, runs the shared engine ({{date:...}}, {{var:...}}, {{prompt:...}} degrading to a literal TODO), then createNote with the full side-effect fan-out; SupabaseAdapter throws NotImplementedError('Phase 6')isError (lib/storage/local/writes.ts:451-480; lib/storage/supabase.ts:195-200). daily_note returns/creates daily/YYYY-MM-DD.md (lib/mcp/tools/daily.ts:31-84); add_commentpins the note's current versionId with SaaS role gating enforced solely by RLS (lib/mcp/tools/comment.ts:36-80); get_diff tries section-aware diffVersionswith a line-diff fallback (which it "almost always" lands in, per its own comment), and restore_version appends the old body as a NEW agent-attributed version (lib/mcp/tools/diff.ts:127-250).

Stage 9 — AGENTS.md generation (complete but unwired)

lib/agents-md/ holds the full spec §7.5 machinery: a managed-block template inside sentinel comments, spliceManaged that replaces only the managed region, and restoreAgentsMd writing AGENTS.md + CLAUDE.md/GEMINI.md symlinks + a .cursorrules copy (lib/agents-md/restore.ts:58-130; template.ts:15-122). A sibling scaffoldOpenClaw creates the .openclaw/ memory tree (lib/openclaw/scaffold.ts:1-53). Neither has any production caller— the 'Restore eli Guidance' command referenced in their doc comments doesn't exist; the only live pointer agents get is the MCP handshake instructions string naming AGENTS.md (lib/mcp/server.ts:71-74).

Stage 10 — /v1 OpenAI-compatible API

Any OpenAI-SDK client can point its base URL at eli. POST /v1/chat/completions: Bearer auth (local exact ELI_LOCAL_TOKEN compare or open when unset; SaaS PAT verify via service role), the shared rate limiter (429 + retry-after), model→tier mapping (eli/fast|smart|cheap pins plus heuristics), and RAG — the last user message is hybrid-searched (topK 8, eli_top_k=0 opts out) and note bodies (4k chars each) injected as a system message (app/v1/chat/completions/route.ts:87-246). eli_use_tools registers the vault toolset with eli_allow_writes gating writes (agent-attributed chat-tool, SaaS audit rows); output is re-encoded as OpenAI-shape JSON or SSE ending in data: [DONE] (route.ts:225-364). /v1/embeddings (input capped at 256 strings), /v1/models (no auth at all), and /v1/openapi.json round out the surface (app/v1/embeddings/route.ts:84-169; app/v1/models/route.ts:25-47).

Stage 11 — Rate limiting + audit of agent actions

Rate limiting is one pluggable interface with only the in-memory backend shipped (globalThis map, 60/min + 10k/day, rejections don't consume budget; ELI_RATELIMIT_BACKEND=redis throws a not-shipped error, lib/mcp/ratelimit.ts:37-196). Enforced on: SaaS HTTP MCP (post-auth in authForMcp, keyed patId/userId) and /v1 chat + embeddings. Exempt: stdio entirely, ALL local HTTP MCP — authForLocal never calls checkRateLimit, with or without MCP_LOCAL_TOKEN (lib/mcp/auth.ts:99-111 — verified: the only MCP-path caller is authForMcp) — and the backfill CLI (deliberately). Audit in SaaS = audit_log rows from /v1 write tools + membership events, with PAT last_used_at telemetry; MCP tool calls themselves are not audit-logged — the local trail is versions rows (authorKind:'agent', agentName) plus agent-attributed git commits (lib/ai/audit.ts:32-49; lib/storage/local/writes.ts:160-174).

Stage 12 — Digest + background/CLI surfaces agents consume

There is no MCP digest tool — the digest is produced FOR agents as a regular note by the cron (13:00 UTC, CRON_SECRET-gated; SaaS fans out per workspace via service-role) or the session-scoped manual action, and agents read it via list_artifacts {type:'digest'}read_artifact (app/api/cron/digest/route.ts:46-135; lib/ai/digest-runner.ts:42-144). The other agent-adjacent CLI is pnpm embed:backfill --vault|--workspace [--batch N] [--dry-run] — idempotent, key-required, deliberately outside HTTP rate limits — currently the only way agent-written notes become semantically searchable without a UI save (scripts/embed-backfill.mjs:1-57).

Full sequence — detailed

Part A — Setup, credentials, transport boot (stages 1-4):

J3 detailed sequence, part A — setup, credentials, transport boot (stages 1-4)

Part B — Tool execution and side-effect fan-out (stages 5-8):

J3 detailed sequence, part B — tool execution and side-effect fan-out (stages 5-8)

Part C — /v1 surface, digest, backfill (stages 10-12):

J3 detailed sequence, part C — /v1 surface, digest, backfill (stages 10-12)

Where the seams show

  • SaaS MCP-over-HTTP is effectively broken for PAT agents: defaultMcpContextProvider (lib/mcp/context.ts:79-91 — verified: it hard-codes authInfo nulls and calls getStorage()) never reads the AuthInfo that experimental_withMcpAuth attached; cookie-less PAT requests throw at SupabaseAdapter.forActiveWorkspace(), and the PAT's workspaceId/role/agentName never reach tool handlers. The route comment claims the wiring exists; it doesn't.
  • PAT scopes are stored but never checked['*'] default, and no code consults scopes before running a tool; only the Postgres RLS role is actually enforced.
  • MCP writes never feed the embed queue — agent-written notes are invisible to vector ranking until a UI save or pnpm embed:backfill.
  • AGENTS.md generation is a stub at the integration layer: restoreAgentsMd and scaffoldOpenClaw have zero production callers; nothing guarantees AGENTS.md exists in a vault the handshake points agents at.
  • Rate limiting is single-instance in-memory only (redis backend is a throw-stub); per-workspace limits from the spec are unimplemented. Enforcement is uneven: stdio has no auth/limits at all; local HTTP MCP is never rate-limited even when MCP_LOCAL_TOKEN gates it (authForLocal skips checkRateLimit); and a token-less local route is additionally fully open, relying on a 127.0.0.1 bind not enforced in code.
  • MCP tool calls are not centrally audit-logged— attribution lives only in versions rows and git commit messages; there is no "agent X called search 400 times" record.
  • Stale/incorrect tool metadata: the search tool description still says semantic/hybrid "land in Phase 5"; adapter.semanticSearch throws NotImplementedError in both adapters (the vector path lives only in lib/search/hybrid.ts); the MCP server version is a hard-coded 0.0.1; the HTTP route comment says "15 tools" where 16 are registered; list_artifacts' workspace arg is a no-op; edit_artifact frontmatter is full-replacement — an agent that omits keys silently drops them; and the mcpToken in ~/.eli/config.json is minted and rotated but consumed by nothing.
  • /v1 OpenAI tools[] passthrough is claimed but unimplemented — the route comment promises standard tools[]"works as a passthrough", yet the zod-parsed array is never forwarded to the model (only eli_use_tools builds a toolset, app/v1/chat/completions/route.ts:59,225-231) and role:'tool' messages are filtered out of the conversation (route.ts:216-223), so genuine OpenAI tool-calling round-trips are unsupported.
  • apply_template is local-only (SaaS NotImplementedError('Phase 6')), and {{prompt:...}} placeholders degrade to a literal TODO string.
  • get_diff almost always lands in its line-diff fallback (per its own code comment), returning a single synthetic section with fallback:true.
  • The stdio bin requires the esbuild bundle — a fresh checkout that runs setup before pnpm build:mcp ships broken agent configs (exit 2 with a build hint).
  • No digest MCP tool, no digest email; local cron depends on something actually curling /api/cron/digest with CRON_SECRET. setupOpenclaw swallows the openclaw mcp add CLI failure entirely; rotatePatAction's non-atomic window can leave an agent with no working PAT.