Skip to documentation

Capability clusters

C8 · Capture, templates & exchange

eli’s border crossing: three inbound doors (web clipper, phone share sheet, bulk import), two outbound doors (note download, vault zip), one template factory — all funneling through the same storage adapter as hand-written notes.

This cluster gets external content into the workspace (web-clipper bookmarklet + POST /api/clip, PWA share-target /share, bulk zip/JSON import) and out of it (single-note markdown download, whole-vault zip export), and it owns the note-template subsystem — a placeholder language ({{date}}, {{var}}, {{prompt}}), a parser + apply engine, an adapter-level applyTemplate surfaced as the apply_template MCP tool, a two-pane template editor, and AI-generated template skeletons. It also owns the PWA shell (manifest, service worker, offline fallback, iOS install guidance) that makes the share-target capture path installable on phones. Everything funnels through the same StorageAdapter contract as the rest of the platform (see C1 · Core data & storage), so a clip, an imported note, and a template-instantiated note are indistinguishable from hand-written notes once they land.

Modes: local + saas (bookmarklet/pairing and apply_template are local-only; install guidance page is saas) | Key dirs: lib/clipper/, lib/templates/, lib/pair/, app/api/{clip,import,export,pair}/, app/share/, app/_actions/templates*.ts, public/sw.js, app/manifest.ts | Depends on: storage adapters (C1), PAT + local-token auth (C5/C7), AI gateway (C4), frontmatter helpers (C1) | Feeds: FTS/semantic search (C3), version history (C6), authoring surfaces (C2), agent tools (C5)

Capabilities

CapabilityWhat it doesEntry pointsModes
Web clipper install pageMints a fresh pairing token per page load, builds a javascript: bookmarklet, renders drag-to-install anchor + copy-source + pairing id for revocation (app/(local)/bookmarklet/page.tsx:24-47, components/clipper/bookmarklet-install.tsx:19-74); notFound() in saas/bookmarkletlocal
Bookmarklet runtime captureCaptures location.href, title, selection-or-body.innerText (50k cap) as pseudo-markdown plus outerHTML (200k cap); confirm() then POSTs to /api/clip with Bearer <token>; offers to open the created note (lib/clipper/bookmarklet-source.ts:35-47)bookmarklet in any page → POST /api/cliplocal
POST /api/clip ingestBearer-authenticated JSON endpoint: saas = workspace PAT via service-role lookup, local = pairing token then MCP_LOCAL_TOKEN constant-time fallback; CORS * + OPTIONS preflight; 401/400/413/500 error mapping (app/api/clip/route.ts:44-145,163-235)POST /api/clip, OPTIONS /api/clipboth
Clip handling coreStorage-agnostic handleClip: 60s idempotency window, title/path derivation to clipped/<slug>-<host>.md, collision-retry suffixes, data-URL attachment ingest (25MB cap, persisted saas-only) with markdown URL rewriting (lib/clipper/handler.ts:162-313)called by /api/clip and /share actionboth
Extension pairingeli_pair_* token mint/verify/revoke/list; sha256 hash in ~/.eli/config.json#pairings[], plaintext shown once; approval page postMessages token to opener (lib/pair/extension.ts, app/api/pair/route.ts, components/pair-approval.tsx)/pair?extensionId=…, POST /api/pair, GET /api/pairlocal
PWA share-target captureManifest share_target GET-navigates /share?title=&text=&url=; editable confirm card whose server action composes markdown and feeds handleClip under the app session (no PAT), then redirects to the note (app/manifest.ts:75-83, app/share/page.tsx:20-168)OS share sheet → /shareboth
Bulk importPOST /api/import accepts a zip (JSZip walk of .md entries, 5000 cap) or {notes:[…]} JSON; upsert = getNote(path) hit → updateNote with agent 'import' attribution else createNote; returns per-path error list (app/api/import/route.ts:48-172)POST /api/import (API/OpenAPI-only)both
Single-note markdown exportSerializes frontmatter + body exactly as on disk, content-disposition attachment (app/api/export/note/[id]/route.ts:16-43)GET /api/export/note/[id], inspector download buttonboth
Whole-vault zip exportUp to 5000 non-trashed notes at original paths + eli-export.json manifest so the archive round-trips through /api/import; streamed as eli-vault-YYYY-MM-DD.zip (app/api/export/vault/route.ts:22-74)GET /api/export/vault, command paletteboth
Template language (parser)One-pass tokenizer → literal/date/var/prompt tokens; {{date:<fmt>}}, {{var:<name>}} (bare {{name}} sugar), {{prompt:<q>}}; friendly error on unmatched {{ (lib/templates/parser.ts:43-119)parseTemplate()both
Template apply engineRenders whole source then re-parses frontmatter (unquoted {{date}} works in YAML); Day.js→date-fns token translation; unsupplied vars left in place + missingVars[]; _-prefixed frontmatter keys stripped; pluggable prompt runtimes (lib/templates/apply.ts:90-126, lib/templates/prompt-runtime.ts)applyTemplate()both
apply_template MCP tool{template, path, vars?}storage.applyTemplate → reads <vault>/.eli/templates/<id>.md, runs engine, createNote; only LocalAdapter implements — Supabase/memory/json-file throw NotImplementedError (lib/mcp/tools/template.ts:32-74, lib/storage/local/writes.ts:451-480)MCP tools apply_template, daily_note (companion)local
Template management UITwo-pane list/editor with Save/Delete/unsaved-guard; server actions do fs CRUD (local) or .eli/templates/-prefixed note CRUD (saas) (components/templates/template-editor.tsx:36-250, app/_actions/templates.ts:41-233)/templates (local), /w/<slug>/settings/templates (saas)both
AI template generationgenerateTemplateAction resolves an AI key, calls generateText on the smart-tier model with a syntax-teaching system prompt, sanitizes output, persists to the templates path; usage recorded (app/_actions/templates-ai.ts:77-165)editor “Generate with AI” buttonboth
PWA shellManifest (standalone, shortcuts Today/Search/Chat, share_target), production-only SW registration, eli-v2 service worker: network-first navigations with /offline.html fallback, cache-first static assets, /api/+/v1/+_actions always bypassed (app/manifest.ts, public/sw.js:22-118, components/pwa-register.tsx:12-27)/manifest.webmanifest, /sw.js, /offline.htmlboth
iOS / desktop install guidanceSettings → About walks through Safari Share-Sheet → Add to Home Screen; root layout ships appleWebApp metadata + themeColor pair (app/(saas)/(app)/w/[workspace]/settings/about/page.tsx:18-79, app/layout.tsx:22-51)/w/<slug>/settings/aboutsaas

System view — simplified

System view — simplified

C8 is the content airlock. Three inbound doors (clipper, share sheet, bulk import) and two outbound doors (note download, vault zip) all speak plain markdown + frontmatter, and one factory (templates) stamps out new notes from .eli/templates/*.md blueprints. Standalone — with search, AI, sync, and collaboration all turned off — this cluster is still a complete capture-and-portability toolkit: you can clip any web page into clipped/<slug>-<host>.md, share a link from your phone into an inbox note, batch-migrate a vault in or out as a lossless zip, and instantiate structured notes from templates with resolved dates and variables. Its only hard dependency is the storage adapter; everything else (AI generation, embeddings, agents) is additive.

System view — detailed

System view — detailed
ComponentRoleFiles
Clip handler corePure, storage-agnostic clip logic: body schema, 60s idempotency cache, title/path derivation, collision retry, data-URL attachment ingest with 25MB cap and markdown URL rewritinglib/clipper/handler.ts
Clip API routeThin Next wrapper: PAT (saas) / pairing-token-then-MCP_LOCAL_TOKEN (local) bearer auth, CORS + OPTIONS preflight, 401/400/413/500 mappingapp/api/clip/route.ts
Bookmarklet generator + install UIBuilds the javascript: bookmarklet embedding token + endpoint; local-only page mints a pairing token per load, drag-to-install + copy-sourcelib/clipper/bookmarklet-source.ts, app/(local)/bookmarklet/page.tsx, components/clipper/bookmarklet-install.tsx
Extension pairingLocal credential mint/verify/revoke/list; eli_pair_* tokens hashed into ~/.eli/config.json; approval page postMessages plaintext to openerlib/pair/extension.ts, app/(local)/pair/page.tsx, app/api/pair/route.ts, components/pair-approval.tsx
Share-target pagePWA share-sheet landing: confirm-and-edit card whose server action feeds handleClip using the app session (no PAT), then redirectsapp/share/page.tsx
Import APIBulk zip (JSZip) or JSON import, create-or-update upsert, 5000-entry cap, per-path error collectionapp/api/import/route.ts
Export APIsSingle-note markdown download and vault zip (manifest eli-export.json, 5000-note cap) that round-trips through /api/importapp/api/export/note/[id]/route.ts, app/api/export/vault/route.ts
Template engineParser (literal/date/var/prompt tokens), applier (Day.js→date-fns translation, frontmatter merge with _-key protection, missingVars reporting), pluggable prompt runtimeslib/templates/parser.ts, lib/templates/apply.ts, lib/templates/prompt-runtime.ts
Template storage + MCP surfaceAdapter contract applyTemplate(templateId, {path, vars}); LocalAdapter implements against <vault>/.eli/templates/; supabase/memory/json-file throw NotImplementedError; exposed as apply_templatelib/storage/adapter.ts, lib/storage/local/writes.ts, lib/storage/supabase.ts, lib/mcp/tools/template.ts, lib/mcp/tools/daily.ts
Template management UITwo-pane list/editor with Save/Delete/Generate-with-AI; server actions do fs CRUD (local) or .eli/templates/-prefixed note CRUD (saas)components/templates/template-editor.tsx, app/_actions/templates.ts, app/_actions/templates-ai.ts, app/(local)/templates/page.tsx, app/(saas)/(app)/w/[workspace]/settings/templates/page.tsx
PWA shellManifest with shortcuts + GET share_target, production-only SW registration, stale-while-revalidate SW with offline navigation fallback, iOS install metadata + guidanceapp/manifest.ts, components/pwa-register.tsx, public/sw.js, public/offline.html, app/layout.tsx, app/(saas)/(app)/w/[workspace]/settings/about/page.tsx
Discoverability surfacesCommand-palette “Export vault as zip”, inspector “Download as markdown”, OpenAPI 3.1 docs of export/importcomponents/command-palette.tsx, components/inspector/properties-panel.tsx, app/v1/openapi.json/route.ts

Data flow — simplified

Data flow — simplified

One click on the bookmarklet turns any web page into an inbox note: the captured text travels as pseudo-markdown over an authenticated cross-origin POST, handleClip derives a stable clipped/<slug>-<host>.md path, and the storage adapter persists file + SQLite row in one transaction. Full-text indexing is free — a SQLite trigger (lib/db/migrations/sqlite/0001_fts.sql:23-37) fires on the notes insert. The response’s viewUrl lets the bookmarklet offer to open the freshly clipped note.

Data flow — detailed

Sequence (a) — bookmarklet click → paired token → /api/clip → inbox note → index side effects:

Data flow — detailed (a): bookmarklet clip → paired token → inbox note → index side effects

Sequence (b) — create a template with AI, then apply it with prompt variables:

Data flow — detailed (b): AI template generation, then apply_template with prompt variables

Mode divergence in one line each: clip auth is PAT-vs-pairing (app/api/clip/route.ts:163 vs :203), share auth is Supabase session + eli_ws cookie vs ELI_VAULT (app/share/page.tsx:138-156), template persistence is real files vs .eli/templates/-prefixed notes (app/_actions/templates.ts:67-77), and apply_template works only against LocalAdapter.

Works separately / works together

Standalone

With every other cluster switched off, C8 still delivers a working capture-and-portability layer on top of bare storage: install the bookmarklet (/bookmarklet), clip pages into clipped/ notes (attachment bodies are silently dropped in local mode — see Gaps), share URLs from a phone via the installed PWA (/share), migrate content in with POST /api/import, and get everything back out as a lossless zip (GET /api/export/vault → re-importable via the embedded eli-export.json manifest). Templates apply with dates and caller variables resolved using zero AI — the default prompt runtime degrades gracefully to (TODO: <question>) markers (lib/templates/apply.ts:128-132). The service worker keeps the app shell loadable offline with no server at all (public/sw.js:70-90).

Composed

  • → C1 storage (Core data & storage): every capability funnels through getStorage()/StorageAdapter handleClip uses createNote/putAttachment/updateNote, import/export use listNotes/getNote/createNote/updateNote, templates use the applyTemplate contract (lib/storage/adapter.ts:173-176). Export and the template engine share serializeFrontmatter/parseFrontmatter, guaranteeing round-trip fidelity.
  • → C3 search (Semantic search): clipped/imported notes become FTS-searchable instantly via the notes_fts_ai insert trigger (lib/db/migrations/sqlite/0001_fts.sql:23); semantic vectors arrive later via pnpm embed:backfill (package.json:20) or the debounced scheduleEmbed fired by editor saves (app/_actions/notes.ts:96-105).
  • → C4 AI (AI platform): generateTemplateAction consumes modelFor('smart'), resolveAiKey, gateway config and recordUsage — the same plumbing as chat (app/_actions/templates-ai.ts:85-113).
  • → C5 agents (Agent surface): apply_template and daily_note MCP tools expose the engine to agents (lib/mcp/tools/template.ts, daily.ts:36-41 explicitly points agents from empty daily notes to apply_template); /api/clip reuses lib/auth/pat bearer parsing and the MCP_LOCAL_TOKEN used by the MCP server; /v1/openapi.json documents import/export for API consumers (app/v1/openapi.json/route.ts:293-300).
  • → C6 versioning (Versioning & history): import updates preserve version history and stamp authorKind:'agent', agentName:'import' attribution (app/api/import/route.ts:144-172); every clip/create writes a versions row inside the same transaction (lib/storage/local/writes.ts:159-174).
  • → C7 saas (SaaS & collaboration): saas clip auth resolves workspace PATs via the service-role client (app/api/clip/route.ts:163-198); /share resolves the workspace from the Supabase session + eli_ws cookie (app/share/page.tsx:138-156); import/export rely on RLS through the session-bound adapter.
  • → C2 authoring (Authoring UX): command palette hosts “Export vault as zip” (components/command-palette.tsx:224-232), the inspector hosts “Download as markdown” (components/inspector/properties-panel.tsx:353-359), and note:created events emitted by clip-created notes flow to the live UI via the events bus (lib/watch/events-bus.ts:24).
  • Internal composition: /share and /api/clip are two front doors to one handleClip, sharing the same globalThis idempotency cache — a share-then-clip of the same URL within 60s dedupes (lib/clipper/handler.ts:137-152).

Load-bearing details

  • No readability/article extraction exists: the bookmarklet sends # title + selection-or-document.body.innerText(50k cap) as “markdown”; Defuddle is referenced only in comments as a future extension pipeline (lib/clipper/bookmarklet-source.ts:42-43, lib/clipper/handler.ts:28-29); package.json has no defuddle/readability dependency.
  • The bookmarklet embeds the plaintext pairing token in the javascript:URL itself — the page warns “Treat the link like a password” (app/(local)/bookmarklet/page.tsx:66-68); every /bookmarklet reload mints a NEW pairing without revoking prior ones (page.tsx:15-19).
  • Clip idempotency is a 60s window keyed workspaceKey::url in a globalThis-pinned Map (__ELI_CLIP_IDEMPOTENCY__, survives Turbopack per-bundle module isolation) — process-local, so multi-instance saas deployments would not dedupe across instances (lib/clipper/handler.ts:39-40,137-152).
  • Local clip auth composes the pairing id into the idempotency key (<vault>::pair:<id>) so two extensions clipping the same URL get separate caches (app/api/clip/route.ts:209-212).
  • /api/clip sets Access-Control-Allow-Origin: * plus an OPTIONS preflight handler; token compromise, not origin, is the stated security boundary (app/api/clip/route.ts:117-145).
  • /api/import and /api/export/* have NO bearer auth — they rely entirely on getStorage() (Supabase session + RLS in saas; single-tenant trust in local) (app/api/import/route.ts:26-28, app/api/export/note/[id]/route.ts:10-12).
  • The vault export zip embeds eli-export.json {version:1,…} which the importer explicitly skips, making export→import a lossless round-trip of frontmatter + body at original paths; both sides cap at 5000 notes; entries are DEFLATE level 6 and an empty vault returns 404 (app/api/export/vault/route.ts:26-28,44-61, app/api/import/route.ts:88-97).
  • FTS indexing of clipped/imported notes is automatic: SQLite AFTER INSERT/UPDATE/DELETE triggers on notes maintain notes_fts (lib/db/migrations/sqlite/0001_fts.sql:23-37) — persistRow never touches the FTS table directly (lib/storage/local/writes.ts:99-195).
  • Clips are NOT auto-embedded: scheduleEmbed (30s debounce, lib/ai/auto-embed-queue.ts:33) is only invoked from saveNoteAction (app/_actions/notes.ts:96-105); clipped notes gain vectors via pnpm embed:backfill (package.json:20) or a later editor save.
  • The template engine renders placeholders across the ENTIRE source before parsing frontmatter, so created: {{date:YYYY-MM-DD}} works unquoted in YAML; _-prefixed frontmatter keys from templates are always stripped as eli-managed (lib/templates/apply.ts:98-119).
  • Day.js→date-fns translation quotes literal T AFTER token mapping so ISO timestamps ({{date:YYYY-MM-DDTHH:mm:ssZ}}) render correctly; Z maps to xxx (±HH:mm offset) (lib/templates/apply.ts:66-88); malformed formats fall back to emitting the raw placeholder (apply.ts:140-146).
  • Template ids are validated with /^[a-z0-9][a-z0-9-]*$/i (case-insensitive, ≤40 chars; bodies ≤40k) at get/save/delete boundaries (app/_actions/templates.ts:108,139-146,210-212); AI generation is stricter — /^[a-z][a-z0-9-]*$/i (letter-first) with hint ≤500 (app/_actions/templates-ai.ts:42-49) — all preventing path traversal through the .eli/templates/<id>.md path join.
  • Pairing tokens are eli_pair_<26-char-ulid>_<base64url secret> with a 32-byte randomBytes secret (lib/pair/extension.ts:44-69,144); verification is constant-time (timingSafeEqual over sha256 hex) to prevent pairing-id enumeration, and the MCP_LOCAL_TOKEN fallback comparison is also constant-time (lib/pair/extension.ts:133-139, app/api/clip/route.ts:238-245).
  • Clipped notes always land under clipped/ as <slug>-<hostname>.md (NFKD slug 80-char cap; hostname www.-stripped, 40-char cap); collision retry appends -YYYY-MM-DD then -YYYY-MM-DD-<ms%1000> on duplicate-path errors (lib/clipper/handler.ts:341-361,363-389).
  • Attachments are capped at 25MB each (413 AttachmentTooLargeError with filename + bytes), pre-validated BEFORE note creation to avoid orphan parents (lib/clipper/handler.ts:100-118,197-206).
  • Attachment persistence is saas-only: LocalAdapter.putAttachment throws NotImplementedError('Phase 5 (clipper)') (lib/storage/local.ts:299-300) and handleClip swallows per-attachment failures (handler.ts:273-275), so local clips keep their original attachment URLs unrewritten; saas uploads to the Supabase Storage attachments bucket at <workspaceId>/<noteId>/<id>-<safeName> plus an attachments row (lib/storage/supabase/attachments.ts:33-90).
  • ClipBodySchema exact caps: url required and must parse as a URL, markdown required ≤2MB, html ≤4MB, title ≤500 chars, tags ≤50 (each ≤80), attachments ≤100 entries (lib/clipper/handler.ts:52-74).
  • The share-target uses method GET (not POST) so /share stays Server-Component renderable and survives hard reloads; URL-less shares get a synthetic https://share.local/<slug> URL purely for path derivation; shared text is capped at 4000 chars (title 500) and empty shares — an OS double-fire quirk — bounce to /v (local) or / (app/manifest.ts:69-83, app/share/page.tsx:39-46,116,129-134).
  • The service worker deliberately never caches /api/, /v1/, /_next/data/, or _actions paths so RLS/auth always hit the live server; bumping the cache name (eli-v2) is the invalidation mechanism (public/sw.js:19,57-65). The prod-only registration gate checks NODE_ENV === 'production' — the NEXT_PUBLIC_PWA flag its comment mentions is honour-only, never read (components/pwa-register.tsx:8,15).
  • saas saveTemplateAction parses template frontmatter with a line-based key: value regex, not a YAML parser — nested YAML in templates will flatten to strings (app/_actions/templates.ts:180-189).

Gaps and partially wired

  • {{prompt:…}} AI resolution is a stub: aiPromptRuntimeunconditionally throws “requires the AI SDK (P5-AI-CHAT-01 carry-over)” and no dialog is registered for dialogPromptRuntime, so prompt placeholders resolve to (TODO: <question>) everywhere (lib/templates/prompt-runtime.ts:59-68, lib/templates/apply.ts:128-132).
  • apply_template is local-only: SupabaseAdapter.applyTemplate throws NotImplementedError('Phase 6 (templates)') — even though the saas template editor and AI generation happily store templates as .eli/templates/ notes, no saas surface can apply them (lib/storage/supabase.ts:195-200 vs app/_actions/templates.ts:67-77).
  • Documented-but-missing {{{{ escape: parser.ts:30 claims a handlebars-style escape, but no escaping code exists — {{{{ actually parses as an empty-placeholder literal followed by braces.
  • No article extraction: raw innerText capture only; the Defuddle/Readability pipeline exists solely in comments (lib/clipper/handler.ts:28-29).
  • HEIC→PNG conversion documented, not implemented: the sharp conversion is a carry-over noted in comments only (lib/clipper/handler.ts:26-29).
  • Local clip attachments are dropped: LocalAdapter.putAttachment throws NotImplementedError('Phase 5 (clipper)') and handleClip swallows per-attachment failures, so local clips enforce the 25MB cap but never persist attachment bodies — markdown keeps pointing at the origin URLs (lib/storage/local.ts:299-300, lib/clipper/handler.ts:273-275).
  • ?format=md on single-note export is documented but never read: the doc comment advertises the param yet the handler ignores the query string — output is always markdown (app/api/export/note/[id]/route.ts:7,16-43).
  • No saas bookmarklet page: saas users must manually paste a PAT into a hand-built bookmarklet; /bookmarklet 404s outside local mode (app/(local)/bookmarklet/page.tsx:20-25).
  • Pairing hygiene: each /bookmarklet load mints a new pairing without revoking earlier ones, so ~/.eli/config.json#pairings[] accretes stale entries; the approval page postMessages the plaintext token with targetOrigin '*' (components/pair-approval.tsx:54-61).
  • /api/import has no UI: API/OpenAPI-only surface — no drag-and-drop or settings screen invokes it (app/v1/openapi.json/route.ts:293-300).
  • Process-local idempotency: the 60s clip dedupe cache does not span instances, so horizontally scaled saas deployments can double-clip (lib/clipper/handler.ts:39-40).
  • saas template frontmatter round-trip is lossy for nested YAML (line-based key: value parser, app/_actions/templates.ts:180-189).
  • Clip-created notes are not embedded until a backfill run or a later editor save — a freshly clipped note is findable by FTS but invisible to pure semantic search in the interim (app/_actions/notes.ts:96-105, lib/ai/auto-embed-queue.ts).

See also: the workspace overview for the platform overview, End-to-end journeys for cross-cluster walkthroughs that start with a clip, and Platform & ops for the env vars (MCP_LOCAL_TOKEN, ELI_VAULT) that gate local clip auth.