Overview
Avocado Studio has two entry points for images: the Asset Manager modal in the editor, and theimage.generate tool that the chat planner can call
during natural-language edits. Both paths share the same providers and the
same on-disk storage.
The modal is defined in apps/editor/src/components/ImagePickerModal.tsx.
The multi-turn chat view in apps/editor/src/components/ImageGenerateChat.tsx
is an assistant-style chat that talks to POST /image/generate/chat when
Gemini is configured. The image.generate tool manifest lives in
packages/orchestrator-core/src/tools/builtins/image-generate.ts.
Image sources (modal tabs)
Which tabs appear depends on orchestrator env vars and the CMS media config the site passes to the editor. Tab selection is computed inImagePickerModal.tsx (availableTabs).
The feature flags the editor reads (
imageGenerate, imageGenerateChat,
googleDrive, unsplash, cmsMedia) are published by the orchestrator at
GET /status/planner. All but cmsMedia are set purely from env vars;
cmsMedia is derived from whether the mount’s adapter implements getMedia.
There is no per-session toggle today.
CMS asset libraries
One tab, filled two ways. Both answerPOST /media/cms; neither reaches a
CMS from the browser.
The adapter path (library mode). Implement getMedia on your CmsAdapter
and the tab appears — no editor configuration, no credentials leaving the
process that already holds them:
cmsMediaSource is a convenience for Contentful, Sanity and Strapi. It is not
the seam — getMedia is. A site on a CMS none of those three describes writes
the method itself:
GET /status/planner reports features.cmsMedia, and /whoami reports
capabilities.readsMedia; both are derived from the presence of the method,
never declared. An adapter cannot claim a library it did not implement.
The per-site path (standalone). The standalone multi-site orchestrator
wires no adapter, so the editor sends the connection details it holds for the
active site. CmsMediaConfig (apps/editor/src/lib/editor-types.ts) is a
discriminated union over the same three providers, stored on the editor’s
site entry:
Whichever is configured, the request goes to the orchestrator and the vendor
call is made there. That is why the route is a POST: the body carries a token,
and a token in a query string is a token in every access log between the
orchestrator and the browser.
The adapter wins when both are available. A site that implemented
getMedia
meant it, and its own reader can see things a generic one cannot.cmsMedia:
- Open the editor at
http://localhost:4100. - Click the site name in the chat header to open its dropdown, then choose Site settings (the gear row inside it), or open the Sites page and click Configure on a site card.
- In the drawer that slides in, scroll to the CMS media section.
- Pick a Provider (Contentful / Sanity / Strapi / none) and fill in
the provider-specific fields:
- Contentful — Space ID and Delivery token (environment defaults
to
master). - Sanity — Project ID and optional dataset (defaults to
production). No token needed for public datasets. - Strapi — Base URL (e.g.
https://cms.example.com) and optional API token. Public upload endpoints work without a token.
- Contentful — Space ID and Delivery token (environment defaults
to
- Close the drawer. The CMS tab appears in the asset picker on next open.
Documents, and adding one
A CMS library holds more than pictures.getMedia takes a kind — "image"
(the default, and what every caller meant before documents existed) or
"file" — and the link field’s document picker asks for the latter. For
Sanity that is a second asset type, sanity.fileAsset, which cmsMediaSource
queries when kind is "file"; a PDF uploaded in the Studio is one of those,
and a query for sanity.imageAsset finds none of them.
The write half is CmsAdapter.uploadMedia, and cmsMediaUploader implements
it for Sanity from the same connection details:
cdn.sanity.io/files/… URL — durable, CDN-served, and unaffected by any
redeploy of the site that links to it. That is the whole reason to prefer this
over writing into the site’s own public/ directory: a file written to the
running host’s disk does not survive an ephemeral deployment, and a link to it
is a 404 waiting for the next deploy.
cmsMediaUploader returns null when the provider has no uploader
(Contentful’s is a three-step asynchronous create/process/publish; Strapi’s is
not written yet) or when no token is configured — hence the spread. The editor
derives capabilities.writesMedia from whether the method exists, so an
absent one hides the upload control, and a present one that always fails
would be a button that is always there and never works.A read-only token is the trap: Sanity’s query API answers one, so the
picker fills with images and only the upload fails. The uploader says so in
the refusal it hands back.Unsplash: licensing & attribution
Why Unsplash is in the picker. The Unsplash tab ships primarily as a
demo / placeholder convenience — it lets stakeholders, evaluators, and
new sites populate a site with real-looking imagery during a live session
without leaving the editor or wiring up a DAM. Production sites should
usually graduate to branded photography, generated imagery, or a CMS-backed
asset library. If you do ship Unsplash photos to end users, the rules
below apply — attribution and API compliance are the adopter’s
responsibility, not the orchestrator’s.
unsplash.search tool) are
served under the Unsplash License. The
license is permissive — photos are free for commercial and non-commercial
use and no permission from the photographer is required — but the Unsplash
API Terms impose a few concrete obligations that adopters are responsible
for meeting:
- Credit the photographer and Unsplash wherever a selected photo is
rendered. Note that the
unsplash.searchtool does not carry the photographer: itsauthoris the literal"Unsplash"and itssourceUrlis the CDN image URL. To build real attribution, use the HTTP routeGET /unsplash/search, which returns the photographer’s name — for example:Photo by <a href="{sourceUrl}">Photographer Name</a> on <a href="https://unsplash.com">Unsplash</a> - Do not resell, redistribute, or host unmodified Unsplash photos as a stock-photo service, wallpaper pack, or competing search product.
- Do not imply endorsement by photographers or by Unsplash of your product, brand, or customers.
- Track downloads when building your own Unsplash-powered integration.
The API requires a
GET /photos/:id/downloadtrigger per selected photo; theunsplash.searchtool in this repo does not do this automatically — adopters integrating Unsplash into a custom flow outside of the built-in tab should implement it to stay within Unsplash’s API guidelines.
AI providers
OpenAI (default)
- Models:
OPENAI_IMAGE_MODEL(defaultgpt-image-2) forquality: "final",OPENAI_IMAGE_MODEL_DRAFT(defaultgpt-image-1-mini) forquality: "draft". - Sizes map from aspect ratio:
landscape → 1536x1024,square → 1024x1024,portrait → 1024x1536. - Native transparency via the
backgroundparameter (transparent,opaque,auto). - Output formats:
png,webp,jpeg.
Google Gemini — “nano-banana”
Gemini 2.5 Flash Image is the model publicly nicknamed “nano-banana”. Avocado calls it via the@google/genai SDK.
- Model:
GOOGLE_GENAI_IMAGE_MODEL, defaulting togemini-3.1-flash-lite-image. - Aspect ratios are mapped to Gemini’s
imageConfig.aspectRatiostrings:landscape → 3:2,square → 1:1,portrait → 2:3.16:9and9:16can be triggered from prompt hints (e.g. “wide 16:9”). - Quality tiers map to Gemini’s
imageSize:draft → 1K,final → 2K. - No native transparency. When
background: "transparent"is requested, the orchestrator appends a prompt hint telling the model to render on a fully transparent background — results vary. - Supports multi-turn chat and reference images (see next section).
Provider selection
Theimage.generate tool (the one the AI planner calls) picks its backend
from IMAGE_GEN_PROVIDER — openai (default) or gemini. The editor’s
Generate tab picks OpenAI for single-shot generation and routes to
POST /image/generate/chat when imageGenerateChat is enabled
(i.e. GOOGLE_GENAI_API_KEY is set).
Multi-turn image chat (Gemini only)
WhenGOOGLE_GENAI_API_KEY is set, the Generate tab becomes a full assistant
chat built on @assistant-ui/react, streaming against
POST /image/generate/chat. The route lives in
apps/orchestrator/src/routes/media.ts and in library mode’s
createOrchestrator; both call the same
orchestrator-core/src/http/image-generate-actions.ts, so the chat sessions,
aspect-ratio rules and SSE frames are one implementation, not two.
Request body
SSE event stream (stream: true)
stream is omitted, the endpoint returns a single JSON payload with
{ chatId, url, alt, text, aspectRatio }.
UI modes
ImageGenerateChat.tsx switches between three modes depending on whether an
image already exists in the field:
choose— current image exists; user picks “Edit this image” or “Generate a new one”.edit— re-generate using the current image as reference context, so Gemini can honor existing composition, palette, or subjects.new— fresh generation from the prompt alone.
Session management
- Sessions are kept in an in-memory map on the orchestrator, capped at 200 concurrent sessions.
- Idle sessions are evicted after 30 minutes (LRU).
- Changing aspect ratio mid-session creates a new session. Gemini’s
imageConfigis immutable once a chat is created, so the orchestrator detects the change, deletes the old session, and starts a new one. - Sessions do not survive an orchestrator restart.
Reference images
Up to 14 reference images can be attached to the first message in a session. Each is fetched server-side, validated against a 5 MB size cap, and forwarded to Gemini as base64inlineData parts alongside the prompt.
Failed fetches are logged and skipped — the request does not fail if some
references are unreachable.
image.generate tool (chat pipeline path)
When the user asks the chat to produce or replace an image, the planner can
call the image.generate tool. The full manifest is in
packages/orchestrator-core/src/tools/builtins/image-generate.ts.
Prompt enrichment
WhenblockType, blockId, or pageSlug are provided, the tool enriches
the raw prompt with the block’s composition hint, the page title, and the
block’s existing heading / subheading / title props. It also appends
the default constraints: no text overlays, no logos, no watermarks. Known
composition hints include Hero, Banner, CTA, Card, CardGrid,
FeatureGrid, Gallery, Carousel, and TwoColumn.
Progress streaming
The handler emits five progress stages viacontext.onImageProgress, and
those events surface on the chat SSE stream as image_progress events (see
How It Works › Step 5):
Deferred image resolution
WithCHAT_DEFER_IMAGE_RESOLUTION=1 (the default), the chat pipeline applies
text and structural ops immediately and resolves image tool calls in the
background. The editor receives the text updates at once and the image URLs
patch in via follow-up SSE events once generation completes.
Other endpoints
Registered inapps/orchestrator/src/routes/media.ts and answered identically by
library mode, from the shared orchestrator-core/src/http/*-actions.ts modules — except
the Drive routes, which are registered in apps/orchestrator/src/routes/gdrive.ts and are
not served by createOrchestrator().
Environment variables
Limits & constraints
- Reference images: ≤14 per message, ≤5 MB each. Must be reachable over
HTTP(S) from the orchestrator, or uploaded first via
POST /image/upload. - Gemini sessions: in-memory only. Capped at 200 concurrent with 30-minute LRU eviction. An orchestrator restart wipes all sessions.
- Gemini transparency: no native support —
background: "transparent"falls back to a prompt hint and the result is best-effort. image.generatetimeout: 90 seconds, no automatic retry.- Image storage: generated images are written to
ORCHESTRATOR_GENERATED_IMAGE_DIR. For production, mount a persistent volume or upload to an external bucket as part of your publish target.
Related pages
- How It Works › Step 5 —
where
image_progressevents fit in the chat SSE stream. - Tools MVP — the broader tool contract that
image.generateandunsplash.searchimplement. - Custom Blocks › Image fields — how to declare image fields on your own blocks so the Asset Manager opens for them.