Skip to content
ErmisAI

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

LayerChoiceNote
FrameworkNext.js 16 App Router, canary tagMiddleware is src/proxy.ts, not middleware.ts
UIReact 19 (next tag), Tailwind v4React Compiler on (next.config.ts)
LanguageTypeScript 7 native previewnext build does no type checking; pnpm typecheck does
DatabasePostgres via Drizzle ORM (rc)CapyDB is the live host; Supabase is a supported host, not a coupling
AIAI SDK v7Vercel AI Gateway by default, Azure Foundry as an alternative mode
AuthClerkOrganizations map to tenants
BillingPolarCheckout, portal and webhooks are all Polar-hosted
RuntimeVercel, region arn1pnpm 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.

14 public marketing and legal pages × 10 locales
  • [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 /pricing is redirected to /en/pricing.
  • (tenant)/app/ — the authenticated newsroom workspace. Not locale-prefixed; the tenant layout calls setRequestLocale(newsroomProfile.uiLocale) so pages get the tenant's stored UI locale without a URL segment.
  • (internal)/admin/ — platform operations, gated on the Clerk session claim app_role (super_admin, editor, ops). It has no inbound link from the tenant app.
  • api/ — 88 route.ts handlers. 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 ThemeScript both read headers() 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 deletes Set-Cookie because Vercel refuses to cache a response carrying cookies.
  • A new public marketing route needs more than a page file. It must be added to isLocalizedPublicRoute in src/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 the story_snapshot, and a human compose action is what turns it into an article.
  • Pipeline published and editorial published are unrelated. The pipeline sets status published when confidence ≥ 85 and the story is not sensitive — a confidence signal. Editorial published is only ever set by a human approve transition, and published_at is 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) and azure (Azure OpenAI / Foundry direct). Provider isolation is enforced — in azure mode only azure/* model ids are accepted.
  • Four stages: chat, cluster, synthesis, compose. Each has a static execution policy (temperature, output tokens, retries, timeouts) in src/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 modelopenai/gpt-5-mini in gateway mode, azure/gpt-5.4-mini in azure mode. Per-stage models are an operator choice, not a shipped default.
  • Model id precedence: ERMIS_MODEL_OVERRIDE beats 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 an ai_usage_ledger row, 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-compose and story-refinement are versioned and hash-addressed, hand-written in all 10 content locales. The cluster and synthesis prompts are not versioned — they are inline string literals in rss-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:

PathSchedule
/api/admin/ai/metering/flush*/2 * * * *
/api/admin/ai/ledger/retry/flush*/2 * * * *
/api/admin/rss/refresh*/10 * * * *
/api/admin/state/cleanup15 3 * * *
/api/admin/erasure/purge45 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.

ThingState
Vercel Queues RSS pipelinerssQueuePipeline 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.synthesizeThrow 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 modelsProvider 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 callingNot 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 pickerchatAllowModelOverride defaults false, and the admin panel hard-writes false on every save
experimental.cacheComponentsCommented out in next.config.ts — PPR, "use cache" and cacheLife() are not active
Sentry tunnel routetunnelRoute 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 handlersrc/proxy.ts, then the relevant layout under src/app
A query or a repositorysrc/lib/db/schema.ts (994 lines, all 25 tables), then the domain repository in src/lib/db/
Any piece of runtime statesrc/lib/platform/shared/state.ts (the canonical store) and state-schemas.ts (what each key must contain)
Feed freshness, cost or an empty scopesrc/lib/services/rss-aggregation.ts (3,903 lines — the single largest subsystem)
Anything cross-domain in the workspacesrc/lib/platform/local-platform-data.ts (2,786 lines — the central orchestrator)
Any model callsrc/lib/ai/providers.ts, specifically getLanguageModel
A client/server contractsrc/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.

On this page