Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.avocadostudio.dev/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Variants are AI-authored alternatives for a single, already-selected block. A user picks a block, asks for “3 variations”, and sees a set of scaled previews side-by-side. Clicking one applies that patch to the draft through the normal update_props op, so undo/redo and the version log behave as they do for any other edit. The feature has two independent code paths that both land in the same UI component (VariationScaledPreview). Which path runs depends on whether the user is in the legacy chat pipeline or in the newer agent loop: Both paths ultimately validate each candidate patch against the block’s Zod schema via validateBlockProps, so variants can never produce a block that the editor would reject.

When to use which path

You don’t choose directly — the routing is automatic — but it helps to know which one is running when you’re debugging.
PathRuns whenWhere it livesWhat it’s good at
REST variation pipelineClassic chat pipeline (/chat + intent detection)apps/orchestrator/src/chat/variation-pipeline.tsDedicated LLM prompt, built-in image variation (Unsplash per-card + uniqueness enforcement), deterministic fallback when the LLM fails
Agent tool (generate_variations)Onboarding agent / agent SDK loopapps/orchestrator/src/agent/agent-tools.ts (lines ~699-768)Claude authors the variants inside its reasoning loop — no separate LLM call. Fast, tool-use-native, no image variation
Both surface to the user through the same React component, so end-user experience is consistent.

Triggering variants (REST pipeline)

Detection

The chat pipeline calls isVariationRequestMessage() in apps/orchestrator/src/chat/chat-pipeline-shared.ts to decide whether a message is asking for variants. The rule is verb + noun in the same message:
  • Verbs: generate, create, make, show, give, produce, draft
  • Nouns: variations?, variants?, alternatives?, options
If both match and a block is selected, the pipeline detours to runVariationPipeline instead of the normal planner. If both match but no block is selected, the pipeline returns a needs_clarification asking the user to select a block first.

Example messages

All of these trigger variants:
  • “generate 3 variations”
  • “show me 5 alternatives”
  • “give 2 variants”
  • “create four options for this hero”
  • “make some variants with different copy”
The word “variants” is normalized to “variations” earlier in the translation layer (chat-pipeline-translation.ts) to harden the detector against typos.

Count parsing

requestedVariationCount(message) parses the count:
  • Numeric: \b(\d{1,2})\s+(variations?|variants?|alternatives?|options)\b
  • Word form: one..twelve followed by the same nouns
  • Default: 3
  • Cap: 12 (anything higher is clamped silently)

Constraints

The pipeline recognizes two inline constraints in the message:
ConstraintTrigger (regex)Effect
Keep the same title\bsame\s+title\b, \bkeep\s+(the\s+)?title\b, \btitle\s+(unchanged|same)\bStrips title from every variation’s patch
Cards only (CardGrid)\bcards?\s+only\b, \bonly\s+cards?\b on a CardGrid blockReduces each patch to only the cards key
And for image-bearing blocks (supportsImageVariation = block has an imageUrl prop):
Image constraintTriggerEffect
Same image across variantssame image, same photo, keep the same image, use one image, single imageAll variants reuse the block’s current imageUrl
No duplicatesno duplicates, do not reuse, unique imagesRetries Unsplash up to 8 times per variant to find a distinct photo (default cap is 5)
Explicit URLA URL present anywhere in the message (firstUrlFromText)That URL is used for every variant

Triggering variants (agent tool)

When the Onboarding agent is active, Claude calls the generate_variations tool directly. The prompt tells Claude to call get_page first to read current props, then author 2-4 materially different variations, each with a title, summary, and patch. The handler does not re-run the LLM. It only:
  1. Loads the page + block.
  2. Filters each patch to keys that exist on the block and differ from current values.
  3. Caps the list at 6.
  4. Serializes the result as a tool response.
This means the agent path is cheaper and faster than the REST pipeline (no second LLM call, no image resolution), but it also has no deterministic fallback — if Claude produces a bad shape the cards just don’t render.

How variants are generated (REST pipeline)

1. LLM generation

generateVariationsWithOpenAI and generateVariationsWithAnthropic both send the same shape:
// system
buildVariationSystemPrompt({ count, keepTitle, cardsOnly, blockType, locale })

// user
{
  "request":           "generate 3 variations with a crisper tone",
  "blockId":           "b_hero",
  "blockType":         "Hero",
  "currentProps":      { "heading": "…", "subheading": "…",  },
  "allowedPatchKeys":  ["heading", "subheading", "ctaText", "imageUrl", ]
}
They expect the model to return:
{
  "variations": [
    {
      "title":   "Crisp & Direct",
      "summary": "Shorter and more action-oriented copy.",
      "patch":   { "heading": "Fresh avocados, daily", "ctaText": "Shop now" }
    },

  ]
}
  • OpenAI: called with response_format: { type: "json_object" }.
  • Anthropic: system prompt goes through anthropicSystemPromptWithCache() for prompt caching; the response is parsed from the first text block via extractJsonObject.
  • Both providers go through resolveEffectiveProvider / resolveModelKey for routing, so /status/planner availability applies.
If the provider throws (rate limit, timeout, invalid JSON), the pipeline logs a warn with { err, provider, model, blockType } and falls through to the deterministic fallback.

2. Deterministic fallback

deterministicVariations always generates something, so the API rarely returns zero variants:
  • CardGrid blocks get three pre-written tones — Crisp, Benefit-led, Action-driven — that rewrite each card’s description + ctaText.
  • Every other block type with a string prop gets a suffix appended to the inferred primary text key (preference order: heading, title, subheading, description, body, ctaText, imageAlt, then the first non-empty string).
The fallback is also used to top up a short LLM response: if the model only returns 2 valid variations out of 3 requested, the fallback fills the third slot.

3. Image variation

withDefaultImageVariations runs after text is settled. For each variant, in order:
  1. If the message contains a URL, every variant uses that URL.
  2. If deriveVariationImageIntent classifies the message as wanting AI- generated images, the pipeline dispatches to either Gemini (generateVariationImageWithGemini, default) or OpenAI (generateVariationImageWithOpenAI) based on IMAGE_GEN_PROVIDER. The per-variant prompt is built from the block type, heading, and subheading.
  3. Otherwise it calls Unsplash via resolveUnsplashImage. With uniqueness enforced (the default), it uses resolveDistinctUnsplashImage which retries against a usedImageUrls set — up to 5 attempts, or 8 if the user explicitly asked for no duplicates.
Picking the provider. The message wins when it’s explicit — unsplash forces Unsplash; gemini, openai, ai-generated, llm, gpt-image-2, gpt-image-1, or nano-banana force AI generation. When the message has no hint, VARIATION_DEFAULT_IMAGE_SOURCE decides — set to unsplash (default) or ai / gemini / openai / llm to route to AI gen. Once in the AI branch, IMAGE_GEN_PROVIDER (default gemini) picks the actual backend, and OPENAI_IMAGE_MODEL (e.g. gpt-image-2) selects the OpenAI model when that backend is chosen. Failed images don’t kill the variant — the patch just keeps whatever image it had (or none). LLMs don’t author image URLs. The system prompt forbids imageUrl in patches; if an LLM sneaks one in anyway (they occasionally do), the pipeline strips it before resolution. If image resolution then fails to produce a real URL, any paired imageAlt is also dropped so the block keeps its original image + alt rather than showing a broken <img> with stray alt text from the LLM.

4. Patch sanitization

Every variant flows through sanitizeVariationPatch:
const safePatch  = coercePatchForBlock(block, patch)      // type-coerce
const nextProps  = { ...block.props, ...safePatch }
const validated  = validateBlockProps(block.type, nextProps)
if (!validated.success) return null                       // drop invalid
if (deep-equal to current props) return null              // drop no-op
return safePatch
If sanitization drops a variant, the pipeline doesn’t top it back up — the caller receives fewer variants than requested. (This is why the LLM prompt asks for patches keyed by allowedPatchKeys only.)

Rendering

Variation modal (REST pipeline)

The editor opens a modal with:
  • A read-only reference panel showing the block’s current props.
  • A grid of variant cards. Each card has:
    • Title + summary from the variant.
    • Live preview — the real block renderer with the variant’s patch applied, wrapped in VariationScaledPreview. The component uses a ResizeObserver to measure the shell and canvas, then applies a CSS transform: scale(…) to fit the preview into the card without re-flowing the block’s internal layout.
    • Apply button — fires POST /ops with a single update_props op carrying variationModal.pageSlug, variationModal.blockId, and the variant’s patch.
When a variant is applied the pipeline:
  1. Sets isApplyingVariation on the editor store to prevent double-clicks.
  2. Sends the op.
  3. Moves block focus to data.focusBlockId (usually the same block).
  4. Broadcasts the patch to the preview iframe — either via postPatchToSite (if enablePatchTransport is on and we got a previewVersion) or the coarser draftUpdated broadcast.
  5. Closes the modal and pushes an applied assistant message with canUndo: true.

Inline agent cards (agent tool)

When the agent tool returns variants, the editor renders them as inline cards inside the chat message bubble. Same VariationScaledPreview component, no modal — clicking a card applies the patch immediately. The normal “suggested next actions” pills are suppressed while variation cards are present, so the variants themselves serve as the next step.

Data shape

Request (REST)

POST /chat/variations
{
  session: string
  siteId?: string
  slug: string
  message: string                               // the user prompt
  modelKey?: "fast" | "balanced" | "reasoning" | "codex"
  provider?: "openai" | "anthropic" | "gemini"
  activeBlockId: string                         // required — the selected block
  activeBlockType: string
  activeEditablePath?: string
  locale?: string
  businessContext?: { purpose?, tone?, constraints? } | string
  siteContext?: { siteId?, siteName?, purpose?, hosting?, tone?, constraints? } | string
}

Response (REST)

{
  status: "ok"
  summary: string                               // "Generated 3 variations for Hero."
  blockId: string
  blockType: BlockType
  pageSlug: string
  baseProps: Record<string, unknown>            // current block props, as reference
  variations: Array<{
    id: string                                  // "var_1713792134567_a3f2x"
    title: string                               // "Crisp & Direct"
    summary: string                             // "Shorter, more direct copy."
    patch: Record<string, unknown>              // only the keys that should change
    changedKeys: string[]
  }>
  plannerSource: "openai" | "anthropic" | "gemini" | "demo"
  modelUsed: string                             // e.g. "gpt-4o"
  modelKey: ModelKey
  usage?: {
    inputTokens: number
    outputTokens: number
    totalTokens: number
    cacheCreationInputTokens?: number
    cacheReadInputTokens?: number
    estimatedUsd: number | null
  }
}
Error responses:
CodeWhen
400session / slug / message missing; no activeBlockId; zero valid variants after sanitization
404Page or selected block not found

Agent tool output

Same variations / baseProps shape, but wrapped as a tool result string that the agent transport parses out of the final SSE message and hands to the editor store as a variations payload on the assistant message.

Persistence

Variants are ephemeral until applied:
  • No variant list is ever persisted. Only the chosen patch, applied as a standard update_props op, lands in the draft.
  • The applied variant participates in the normal session store history (undo/redo, version log, recent edits).
  • If the user dismisses the modal, all generated variants are discarded client-side — nothing ever went to SQLite.
This is deliberate: there’s no “saved variant” concept, no A/B state, and no variant history to reason about on later requests.

Demo mode

DEMO_MODE=1 interacts with variants in two ways:
  • REST pipelineplannerSource resolves to "demo" for demo sessions. The pipeline currently only has LLM branches for openai and anthropic, so demo sessions skip the LLM and hit the deterministic fallback directly. Users still get three variants, just from the hand-written tones rather than the model.
  • Image variationDEMO_DISABLE_IMAGE_GEN=1 short-circuits both Unsplash and DALL·E calls inside detectImageOps, so demo variants keep whatever image the block already has.

Flags, caps, and defaults

All in variation-pipeline.ts unless noted:
ConstantDefaultNotes
DEFAULT_VARIATION_COUNT3Returned when the message has no count
MAX_VARIATION_COUNT12Any higher count is clamped silently
Agent tool cap6In agent-tools.ts — hard cap on items Claude can surface
Unsplash uniqueness retries (default)5Inside resolveDistinctUnsplashImage
Unsplash uniqueness retries (explicit “no duplicates”)8Same function, when the message opts in
validateBlockPropsHard gate — invalid patches are dropped, not fixed

Env vars

VariableDefaultValuesNotes
VARIATION_DEFAULT_IMAGE_SOURCEunsplashunsplash | ai (aliases: llm, gemini, openai)Which branch runs in withDefaultImageVariations when the user’s message has no image-provider hint. ai routes to AI generation; the actual backend is then chosen by IMAGE_GEN_PROVIDER. Explicit message keywords (e.g. "unsplash", "gemini", "ai-generated") always override this.
IMAGE_GEN_PROVIDERgeminigemini | openaiWhich AI backend handles image gen once the AI branch is selected (for variations, the image.generate tool, and /image/generate). Falls back to the other provider if the chosen one has no API key.
OPENAI_IMAGE_MODELgpt-image-1-mini (variations), gpt-image-1 (final-quality tool calls)any OpenAI image model (e.g. gpt-image-2)Model used when IMAGE_GEN_PROVIDER=openai.
GOOGLE_GENAI_IMAGE_MODELgemini-2.5-flash-imageany Gemini image modelModel used when IMAGE_GEN_PROVIDER=gemini.

Testing

Run the variation unit tests:
pnpm --filter @ai-site-editor/orchestrator test:chat
# or target the single file:
cd apps/orchestrator && npx tsx --test src/chat/variation-pipeline.test.ts
The test file covers:
  • Unsplash uniqueness enforcement (retries, final URL set size).
  • “Keep same image” opt-out path.
  • requestedVariationCount across synonyms (variations, variants, alternatives, options), word forms, and the max-count clamp.
Agent tool coverage lives in apps/orchestrator/src/agent/agent-tools.test.ts.

Key files

PurposePath
REST pipeline entryapps/orchestrator/src/chat/variation-pipeline.ts
Chat pipeline detourapps/orchestrator/src/chat/chat-pipeline.ts (look for isVariationRequestMessage)
Intent detectorapps/orchestrator/src/chat/chat-pipeline-shared.ts
Route bindingapps/orchestrator/src/routes/chat.ts (POST /chat/variations)
System promptapps/orchestrator/src/chat/prompts.ts (buildVariationSystemPrompt)
Agent toolapps/orchestrator/src/agent/agent-tools.ts (generate_variations)
Unsplash uniqueness helperapps/orchestrator/src/variation-images.ts
Editor hookapps/editor/src/hooks/chat-engine/useVariations.ts
Agent response handlingapps/editor/src/hooks/chat-engine/agent-transport.ts
Preview componentapps/editor/src/components/VariationScaledPreview.tsx
  • How It Works — the chat pipeline the REST variation detour slots into.
  • Asset Manager & AI Images — the providers behind withDefaultImageVariations (Unsplash, OpenAI image models).
  • Custom Blocks — how allowedPatchKeys is derived from your block’s Zod schema.