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
| Component | Folder | Owns |
|---|---|---|
| Chrome extension | src/ | UI, signal capture, interpretation/synthesis orchestration, prompt injection, folder sync. The only writer of profile artifacts. |
| Local server | server/ | Read-only HTTP shim that streams the active prompt to CLI tools, plus POST /signals that drops into the inbox. |
| Website | website/ | cotext.io — Next.js app for content-addressed publishing (/p/<hash>) and account-scoped push/pull (@<user>/<slug>). |
| CLI / MCP package | packages/cli/ | cotext (terminal CLI) and cotext-mcp (stdio MCP server). Read-only on profiles, write-only on signals. |
| LLM provider | external | Runs 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:
| # | Prompt | Extension source | Daemon source | What it produces |
|---|---|---|---|---|
| 1 | INTERPRET_SYSTEM_PROMPT | packages/core/src/interpret-prompt.ts | same (shared) | {dimension, preference, confidence, reasoning} JSON for one signal |
| 2 | FACT_SYSTEM_PROMPT | src/llm/extract-facts.ts | packages/cli/src/lib/extract-facts.ts | {facts: [{key, value}]} JSON of user-stated values (name, role, etc.) |
| 3 | LIBRARY_SYSTEM_PROMPT | src/llm/synthesize.ts | packages/cli/src/lib/synthesize.ts | Deduplicated, section-grouped rule library from all signals + starters |
| 4 | PROSE_SYSTEM_PROMPT | src/llm/synthesize.ts | packages/cli/src/lib/synthesize.ts | The 2-3 sentence "Core Style" paragraph |
| 5 | PROMPT_HEADER | src/llm/synthesize.ts | packages/cli/src/lib/synthesize.ts | Static 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[("<folder>/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>.*andactive.txt). External tools never edit them — they read. - External tools only write
inbox/*.json. The extension is the only reader ofinbox/. After ingestion, files are deleted. <slug>.mdcarries a markdown header the extension generates:# <name> — <context>\n\n*Version N · updated <iso>*\n\n<prompt>. Readers strip it (seepackages/cli/src/lib/profile.tsstripMarkdownHeader) 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'srecordSignalwrites 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:
FolderSourcedelegates tolib/profile(reads) andlib/signal(writes to inbox). Bonus: a folder-onlysignalCount()forcotext status.CloudSourceissuesfetchcalls againsthttps://cotext.io/api/...(orCOTEXT_API_URLoverride) with aAuthorization: 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
| Artifact | Anonymous mode | Accounts mode |
|---|---|---|
| Profile payload | Vercel Blob (or .local-blobs/) keyed by 12-char hash | Same |
| User / slug index | — | Postgres via Prisma |
| API tokens | — | Postgres |
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
| What | Where | Owner |
|---|---|---|
| Signals (canonical) | chrome.storage.local | Service worker |
| Profiles (canonical) | chrome.storage.local | Service worker |
| Action log | chrome.storage.local | Service worker |
| Folder handle | IndexedDB (Chrome FS Access API) | Service worker |
| Profiles (mirror) | <folder>/<slug>.{json,md} | Service worker |
| Signal log (mirror) | <folder>/signals.jsonl | Service worker |
| Inbound signals | <folder>/inbox/<ts>-<uuid>.json | CLI / MCP / server |
| CLI saved config | ~/.config/cotext/config.json | cotext init |
| Published profile | Vercel Blob (or .local-blobs/) | Website |
| Account / slug metadata | Postgres | Website (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:11434directly. CORS is handled by adeclarativeNetRequestrule 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 ↔ website —
CloudSourceonly, used when no folder is configured. Samectx_…token viaCOTEXT_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
Sourceimpl. - Per-request source resolution. Both
cotextandcotext-mcpresolve 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
- Getting started — install + first profile
- CLI & MCP —
cotextandcotext-mcpreference - Developer setup — CLI install + MCP wiring + daemon
- Self-hosting — running your own
cotext.io - Telemetry — opt-in event schema and receiver recipes