Platform overview
How ErmisAI is put together: route surfaces, the hybrid persistence model, the AI layer, and where to start reading.
ErmisAI is a single Next.js App Router application deployed to Vercel in one region, arn1. It serves four route surfaces from one tree, stores durable data in Postgres through Drizzle, keeps mutable runtime state in two generic key-value tables in that same Postgres, and calls language models through AI SDK v7.
The repository directory is hermesai and the package name is ermisai. Both are historical artefacts of the same rename. Every user-visible string, every environment variable prefix (ERMIS_), and the production origin (https://ermisai.com) say ErmisAI.
Two facts that reorganise your mental model
Read these before you read any code, because most wrong assumptions about this codebase trace back to one of them.
There is no story table, no cluster table, and no article table. The only editorial table is editorial_story_drafts, keyed (tenant_scope_id, story_id), which embeds the whole source story as a story_snapshot jsonb column (src/lib/db/schema.ts:530). Aggregated stories themselves live in a cache — a platform_state row with a TTL, plus per-instance globals. If you go looking for SELECT * FROM stories, you will not find it.
The Vercel Queues pipeline is declared in vercel.json but switched off, and three of its four consumers throw on purpose. The rssQueuePipeline feature flag defaults to false (src/lib/contracts/feature-flags.ts). rss.enrich, rss.cluster and rss.synthesize each raise "<topic> consumer is not implemented; do not enable a producer for this topic". The authoritative ingestion path is inline, synchronous, and lives entirely in src/lib/services/rss-aggregation.ts.
The stack
| Layer | Choice | Note |
|---|---|---|
| Framework | Next.js 16 App Router, canary tag | Middleware is src/proxy.ts, not middleware.ts |
| UI | React 19 (next tag), Tailwind v4 | React Compiler on (next.config.ts) |
| Language | TypeScript 7 native preview | next build does no type checking; pnpm typecheck does |
| Database | Postgres via Drizzle ORM (rc) | CapyDB is the live host; Supabase is a supported host, not a coupling |
| AI | AI SDK v7 | Vercel AI Gateway by default, Azure Foundry as an alternative mode |
| Auth | Clerk | Organizations map to tenants |
| Billing | Polar | Checkout, portal and webhooks are all Polar-hosted |
| Runtime | Vercel, region arn1 | pnpm 11.15.1, Node ≥ 24.19.0 |
Bleeding-edge dist-tags (canary, next, rc, beta) are deliberate policy. Never downgrade them.
Never remove @typescript/native-preview while the repo is on typescript@7. Without it, next build dies silently just after printing "Skipping validation of types" — no error, no stack, no clue. Knip flags it as unused, which is why it sits in ignoreDependencies.
The four route surfaces
All four live in one App Router tree under src/app, and all four share the root layout at src/app/layout.tsx.
[locale]/— the anonymous marketing and legal surface. Always locale-prefixed (localePrefix: 'always',src/i18n/routing.ts:6-8), ten locales:en, el, pl, it, es, pt, sv, da, nb, fi. A bare/pricingis redirected to/en/pricing.(tenant)/app/— the authenticated newsroom workspace. Not locale-prefixed; the tenant layout callssetRequestLocale(newsroomProfile.uiLocale)so pages get the tenant's stored UI locale without a URL segment.(internal)/admin/— platform operations, gated on the Clerk session claimapp_role(super_admin,editor,ops). It has no inbound link from the tenant app.api/— 88route.tshandlers. Not a user-visible surface except for its error JSON, which is uniform:{ error, message, ...payload, code? }(src/lib/utils/error-format.ts:330-343).
Every page and layout under src/app is a Server Component. The only client components in that tree are the five error boundaries. Pages fetch server-side and hand data to client workspace components as initial* props.
The request path
Middleware is src/proxy.ts under the Next.js 16 proxy convention. It wraps clerkMiddleware and composes Clerk auth with next-intl routing in one pass. On every matched request it injects three headers: Content-Security-Policy, x-nonce, and x-pathname (src/proxy.ts:273-279). The third exists because Next.js does not otherwise expose the pathname to server components; the tenant layout reads it to run the onboarding redirect without looping.
Two consequences you will hit early:
- Every page renders dynamically. The root layout and
ThemeScriptboth readheaders()for the CSP nonce, which opts every route out of static rendering. The 14 cacheable marketing pages compensate with a Vercel edge cache applied in middleware, which also deletesSet-Cookiebecause Vercel refuses to cache a response carrying cookies. - A new public marketing route needs more than a page file. It must be added to
isLocalizedPublicRouteinsrc/proxy.ts, or anonymous visitors are redirected to sign-in.
There is no global middleware auth gate for API routes. Middleware matchers are documented in-code as defense-in-depth only (src/proxy.ts:3-6); every handler authenticates itself. tests/api-route-auth-guard.vitest.ts fails CI when a new route.ts carries no recognized gate token and is not in the two-entry allowlist (/api/health, /api/openapi/public-integrations). Note that the check is textual — it proves a gate token is present in the file, not that it is applied correctly.
Full detail: Route surfaces, proxy and auth gates.
Persistence is a hybrid — know which side you are on
Durable relational — Postgres, 25 tables
One schema source of truth, src/lib/db/schema.ts, defining 25 tables across billing (Polar), identity (Clerk), the source catalog, editorial, integrations, AI accounting, runtime KV, and compliance. One repository module per domain in src/lib/db/. Supabase is only ever a possible host; there are no parallel Supabase SQL migrations.
Runtime mutable state — two generic KV tables, also in Postgres
platform_state (key → jsonb, optional expires_at) and platform_state_hash ((key, field) → jsonb) — src/lib/db/schema.ts:870 and :889. Monitoring rules, alert history, notifications, team snapshots, feature flags, the AI runtime config, delivery counters and the feed cache all live here, reached through src/lib/platform/**. Keys follow buildScopedPlatformKey(domain, id) → platform:<domain>:<normalized-id>, and every payload is zod-validated on read and write via src/lib/platform/shared/state-schemas.ts.
The composite primary key on platform_state_hash is what gives addHashRecordIfAbsent its HSETNX semantics, and mutateState is a transaction with SELECT ... FOR UPDATE. Its mutator must be synchronous — holding a row lock across awaits would exhaust the default three-connection pool.
Redis (Upstash) — never application state
Rate limiting (ermis:rl:*), AI guardrail windows, the AI ledger retry queue and its DLQ lists, the 7-minute feed build lock, the 6-hour compose response cache, and short newsroom-profile caches. That is the complete list. Rate limiting fails open; the per-tenant AI spend guardrail stays fail-closed.
Object storage — a deliberate public/private split
Public uploads and source icons go to the public store. Webhook payload archives carry PII (emails, names, billing ids) and go to a separate private Vercel Blob store via BLOB_ARCHIVE_READ_WRITE_TOKEN, because Blob store access is fixed at store creation and the public store rejects private puts outright. Without that token, payloads inline into the DB row instead — non-public, but never pruned.
Production has no in-memory fallback for shared state. ERMIS_FORCE_INMEMORY_STORE=true is inert whenever VERCEL_ENV is set. An unmigrated or unreachable Postgres 503s every authenticated surface, by design. GET /api/health probes the platform_state table specifically and returns 503 with platform_state unreachable (run db:migrate before serving traffic).
Full detail: Database schema and runtime state and Migrations and schema changes.
Tenant scope
A tenant is a string key, not a row: org:<clerkOrgId> for an organization workspace, user:<clerkUserId> for a personal one (src/lib/platform/shared/tenant-scope.ts). A live session with no org claim is the personal workspace. Isolation is tenant_scope_id filtering in application queries plus per-route gates — there is no row-level security and no database-level ACL.
Every KV key embeds the scope id, which is also how the GDPR purge locates a tenant's slice.
Two role systems coexist and are resolved differently. appRole (the platform role) is always read from the live Clerk JWT, so a demoted operator loses access on the next token refresh. tenantRole and tenantTitle are DB-authoritative, read from tenant_memberships. Unrecognised Clerk org roles fall closed to member.
See Tenant scoping and isolation.
The pipeline, in one paragraph
listAggregatedStories() in src/lib/services/rss-aggregation.ts does fetch → dedupe → teaser enrichment → heuristic clustering → AI cluster refinement → AI synthesis → cache → draft materialization, all inline in one request. Clustering is a Jaccard heuristic at threshold 0.24; the model only refines cluster metadata and never decides which articles belong together. Synthesis writes in the newsroom's configured content language, translating source material as needed — output language is a setting, never derived from the sources. Results are cached per (source set + content locale) scope, so two tenants with identical sources and locale share one build and one AI spend.
Two things about the output surprise people:
- A materialized draft contains no article body text.
buildEditorialDraftBody(src/lib/platform/local-platform-data.ts:434-439) always writes the placeholder body —"Initial article composition is pending in English.\n\nUse the compose flow to generate the first publishable draft."(src/lib/stories/draft-state.ts:4-11) — plus a localized source line. The headline is separate:buildEditorialDraftHeadline(local-platform-data.ts:421-432) substitutes the placeholder"Draft in composition"only when the synthesized headline is empty or mostly Latin script, so a Greek (or other non-Latin) synthesized headline is kept as-is. The synthesized article is preserved in thestory_snapshot, and a human compose action is what turns it into an article. - Pipeline
publishedand editorialpublishedare unrelated. The pipeline sets statuspublishedwhen confidence ≥ 85 and the story is not sensitive — a confidence signal. Editorialpublishedis only ever set by a human approve transition, andpublished_atis owned exclusively by that transition.
Full detail: Ingestion pipeline: fetch, cluster, synthesize.
The AI layer
Everything model-facing lives under src/lib/ai, plus the admin-managed runtime config at src/lib/platform/ai-runtime-config.ts.
- Two mutually exclusive provider modes, selected by
ERMIS_AI_PROVIDER_MODE:gateway(Vercel AI Gateway, the default) andazure(Azure OpenAI / Foundry direct). Provider isolation is enforced — in azure mode onlyazure/*model ids are accepted. - Four stages:
chat,cluster,synthesis,compose. Each has a static execution policy (temperature, output tokens, retries, timeouts) insrc/lib/ai/language-model-policies.ts:132-170, modulated by a global profile (fast/balanced/high-accuracy). - Out of the box all four stages use the same model —
openai/gpt-5-miniin gateway mode,azure/gpt-5.4-miniin azure mode. Per-stage models are an operator choice, not a shipped default. - Model id precedence:
ERMIS_MODEL_OVERRIDEbeats everything, then the persisted per-stage model, then the provider-mode default. - Every call is wrapped. A middleware stack emits
[ai-runtime]telemetry, normalizes token usage across three provider shapes, resolves actual-versus-estimated cost, writes anai_usage_ledgerrow, and updates guardrail spend counters. Billable tenant ledger writes are awaited inline — adding latency on purpose, because serverless can kill post-response tasks and this spend drives the tenant's monthly envelope. - Prompts:
story-composeandstory-refinementare versioned and hash-addressed, hand-written in all 10 content locales. The cluster and synthesis prompts are not versioned — they are inline string literals inrss-aggregation.ts.
ERMIS_AI_PROVIDER_MODE is the one env seed that overrides the persisted admin config on every normalize pass. Setting it in Vercel makes the /admin/ai provider-mode dropdown settable but functionally inert.
Full detail: AI runtime: providers, stages and prompts and AI usage accounting, guardrails and metering.
Scheduled work
Five crons are declared in vercel.json:
| Path | Schedule |
|---|---|
/api/admin/ai/metering/flush | */2 * * * * |
/api/admin/ai/ledger/retry/flush | */2 * * * * |
/api/admin/rss/refresh | */10 * * * * |
/api/admin/state/cleanup | 15 3 * * * |
/api/admin/erasure/purge | 45 2 * * * |
All five authenticate GET with a constant-time comparison against Bearer ${CRON_SECRET} and authenticate POST with an admin Clerk session — the same route serves both. isAuthorizedCronRequest returns false when CRON_SECRET is unset (src/lib/api/cron-auth.ts:47-51), so a missing environment variable can never open the endpoint. It can, however, silently stop all scheduled ingestion.
See Scheduled jobs and the queue subsystem.
Wired but switched off
Do not document any of these as working. They exist in the tree, and knowing why saves an afternoon.
| Thing | State |
|---|---|
| Vercel Queues RSS pipeline | rssQueuePipeline flag defaults to false; only rss.ingest is implemented, and it just triggers the same inline aggregation for the global scope |
rss.enrich, rss.cluster, rss.synthesize | Throw on every message, deliberately fail-loud rather than silently acking |
/admin/queues "Queue health" | Synthetic — four rows derived from story phase counts and source health, with hardcoded zeros. Reads no Vercel Queue state |
| Embeddings, image models | Provider bridges exist (src/lib/ai/provider-registry.ts:60-64, 79-88); a repo-wide grep finds zero call sites for embed/embedMany/embeddingModel/imageModel outside the registry itself |
| Reranking, tool calling | Not wired at all — rerank appears only in a comment at src/instrumentation.ts:8, and ChatMessage carries an empty tool map (src/lib/ai/types.ts:67) |
| User-facing chat model picker | chatAllowModelOverride defaults false, and the admin panel hard-writes false on every save |
experimental.cacheComponents | Commented out in next.config.ts — PPR, "use cache" and cacheLife() are not active |
| Sentry tunnel route | tunnelRoute commented out, though /monitoring/sentry(.*) is allowlisted in the proxy |
Where to start reading
| If you are touching… | Start here |
|---|---|
| A page, layout or API handler | src/proxy.ts, then the relevant layout under src/app |
| A query or a repository | src/lib/db/schema.ts (994 lines, all 25 tables), then the domain repository in src/lib/db/ |
| Any piece of runtime state | src/lib/platform/shared/state.ts (the canonical store) and state-schemas.ts (what each key must contain) |
| Feed freshness, cost or an empty scope | src/lib/services/rss-aggregation.ts (3,903 lines — the single largest subsystem) |
| Anything cross-domain in the workspace | src/lib/platform/local-platform-data.ts (2,786 lines — the central orchestrator) |
| Any model call | src/lib/ai/providers.ts, specifically getLanguageModel |
| A client/server contract | src/lib/contracts/ for the shared zod contracts, src/lib/api/ for the typed frontend clients |
Two conventions worth internalising immediately. Client components call the typed API clients in src/lib/api/* rather than fetch directly. Tests live in tests/*.vitest.ts — never colocated — and run in plain Node with server-only aliased to a stub.
Canonical in-repo specs live in docs/, starting with docs/ermisai-unified-context-document.md for product constraints and docs/ops/ for runbooks.
Local development setup
Get it running, with or without Postgres, and the version pins that matter.
Scripts, quality gates and CI
Every pnpm script, and the four structural test suites that fail CI.
Route surfaces, proxy and auth gates
How src/proxy.ts composes Clerk and next-intl, and the per-route gate convention.
Database schema and runtime state
25 tables, two KV tables, and the rule for deciding which side state belongs on.
Ingestion pipeline
The inline aggregation path end to end, its cache scoping, and every tuning knob.
AI runtime
Two provider modes, four stages, model resolution and the versioned prompt families.
Environment variable reference
Every variable the app reads, with its default and what silently breaks when it is unset.
What ErmisAI does
The product side: how a wire story becomes a reviewed article.
