Skip to main content
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.
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.
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

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: 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. Otherwise 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.

Path A: frame the widget page

scope3/widget-page looks like this:
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). 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. A widget also reads the host context your bridge reports, such as locale and timeZone, so set those too. See 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). 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. 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). 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. 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), 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. 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:

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.

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.
  • Sign in a self-built agent covers the OAuth sign-in your host needs before any of this.
  • MCP App HTML describes the authenticated HTML endpoint, the Page capability, and the tool bridge from the API side.
  • Creative Engines covers the entitlement the variant gallery needs.