Self-hosting Cotext
cotext.io runs the public hub, but the whole stack is open source.
Run your own copy if you want:
- A team-private hub where your profiles aren't on a public domain
- Custom categories or starter packs that aren't on the canonical cotext.io
- Complete control over data residency
- An air-gapped deployment for compliance reasons
The minimum useful self-host is the Next.js website + a Postgres database + a blob store. The browser extension can stay on the official Chrome Web Store version once it ships — you just point it at your hosted domain via the extension's "Publish endpoint" setting.
For the user-facing extension setup, see getting started. For the CLI / MCP integration, see developer setup.
What gets self-hosted
| Component | Folder | What it does | Required? |
|---|---|---|---|
| Website | website/ | Profile hub, /api/push, /api/pull, account auth | yes |
| Postgres | external | Account + slug + token storage | yes for accounts mode |
| Blob storage | external (Vercel Blob, S3, or filesystem) | Stores the content-addressed profile JSON | yes |
| Local server | server/ | Read-only HTTP shim that serves profiles from a .cotext folder over loopback | optional — for Claude Code hooks etc. |
| Email sender | external (Resend, SES, etc.) | Sends sign-in magic links | optional — falls back to console logging in dev |
For a single-user self-host you can skip Postgres and run the anonymous-only mode described below.
1. Prerequisites
- Node.js 20+ and npm
- (For accounts mode) Postgres — Neon's free tier is the easiest path
- (For accounts mode) An email sender — Resend, SES, Postmark, Mailgun, or any SMTP server
- (For production deploys) A Vercel account, or any host that can run Next.js 16+
2. Two modes
The website ships with two modes, picked automatically based on which env vars are set:
| Mode | What works | Required env |
|---|---|---|
| Anonymous-only | /p/<hash>, /api/publish, /api/raw | none (or just BLOB_* for prod) |
| Accounts + push/pull | All of the above + @<user>/<slug>, /api/push, /api/pull, the explore feed, stars | above plus DATABASE_URL, AUTH_SECRET, EMAIL_FROM (and optionally EMAIL_SERVER) |
Anonymous mode is the "personal hub" version — anyone can publish a
profile, get a content-addressed URL like /p/abc123def456, and
share that URL. No accounts, no usernames, no API tokens.
Accounts mode adds the full social surface — usernames, named
profiles at /@user/slug, version history, stars, the explore
feed, and CLI / MCP cloud-source support.
3. Anonymous-only — local development
The fastest path to a working local hub:
git clone https://github.com/cotext-io/cotext
cd cotext/website
npm install
npm run dev # → http://localhost:3000
That's it. Without BLOB_READ_WRITE_TOKEN, uploads land in
.local-blobs/ (gitignored). Smoke test:
curl -s -X POST http://localhost:3000/api/publish \
-H 'content-type: application/json' \
-d '{"name":"demo","prompt":"# Hello\n\nBe direct."}' | jq
The response includes a viewUrl like
http://localhost:3000/p/abc123def456. Open it in a browser.
Pointing the extension at local dev
In the extension popup → Publish endpoint → click Local to
swap the publish target to http://localhost:3000. The extension's
manifest already includes http://localhost:3000/* as a
host_permission in dev builds (production builds strip it).
4. Accounts mode — local development
Accounts mode adds Postgres and email magic-link auth. For local dev, you can run without an SMTP server — the magic link gets logged to the dev server console.
# website/.env.local
DATABASE_URL="postgres://user:pass@host/cotext?sslmode=require"
AUTH_SECRET="$(openssl rand -hex 32)"
EMAIL_FROM="Cotext <noreply@your-domain.com>"
# Optional. Without EMAIL_SERVER, the magic-link URL is logged to
# the server console — copy it from the terminal. Set this for
# real email:
# EMAIL_SERVER="smtp://user:pass@smtp.example.com:587"
npx prisma migrate dev --name init # one-time DB setup
npm run dev
Whenever the Prisma schema changes (a new column, a new table), run
npx prisma migrate dev --name <short-description> to update your
local DB and add a migration file. CI / Vercel pick up the file
automatically — see the production deploy section for the build
command that runs migrate deploy.
Sign-in flow
- Open
http://localhost:3000/signin - Enter any email, hit submit
- Without
EMAIL_SERVER, the dev server prints:
[cotext auth] magic link for you@example.com
→ http://localhost:3000/api/auth/callback/nodemailer?...
- Click that URL → you're signed in
- Visit
/settingsto claim a username and generate an API token
The API token is shown exactly once on creation. Paste it into the
extension popup's API token field (Advanced settings) or set
COTEXT_API_TOKEN for the CLI — both unlock Push to your
self-hosted instance.
What's at /settings
- Username — claimed once; appears in
@user/slugURLs - API tokens — generate, revoke, list. Raw value shown once
- Your data — export a JSON archive (right to data portability) or delete your account (right to erasure)
5. Production deploy (Vercel)
- Push your fork to GitHub.
- Import the repo into Vercel; set root directory to
website/. - Storage → Create → Blob.
BLOB_READ_WRITE_TOKENauto-injects. - Set
BLOB_PUBLIC_URL_PREFIXto the store's public base URL. - (Optional, for accounts mode) Add the env vars:
DATABASE_URL postgres://... (Neon connection string with ?sslmode=require)
AUTH_SECRET <output of: openssl rand -hex 32>
AUTH_URL https://your-domain.com
AUTH_TRUST_HOST true
# Optional — only set if your public site URL differs from AUTH_URL.
# NEXT_PUBLIC_SITE_URL https://your-domain.com
EMAIL_FROM Cotext <hi@your-domain.com>
EMAIL_SERVER smtp://resend:<api-key>@smtp.resend.com:465
# Optional — opt-in error monitoring. Without these the SDK no-ops.
# SENTRY_DSN https://<key>@<org>.ingest.sentry.io/<project>
# NEXT_PUBLIC_SENTRY_DSN <same as above; needed for client-side errors>
# SENTRY_AUTH_TOKEN <only needed if you want unminified stack traces>
# SENTRY_ORG, SENTRY_PROJECT
# Optional — required only if you wired the blob-GC cron.
# CRON_SECRET <any random string>
# Optional — flips install CTAs from waitlist form to Chrome Web Store link.
# NEXT_PUBLIC_CHROME_WEB_STORE_URL https://chromewebstore.google.com/detail/<id>
- Override the build command so migrations run before the build:
prisma migrate deploy && next build
Without this, code that depends on a new column will ship before the column exists, and you'll see production errors as soon as the new code hits a request.
- Verify your email sender's domain — Resend (or whichever) needs DKIM / SPF / DMARC records on the sending domain, or sign-in emails land in spam.
6. Local HTTP server (optional)
There's also a ~200-line Node HTTP server (server/server.mjs) that
reads the same .cotext folder layout as the CLI and serves the
active profile's prompt over loopback. It exists for Claude Code
hooks, generic shell pipelines, and anything that can talk HTTP but
isn't MCP-aware.
# Option A: pass --folder
node server/server.mjs --folder /path/to/your/cotext/folder
# Option B: env var + npm script
export COTEXT_FOLDER=/path/to/your/cotext/folder
npm run server
Defaults: --port 7337, --host 127.0.0.1 (loopback only).
Routes:
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)
Wire it into Claude Code
~/.claude/settings.json:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "curl -s localhost:7337/prompt" }
]
}
]
}
}
Claude Code appends the hook's stdout to your prompt — your synthesized preferences ride along on every message without needing the MCP server.
The CLI / MCP package is a strict superset functionally; this server is the zero-dependency option for environments where installing the CLI is overkill.
7. Categories and starter packs in a fork
If you edit packages/core/src/categories.ts or
packages/core/src/starter-packs.ts in your fork:
- Self-hosted deployment: everything works end-to-end. Your custom
categories appear in
/c/<your-slug>, your custom starter packs show up in the create-profile picker. - Pushing from a forked extension to the canonical cotext.io:
unknown categories silently route to
general; unknown starter pack ids are rejected. This is a curation guard, not a permanent limitation — user-defined categories are on the roadmap.
See the README's fork section for the longer write-up.
8. Troubleshooting
Sign-in email doesn't arrive
- Check Resend / your SMTP provider's dashboard for delivery status.
- Verify the sender domain's DKIM / SPF / DMARC records.
- Check the Vercel function logs — if
EMAIL_SERVERisn't set, the magic-link URL is logged but the email isn't sent.
Push returns 401 / 403
The API token is wrong, expired, or revoked. Generate a fresh one
at /settings. Make sure the extension popup's token field has the
raw value (not the SHA-256 hash shown in the token list).
Push returns 409 / 422
Slug collision (you already have a profile at that path) or validation failed (slug isn't URL-safe). Check the error body for details.
/api/explore empty after deploying
The Profile rows exist but categorySlug defaults to "general".
Check /c/general instead. Or run the seed script
(npm run seed:starter-packs) to populate the @cotext/* packs as
real profiles.
"Module not found" errors after pulling new code
Schema or dependencies changed. Run:
npm install
npx prisma generate
npx prisma migrate deploy
See also
- Architecture — internals and data flow
- Getting started — non-developer setup
- Developer setup — CLI / MCP integration
- Telemetry — opt-in event schema