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):
| ID | Cluster |
|---|---|
| C1 | Dual-mode data & storage core (lib/storage, lib/db, lib/index, lib/watch, lib/config) |
| C2 | Authoring & navigation UX shell (editor, note list, sidebar, palette, keybindings) |
| C3 | Hybrid search & embeddings pipeline (FTS5/tsvector, auto-embed queue, RRF) |
| C4 | AI platform (gateway, model registry, key resolver, chat/RAG endpoints, usage metering) |
| C5 | Agent-native surface (MCP stdio + HTTP, tools, PAT/local-token auth, /v1 API, rate limits) |
| C6 | SaaS platform & collaboration (Supabase auth, workspaces, invites, RLS, comments, realtime, shares) |
| C7 | Git, versioning & diff (commit loop, versions table, section diff/restore) |
| C8 | Capture & templating (clipper, bookmarklet, pairing, templates, daily notes) |
| C9 | Platform 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
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). saveNoteAction → updateNote 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_embeddings — chunkText 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):
Part B — External edit, embed, hybrid search (stages 3-5):
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", butstartWatcher's only caller in the repo isinstrumentation.register()(verified by grep). A first-run user who picks a vault gets no live FS watching until they restartpnpm dev;pickVaultActionnever starts a watcher. - Vault picking blocks on the full initial scan —
pickVaultActionawaitsscanVaultsynchronously inside the dialog action with no progress UI (scanVault'sonProgresshook 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 —VaultChatuses 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.tsis 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 issaveNoteAction(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-keyindexer orpnpm embed:backfillruns. createNotenever enqueues a git commit — onlyupdateNotecallsenqueueCommit(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;
reenableCommitLoopexists with zero callers, and there is nogit initaffordance. - Local digest has no scheduler:
vercel.jsoncrons only fire on Vercel; locally it's a manual curl withCRON_SECRETor the Generate-now button, and an unsetCRON_SECRETmakes the route always-401. No digest email exists in any mode. - No whole-version restore in the UI — only per-section;
storage.restoreVersionis reachable only through the MCP tool. Git history is write-only bookkeeping in this journey (diff reads the SQLite versions table, nevergit 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) andBlockNoteViewis hard-pinned totheme="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
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):
Part B — Concurrent editing, realtime, comments (stages 4-5):
Part C — Shares, clip, cron, audit, admin (stages 6-10):
Where the seams show
- SaaS clipping is broken end-to-end:
/api/clipauthenticates 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 workspaceeli_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 thenotestable.- Conflict snapshots are best-effort: a failed
auto-conflict-snapshotINSERT 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.addedwith a null actor, andcomment.reattachedrows 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_shareRLS 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. ShareEditClientnever refreshes itsversionIdafter 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);
SupabaseAdapterthrowsNotImplementedErrorforsemanticSearch('Phase 5'),graph('Phase 7'),applyTemplate('Phase 6'). - In-process state on serverless: the auto-embed queue and clip idempotency cache are
globalThismaps — 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
updatedAtas a proxy fortrashed_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
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 /pair → POST /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):
Part B — Tool execution and side-effect fan-out (stages 5-8):
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-codesauthInfonulls and callsgetStorage()) never reads the AuthInfo thatexperimental_withMcpAuthattached; cookie-less PAT requests throw atSupabaseAdapter.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:
restoreAgentsMdandscaffoldOpenClawhave zero production callers; nothing guaranteesAGENTS.mdexists 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_TOKENgates it (authForLocalskipscheckRateLimit); 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.semanticSearchthrowsNotImplementedErrorin both adapters (the vector path lives only inlib/search/hybrid.ts); the MCP server version is a hard-coded0.0.1; the HTTP route comment says "15 tools" where 16 are registered;list_artifacts'workspacearg is a no-op;edit_artifactfrontmatter is full-replacement — an agent that omits keys silently drops them; and themcpTokenin~/.eli/config.jsonis minted and rotated but consumed by nothing. /v1OpenAItools[]passthrough is claimed but unimplemented — the route comment promises standardtools[]"works as a passthrough", yet the zod-parsed array is never forwarded to the model (onlyeli_use_toolsbuilds a toolset,app/v1/chat/completions/route.ts:59,225-231) androle:'tool'messages are filtered out of the conversation (route.ts:216-223), so genuine OpenAI tool-calling round-trips are unsupported.apply_templateis local-only (SaaSNotImplementedError('Phase 6')), and{{prompt:...}}placeholders degrade to a literal TODO string.get_diffalmost always lands in its line-diff fallback (per its own code comment), returning a single synthetic section withfallback:true.- The stdio bin requires the esbuild bundle — a fresh checkout that runs setup before
pnpm build:mcpships 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/digestwithCRON_SECRET.setupOpenclawswallows theopenclaw mcp addCLI failure entirely;rotatePatAction's non-atomic window can leave an agent with no working PAT.