This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
- pnpm workspaces + Turborepo. Node >=22, TypeScript 5.9, strict mode (see
tsconfig.base.json). - api runs on workerd via the Ploy platform (https://docs.meetploy.com). Each project has its own
ploy.yaml; the repo root has aploy-workspace.yaml. - dashboard, marketing, and showcase are Next.js 15 / React 19 apps, declared as
kind: nextjsin their ploy.yaml. Note: Ploy 1.35 workspace mode only launchesworker | dynamic | nextjs— Vite apps are skipped, so anything that needspnpm devintegration has to be Next.js. - db is a single Ploy
db:binding (D1-compatible SQLite). Migrations live atapps/api/migrations/(Ploy auto-discovers and applies them onploy devand deploy). The Drizzle schema is inpackages/db/src/schema.tsand emits SQL into that directory viapackages/db/drizzle.config.ts(out: ../../apps/api/migrations). - cache / rate-limit uses a Ploy
state:binding (KV-compatible API:get/put/delete/list). - Inference uses LLM Gateway via
@llmgateway/ai-sdk-provider+ai(Vercel AI SDK v6 —streamText,UIMessage,convertToModelMessages).
The Ploy yaml schema only accepts the fields documented in packages/tools/src/ploy-config.ts of polarlightsllc/ploy. Confirmed shape:
- Top-level:
kind(worker|dynamic|nextjs|static),name,build,out,main,base,dev: { port?, host? },compatibility_date,compatibility_flags,agentSDK,ai. - Bindings (each is a binding-name → resource-name map; binding names UPPER_SNAKE, resource names lower_snake):
db,state,queue,workflow,cron,timer,fs,env. There is nokv:field — KV isstate:. There is noroutes:orsecrets:field — domains are dashboard-managed and secrets come from.env(interpolated via$VARreferences inside theenv:block). ploy-workspace.yamlacceptsexclude,env,ports.worker.from,dashboard.port. Nothing else.- Migrations: there is no
migrations:field. The Ploy build/emulator scans<project>/migrations/and applies*.sqlfiles to all DB bindings (or<project>/migrations/<BINDING>/*.sqlfor a specific binding).
ploy dev from the repo root runs workspace mode: starts every project (worker, dynamic, and Next.js), allocates ports per each dev: { port } in their ploy.yaml, and serves a shared Ploy dashboard on 9787. As of @meetploy/cli@1.35.0, Next.js apps are included.
pnpm install
pnpm dev # = ploy dev — boots api :8787, dashboard :3001, marketing :3002, showcase :3003
pnpm build # turbo run build across all workspaces
pnpm lint # turbo run lint (prettier --check) + oxlint (.oxlintrc.json at repo root)
pnpm format # turbo run format (prettier --write)
pnpm migrations # drizzle-kit generate → apps/api/migrations/
pnpm gen:web-search-models # regenerate the web-search model snapshot from @llmgateway/models
pnpm clean # remove dist/.turbo/.next/.ployThe dashboard model picker (and the chat guard / data migration) only allow web-search models. That set is generated from the @llmgateway/models package into packages/shared/src/web-search-models.generated.ts (committed) by pnpm gen:web-search-models — the filter is models.filter(m => m.providers.some(p => p.webSearch === true)). @llmgateway/models is a dev dependency of @llmchat/shared used only for regeneration; the committed snapshot means build/deploy never needs it. After bumping @llmgateway/models, run pnpm gen:web-search-models, then pnpm format. @llmchat/shared re-exports the list with helpers (isWebSearchModel, effectiveModel, DEFAULT_MODEL) as the single source of truth, and throws at import if the snapshot is ever empty (never silently blanks the picker).
Per-package:
pnpm --filter @llmchat/api build—tsc --noEmit. The actual worker bundle is built byploy build(esbuild under the hood) at deploy time; entry is auto-detected assrc/index.ts.pnpm --filter @llmchat/widget build— Vite IIFE lib →packages/widget/dist/widget.js, thenscripts/emit-api-asset.mjsembeds it intoapps/api/src/generated/widget-bundle.ts(gitignored) so the api can serve it at/widget.jsfrom workerd (no filesystem).
Tests: pnpm test runs vitest in api, dashboard, and widget (other packages have no tests yet).
Local env: cp apps/api/.env.example apps/api/.env and fill in keys. ploy dev interpolates .env values into the env: block of apps/api/ploy.yaml (each value uses $VAR_NAME).
The dev seed is apps/api/seed/dev-seed.sql, applied only by pnpm seed (the runner is apps/api/scripts/seed.mjs). It is deliberately not in apps/api/migrations/: Ploy auto-applies every migration on ploy dev and on deploy, so a seed there would create the admin in production too. Keeping it out means production deploys never create or re-assert admin@example.com. The seed is idempotent (INSERT OR IGNORE) and creates:
- Admin user:
admin@example.com/admin@example.com(Better Auth scrypt hash with a fixed salt — only matches that literal password, safe to commit). - Dev workspace + owner member for the user.
- Demo project with
publicKey = local-dev-key,inboundEmailLocal = dev, brand#4f46e5.
To exercise the full loop locally:
pnpm dev— boots api, dashboard, marketing, showcase; Ploy applies the real schema migrations and creates the local DB at.ploy/db/llmchat_db.db.pnpm seed— once, in another terminal, to insert the admin/workspace/demo project (re-runnable; resolves the local DB, orPLOY_DB_PATH=<file>to override). Refuses to run underNODE_ENV=production.- Open
http://localhost:3003— the showcase (apps/showcase) is a fake "Acme Tools" landing page that embeds the widget viaWidgetMount.tsx, pinned tolocal-dev-keyandhttp://localhost:8787. - Chat with the bubble; send 3+ messages to trigger "Talk to a human".
- Sign in at
http://localhost:3001with the admin credentials to see the conversation in the dashboard inbox.
apps/api/src/seed.test.ts enforces the contract: the committed migrations never create the admin/demo project, and the dev seed does (idempotently). The seed hash is computed for scrypt { N: 16384, r: 16, p: 1, dkLen: 64 } — Better Auth's defaults via @better-auth/utils/password. If they ever change those params, regenerate the hash and update apps/api/seed/dev-seed.sql.
The api ships to workerd. Avoid Node-only deps — they fail to bundle. Already removed for this reason: resend SDK (replaced with direct fetch in lib/email.ts because the SDK pulled in svix), @better-auth/passkey (pulled in @simplewebauthn/server → @peculiar/x509 + asn1js). Email+password auth only, for now. Same risk applies to @llmgateway/ai-sdk-provider — if a future version pulls Node deps, swap to a direct fetch against ${LLMGATEWAY_BASE_URL}/v1/chat/completions.
- Public widget (
/v1/*, CORS open to*— unauthenticated, gated by per-project public key + rate limiting):POST /v1/chatstreams a UI message stream to the embedded widget;POST /v1/escalateflips the conversation to escalated and emailsproject.notifyEmailwith aReply-Toofreply+<inboundEmailLocal>@<INBOUND_EMAIL_DOMAIN>. - Dashboard API (
/api/*, CORS pinned toDASHBOARD_URL, credentials):workspaces,projects,conversations,billing. Sits behindrequireSession+requireWorkspace(apps/api/src/middleware/session.ts); workspace membership is asserted via themembertable using thex-workspace-idheader. - Auth (
/api/auth/*): Better Auth with the Drizzle adapter, email+password.createAuth(env)is called per-request because env is a Ploy binding, not a module-scope value. - Widget asset (
/widget.js): served by api withcache-control: public, max-age=300. - Inbound email (
routes/inbound-email.ts): Resend webhook for replies;email Message-IDis stored onmessage.emailMessageIdso reply matching can find the conversation.
- Better Auth tables (
user,session,account,verification,passkey— kept in schema for future use even though the runtime plugin is removed). workspace(billing entity) →member(RBAC: owner/admin/agent) →project(the embed unit;publicKeyfor widget bootstrap,inboundEmailLocalfor reply email).conversationkeyed by(projectId, clientId); messages are append-only with a per-conversationsequence.message.roleis one ofuser | assistant | admin.usageEventis the source of truth for metering. Stripe billing is a 501 stub inapps/api/src/routes/billing.ts.- IDs default via
crypto.randomUUID()($defaultFn). Timestamps stored as unix seconds.
The handler returns result.toUIMessageStreamResponse() immediately and uses c.executionCtx.waitUntil(...) to persist the assistant message + bump conversation.messageCount + insert the usageEvent after the stream finishes. When changing chat persistence, keep DB writes inside waitUntil so they don't block the response. Increment sequence from the pre-fetched messageCount (user = N+1, assistant = N+2).
apps/apiuses@/*→src/*(seeapps/api/tsconfig.json).@llmchat/dbre-exports tables andeq/query operators from drizzle-orm so route files canimport { eq, conversation } from "@llmchat/db".@llmchat/sharedholds Zod schemas (Zod v4:z.email(),z.url(),z.iso.datetime()) and the analytics event taxonomy (ANALYTICS_EVENTS) — the single source of truth for event names across all apps.
packages/widget is a Vite IIFE lib (vite.config.ts: formats: ["iife"], inlineDynamicImports: true, cssCodeSplit: false) — a single self-contained widget.js mounted into a shadow DOM. Currently pulls in @ai-sdk/react + ai (~227KB gzip), too heavy for a public embed; planned: replace with a hand-rolled SSE client.
Two entry points exposed via package exports:
@llmchat/widget→src/widget.tsx(theWidgetReact component, for in-tree consumers likeapps/showcase).@llmchat/widget/styles→src/styles.ts(awidgetStylesstring for injecting into a shadow root<style>element).
The CSS lives as a TS template literal rather than a .css file because Next.js (the showcase consumer) doesn't grok Vite's ?inline syntax — keeping it as a string export works for both bundlers.
Event names live in @llmchat/shared (ANALYTICS_EVENTS, object-action / lowercase_snake). All instrumentation imports from there so names never drift. Analytics is optional everywhere — every integration no-ops when its key is unset, so local dev needs no PostHog setup.
- marketing, dashboard, and showcase use
posthog-jsvia aPostHogProvider(manual$pageviewon App Router navigation, autocapture on). Marketing + showcase are anonymous (person_profiles: "identified_only"); the dashboardidentify()s the Better Auth user. Fire custom events with thetrack()helper in each app'ssrc/lib/analytics.ts;<TrackedLink>/<TrackView>(marketing) cover CTA clicks and page-view conversions. Env:NEXT_PUBLIC_POSTHOG_KEY,NEXT_PUBLIC_POSTHOG_HOST(defaults tohttps://eu.i.posthog.com— the project is on EU cloud). Showcase's public prod key lives in the committedapps/showcase/.env.production(same convention as its widget key). - api (workerd) captures widget/server events (
conversation_started,widget_message_sent,conversation_escalated) via a directfetchto the PostHog capture API inlib/posthog.ts— the Node SDK's timers/batching don't fit a Worker. Always called insideexecutionCtx.waitUntil(...), never PII (distinct_id = the widget's anonymousclientId). Env:POSTHOG_API_KEY,POSTHOG_HOST(wired inapps/api/ploy.yaml→ set inapps/api/.env). The widget itself is not instrumented client-side — keeping its bundle lean — so its events come from the api. - Google Search Console ownership for the marketing site is verified with a
<meta>tag wired into the root layoutmetadata.verification.google— the token is hardcoded as the default (public, so it ships in every build's<head>) and overridable viaNEXT_PUBLIC_GOOGLE_SITE_VERIFICATION. PostHog is the only analytics tool — no Google Analytics. - Privacy / consent: no PII in event properties. EU/EEA + UK visitors get a cookie-consent banner and nothing loads until they opt in; elsewhere analytics loads on implied consent. Region is detected from the browser time zone and the decision stored in
localStorage. Shared logic lives in@llmchat/shared(isConsentRequiredRegion,getStoredConsent,setStoredConsent); all three apps gateposthog.initinline in theirPostHogProvider(which owns theConsentBanner).
- Prettier with tabs (see
.editorconfig,.prettierrc). - Drizzle
casing: "snake_case"— TS fields are camelCase, DB columns are snake_case automatically. - Routes return
c.json({ error: "..." }, status)on errors; each route file exports aHonoinstance mounted inapps/api/src/index.ts. - The Ploy
db:binding is the only database; thestate:binding is for ephemeral data (rate limits, caches), not source-of-truth. - Resource names (right-hand side of binding maps) must be lowercase + underscores (e.g.
llmchat_db, notllmchat-db). Ploy validation rejects hyphens.
- Use Conventional Commits (
feat:,fix:,refactor:,chore:, etc.). - Commit body uses short, concise bullet points.
- Before every commit, run tests, lint, and formatter (
pnpm test,pnpm lint,pnpm format). - Scan for security leaks (secrets, keys, tokens, credentials) before committing.
- Commit features/fixes atomically — one logical change per commit, even when multiple features are in progress.