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 normalupdate_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.| Path | Runs when | Where it lives | What it’s good at |
|---|---|---|---|
| REST variation pipeline | Classic chat pipeline (/chat + intent detection) | apps/orchestrator/src/chat/variation-pipeline.ts | Dedicated 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 loop | apps/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 |
Triggering variants (REST pipeline)
Detection
The chat pipeline callsisVariationRequestMessage() 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
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”
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..twelvefollowed by the same nouns - Default: 3
- Cap: 12 (anything higher is clamped silently)
Constraints
The pipeline recognizes two inline constraints in the message:| Constraint | Trigger (regex) | Effect |
|---|---|---|
| Keep the same title | \bsame\s+title\b, \bkeep\s+(the\s+)?title\b, \btitle\s+(unchanged|same)\b | Strips title from every variation’s patch |
| Cards only (CardGrid) | \bcards?\s+only\b, \bonly\s+cards?\b on a CardGrid block | Reduces each patch to only the cards key |
supportsImageVariation = block has an
imageUrl prop):
| Image constraint | Trigger | Effect |
|---|---|---|
| Same image across variants | same image, same photo, keep the same image, use one image, single image | All variants reuse the block’s current imageUrl |
| No duplicates | no duplicates, do not reuse, unique images | Retries Unsplash up to 8 times per variant to find a distinct photo (default cap is 5) |
| Explicit URL | A 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 thegenerate_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:
- Loads the page + block.
- Filters each patch to keys that exist on the block and differ from current values.
- Caps the list at 6.
- Serializes the result as a tool response.
How variants are generated (REST pipeline)
1. LLM generation
generateVariationsWithOpenAI and generateVariationsWithAnthropic both
send the same shape:
- OpenAI: called with
response_format: { type: "json_object" }. - Anthropic: system prompt goes through
anthropicSystemPromptWithCache()for prompt caching; the response is parsed from the firsttextblock viaextractJsonObject. - Both providers go through
resolveEffectiveProvider/resolveModelKeyfor routing, so/status/planneravailability applies.
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).
3. Image variation
withDefaultImageVariations runs after text is settled. For each variant,
in order:
- If the message contains a URL, every variant uses that URL.
- If
deriveVariationImageIntentclassifies the message as wanting AI- generated images, the pipeline dispatches to either Gemini (generateVariationImageWithGemini, default) or OpenAI (generateVariationImageWithOpenAI) based onIMAGE_GEN_PROVIDER. The per-variant prompt is built from the block type, heading, and subheading. - Otherwise it calls Unsplash via
resolveUnsplashImage. With uniqueness enforced (the default), it usesresolveDistinctUnsplashImagewhich retries against ausedImageUrlsset — up to 5 attempts, or 8 if the user explicitly asked for no duplicates.
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 throughsanitizeVariationPatch:
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 aResizeObserverto measure the shell and canvas, then applies a CSStransform: scale(…)to fit the preview into the card without re-flowing the block’s internal layout. - Apply button — fires
POST /opswith a singleupdate_propsop carryingvariationModal.pageSlug,variationModal.blockId, and the variant’spatch.
- Sets
isApplyingVariationon the editor store to prevent double-clicks. - Sends the op.
- Moves block focus to
data.focusBlockId(usually the same block). - Broadcasts the patch to the preview iframe — either via
postPatchToSite(ifenablePatchTransportis on and we got apreviewVersion) or the coarserdraftUpdatedbroadcast. - Closes the modal and pushes an
appliedassistant message withcanUndo: true.
Inline agent cards (agent tool)
When the agent tool returns variants, the editor renders them as inline cards inside the chat message bubble. SameVariationScaledPreview
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
Response (REST)
| Code | When |
|---|---|
400 | session / slug / message missing; no activeBlockId; zero valid variants after sanitization |
404 | Page or selected block not found |
Agent tool output
Samevariations / 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_propsop, 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.
Demo mode
DEMO_MODE=1 interacts with variants in two ways:
- REST pipeline —
plannerSourceresolves to"demo"for demo sessions. The pipeline currently only has LLM branches foropenaiandanthropic, 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 variation —
DEMO_DISABLE_IMAGE_GEN=1short-circuits both Unsplash and DALL·E calls insidedetectImageOps, so demo variants keep whatever image the block already has.
Flags, caps, and defaults
All invariation-pipeline.ts unless noted:
| Constant | Default | Notes |
|---|---|---|
DEFAULT_VARIATION_COUNT | 3 | Returned when the message has no count |
MAX_VARIATION_COUNT | 12 | Any higher count is clamped silently |
| Agent tool cap | 6 | In agent-tools.ts — hard cap on items Claude can surface |
| Unsplash uniqueness retries (default) | 5 | Inside resolveDistinctUnsplashImage |
| Unsplash uniqueness retries (explicit “no duplicates”) | 8 | Same function, when the message opts in |
validateBlockProps | — | Hard gate — invalid patches are dropped, not fixed |
Env vars
| Variable | Default | Values | Notes |
|---|---|---|---|
VARIATION_DEFAULT_IMAGE_SOURCE | unsplash | unsplash | 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_PROVIDER | gemini | gemini | openai | Which 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_MODEL | gpt-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_MODEL | gemini-2.5-flash-image | any Gemini image model | Model used when IMAGE_GEN_PROVIDER=gemini. |
Testing
Run the variation unit tests:- Unsplash uniqueness enforcement (retries, final URL set size).
- “Keep same image” opt-out path.
requestedVariationCountacross synonyms (variations,variants,alternatives,options), word forms, and the max-count clamp.
apps/orchestrator/src/agent/agent-tools.test.ts.
Key files
| Purpose | Path |
|---|---|
| REST pipeline entry | apps/orchestrator/src/chat/variation-pipeline.ts |
| Chat pipeline detour | apps/orchestrator/src/chat/chat-pipeline.ts (look for isVariationRequestMessage) |
| Intent detector | apps/orchestrator/src/chat/chat-pipeline-shared.ts |
| Route binding | apps/orchestrator/src/routes/chat.ts (POST /chat/variations) |
| System prompt | apps/orchestrator/src/chat/prompts.ts (buildVariationSystemPrompt) |
| Agent tool | apps/orchestrator/src/agent/agent-tools.ts (generate_variations) |
| Unsplash uniqueness helper | apps/orchestrator/src/variation-images.ts |
| Editor hook | apps/editor/src/hooks/chat-engine/useVariations.ts |
| Agent response handling | apps/editor/src/hooks/chat-engine/agent-transport.ts |
| Preview component | apps/editor/src/components/VariationScaledPreview.tsx |
Related
- 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
allowedPatchKeysis derived from your block’s Zod schema.