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.
@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
-
Call the tool that opens the widget. The tool declares
_meta.ui.resourceUriintools/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,structuredContentand_meta. You deliver it to the widget unchanged in a later step. -
Read the resource. Call
resources/readon theui://URI. The result is HTML (text/html;profile=mcp-app) and a_metaobject. 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. -
Choose a render path. If
scope3/widget-pageis present and its origin is one you trust, frame the page. Otherwise render the HTML as asrcdoc. 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. - 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:urlplus one query parameter for each name inappearanceParamsthat you can fill, each exactly once, and nothing else.sandbox: exactlyallow-scripts allow-same-origin allow-forms. Do not addallow-popups,allow-modals,allow-top-navigationor 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.originorevent.sourceis not the page’s origin and window. - Your own CSP: if your host sets a Content Security Policy, allow
https://widgets.apostra.cominframe-src. The page sets its own policy inside the frame, so yourconnect-srcdoes not need to change.
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 returns404 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 withallow-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) orhttps://widgets.staging.apostra.com(staging).
/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 noscope3/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-scriptsonly. Neverallow-same-origin. Asrcdocframe that also hasallow-same-origincan reach into your page. - Build the CSP from
_meta.ui.csp. The resource declaresconnectDomains,resourceDomains,frameDomainsandbaseUriDomains. Treat these as a request. Grant an origin only if your own policy approves it, and accept only well-formedhttps:orwss:origins. With no approvals, the widget gets no network and no extra resource origins. A widget never widens its own policy. Includeform-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/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 overpostMessage. 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 throughtools/call on the bridge. Your host forwards each
call to the same /mcp/v3 endpoint and returns what Apostra returns. Rules:
- Forward
argumentsand_metaexactly 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.visibilityis["app"]are for a widget, such asprepare_creative_asset_upload,finalize_creative_asset_uploadand thecreative_library_page_*tools. Apostra still lists them intools/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 atools/callthat 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/listyou 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.
Links
A widget never opens a page itself. It sits in a sandboxed frame with no popups, so it sendsui/open-link and your host decides. In your handler:
- accept only
http:andhttps: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 throughupload_creative_asset.
- Open the task. Your host calls
upload_creative_assetwith the buyer-ownedadvertiserId. The result listsaccepted_content_typesand the exact limit for each inmax_size_bytes_by_content_type, and afallback_urlfor Apostra’s own upload page if the task does not render. The image types areimage/jpegandimage/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. - The person picks a file. The widget measures its size and SHA-256.
- Reserve the upload. The widget calls
prepare_creative_asset_upload(hidden from the model) through your bridge withadvertiserId,content_type,size_bytesandsha256. Apostra returns anasset_ref, a short-lived signedupload_url, theupload_headersto send with it, andupload_expires_at. - Send the bytes straight to storage. The widget makes a create-only
PUTof the file toupload_urlfrom the browser. The bytes do not pass through your host or the bridge. The storage bucket accepts only exact origins it lists, andhttps://widgets.apostra.comis one of them. That is why the widget must run on its own page: asrcdocframe’snullorigin is refused, and so is any origin that is not on the list. - Finalize. The widget calls
finalize_creative_asset_upload(also hidden from the model) with theasset_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 anasset_idin the advertiser’s Creative Library. - 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.
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
Callprepare_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 HTTPPUT 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
Callfinalize_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_idand aprocessing_stateofquarantined,inspectingorapproved. Call finalize again with the sameasset_ref, every few seconds, untilprocessing_stateispromoted. 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_bytesandsha256, plusverified_mediafor video and audio.partial_write: truewith alabel_errormeans the asset was saved but its labels were not; fix the labels and call finalize again.
save_creative and reference it in
sourceAssets.
Errors you can hit
Errors come back as a tool result withisError: true and a code.
Security notes for hosts
- Frame a page only from
https://widgets.apostra.comorhttps://widgets.staging.apostra.com. Do not frame an origin a resource names, becauseallow-same-origingives the page its own origin. - Exchange messages with that one exact origin and window. Check
event.originandevent.source, and post with an explicit target origin, never*. - Never give
allow-same-origintosrcdoccontent. 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.open_creative_libraryfor an advertiser, with a person signed in. Pass: Creative Library renders, lists creatives, and does not show a launch error.- Inspect the frame. Pass: its
srcis onwidgets.apostra.comand carriescolor-schemeandproduct-brandonce each, with no other parameter, and itssandboxattribute is exactlyallow-scripts allow-same-origin allow-forms. - Remove
scope3/widget-pagefrom the resource in a test. Pass: your host renders thesrcdocfallback, and that frame’s sandbox has noallow-same-origin. - Open a resource whose page URL is on another origin, such as
https://widgets.example.com. Pass: your host does not frame it. - 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
argumentsand_metauntouched, under the same account, and the result reaches the widget and not the model’s context. - Check the tool list you give the model. Pass:
prepare_creative_asset_uploadandfinalize_creative_asset_uploadare not in it, and your host refuses a model-originated call to either. Your own code can still call them. - Upload a
.jpgor.pngfile throughupload_creative_asset. Pass: the task reaches the finished state, the asset appears in Creative Library, and the conversation receives the completion message. - Upload an
.mp4file for an advertiser that listsvideo/mp4. Pass: the task waits while the video is inspected, then finishes. - Click a link in a widget. Pass: your host receives
ui/open-link, opens anhttpsURL with no opener, and refuses ajavascript:URL. - Change your theme while a widget is open. Pass: your host sends
ui/notifications/host-context-changed, and a reopened widget loads with the matchingcolor-scheme. - Call
open_variant_galleryfor 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. - 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 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.