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

ComponentFolderWhat it doesRequired?
Websitewebsite/Profile hub, /api/push, /api/pull, account authyes
PostgresexternalAccount + slug + token storageyes for accounts mode
Blob storageexternal (Vercel Blob, S3, or filesystem)Stores the content-addressed profile JSONyes
Local serverserver/Read-only HTTP shim that serves profiles from a .cotext folder over loopbackoptional — for Claude Code hooks etc.
Email senderexternal (Resend, SES, etc.)Sends sign-in magic linksoptional — 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:

ModeWhat worksRequired env
Anonymous-only/p/<hash>, /api/publish, /api/rawnone (or just BLOB_* for prod)
Accounts + push/pullAll of the above + @<user>/<slug>, /api/push, /api/pull, the explore feed, starsabove 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

  1. Open http://localhost:3000/signin
  2. Enter any email, hit submit
  3. Without EMAIL_SERVER, the dev server prints:
[cotext auth] magic link for you@example.com
  → http://localhost:3000/api/auth/callback/nodemailer?...
  1. Click that URL → you're signed in
  2. Visit /settings to 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/slug URLs
  • 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)

  1. Push your fork to GitHub.
  2. Import the repo into Vercel; set root directory to website/.
  3. Storage → Create → Blob. BLOB_READ_WRITE_TOKEN auto-injects.
  4. Set BLOB_PUBLIC_URL_PREFIX to the store's public base URL.
  5. (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>
  1. 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.

  1. 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_SERVER isn'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