AI system
The one rule (validate before write), the OpenRouter-only provider, model tiers, generation flow, the chat assistant loop, and anti-fabrication guardrails.
How AI is used, constrained, and validated. Core files: convex/ai.ts,
convex/generate.ts, convex/generation/*, convex/aiChat.ts,
convex/aiContent.ts, convex/images.ts, convex/logo.ts.
The one rule
AI output is validated against the section schema before it is ever written.
Every AI surface coerces model JSON into the typed sectionContent union (drops
unknown types, strips markup and links, length-clamps), and the Convex v.union
insert is the final guard. No raw HTML, no AI-generated React, and no invented
business facts reach the database.
Provider & keys
- Single provider: OpenRouter (
https://openrouter.ai/api/v1). Wrapper:convex/generation/openrouter.ts. There is no separate AI gateway — OpenRouter is the gateway. - Key:
OPENROUTER_API_KEY, set on the Convex deployment (server-side only, never the browser).aiEnabled()is!!process.env.OPENROUTER_API_KEY. - Graceful degradation: every AI call times out (30–90s) and returns
nullon failure rather than throwing. The product works fully with no key — AI is additive polish, never load-bearing. See Environment variables.
Model tiers (env-overridable; defaults in convex/generation/models.ts)
| Tier | Override env | Used for |
|---|---|---|
| heavy | OPENROUTER_MODEL_HEAVY | site generation, FAQ, offers |
| agent | OPENROUTER_MODEL_AGENT | AI chat "smart" (default = heavy) |
| fast | OPENROUTER_MODEL_FAST | AI chat "fast" (default = small) |
| small | OPENROUTER_MODEL_SMALL | single-field rewrite |
| tiny | OPENROUTER_MODEL_TINY | SEO meta, brief extraction |
| vision | OPENROUTER_VISION_MODEL | photo placement, chat image vision |
| STT | OPENROUTER_STT_MODEL | voice dictation (Whisper) |
| image pro / free | OPENROUTER_IMAGE_MODEL_PRO / _FREE | image studio + logo |
Generation flow (describe business → structured draft)
Two tiers — the deterministic tier always runs; the AI tier only polishes.
A. Deterministic (synchronous, guaranteed). convex/generate.ts
startGeneration takes an onboardingDrafts row → mapFreeTextToVertical() →
planSite(profile) (pure, no IO, convex/generation/build.ts) → builds
bilingual pages + sections from curated copy (convex/generation/copy.ts). The
output is typed SectionContent validated by the union on insert. Ships with
no AI key.
B. Optional LLM polish (scheduled after generation).
- Block selection (
aiSelectBlocks.selectHomeBlocks) — reorders / hides sections, never deletes; the result is coerced to thesectionTypeliteral union. The selector reads each block'swhenToUsespec — see the section registry. - Copy polish (
polish.polishWebsiteCopy) — rewrites hero / about / cta;validRewrite()rejects markup, links, and length blow-ups. - Vision placement (
visionPlace.refineImagePlacement) — assigns uploaded photos to slots + alt text (validated: no markup, under 160 chars).
Each pass charges after success (chargeOnceForJob, idempotent per
jobId + reason). Builds are tracked in generationJobs
(kind: initial-generate | llm-polish | regenerate-section); polish is scheduled
via ctx.scheduler.runAfter(0, …). imageJobs is a separate table for Image
Studio.
In-editor AI chat assistant
convex/aiChat.ts sendMessage runs a tool-calling agent loop (up to ~10 turns)
with ~19 editing tools, auto / accept / ask modes, per-edit undo
(undoChange), persisted threads (chatThreads / chatMessages with background
summarization), image attachments (a vision pre-pass), and a fast/smart model
selector.
- One executor —
applyTool(internalMutation): the single chokepoint that runs one tool against the draft. It does no auth of its own (the calling action owns that) but enforces tenancy — everysectionId/pageId/assetIdin the args must belong to the target website, elseNOT_FOUND. Content tools route through the samelib/sectionOps.tsthe on-canvas editor uses, so preview == production holds and the AI can never address a section it wasn't shown. - Confirmation for destructive ops: the tool throws
ConvexError({ code: "NEEDS_CONFIRM" })(deleteSectionalways;updateSettingswhen a slug change is present). In ask-mode every call is staged and applied only when the user approves the batch (applyStagedEdits). - Web search: one read-only external tool (
webSearch, Tavily, gated onTAVILY_API_KEY) is appended only when the key is set and the message's Web control isn't "off". Results return as a numbered tool message the model must cite inline as[n]; URLs live only in the structuredsources, never in the reply prose. Each executed search costs 1 credit. - Live transparency: because
sendMessageis non-streaming, the loop writes progress to an ephemeralchatStepstable mid-loop; the client subscribes via the reactivegetActiveStepsquery ("streaming without websockets"). On finish the trail + deduped sources fold onto the assistant message and the step rows are cleared.
Usage is metered as credits. See Auth & permissions for the tenancy guarantee.
Other AI capabilities
| Capability | Status | Where |
|---|---|---|
| Site plan + copy | working (deterministic) | convex/generation/build.ts, copy.ts |
| Copy polish / block selection / vision placement | working (optional) | convex/generation/*, convex/aiSelectBlocks.ts |
| Blog / article generation | working | convex/aiArticle.ts (coerceArticle) |
| AI Content Assistant ("tell AI what happened") | working | convex/aiContent.ts (coerceContent) |
| AI image generation / edit | working (MVP) | convex/images.ts, imageJobs |
| Logo generate / improve | working | convex/logo.ts, convex/logoActions.ts |
| Publish-time translation | working (optional) | convex/generation/translate.ts |
| Voice dictation (Whisper) | working | convex/transcribe.ts |
Image generation builds prompts from brand context + style rules
(brandImageProfile). Results (base64 data URL) are decoded, dimension/mime
validated, and stored as assets rows with generation provenance; each AI edit
is a new asset chained via parentAssetId / rootAssetId, charged after
success.
Anti-fabrication guardrails
- System prompt (
openrouter.ts): "never invent prices, names, phone numbers, addresses, awards, statistics." Offers / invoices pull prices only from the owner's service list (unknown price = 0, never invented). - Logos: "no text, no letters, no words" (diffusion can't render text reliably).
- Images: AI never fabricates an image into a content slot during generation — image sections start empty until a real asset is placed.
- CTAs / links: AI never invents a URL; suggested internal links become typed
cta-bandtargets (the model has no raw-link primitive). - Input bounds: the business description is capped (~1000 chars) and
rate-limited before the model call (
convex/brief.ts); chat context is bounded (~200 msgs). - Guard tests:
convex/aiGuards.test.tscoversisCleanText,sanitizeText,parseJsonObject,parseJsonArray.
Validation pipeline (AI JSON → DB)
- Parse:
parseJsonObject/parseJsonArrayextract the first JSON blob. - Sanitise:
sanitizeTextstrips</>, http(s) URLs, length-clamps. - Coerce:
coerceArticle/coerceContent/ per-tool coercion map JSON into the typed union and drop unknown types / blocks. - Schema insert: the
sectionContentv.unionrejects anything off-shape — the final guard.
On invalid output the action returns { ok: false, reason } (e.g.
insufficient_credits); it never throws raw to the user and never writes bad
data. Credits are metered via the append-only creditLedger + a denormalized
workspaces.creditsBalance.
Publishing
The two-world draft/published separation, the atomic snapshot flip, selective publish, preview surfaces, multi-language, and SEO output.
Environment variables
Every env var by group, where it lives (Convex deployment vs Vercel), and the dev-fallback principle that lets most integrations run unset.