> ## Documentation Index
> Fetch the complete documentation index at: https://docs.apostra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Render Apostra widgets in your own host

> What a customer-built MCP Apps host must do to render Apostra V3 widgets: the two render paths, appearance parameters, the bridge, tool routing, uploads, security rules, and a conformance checklist.

Apostra's V3 tools can open a widget: Creative Library, the upload task, the
variant gallery, and others. In Claude, ChatGPT and the Apostra web app the host
already knows how to show one. If you build your own chat or agent host, you
show it yourself. This page is the contract your host follows so those widgets
render, call tools, and upload files correctly. You need nothing beyond this
page and an [Apostra sign-in](/v3/agent-sign-in).

<Note>
  This host contract is in preview. Apostra's own hosts follow it, and the first
  customer-built hosts are adopting it now. If anything here does not match what
  you see, tell us.
</Note>

Widgets follow the MCP Apps protocol (the `@modelcontextprotocol/ext-apps` 2.x
bridge). If your host already speaks it, most of this page is the Apostra
specifics: which frame to load, which parameters to send, which messages to
pass through untouched, and what to refuse.

## Who does what

| Apostra provides | Your host provides |
| - | - |
| The widget resource (`ui://agentic-api/...`) with its HTML and `_meta` | The sandboxed iframe |
| The widget page on `widgets.apostra.com`, which any host may frame | The MCP session to `/mcp/v3` and the signed-in person's credentials |
| The tool results that launch a widget, including the page capability | The bridge between the iframe and your MCP session |
| Authorization of every tool call the widget makes | The decision about which links open and which messages reach the model |
| The storage upload target for files | A conversation that can take a message and a context note from a widget |

The widget never holds your session. It runs in an iframe, and every call it
makes goes through your bridge, under your signed-in person's account.

## Launch a widget

1. **Call the tool that opens the widget.** The tool declares
   `_meta.ui.resourceUri` in `tools/list` (and the older `_meta["ui/resourceUri"]`
   key, with the same value). The tools used in this page are:

   | Tool | Opens |
   | - | - |
   | `open_creative_library` | Creative Library for one advertiser |
   | `upload_creative_asset` | The upload task for one advertiser |
   | `open_variant_gallery` | The gallery of generated variants for one saved Creative Session. The account needs the [Creative Engines](/v2/setup/v3/creative-engines) entitlement |

   Keep the whole tool result: `content`, `structuredContent` and `_meta`. You
   deliver it to the widget unchanged in a later step.

2. **Read the resource.** Call `resources/read` on the `ui://` URI. The result is
   HTML (`text/html;profile=mcp-app`) and a `_meta` object. Two keys matter here:
   `_meta.ui.csp`, the origins the widget asks to reach, and
   `_meta["scope3/widget-page"]`, which names the widget's page.

3. **Choose a render path.** If `scope3/widget-page` is present and its origin is
   one you trust, [frame the page](/v3/mcp-apps-host#path-a-frame-the-widget-page).
   Otherwise [render the HTML as a `srcdoc`](/v3/mcp-apps-host#path-b-render-the-html-as-a-srcdoc).
   Always handle the key being absent. Every V3 widget publishes a page, but a
   resource served outside V3, or in an environment with no widget origin, does
   not.

4. **Connect the bridge before the document loads,** wait for the widget to
   initialize, then [deliver the launch result](/v3/mcp-apps-host#the-bridge).

## Path A: frame the widget page

`scope3/widget-page` looks like this:

```json theme={null}
{
  "scope3/widget-page": {
    "url": "https://widgets.apostra.com/creative-library-v3-assets-v1/mcp-app.html",
    "appearanceParams": ["color-scheme", "product-brand", "host-corner"]
  }
}
```

`url` is the page without a query string. `appearanceParams` lists the
parameters you append. Set the iframe up like this:

* **`src`:** `url` plus one query parameter for each name in
  `appearanceParams` that you can fill, each exactly once, and nothing else.
* **`sandbox`:** exactly `allow-scripts allow-same-origin allow-forms`. Do not
  add `allow-popups`, `allow-modals`, `allow-top-navigation` or any other flag.
* **Bridge:** exchange messages with the page's exact origin only. Pass that
  origin as the target when you post to the frame, and ignore any message whose
  `event.origin` or `event.source` is not the page's origin and window.
* **Your own CSP:** if your host sets a Content Security Policy, allow
  `https://widgets.apostra.com` in `frame-src`. The page sets its own policy
  inside the frame, so your `connect-src` does not need to change.

The page is a real page on a real origin. That is why it works for uploads (see
[Upload a file](/v3/mcp-apps-host#upload-a-file)). Every widget page may connect
to one origin only, `https://storage.googleapis.com`, for signed uploads and
downloads. A page never calls the Apostra API itself: every tool call goes
through your bridge, under your credentials. It sends `Cache-Control:
no-store`, `Referrer-Policy: no-referrer`, and a permissions policy that turns
off camera, microphone, geolocation and similar features. It sets no
`frame-ancestors` restriction, so any host may frame it, and it holds no cookies
or session. After an Apostra deployment, a changed widget is live on its page
within about five minutes.

### Appearance parameters

The page reads three parameters. It returns `404` if a required one is missing,
if any value is not one listed here, if a parameter repeats, or if any other
parameter is present.

| Parameter | Values | Required | What it does |
| - | - | - | - |
| `color-scheme` | `light`, `dark` | Yes | Sets the page's color scheme. Send the scheme your host is showing |
| `product-brand` | `new`, `legacy` | Yes | Selects the visual brand. `new` is the current Apostra look. `legacy` is the earlier look, kept for older Apostra hosts. Send `new` |
| `host-corner` | `reserved`, `released` | No, defaults to `reserved` | `reserved` leaves room in the widget's top-right corner for a control your host draws there. Send `released` when your host draws nothing in that corner |

A widget also reads the host context your bridge reports, such as `locale` and
`timeZone`, so set those too. See [the bridge](/v3/mcp-apps-host#the-bridge).

### Trust only Apostra's widget origins

Framing a page with `allow-same-origin` gives it its own origin, so your host
decides which origins it will frame. Frame a page only when its `url`:

* uses `https`;
* has no username, password, query string or fragment; and
* has the origin `https://widgets.apostra.com` (production) or
  `https://widgets.staging.apostra.com` (staging).

For anything else, fall back to path B or refuse to render. Do not take the
origin from a resource served by a server you do not control.

Do not load a widget from the API's shell route
(`/mcp-app-shells/<view>/mcp-app.html`). Apostra's widget origin fetches widget
code from it, and it answers with a locked policy (`sandbox`, `script-src
'none'`, `frame-ancestors 'none'`) that stops the widget from running or being
framed. Use `scope3/widget-page` or `resources/read`.

## Path B: render the HTML as a srcdoc

Use this when the resource has no `scope3/widget-page`, or when you do not
trust the page's origin. You build a document around the resource HTML and load
it as a `srcdoc`. This path is more work and has one hard limit: **uploads do
not work here.** A `srcdoc` frame has the opaque origin `null`, and no Apostra
storage bucket allows `null`. Everything else, including browsing Creative
Library and opening the variant gallery, works.

Your host owns the document the widget runs in:

* **Sandbox `allow-scripts` only.** Never `allow-same-origin`. A `srcdoc` frame
  that also has `allow-same-origin` can reach into your page.
* **Build the CSP from `_meta.ui.csp`.** The resource declares `connectDomains`,
  `resourceDomains`, `frameDomains` and `baseUriDomains`. Treat these as a
  request. Grant an origin only if your own policy approves it, and accept only
  well-formed `https:` or `wss:` origins. With no approvals, the widget gets no
  network and no extra resource origins. A widget never widens its own policy.
  Include `form-action 'none'` so a form cannot send data or navigate the frame.
* **Put the policy first.** Insert the policy and your link guard ahead of any
  widget script, in a document you parse and serialize yourself, not by string
  replacement in the widget's `<head>`.
* **Cancel every link in the widget.** A widget never navigates by itself; it asks
  your host to open a page (see [links](/v3/mcp-apps-host#links)). A link that
  navigates the frame would replace the widget document.
* **Retire a widget that loads a second document.** If the frame ever loads
  anything other than the widget document, stop relaying messages and remount it.

Apostra's own web app builds this document from a reference implementation,
`@apostra/mcp-app-host`. It is not published to a package registry yet, so there
is no install path today. Follow this page, which describes what it does.

## The bridge

The bridge is the MCP Apps host side: your host answers the widget's requests
and notifications over `postMessage`. An ext-apps 2.x `AppBridge` implements the
protocol itself; you supply the handlers below. Create the bridge before the
frame's document loads, so it is listening when the widget sends its first
message.

| Widget sends | Your host does |
| - | - |
| `ui/initialize` (request) | Answers with your host info, your capabilities, and the host context. Declare `openLinks`, `serverTools`, `logging`, `message` (text), and `updateModelContext` (text and structured content), and report `theme`, `locale` and `timeZone` in the host context. Use your own product name in host info (see the note below the table) |
| `ui/notifications/initialized` | Now send the launch: `ui/notifications/tool-input` with the launch tool's arguments, then `ui/notifications/tool-result` with the **whole** launch result |
| `tools/call` (request) | Forward to Apostra, unchanged, under the launching person's session. Return the result unchanged. See [tool calls](/v3/mcp-apps-host#tool-calls-from-the-widget) |
| `ui/open-link` (request) | Open the URL only if it passes your link check. See [links](/v3/mcp-apps-host#links) |
| `ui/message` (request) | Add the text to the conversation as a message from the person, then answer `{}`. Answer `{"isError": true}` if you cannot |
| `ui/update-model-context` (request) | Keep the text and structured content as context for the model's next turn, then answer `{}`. A newer update from the same widget replaces its earlier one |
| `ui/notifications/size-changed` | Set the iframe height to the reported height |
| `ui/notifications/request-teardown` | Send `ui/resource-teardown`, wait briefly (under a second) for the answer, then remove the frame |
| `notifications/message` | Optional. Treat it as the widget's log line |

You also send `ui/notifications/host-context-changed` when your theme, locale
or time zone changes while a widget is open.

**Deliver the result as received.** Creative Library, for example, reads the
launch `structuredContent` to draw itself and the launch `_meta` to authorize its
later calls. A result with either removed makes it show a launch failure. Creative
Library treats the first tool result it receives as its launch and ignores later
ones.

**Name your host.** The upload task reports completion differently for the two
names Apostra's own apps use, `Agentic UI` and `Agentic host`. For any other host
name it reports completion with a `ui/message` that your host adds to the
conversation. Report your own product name in host info, and never one of those
two.

## Tool calls from the widget

A widget calls tools through `tools/call` on the bridge. Your host forwards each
call to the same `/mcp/v3` endpoint and returns what Apostra returns. Rules:

* **Forward `arguments` and `_meta` exactly as the widget sent them.** The
  widget adds the page capability described below. A host that rewrites or
  drops either breaks authorization. Bound each call in time (Apostra's own host
  uses 60 seconds), and cancel it if the widget cancels.
* **Use the same principal as the launch.** A widget's calls act as the person
  and the account that launched it. Send them with the same credentials and
  account context as the tool call that opened the widget, and never let a
  widget call act for a different account.
* **Keep app-only tools away from the model.** Tools whose `_meta.ui.visibility`
  is `["app"]` are for a widget, such as `prepare_creative_asset_upload`,
  `finalize_creative_asset_upload` and the `creative_library_page_*` tools.
  Apostra still lists them in `tools/list`, so your host hides them: leave them
  out of the model's tool list, and never let a model call them. Your own host
  or agent code may call the two upload tools directly, with no widget (see
  [Upload from your own code](/v3/mcp-apps-host#upload-from-your-own-code)).
  Forward the other app-only tools only for a `tools/call` that arrives over a
  widget's bridge.
* **Keep app-only results away from the model and your logs.** An app-only
  result can carry a signed write URL or a capability. Return it to the widget
  and nowhere else.
* **Keep your own allow-list.** Apostra authorizes every call, but your host
  should still refuse a call to a tool that is not in the `tools/list` you read,
  and should refuse any widget call that your product does not intend.

### The page capability

A widget that reads private data needs proof that your host launched it. Apostra
returns that proof in the launch result's `_meta`, as the opaque string
`scope3/storefront-v3-page-capability`. It is signed, expires, and is bound to
the person, the widget, and the advertiser.

You do nothing with it except pass it through. The widget reads it from the tool
result you deliver, then attaches it to its own calls: as the same `_meta` key,
and, on the app-only tools that take it, as the reserved argument
`_scope3PageCapability`. The reserved argument exists because many hosts run the
model's tool calls and the widget's calls on different MCP sessions, so Apostra
accepts a capability from the widget's own session when it arrives that way.
When it expires, the widget asks for a new one itself, once, and retries.

A missing, expired, wrong-widget or wrong-account capability returns
`ACCESS_DENIED`, and so does a call outside the widget's allowed set. Never
decode, log, store, or show the capability to the model. The same rule is
stated for [Pages](/v2/reference/mcp-apps#page-capability-and-tool-bridge).

## Links

A widget never opens a page itself. It sits in a sandboxed frame with no popups,
so it sends `ui/open-link` and your host decides. In your handler:

* accept only `http:` and `https:` URLs, and refuse every other scheme
  (`javascript:`, `data:`, `file:`, and so on);
* open the URL in a new browsing context with no opener, so the opened page
  cannot reach your host;
* answer `{"isError": true}` for a URL you refuse.

## Upload a file

Uploads are why the page path exists. This is what happens when a person adds an
image or an MP4 through `upload_creative_asset`.

1. **Open the task.** Your host calls `upload_creative_asset` with the
   buyer-owned `advertiserId`. The result lists `accepted_content_types` and the
   exact limit for each in `max_size_bytes_by_content_type`, and a `fallback_url`
   for Apostra's own upload page if the task does not render. The image types
   are `image/jpeg` and `image/png`, up to 30,000,000 bytes. `video/mp4`, up to
   2,000,000,000 bytes, is listed only when managed video is enabled for the
   advertiser's buyer account. Read the lists from the result instead of hard-coding
   them. Uploading does not create or deliver a Creative.
2. **The person picks a file.** The widget measures its size and SHA-256.
3. **Reserve the upload.** The widget calls
   `prepare_creative_asset_upload` (hidden from the model) through your bridge with `advertiserId`,
   `content_type`, `size_bytes` and `sha256`. Apostra returns an `asset_ref`, a
   short-lived signed `upload_url`, the `upload_headers` to send with it, and
   `upload_expires_at`.
4. **Send the bytes straight to storage.** The widget makes a create-only `PUT`
   of the file to `upload_url` from the browser. The bytes do not pass through
   your host or the bridge. The storage bucket accepts only exact origins it
   lists, and `https://widgets.apostra.com` is one of them. That is why the
   widget must run on its own page: a `srcdoc` frame's `null` origin is refused,
   and so is any origin that is not on the list.
5. **Finalize.** The widget calls `finalize_creative_asset_upload`
   (also hidden from the model) with the `asset_ref` (and optionally a name, tags and labels). Apostra checks
   the stored bytes against the declared size and hash, and for an MP4 inspects
   the video, so the widget repeats the call while inspection runs. A finished
   upload returns an `asset_id` in the advertiser's Creative Library.
6. **Completion.** The widget tells your host it is done with a `ui/message`
   (see [Name your host](/v3/mcp-apps-host#the-bridge)), so make sure your host
   adds that message to the conversation.

To use the uploaded asset, reference it when you save a creative with
`save_creative` (its `sourceAssets` parameter). To compare generated variants for
a saved Creative Session, find the session with `search` or `get`
(kind `creative_session`) and call `open_variant_gallery` with its `sessionId`.

## Upload from your own code

You do not need a widget to upload. `prepare_creative_asset_upload` and
`finalize_creative_asset_upload` are hidden from the model, but your code can
call them over MCP like any other tool. Use this path when your host renders
widgets as a `srcdoc` (where the widget cannot upload), when a server-side agent
adds assets, or when a pipeline has no widget at all.

The caller is signed in as a buyer account with write access, and the
advertiser must be one that account owns. No page capability is needed: the
capability exists to prove a widget was launched by your host, and a call with
none is authorized by the signed-in person's account alone. If you do attach one
to `finalize_creative_asset_upload`, Apostra checks it against the Creative
Library and refuses a capability that is expired, for another advertiser or
account, or for another widget. `prepare_creative_asset_upload` takes no
capability argument, so do not send one. Call `upload_creative_asset` first if
you want the accepted content types and limits for this advertiser; it also
works without rendering anything.

### Step 1: reserve the upload

Call `prepare_creative_asset_upload` with these arguments. It accepts no others.

| Argument | Value |
| - | - |
| `advertiserId` | The advertiser that will own the asset |
| `content_type` | One of the types below |
| `size_bytes` | The exact size of the file in bytes, a positive integer within the limit for its type |
| `sha256` | The lowercase hexadecimal SHA-256 of the file's bytes (64 characters) |

| Kind | `content_type` | Largest file |
| - | - | - |
| Image | `image/jpeg`, `image/png` | 30,000,000 bytes |
| Video | `video/mp4` | 2,000,000,000 bytes |
| Audio | `audio/wav`, `audio/x-wav`, `audio/wave`, `audio/mpeg`, `audio/mp3` | 50,000,000 bytes |

Video is available only when managed video is enabled for the advertiser's buyer
account, and audio only where managed audio is configured. Read the types the
advertiser accepts, and each limit, from the `accepted_content_types` and
`max_size_bytes_by_content_type` fields that `upload_creative_asset` returns.
Apostra also accepts `application/xml` (VAST, up to 1,000,000 bytes),
`application/json` and `text/csv` (catalog feeds, up to 5,000,000 bytes) through
these same two calls. This page covers images, video and audio; a VAST or
catalog file finalizes into a Draft Creative or Draft catalog instead of a
library asset.

The result carries:

| Field | Meaning |
| - | - |
| `asset_ref` | The reference you pass to `finalize_creative_asset_upload` |
| `upload_url` | A signed storage URL to `PUT` the file to |
| `upload_headers` | The headers the `PUT` must send: `Content-Type` (the declared type) and `x-goog-if-generation-match: 0` |
| `size_bytes`, `sha256` | The values you declared |
| `upload_expires_at` | When the reservation and the URL stop working |

### Step 2: send the bytes

Make an HTTP `PUT` of the exact file to `upload_url` from your own process, with
exactly the headers in `upload_headers`. Use a server or an agent runtime, not
a browser frame: a call from your own code has no browser origin, so the
storage bucket's cross-origin rules do not apply. Send the file once, unchanged.
`x-goog-if-generation-match: 0` makes the write create-only, so a second `PUT` to
the same URL is refused.

The URL is a bearer credential: anyone who holds it can write one object until it
expires, about five minutes after you reserved the upload. Keep it out of logs,
and never show it to a model. If the reservation has expired, reserve again.

### Step 3: finalize

Call `finalize_creative_asset_upload` with `advertiserId` and the `asset_ref`
from step 1. You may also set `name` (up to 255 characters), `tags` (up to 50,
each up to 64 characters) and `labels`. Apostra checks the stored bytes against
the size and hash you declared, and for video and audio it inspects the file.

* **Images** finish in one call.
* **Video and audio** are inspected before they are usable. While inspection
  runs, the result has no `asset_id` and a `processing_state` of `quarantined`,
  `inspecting` or `approved`. Call finalize again with the same `asset_ref`,
  every few seconds, until `processing_state` is `promoted`. Repeating the call is
  safe. If your client sends a progress token, Apostra holds each call for up to
  25 seconds and reports progress while it waits; without one, each call returns
  straight away.
* **A finished upload** returns `asset_id`, `asset_name`, `tags`, `labels`,
  `library_url`, `content_type`, `size_bytes` and `sha256`, plus
  `verified_media` for video and audio. `partial_write: true` with a
  `label_error` means the asset was saved but its labels were not; fix the labels
  and call finalize again.

Finalizing adds the asset to the advertiser's Creative Library. It does not
create a Creative. To use the asset, call `save_creative` and reference it in
`sourceAssets`.

### Errors you can hit

Errors come back as a tool result with `isError: true` and a `code`.

| When | Code | What to do |
| - | - | - |
| The account is not a buyer account | `WRONG_ACCOUNT` | Sign in with a buyer account |
| The signed-in person lacks write access, or MP4 is not enabled for this buyer | `ACCESS_DENIED` | Request write access, or upload an image |
| The type or size is outside the limits above, or an argument is missing or unknown | `VALIDATION_FAILED` | Fix the arguments; the message names the field |
| WAV or MP3 on a deployment without managed audio | `UNSUPPORTED_FEATURE` | Upload a different kind |
| The `PUT` has not finished, or the object changed (`ingressCode: UPLOAD_INCOMPLETE`) | `INVALID_REQUEST` on `asset_ref` | Finish the `PUT`, then finalize again |
| The stored bytes differ from the declared size or hash (`ASSET_MISMATCH`) | `INVALID_REQUEST` on `asset_ref` | Reserve a new upload and send the correct file |
| The reservation expired before you finalized (`UPLOAD_EXPIRED`, `ASSET_EXPIRED`) | `INVALID_REQUEST` on `asset_ref` | Start again from step 1 |
| The asset or advertiser is not found or not yours | `NOT_FOUND` | Check `advertiserId` and `asset_ref` |
| Inspection rejected the file (`processingState` of `rejected`, `revoked`, `deleting` or `deleted`) | `INVALID_REQUEST` on `asset_ref` | Fix the file and start again from step 1 |
| A capability you attached is expired, or for another advertiser, account or widget | `ACCESS_DENIED` | Send none, or use the one from your launch |
| Storage is not available | `SERVICE_UNAVAILABLE` | Retry after a short wait |

## Security notes for hosts

* **Frame a page only from `https://widgets.apostra.com` or
  `https://widgets.staging.apostra.com`.** Do not frame an origin a resource
  names, because `allow-same-origin` gives the page its own origin.
* **Exchange messages with that one exact origin and window.** Check
  `event.origin` and `event.source`, and post with an explicit target origin,
  never `*`.
* **Never give `allow-same-origin` to `srcdoc` content.** The widget would share
  your origin and could read your page, cookies and storage.
* **Never add sandbox flags the page does not need.** No popups, top navigation,
  modals, or downloads unless your product has a reason and you control the page.
* **Grant CSP origins from your policy, not from the resource.**
* **Keep every credential on your side.** The widget page holds no session. Do
  not put your access token in a URL, a message to the widget, or the frame.
* **Do not log app-only results or the page capability.**

## Check your host

An automated conformance suite is not available yet. Until it is, run these
checks against the staging endpoint or a staging account. Each has one expected
outcome.

1. `open_creative_library` for an advertiser, with a person signed in. Pass:
   Creative Library renders, lists creatives, and does not show a launch error.
2. Inspect the frame. Pass: its `src` is on `widgets.apostra.com` and carries
   `color-scheme` and `product-brand` once each, with no other parameter, and its
   `sandbox` attribute is exactly `allow-scripts allow-same-origin allow-forms`.
3. Remove `scope3/widget-page` from the resource in a test. Pass: your host
   renders the `srcdoc` fallback, and that frame's sandbox has no
   `allow-same-origin`.
4. Open a resource whose page URL is on another origin, such as
   `https://widgets.example.com`. Pass: your host does not frame it.
5. In Creative Library, run a search or switch lens, which makes the widget call an
   app-only tool. Pass: your host forwards the call with its `arguments` and `_meta` untouched,
   under the same account, and the result reaches the widget and not the model's
   context.
6. Check the tool list you give the model. Pass: `prepare_creative_asset_upload`
   and `finalize_creative_asset_upload` are not in it, and your host refuses a
   model-originated call to either. Your own code can still call them.
7. Upload a `.jpg` or `.png` file through `upload_creative_asset`. Pass: the task
   reaches the finished state, the asset appears in Creative Library, and the
   conversation receives the completion message.
8. Upload an `.mp4` file for an advertiser that lists `video/mp4`. Pass: the task
   waits while the video is inspected, then finishes.
9. Click a link in a widget. Pass: your host receives `ui/open-link`, opens an
   `https` URL with no opener, and refuses a `javascript:` URL.
10. Change your theme while a widget is open. Pass: your host sends
    `ui/notifications/host-context-changed`, and a reopened widget loads with the
    matching `color-scheme`.
11. Call `open_variant_gallery` for a saved Creative Session on an account with
    Creative Engines. Pass: the gallery renders. On an account without it, pass
    means your host shows Apostra's error and renders nothing.
12. Close the frame while a call is in flight. Pass: your host cancels the call,
    sends `ui/resource-teardown`, and removes the frame.

## Related

* [Sign in a self-built agent](/v3/agent-sign-in) covers the OAuth sign-in your
  host needs before any of this.
* [MCP App HTML](/v2/reference/mcp-apps) describes the authenticated HTML
  endpoint, the Page capability, and the tool bridge from the API side.
* [Creative Engines](/v2/setup/v3/creative-engines) covers the entitlement the
  variant gallery needs.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.