Skip to content

Architecture

kimtos is a local-first Chrome MV3 new-tab extension: everything you create — notes, todos, PDFs, chats — lives in your browser and never leaves the device. The only server touch is accounts — an optional sign-in (brokered by the marketing site) and Stripe billing that unlock the Pro apps; your content is never uploaded (see the extension's lib/session.ts + lib/entitlement.ts, and the www-backend). This doc describes how that works.

Apps

Every app is a folder under src/apps/<id>/ with a default-export descriptor in index.ts. Apps are auto-discovered (apps/index.ts globs ./*/index.{js,ts}) — there is no central registry to edit.

FileRole
meta.tsIdentity — id (= folder name), name, accent, order, plan ('free' | 'pro'), and an optional marketing block. The single source of truth: the descriptor, dock, App Store, onboarding, the marketing site, and AnubisDB all read it.
index.tsThe descriptor (default export): render(body, ctx) plus the shared conventions — a first-time tip, an MCP-shaped tools array for the AI agent, per-app settings, dialog sizing, home widget/badge. See the app conventions in the root CLAUDE.md.
<id>.local.tsOptional on-device data layer — register() REST routes, auto-discovered. Name partitions by app id (see Data layer).
<id>.cssOptional colocated styles, imported by index.ts.

Adding a new app — a few lines, not a rewrite:

  1. apps/<id>/meta.tsid, name, accent, plan, order.
  2. apps/<id>/index.ts — the descriptor + the CLAUDE.md app conventions (tip, tools, per-app settings, storage prefixing).
  3. The app's icon glyph — see App icons below.
  4. apps/<id>/<id>.local.ts if it stores data.
  5. A docs booklet under docs/extension/apps/<id>/ (see docs/extension/apps/README.md).
  6. Free apps auto-install; Pro apps (plan: 'pro') ship uninstalled and are gated by lib/entitlement.ts.

App icons

Every app's icon is an inline SVG line-mark on a squircle tile built from the app's accent — no image files, nothing fetched (it must work offline and read on any wallpaper, light or dark). The whole set lives in one place, @kimtos/icons (shared-modules/icons/src/glyphs.ts): APP_GLYPHS maps each app id to a 24×24 glyph drawn with currentColor, so it inverts cleanly — white-on-gradient in the dock, accent-on-glass elsewhere.

Both surfaces render the identical glyph from this shared module:

  • ExtensionpaintAppTile() (components/appIcon.ts) paints the accent gradient + glyph in the dock, App Store, and onboarding. A letter monogram is the fallback for any app with no glyph yet.
  • Marketing sitewww/components/AppGrid.vue uses appTileGradient() + appGlyphSvg() for the same tiles (replacing the old Twemoji PNGs).

To add an icon, add one entry to APP_GLYPHS (and the AppId union) keyed by the app id — a 24×24 line mark: fill="none", stroke-width ≈ 1.9, round caps/joins — then rebuild @kimtos/icons. The dock and the site both pick it up.

Data layer

All persistence goes through a small client-side stack:

ModuleRole
lib/idb.tsZero-dependency IndexedDB wrapper. One DB (kimtos) with stores records, blobs, kv.
lib/dataStore.tsCRUD over records (partitioned by collection), blob storage exposed as object URLs, and a kv cache. Also exportAll/importAll for backup.
lib/localRouter.tsA tiny REST-style router. Handlers register(method, pattern, fn) and receive { params, query, body, form }. The pseudo-method FILE returns an object URL.
lib/api.tsWhat apps call: getApi/jsonApi/callApi/fileUrl. Dispatches straight to the local router.

App handlers live next to their app as *.local.ts and are auto-discovered by lib/localRoutes.ts (import.meta.glob('/src/**/*.local.{js,ts}')). To add an app's data layer, register routes in a *.local.ts file — no wiring needed.

apps/pdflib/index.ts   →  getApi('books')  →  localRouter.dispatch  →  apps/pdflib/pdflib.local.ts

Name partitions by app id. A records collection, blobs namespace, or kv key should be prefixed with the owning app's id — thoth-chunks, thoth:hashes, homeai:downloaded, history:<app>. AnubisDB (apps/anubisdb/, the on-device data explorer) derives app labels from every meta.ts and resolves ownership from that prefix (registry.tsownerOf), so a new app's data appears named and grouped with no registry edit. Un-prefixed data lands under "Unmapped data".

On-device compute (televoica / snaptext)

The two compute apps run their models in the browser so audio and images never leave the device:

  • snaptext (Image to Text) — OCR via Tesseract.js (lib/engines/ocr.ts). Worker + core are self-hosted under /ocr (MV3 bans remote worker scripts); English data is bundled.
  • televoica (Voice to Text) — speech-to-text via @huggingface/transformers Whisper (lib/engines/transcribe.ts), device: 'wasm', dtype: 'fp32'. The pipeline is disposed after each run to free memory; model files stay cached.

Model files are fetched once and cached. The onboarding overlay (views/onboarding.ts) pre-downloads them on first visit with a progress bar.

Downloads run in the background (consistent everywhere)

Any model/asset download keeps running even if the surface that started it is closed, and closing that surface mid-download raises a snackbar (components/snackbar.ts) — "Download continues in the background." with a Reopen action button — so it never feels cancelled. Reopening (button or manually) re-attaches to the live progress bar (the bar is always kept). This holds for all three download surfaces:

  • App Store and Settings (download-affecting setting changes) → the global lib/installManager.ts already runs installs/ensureModel detached; the dialogs fire the snackbar on close via onClose when anyDownloading().
  • KimtAI Model Library → a module-scoped activeDownloads registry in apps/homeai/index.ts plays the same role for chat-model downloads.

New download UI should follow this: run the work off the closeable surface, keep a progress bar that can be re-attached, and snackbar on close while it's in flight.

Backup / portability

dataStore.exportAll() serialises every record and blob (blobs as data URLs) into one JSON file; importAll() restores it. This is how a user moves their data between machines — they own the file.

Floating windows (Picture-in-Picture)

Apps that set popout: true (currently Notes and Clipboard) get a pop-out control in their window title bar. Clicking it uses the Document Picture-in-Picture API (lib/pip.ts) to open an always-on-top OS window — the same feature Google Meet uses — and moves the app's DOM into it, leaving a "bring it back" placeholder in the dialog. The PiP window is a same-origin child of the new-tab page, so the moved nodes keep their event listeners and share our IndexedDB/JS — nothing is copied or uploaded; we only mirror the stylesheets so it looks identical.

The orchestration lives in appsController.ts (popOut/canPopOut); the title-bar control is wired through createDialog({ onPopOut }) + setPopOutEnabled(). Each app exposes an on/off Floating window setting plus a Floating window size preset (Compact / Standard / Large → PIP_SIZES in lib/pip.ts, clamped to the screen; the window stays drag-resizable on top of that), both per-app schema → Settings ▸ Apps. A one-time intro banner (pipNoteSeen in the store) tells users the feature exists. Caveat: a Document PiP window only lives as long as the new-tab page does — keep that tab open and browse in other tabs to keep the floating window around.

Manifest notes

  • permissions: storage, geolocation, unlimitedStorage, identity (the account sign-in uses chrome.identity.launchWebAuthFlow), contextMenus.
  • host_permissions: https://*/* — lets import-by-URL and key-free feeds (Open-Meteo, DEV/HN/Lobsters) be fetched directly, bypassing CORS. The same breadth covers the account/entitlements calls to the API.
  • CSP allows 'wasm-unsafe-eval' (for the WASM engines) and worker-src 'self' blob:.