Local development setup
Getting ErmisAI running locally: prerequisites, environment, database, and the first run.
This page gets a working ErmisAI dev server on your machine and explains the version pins and environment traps that break a first run. Paths are relative to the app repository root (the directory is named hermesai; the package is ermisai).
Prerequisites
| Requirement | Version | Where it is pinned |
|---|---|---|
| Node.js | >=24.18.0 | package.json:7 (engines.node) |
| pnpm | 11.15.1 | package.json:5 (packageManager) |
| Postgres | any standard Postgres | optional for a first boot — see Running without Postgres |
| Clerk application | publishable key + secret key | required; the app ships no auth form of its own |
pnpm is the only supported package manager. Corepack pins it to 11.15.1 from the packageManager field regardless of what you have installed globally.
mise.toml contains exactly one entry:
[tools]
pnpm = "latest"It does not pin Node, and there is no .nvmrc or .tool-versions in the repo. Your Node version is your responsibility. CI runs Node 24 (.github/workflows/ci.yml). Node 20 will refuse to install.
Install
pnpm installEnvironment file
Copy the committed example and fill it in:
cp .env.example .env.gitignore ignores .env* and un-ignores only .env.example, so nothing you write into .env or .env.local can be committed by accident.
.env.example (242 lines) is the most complete environment reference in the repository — more complete than README.md. The README contains no mention of MIGRATIONS_DATABASE_URL, POSTGRES_POOL_MAX, ERMIS_QUEUE_SIGNING_SECRET, FLAGS_SECRET, EMAIL_UNSUBSCRIBE_SECRET, SENTRY_DSN, APP_URL, or the ERMIS_FEED_* tuning block. Read .env.example first, and see Environment variable reference for the full grouped list.
The minimum to boot
You do not need Polar, Resend, Upstash, Sentry or an AI provider to get the dev server up. The genuine minimum is Clerk plus one of the two storage choices below.
Proprietà
Tipo
README.md:34-37 also asks you to set the four Clerk URL variables in both their server and NEXT_PUBLIC_ forms: CLERK_SIGN_IN_URL=/sign-in, CLERK_SIGN_UP_URL=/sign-up, and both fallback redirect URLs to /app.
Running without Postgres
ERMIS_FORCE_INMEMORY_STORE=trueThis routes the shared runtime state store (platform_state / platform_state_hash) to an in-memory implementation, so the app boots with no database at all. Everything you create is lost when the dev server restarts, and nothing that pnpm db:seed-bootstrap would have written — the billing plan rows, the synced source catalog rows — exists.
isInMemoryStoreForced() returns false whenever VERCEL_ENV is set, whatever the flag says (src/lib/platform/shared/persistence.ts:16-22). The presence of VERCEL_ENV is a hard gate that wins over the flag, on preview deployments as well as production, so a stray value copied into a Vercel environment can never silently route tenant state to memory. It is a local switch only.
Production has no state fallback at all: if platform_state is unreachable or unmigrated, every authenticated surface fails. That is deliberate. See Database schema and runtime state.
Database setup
Two URLs, two resolution orders
The application runtime and drizzle-kit deliberately read different variables in a different order.
| Consumer | Resolution order | Source |
|---|---|---|
| App runtime | SUPABASE_DATABASE_URL → DATABASE_URL | src/lib/db/client.ts:66-69 |
drizzle-kit (all db:* scripts) | MIGRATIONS_DATABASE_URL → SUPABASE_DATABASE_URL → DATABASE_URL | drizzle.config.ts:10-33 |
MIGRATIONS_DATABASE_URL exists because DDL through a transaction-mode pooler is unreliable. Point it at the direct or session endpoint. On CapyDB the same host serves both: :6432 is the pooled endpoint for the app runtime, :5432 is the direct endpoint for DDL only (.env.example:19-25).
In NODE_ENV=production a direct host in the runtime variable throws at client construction rather than falling through to another candidate (src/lib/db/client.ts:97-104):
<NAME> points at a direct Postgres host. Use the pooled/transaction connection string for the app runtime in production.
Locally this guard does not fire, so a single direct URL in DATABASE_URL is fine for development — but it will fail closed the moment the same value reaches production.
Migrate and seed
Apply migrations
pnpm db:migratedrizzle-kit resolves the connection string from the repo's env file itself, so this works without exporting anything into your shell.
Seed the bootstrap data
pnpm db:seed-bootstrapUpserts the six billing plans, the whole source catalog, and a dataset-version row in one transaction. On success it prints:
Bootstrap seed completed.
billing_plans=<n>
source_catalog_entries=<n>
source_catalog_dataset_versions=<n>
catalog_entries=<n>
catalog_version=<v>Without it, billing_plans is empty, so the Billing and Usage sections of Settings and GET /api/billing/plans have no plan rows (src/lib/db/billing-repository.ts:465). source_catalog_entries being empty is quieter: the repository falls back to the bundled news-sources/catalog.v1.json when the table returns zero rows (src/lib/db/source-catalog-repository.ts:613-614, :472-487), so the source picker still renders the full catalog — a failed or skipped sync looks identical to a healthy deployment.
Confirm the schema matches
pnpm db:checkRuns drizzle-kit check --dialect postgresql. It also runs in the CI quality job.
pnpm db:seed-bootstrap runs through tsx, and tsx does not load .env — the script reads process.env only (scripts/db/seed-bootstrap.ts:107-128). If your connection string lives only in the file, either export it for the command or invoke the script the way email:campaign does in package.json:26:
node --env-file=.env --import tsx scripts/db/seed-bootstrap.tsTwo one-off repair scripts sidestep this by calling process.loadEnvFile('.env') themselves when no DB URL is present: scripts/db/backfill-source-icons.ts and scripts/db/repair-source-catalog-categories.ts. Nothing else does.
Never use npx for drizzle-kit
Use pnpm db:migrate or pnpm exec drizzle-kit. The production migration gate spells out why in its own header: it spawns pnpm exec and "NOT npx — npx re-resolves and trips the version gate" (scripts/deploy/prod-migrate.ts:14-15).
The repo is on drizzle-kit and drizzle-orm 1.0.0-rc.4, whose layout is one directory per migration (drizzle/<timestamp>_<name>/migration.sql + snapshot.json) with intentionally no drizzle/meta/_journal.json. If you are about to change schema.ts, read Migrations and schema changes first.
First run
pnpm devOpen http://localhost:3000.
To confirm the backend is actually wired up, hit the readiness probe — it is one of only two API routes that require no authentication:
curl -s http://localhost:3000/api/health | jqIt returns HTTP 200 when checks.database.status === 'ok' and 503 otherwise (src/app/api/health/route.ts:144,161). Redis being absent reports "status": "not_configured" and never fails readiness. The useful field on day one is checks.database.migrated: when the platform_state probe fails you get migrated: false and
platform_state unreachable (run db:migrate before serving traffic): <error>The config block lists ten launch-critical environment keys and whether each is present. It is informational locally — config.ready: false does not change the HTTP status. See Deploying to production.
Version pins that must not change
Never remove @typescript/native-preview from devDependencies while the repo is on typescript@7. typescript@7.0.2 ships only lib/tsc.js, lib/getExePath.js and lib/version.cjs — there is no lib/typescript.js, i.e. no programmatic compiler API. Next.js needs one of two escape hatches, and this repo sets both: experimental.useTypeScriptCli: true (next.config.ts:304) and the native-preview package. Knip reports it as an unused dependency, which is why it is listed in ignoreDependencies in knip.json — do not "clean it up".
Bleeding-edge dist-tags (canary, next, rc, beta, experimental) are deliberate policy across the dependency tree. Never downgrade one to fix a local problem.
The cost of that policy is drift: an option that a next canary drops becomes a type error on your machine with no change from you. This is real and currently visible — pnpm typecheck fails on next.config.ts:435:
next.config.ts(435,5): error TS2353: Object literal may only specify known properties,
and 'imgOptSkipMetadata' does not exist in type 'ExperimentalConfig'.When pnpm typecheck goes red in a file you never touched, check next.config.ts against the installed canary before assuming your branch is at fault.
Day-one surprises
pnpm lintprints nothing. It isoxlint -f github src/ > lint.md 2>&1(package.json:32) — both streams are redirected intolint.mdat the repo root. Read that file, or the exit code. Usepnpm lint:fixfor terminal output.pnpm buildperforms no type checking at all.typescript.ignoreBuildErrors: trueis set deliberately (next.config.ts:113-118) because the build-time check needs the programmatic TS API that native TS 7.0 does not expose. Type safety comes solely frompnpm typecheck.pnpm buildhard-setsNODE_ENV=production(package.json:11-12). ANODE_ENV=developmentexported in your shell otherwise leaks intonext buildand breaks prerendering.pnpm lintandpnpm formatonly coversrc/.scripts/,tests/anddrizzle/are outside their scope.pnpm testis noisy on a green run. Suites deliberately exercise failure paths, so lines like[news-pipeline] synthesis attempt failed (…)and[billing-status] unrecognized Polar subscription status …are expected output, not failures.AGENTS.mdrewrites itself.next devregenerates it (node_modules/next/dist/server/lib/generate-agent-files.js). Removing it from a diff just recreates the uncommitted change.- Formatting is oxfmt, linting is oxlint. There is no ESLint config and no Prettier config in the repo.
.oxfmtrc.jsoncsets no semicolons, single quotes and printWidth 100;.oxlintrc.jsonsets"typeAware": true, so even a plainpnpm lintruns type-aware rules.
Commands you will use most
| Command | What it does |
|---|---|
pnpm dev | Dev server on http://localhost:3000 |
pnpm typecheck | tsc --noEmit with the native TS 7 compiler; prints an extended diagnostics block |
pnpm test | vitest run over tests/**/*.vitest.ts |
pnpm vitest run tests/<name>.vitest.ts | One suite |
pnpm lint / pnpm lint:fix | oxlint into lint.md / oxlint with terminal output and autofix |
pnpm format | oxfmt over src/ |
pnpm build | Production build, no type checking |
pnpm db:generate / db:migrate / db:studio | drizzle-kit |
Tests live in tests/*.vitest.ts and are not colocated with source. They run in plain node with server-only aliased to an inert stub and part of the environment neutralised by tests/setup.env.ts. The full script inventory and the structural gates that fail CI are on Scripts, quality gates and CI.
Scripts, quality gates and CI
Every pnpm script, the structural test suites, and the checks that never run.
Environment variable reference
Every variable the app reads, with defaults and what silently breaks when it is unset.
Migrations and schema changes
The drizzle-kit v1 layout and the rules that break migrations here.
Database schema and runtime state
25 Postgres tables, two generic KV tables, and which side state belongs on.
Platform overview
Route surfaces, persistence split, and what is wired but switched off.
