Cotext Architecture

This document covers what each component owns, how data flows between them, and the contracts that hold the system together. For a hands-on "how do I run this" walkthrough, see Developer setup or Self-hosting.


Components at a glance

flowchart LR
subgraph local["Local machine"]
  direction LR
  LocalLLM["WebGPU / Chrome AI / Ollama"]:::dep
  Ext(["Browser extension"]):::primary
  Folder[(".cotext<br/>folder")]:::store
  Server["Local server :7337"]:::dep
  CLI["cotext CLI + MCP"]:::dep
end
CloudLLM["Claude / OpenAI API"]:::dep
Cloud[("cotext.io")]:::store

LocalLLM -- "interprets + synthesizes" --> Ext
CloudLLM -. "interprets + synthesizes (optional)" .-> Ext
Ext -- "writes" --> Folder
Folder --> Server
Folder --> CLI
Ext -. "publish (opt-in)" .-> Cloud
CLI -. "fallback (no folder)" .-> Cloud

classDef primary fill:#0c111f,stroke:#5A96FF,stroke-width:2px,color:#ffffff
classDef store fill:#050810,stroke:#0038A8,color:#d9d9d9
classDef dep fill:#0a0e1a,stroke:#1f2a45,color:#c0c8db
ComponentFolderOwns
Chrome extensionsrc/UI, signal capture, interpretation/synthesis orchestration, prompt injection, folder sync. The only writer of profile artifacts.
Local serverserver/Read-only HTTP shim that streams the active prompt to CLI tools, plus POST /signals that drops into the inbox.
Websitewebsite/cotext.io — Next.js app for content-addressed publishing (/p/<hash>) and account-scoped push/pull (@<user>/<slug>).
CLI / MCP packagepackages/cli/cotext (terminal CLI) and cotext-mcp (stdio MCP server). Read-only on profiles, write-only on signals.
LLM providerexternalRuns interpretation + synthesis. The extension supports five interchangeable providers: on-device (WebGPU via web-llm, or Chrome's built-in Gemini Nano), local server (Ollama on localhost:11434), or cloud APIs (Anthropic Claude, OpenAI GPT). Picked in the popup; keys/configuration stay in the browser.

The extension (src/)

Three layers: content scripts in the page, an injected script in the page's main world, and the service worker holding state.

src/
  background/background.ts     service worker — single source of truth
                               for storage, interpretation, synthesis,
                               folder sync, publishing
  content/
    overlay.ts                 floating like/dislike/tag widget
    log-panel.ts               full UI: signals, prompt, templates, history
    inject.ts                  bridges content world ↔ injected script
    context-capture.ts         extracts the AI response text for a signal
    diff.ts, design-tokens.ts  helpers
    content.ts                 entry point — wires everything up
  injected/                    main-world fetch interceptor (rewrites
                               request bodies to prepend the prompt)
  llm/
    ollama.ts                  HTTP client for :11434
    webgpu.ts                  thin shim over @mlc-ai/web-llm in offscreen
    interpret.ts               signal → {dimension, preference, confidence, reasoning}
    extract-facts.ts           signal → user facts (name, role, etc.) — separate
                               tier from interpret because facts and rules
                               have different lifecycles and shouldn't share
                               a generalization pipeline
    synthesize.ts              interpretations + facts + starters → markdown prompt
    prompt-adjustments.ts      regex parser that routes "decrease directness
                               by 0.5" to a metric mutation rather than a
                               custom-rule append (popup's prompt-tab refine box)
    provider.ts                provider abstraction (webgpu / ollama / claude / openai)
  storage/
    signals.ts                 signal CRUD on chrome.storage.local
    history.ts                 per-profile version log
    action-log.ts              global action timeline
    folder-sync.ts             reads/writes <folder>/, ingests inbox/
  popup/                       toolbar popup (settings, status, publish target)
  data/                        seed templates, defaults
  types/                       shared TS types — source of truth
  ui/                          shared React-flavored helpers (no React dep)

Why split into content + injected scripts?

Content scripts run in an isolated world — they share the page's DOM but not its window. The injected script runs in the page's main world, so it can monkey-patch window.fetch and rewrite outgoing request bodies. Content ↔ injected communicate via postMessage events that inject.ts brokers.

Why a service worker?

MV3 forbids long-lived background pages, but the worker still owns:

  • The single in-memory cache of the active profile.
  • The Ollama orchestration queue (interpret pass, then synthesize pass).
  • File System Access handles (kept alive via IndexedDB persistence).
  • Outbound publish/push HTTP requests to the website.

Content scripts speak to it over chrome.runtime.sendMessage. The worker is the only writer to chrome.storage.local.


Data flow — the feedback loop

flowchart TB
R["1 — React 👍 / 👎 on a response"]:::step
C["2 — Signal captured<br/><span style='opacity:0.7'>chrome.storage + signals.jsonl</span>"]:::step
I["3 — Interpret via LLM<br/><span style='opacity:0.7'>~5–10s</span>"]:::step
S["4 — Synthesize prompt<br/><span style='opacity:0.7'>~15–30s</span>"]:::step
W["5 — Write profile to folder<br/><span style='opacity:0.7'>.json + .md + active.txt</span>"]:::step
X["6 — Any reader serves the new prompt<br/><span style='opacity:0.7'>content script · CLI · server · cotext.io</span>"]:::final

R --> C --> I --> S --> W --> X

classDef step fill:#0a0e1a,stroke:#1f2a45,color:#d9d9d9
classDef final fill:#0c111f,stroke:#5A96FF,stroke-width:2px,color:#ffffff

The five meta-prompts

The "interpret + synthesize" steps above are five distinct LLM calls under the hood, each pinned by its own system prompt. Tracking them helps when debugging extraction quality or when something feels off:

#PromptExtension sourceDaemon sourceWhat it produces
1INTERPRET_SYSTEM_PROMPTpackages/core/src/interpret-prompt.tssame (shared){dimension, preference, confidence, reasoning} JSON for one signal
2FACT_SYSTEM_PROMPTsrc/llm/extract-facts.tspackages/cli/src/lib/extract-facts.ts{facts: [{key, value}]} JSON of user-stated values (name, role, etc.)
3LIBRARY_SYSTEM_PROMPTsrc/llm/synthesize.tspackages/cli/src/lib/synthesize.tsDeduplicated, section-grouped rule library from all signals + starters
4PROSE_SYSTEM_PROMPTsrc/llm/synthesize.tspackages/cli/src/lib/synthesize.tsThe 2-3 sentence "Core Style" paragraph
5PROMPT_HEADERsrc/llm/synthesize.tspackages/cli/src/lib/synthesize.tsStatic preamble prepended to every synthesized prompt

Prompt #1 is single-source-of-truth in @cotext/core. Prompts #2-5 are duplicated verbatim between the extension and the daemon — the strings are byte-identical so output is identical on identical input. The duplication is deliberate: keeping the daemon's bundle free of browser-only deps (Chrome AI, WebGPU, chrome.storage) matters more than the small drift risk.

The popup's Advanced settings → "Meta-prompts (what the model sees)" section displays all five read-only. They are deliberately not editable in the UI — they pin output schemas the parser depends on, and a broken edit would fail silently (signals stop producing rules). To steer extraction without breaking the JSON contract, see PreferenceProfile.interpretationHint — appended to the user message side of prompt #1 only.

The other direction — signals from outside the browser

flowchart TB
CLI["cotext signal 'be terser' --type dislike"]:::step
Inbox[("&lt;folder&gt;/inbox/")]:::store
Drain["Extension drains inbox<br/><span style='opacity:0.7'>on next popup open</span>"]:::step
Loop["Continues at step 3 — Interpret"]:::final

CLI --> Inbox --> Drain --> Loop

classDef step fill:#1e1e35,stroke:#3a3a55,color:#e5e7eb
classDef store fill:#050810,stroke:#0038A8,color:#d9d9d9
classDef final fill:#2d2d52,stroke:#a5b4fc,stroke-width:2px,color:#f3f4f6

The .cotext folder — the contract

The folder is the system's interop seam. Three writers (extension, CLI, local server's POST /signals) and three readers (extension on startup, local server, CLI / MCP) all agree on this layout:

<folder>/
  active.txt                  sanitized name of the active profile
  <slug>.json                 full profile object
  <slug>.md                   synthesized prompt (with markdown header)
  <slug>.history.jsonl        per-profile version event log
  signals.jsonl               flat append-only signal log
  inbox/                      drop-zone for external signal writers
    <timestamp>-<uuid>.json   one file per signal; deleted after ingest

Rules of the contract:

  • The extension is the only writer of profile artifacts (<slug>.* and active.txt). External tools never edit them — they read.
  • External tools only write inbox/*.json. The extension is the only reader of inbox/. After ingestion, files are deleted.
  • <slug>.md carries a markdown header the extension generates: # <name> — <context>\n\n*Version N · updated <iso>*\n\n<prompt>. Readers strip it (see packages/cli/src/lib/profile.ts stripMarkdownHeader) so callers receive the synthesized instructions without the editorial frame.
  • Signal shape is shared with the extension's ingestion path — required: id, type, context. The CLI's recordSignal writes the same shape the extension validates.

This is why the CLI and MCP package don't need a database, daemon, or network call to the extension — the folder is the protocol.


CLI / MCP package (packages/cli/)

Designed around a Source abstraction so every command works identically against either a local folder or cotext.io.

packages/cli/src/
  bin/
    cotext.ts             CLI entry — argv parsing, subcommands
    cotext-mcp.ts         MCP stdio server — resources + tools
  lib/
    source.ts              Source interface; FolderSource + CloudSource
                           impls; resolveSource() factory
    folder.ts              read/write of saved config; folder validator
    profile.ts             folder-side reader: list/get/getActive
    signal.ts              folder-side writer: drops into inbox/
    types.ts               ProfileBundle, ProfileSummary

The Source interface

interface Source {
  kind: "folder" | "cloud";
  describe(): string;
  listProfiles():    Promise<ProfileSummary[]>;
  getProfile(slug):  Promise<ProfileBundle | null>;
  getActiveProfile():Promise<ProfileBundle | null>;
  recordSignal(in):  Promise<RecordedSignal>;
}

Two implementations:

  • FolderSource delegates to lib/profile (reads) and lib/signal (writes to inbox). Bonus: a folder-only signalCount() for cotext status.
  • CloudSource issues fetch calls against https://cotext.io/api/... (or COTEXT_API_URL override) with a Authorization: Bearer ${COTEXT_API_TOKEN} header.

Resolution priority

--folder <path>          (CLI flag — highest)
COTEXT_FOLDER           (env)
~/.config/cotext/config.json   (saved by `cotext init`)
COTEXT_API_TOKEN        (cloud fallback — lowest)

Folder always wins when both are set — connecting a folder switches you off the cloud automatically. If none is set, resolveSource throws a single message that lists all four options.

The source is resolved per request in both cotext and cotext-mcp, so prompt edits in the extension (or remote profile changes) show up live without a CLI / client restart.

CLI surface

cotext init                                    pick + save folder
cotext status                                  source kind, active profile, signal count
cotext list                                    all profiles (* marks active)
cotext pull [--profile <slug>]                 active prompt to stdout
cotext cat <slug>                              full markdown including header
cotext signal <note> [--type ...] [--tag ...]  drop a feedback signal

MCP surface

Resources
  cotext://preferences/active                  active profile's prompt (markdown)
  cotext://profiles                            JSON summary list
  cotext://profiles/{slug}                     one profile's prompt

Tools
  get_preferences({ profile? })                 returns prompt text
  list_profiles()                               returns summary list
  record_signal({ type?, tag?, note?, ... })    drops to inbox/ (folder source) or POST /api/signals (cloud)

For the full CLI / MCP details, see CLI & MCP.


Local server (server/)

A ~200-line Node HTTP server. No framework, no DB. Reads the same folder layout as the CLI; serves over loopback only.

GET  /health             →  "ok"
GET  /prompt             →  text/plain  (active profile's synthesized prompt)
GET  /active             →  JSON        ({ name, version, prompt, updatedAt })
GET  /profiles           →  JSON        ({ profiles: string[] })
GET  /profiles/:name     →  JSON        (full profile, no history)
POST /signals            →  201         (writes <folder>/inbox/<id>.json)

It exists for the cases where MCP isn't an option — Claude Code hooks, generic shell pipelines, anything that talks HTTP. The CLI / MCP package is a strict superset functionally; the server is the zero-dependency option.


Website (website/)

Next.js 16 app. Two modes — anonymous-only is the default, accounts mode lights up additional routes when DB + email magic-link auth env vars are present.

website/
  app/                   App Router pages + API routes
    p/[hash]/            anonymous viewer
    api/publish/         POST → content-addressed publish
    api/raw/[hash]/      raw JSON
    @[user]/             account-scoped pages    (DB-only)
    @[user]/[slug]/      mutable pointer         (DB-only)
    api/push/            POST named push         (DB-only)
    api/pull/[user]/[slug]/   public read       (DB-only)
    api/profiles/[user]/[slug]/           PATCH (DB-only)  owner-only inline edit (row + blob)
    api/profiles/[user]/[slug]/fork/      POST  (DB-only)  server-side clone into the forker's account
    api/profiles/[user]/[slug]/download/  GET             anonymous JSON download (signed-out fork path)
    settings/            tokens + username       (DB-only)
  components/            React components
  lib/
    profile-schema.ts    zod schema + canonical-JSON hashing
    blob.ts              Vercel Blob (fallback to .local-blobs/)
    db.ts                Prisma singleton
  prisma/                schema + migrations

Storage strategy

ArtifactAnonymous modeAccounts mode
Profile payloadVercel Blob (or .local-blobs/) keyed by 12-char hashSame
User / slug indexPostgres via Prisma
API tokensPostgres

Hashing & idempotency

lib/profile-schema.ts canonicalizes the payload (recursive key sort, arrays preserved) before hashing with SHA-256, then truncates to 12 chars. The server-assigned publishedAt is added after hashing, so republishing the same logical profile always returns the same hash / viewUrl.

Cloud surface used by CloudSource

The CLI's CloudSource calls these endpoints (auth via Authorization: Bearer ${COTEXT_API_TOKEN}):

GET  /api/preferences/active    →  { profile: ProfileBundle }      ← shipped
GET  /api/profiles              →  { profiles: ProfileSummary[] }  ← shipped
GET  /api/profiles/{slug}       →  { profile: ProfileBundle }      ← shipped
POST /api/signals               →  { id }                          ← shipped
GET  /api/signals/pending       →  { signals: [...], hasMore }     ← shipped
POST /api/signals/{id}/drained  →  { ok: true }                    ← shipped

All endpoints are shipped. The signals surface is a cloud inbox, not a server-side interpreter: POST /api/signals accepts a signal and stores it; a local drainer (extension service worker or cotext daemon with cloud credentials) polls GET /api/signals/pending, runs the LLM interpretation locally, then calls POST /api/signals/{id}/drained to mark each one consumed. The LLM stays on the user's machine — same privacy posture as folder mode.

The "active" pointer is mirrored to User.activeSlug whenever the extension pushes with setActive: true. If never set, the endpoint falls back to the most recently updated profile — same semantics as a folder source's missing active.txt.


Storage map

WhatWhereOwner
Signals (canonical)chrome.storage.localService worker
Profiles (canonical)chrome.storage.localService worker
Action logchrome.storage.localService worker
Folder handleIndexedDB (Chrome FS Access API)Service worker
Profiles (mirror)<folder>/<slug>.{json,md}Service worker
Signal log (mirror)<folder>/signals.jsonlService worker
Inbound signals<folder>/inbox/<ts>-<uuid>.jsonCLI / MCP / server
CLI saved config~/.config/cotext/config.jsoncotext init
Published profileVercel Blob (or .local-blobs/)Website
Account / slug metadataPostgresWebsite (accounts mode)

Everything except the published copies is local to the user's machine.


Process boundaries and trust

  • Browser ↔ Ollama — content + worker call http://localhost:11434 directly. CORS is handled by a declarativeNetRequest rule in the manifest. No proxy, no key.
  • Browser ↔ folder — Chrome's File System Access API. The user picks the folder; the handle persists across restarts via IndexedDB.
  • Folder ↔ CLI / MCP / server — POSIX file I/O. No locks, no daemons. The contract is the directory layout.
  • Browser ↔ website — explicit publish/push/pull from the popup. Anonymous publish needs no auth; push uses an ctx_… API token from /settings.
  • CLI ↔ websiteCloudSource only, used when no folder is configured. Same ctx_… token via COTEXT_API_TOKEN.

Nothing leaves the machine unless the user clicks Publish / Push or sets COTEXT_API_TOKEN. Published payloads contain the rendered prompt, metrics, entries, and templates — they do not contain raw signals, per-response AI text, or history.


Design choices worth noting

  • Folder-as-protocol over IPC. No daemon, no socket, no message queue between the extension and CLI tools. A directory + agreed filenames does the job and survives crashes, sleep, and uninstall.
  • One writer per artifact class. Profiles are written by the extension, signals are written by anyone, but inbox/ is read-only to the extension before delete. No multi-writer races.
  • Source abstraction in the CLI. Folder and cloud are interchangeable behind one interface. Adding new backends (e.g. shared-team folder over Dropbox) only needs a new Source impl.
  • Per-request source resolution. Both cotext and cotext-mcp resolve the source on every call rather than caching, so live edits in the extension propagate without restart.
  • Content-addressed publishing. Canonical-JSON SHA-256 → 12-char hash means the same logical profile always produces the same URL. Useful for dedup, caching, and "did anything change".
  • Privacy by default. Local-first; nothing leaves the machine without an explicit user action.

See also