diff --git a/.changeset/add-creative-rendering-authority.md b/.changeset/add-creative-rendering-authority.md
new file mode 100644
index 0000000000..36bc057751
--- /dev/null
+++ b/.changeset/add-creative-rendering-authority.md
@@ -0,0 +1,5 @@
+---
+"adcontextprotocol": minor
+---
+
+Add per-route creative preview origin discovery, publisher-authorized preview delegation, isolated community reference-renderer declarations, and versioned placement-presentation composition.
diff --git a/docs/creative/canonical-formats-migration.mdx b/docs/creative/canonical-formats-migration.mdx
index 1cb1204c60..ec64a6e8f3 100644
--- a/docs/creative/canonical-formats-migration.mdx
+++ b/docs/creative/canonical-formats-migration.mdx
@@ -73,7 +73,13 @@ The deprecated fields and task remain parseable during the 3.x compatibility win
}
}
}
- ]
+ ],
+ "preview": {
+ "routes": [{
+ "capability_id": "responsive_image_builder",
+ "rendering_origin": "agent_approximation"
+ }]
+ }
}
}
```
diff --git a/docs/creative/canonical-formats.mdx b/docs/creative/canonical-formats.mdx
index d80efd6211..3319e40ade 100644
--- a/docs/creative/canonical-formats.mdx
+++ b/docs/creative/canonical-formats.mdx
@@ -50,6 +50,10 @@ For hands-on authoring practice, use the [S2 creative specialist module](/docs/l
| **`validate_input`** | Spec-defined manifest preflight — buyers verify a manifest's structure against canonicals/products without committing to a render or other expensive creative-production step. It is not a rehearsal of the seller's `sync_creatives` mutation. |
| **`build_creative`** | Creative-agent surface that produces a manifest from inputs (brief, video_brief, brand). Sales agents do NOT expose `build_creative`. |
| **`creative.supported_formats`** | Capabilities-response field on creative agents declaring canonical build, validation, and preview capabilities. New 3.2 producers MUST carry a stable `capability_id`, a full canonical `format` declaration, and explicit non-empty `operations`. Consumers accept legacy 3.x entries without an ID and default absent `operations` to `build`. |
+| **`creative.preview`** | Capabilities-response field declaring which `creative.supported_formats[].capability_id` routes accept `preview_creative` and each route's informational `rendering_origin`. Authority comes only from publisher placement delegation. |
+| **`reference_renderer`** | Pinned browser-ESM npm package export on a community-registry `formats[]` entry. It is a non-authoritative OSS reference presentation, not publisher or serving-platform output. |
+| **`presentation_ref`** | URI+digest reference on a publisher placement to a versioned declarative placement-presentation document. It belongs to the placement, never the shared format. |
+| **`preview_provider`** | Publisher placement delegation to an AdCP creative agent for specific `format_option_id` → `capability_id` preview routes. It is authoritative only for that publisher placement. |
| **`BrandRef`** | `{domain, brand_id?}` reference. Resolves brand context (logos, colors, voice) from `brand.json` automatically. |
| **`brand_kit_override`** | Inline override on `BrandRef` for per-call brand-kit tweaks (logo, colors, voice, tagline) where `brand.json` is missing, stale, or inappropriate. Same pattern as `industries` and `data_subject_contestation` on BrandRef. |
| **`fanout_mode`** | On `sponsored_placement`: how items map to delivery — `per_item`, `multi_item_in_creative`, `single_item`. |
@@ -370,7 +374,85 @@ Product declarations, buyer selectors, and placement references are intentionall
**Naming boundary:** `format_option_id` selects a buyable product or publisher-catalog format contract. Creative-agent `capability_id` remains separate: it selects a build path on `creative.supported_formats` when calling `build_creative`. Do not use `capability_id` on media-buy products, placements, package requests, creative manifests, or creative assets.
-### Sample renders and declaration authority
+### Rendering authority and fallback order
+
+Canonical formats define asset contracts, not presentations. A declaration can establish required slots, dimensions, durations, and other acceptance constraints without establishing how a seller or publisher will compose those assets on screen. Buyer tooling MUST preserve that boundary when it offers previews.
+
+Dynamic preview support is declared per route in `get_adcp_capabilities.creative.preview`:
+
+```json
+{
+ "creative": {
+ "supported_formats": [
+ {
+ "capability_id": "streamhaus_homepage_preview",
+ "operations": ["preview"],
+ "format": {
+ "format_kind": "image",
+ "params": { "width": 300, "height": 250 }
+ }
+ }
+ ],
+ "preview": {
+ "routes": [
+ {
+ "capability_id": "streamhaus_homepage_preview",
+ "rendering_origin": "platform_native"
+ }
+ ]
+ }
+ }
+}
+```
+
+`rendering_origin` is informational. `platform_native` means the route uses the serving platform's preview machinery; `agent_approximation` means the agent renders an approximation. Agents with mixed implementations declare each capability independently in `routes[]`; for example, one route can be `platform_native` while a community fallback is `agent_approximation`. Neither is an authority claim, because a seller or agent cannot make itself authoritative by self-description. `quality: "production"` likewise describes execution quality, not authority.
+
+Buyer tooling resolves two independent axes:
+
+1. **Creative rendering:** use the targeted publisher placement's matching `preview_provider` route; otherwise use an available agent `preview_creative` route as a labeled approximation; otherwise use the reviewed community format's `reference_renderer`; otherwise show the manifest and assets.
+2. **Placement presentation:** if the delegated provider route sets `covers_placement_presentation: true`, its result is the complete publisher-authorized presentation. Otherwise, compose the placement's `presentation_ref` around the selected creative rendering. When no `presentation_ref` exists, show the creative rendering alone.
+
+Only a `preview_provider` delegation obtained from the publisher-origin `adagents.json` grants authority, and only for its declaring placement, format route, normalized provider endpoint, and capability ID. A platform-native route without that delegation remains an undelegated approximation. If more than one placement is targeted, tooling MUST resolve and label each placement independently.
+
+`preview_provider` maps a same-file `format_option_id` to a provider-local `capability_id`. The buyer discovers the provider's current capabilities, verifies that the capability advertises `preview` and satisfies the resolved placement format, then calls `preview_creative`. Discovery and invocation use the full `format_schema` transport contract: HTTPS, public addresses only, DNS resolution pinned through connection, no redirects, short timeout, bounded response body, and credentials selected only after exact normalized-origin binding. Consumers MUST NOT forward seller or publisher credentials to the delegated endpoint. Provider HTML and URLs remain untrusted even when presentation-authoritative: render them only in a cross-origin iframe with an empty sandbox token set and a caller-enforced restrictive CSP.
+
+```json
+{
+ "placement_id": "homepage_image",
+ "name": "Homepage image",
+ "property_ids": ["daily_pulse"],
+ "format_options": [
+ { "format_option_id": "canonical_image_300x250" }
+ ],
+ "preview_provider": {
+ "agent_url": "https://creative.adcontextprotocol.org/mcp",
+ "authority": "publisher_designated",
+ "routes": [
+ {
+ "format_option_id": "canonical_image_300x250",
+ "capability_id": "preview_display_300x250_image",
+ "covers_placement_presentation": false
+ }
+ ]
+ }
+}
+```
+
+`presentation_ref` is an immutable HTTPS `uri` plus SHA-256 `digest`, media type `application/vnd.adcp.placement-presentation+json`, and schema version on `adagents.json` `placements[]`. The referenced body validates against `/schemas/core/placement-presentation.json`: buyers create its canvas, paint `behind_creative` decorations in array order, apply the declared `contain`, `cover`, or `stretch` fit to the selected creative, clip it to `creative_slot`, then paint `in_front_of_creative` decorations in array order. Decoration kinds are discriminated, and image decorations use digest-pinned assets. Text is plain text and the vocabulary is declarative; HTML, CSS, scripts, event handlers, and arbitrary style properties are not allowed. Rectangle bounds MUST fit within the canvas. The document remains in the publisher's namespace and MUST NOT be copied onto a shared format entry.
+
+A community-registry `formats[]` entry may instead declare a `reference_renderer` with `runtime: "browser-esm"`, `package`, exact `version`, named `export`, package-tarball `integrity`, and expected provenance source repository/workflow. The export accepts canonical manifest data and returns an inert presentation without Node.js APIs, ambient credentials, delivery tracking, or undeclared network access. Non-JavaScript clients use a hosted `preview_creative` provider or show the manifest; renderer availability never gates protocol participation. Compatibility is bound between the named export and the enclosing format entry: both carry the same exact `format_revision`, and registry conformance verifies that the export exists and passes that format kind/revision's contract fixtures. Package semantic versioning only identifies the pinned distribution artifact, so one package version MAY expose separate exports for different formats or format revisions.
+
+Consumers accept `reference_renderer` only from the configured, reviewed AgenticAdvertising.org community-registry origin; a `catalog_role` value in an arbitrary publisher file does not establish provenance. Before execution they verify the exact tarball version and SRI, require npm provenance, require the attestation subject digest to match that tarball, and bind the attestation source repository and workflow path to the entry. Registry-selected code MUST NOT be bundled or dynamically imported into the host application realm. Execute it in a dedicated worker or opaque-origin execution realm with no ambient credentials, `connect-src 'none'`, no persistent storage, structured-clone input/output, and strict time and size limits. Render returned HTML in a separate iframe with an empty sandbox token set and caller-enforced CSP. VAST reference players and OpenRTB Native renderers are precedent for this executable half of an interoperable format specification; they are not precedent for treating a reference implementation as serving-platform truth.
+
+If a pinned renderer version has a confirmed vulnerability, the community-registry maintainer MUST publish a reviewed update that replaces `version` and `integrity` and rotates the catalog's `catalog_etag`. Consumers whose vulnerability policy identifies the pin as unsafe MUST NOT execute it; they fail closed for that renderer and continue at the next available layer in the rendering fallback order. Pin rejection does not invalidate the canonical manifest or authorize an unverified replacement package.
+
+AgenticAdvertising.org's hosted reference agent at `https://creative.adcontextprotocol.org/mcp` provides image, native-in-feed, VAST, hosted-video, and hosted-audio approximation routes. Its `PreviewRender` records a hosted-implementation version and exact renderer export for audit; that metadata is not an npm package pin. `tracking_suppressed` is true only when the actual output contains no remote asset or navigation capable of producing a request. The VAST reference path does not dereference inline or remote VAST XML, resolve wrappers, execute VPAID/SIMID code, expand macros, or fire tracking resources. Declared hosted image, video, and audio slots are rewritten to expiring random-token proxy URLs, with strict per-preview and per-principal quotas; allocation failure returns `PREVIEW_CAPACITY_EXCEEDED` instead of a preview whose CSP would block the original URL. Each affected route advertises the proxy's 10 MB ceiling (`max_file_size_kb: 10000` for images or `max_file_size_mb: 10` for hosted audio/video), and compatibility requires `file_size_bytes`, so unverifiable or oversized media fails before a preview URL is issued. The proxy fetches without credentials through public-IP/DNS-pinned SSRF defenses, follows no redirects, accepts only an exact raster/video/audio MIME allowlist (never SVG/XML), caps per-principal and aggregate cache size, and downloads each token once to a streamed temporary-file cache. Completed inactive cache files are LRU-evictable without invalidating their live tokens, and transient cache saturation is reported as HTTP 503. Asset responses carry an inert sandboxed CSP. The preview CSP allows that exact proxy origin rather than arbitrary creative-controlled HTTPS origins. A publisher may designate these routes through `preview_provider`; without that publisher declaration, the hosted output remains an approximation.
+
+No executable package is pinned in the community registry until a hardened immutable release passes the package export, isolation, and behavior fixtures. Bootstrap packages that predate those fixtures MUST NOT be referenced by a registry entry.
+
+The community registry requires `catalog_etag` whenever a reference renderer is present. If a vulnerability requires pin rotation, maintainers update the package version, integrity, any affected export mapping, and `catalog_etag` in one reviewed registry change. Clients that do not execute isolated JavaScript call the hosted `preview_creative` route instead.
+
+#### Sample renders and declaration authority
Starting in 3.2, `sample_render_url` restores the human-preview path that publisher-owned canonical formats otherwise lose when there is no creative agent owning the format. It answers “what does this declared format look like?” using example assets selected by the declaring party. It does not render buyer assets.
@@ -388,7 +470,7 @@ A bare `{format_option_id}` in `Placement.format_options[]` resolves to the same
Sample URLs are untrusted external navigation. They SHOULD be public and unauthenticated. Consumers MUST NOT auto-fetch or iframe a sample solely because it appears in a declaration, and MUST NOT attach buyer assets, authorization credentials, source-origin or account context, or user-specific query parameters. Ordinary browser state belonging to the destination origin is outside AdCP's control. Human-facing clients SHOULD open the URL only after explicit user action in a new browsing context with opener and referrer information suppressed.
-Dynamic preview is a separate capability. Rendering a buyer's creative manifest requires an authorized renderer and canonical format context for `preview_creative`; `sample_render_url` is neither renderer discovery nor a substitute for that task.
+Dynamic preview is a separate capability. Rendering a buyer's creative manifest requires an authorized renderer and canonical format context for `preview_creative`; `sample_render_url` is neither renderer discovery nor a substitute for that task. Because it uses example assets rather than the buyer's manifest, it does not enter the rendering fallback order above.
### Publisher catalog resolution
@@ -1244,6 +1326,8 @@ Buyers ship assets per the format's `slots` declaration; `preview_creative` show
The buyer can iterate on shipped assets and inspect previews before committing to a buy. Different sellers may produce differently internally; the preview surface is uniform. This is what makes "production mechanism is invisible to the buyer" workable in practice — the buyer doesn't need to know HOW the output was produced because they can see WHAT was produced.
+Uniform output shape does not imply equal authority. Buyers discover preview routes and their implementation origin from `creative.preview`, then apply the [rendering authority and composition rules](#rendering-authority-and-fallback-order). A community `reference_renderer` is useful when no delegated preview exists, but its output remains non-authoritative.
+
## Brand identity via brand.json (with override)
v2 formats no longer redeclare `brand_logo`, `brand_colors`, `brand_voice`, `brand_tagline` as explicit slots. When a manifest carries a [`BrandRef`](https://adcontextprotocol.org/schemas/v3/core/brand-ref.json) like `brand: { domain: "acme.example" }` (or with `brand_id` for house-of-brands), the seller fetches `https://acme.example/.well-known/brand.json` for brand context.
diff --git a/docs/creative/formats.mdx b/docs/creative/formats.mdx
index 794188a206..f810b4f00c 100644
--- a/docs/creative/formats.mdx
+++ b/docs/creative/formats.mdx
@@ -66,6 +66,8 @@ A creative agent wraps a canonical declaration in an agent-local operation entry
}
```
+Because the capability entry includes `preview`, the enclosing capability response also lists `streamhaus_vertical_video_builder` in `creative.preview.routes[]` and declares its informational `rendering_origin`. Capability IDs route operations; they do not make the preview authoritative.
+
The buyer passes `capability_id` to that same agent as `build_creative.target_capability_id`. The returned manifest does not carry the capability ID because it must remain portable.
## Portable manifest
diff --git a/docs/creative/generative-creative.mdx b/docs/creative/generative-creative.mdx
index f781ebee78..448a2b5a8f 100644
--- a/docs/creative/generative-creative.mdx
+++ b/docs/creative/generative-creative.mdx
@@ -30,6 +30,8 @@ Call the creative agent's `get_adcp_capabilities` and inspect `creative.supporte
}
```
+Because this entry includes `preview`, the enclosing capability response also lists `native_launch_generator` in `creative.preview.routes[]` and declares its informational `rendering_origin`. Generative output from a standalone creative agent normally uses `agent_approximation`; this self-description never grants authority.
+
`list_creative_formats` is deprecated in 3.2. If you do not already know which creative agent to call, query the registry by canonical kind or exact publisher format option.
## Generate from a brief
diff --git a/docs/creative/implementing-creative-agents.mdx b/docs/creative/implementing-creative-agents.mdx
index 37373f077a..9a451281f9 100644
--- a/docs/creative/implementing-creative-agents.mdx
+++ b/docs/creative/implementing-creative-agents.mdx
@@ -238,6 +238,8 @@ Every supported-format entry uses a stable agent-local `capability_id` for task
Keep capability IDs stable and unique within your agent's `supported_formats[]` catalog. They are scoped to your agent and passed as `target_capability_id`; they are not format identities and never appear on products or manifests.
+When any routable entry explicitly includes `preview` in `operations`, the enclosing capability response MUST also publish `creative.preview`. Its `routes[].capability_id` set equals the preview-operation capability IDs, and each route reports `rendering_origin` as `platform_native` or `agent_approximation`. This is implementation metadata, not authority; only a matching publisher placement `preview_provider` delegation grants authority.
+
### 2. Format validation
Your capability declaration is authoritative for what your creative agent can do. Publisher acceptance and sales-agent deliverability remain authoritative on their own catalog/product surfaces.
@@ -482,7 +484,13 @@ Read the AdCP payload from the MCP result and verify that it contains a creative
}
}
}
- ]
+ ],
+ "preview": {
+ "routes": [{
+ "capability_id": "display_banner",
+ "rendering_origin": "agent_approximation"
+ }]
+ }
}
}
```
diff --git a/docs/creative/sales-agent-creative-capabilities.mdx b/docs/creative/sales-agent-creative-capabilities.mdx
index 5b0e9023c2..8db1c5d7f2 100644
--- a/docs/creative/sales-agent-creative-capabilities.mdx
+++ b/docs/creative/sales-agent-creative-capabilities.mdx
@@ -44,7 +44,13 @@ If the same endpoint can build, validate, or preview creatives, include `creativ
"params": { "width": 300, "height": 250 }
}
}
- ]
+ ],
+ "preview": {
+ "routes": [{
+ "capability_id": "homepage_image_builder",
+ "rendering_origin": "agent_approximation"
+ }]
+ }
}
}
```
diff --git a/docs/creative/specification.mdx b/docs/creative/specification.mdx
index bc76700bf2..84e26525a4 100644
--- a/docs/creative/specification.mdx
+++ b/docs/creative/specification.mdx
@@ -64,7 +64,13 @@ Creative agents MUST declare Creative Protocol support via `get_adcp_capabilitie
"params": { "width": 300, "height": 250 }
}
}
- ]
+ ],
+ "preview": {
+ "routes": [{
+ "capability_id": "display_image_transform",
+ "rendering_origin": "agent_approximation"
+ }]
+ }
}
}
```
diff --git a/docs/creative/task-reference/list_creative_formats.mdx b/docs/creative/task-reference/list_creative_formats.mdx
index 8d28498a59..6481aad7e4 100644
--- a/docs/creative/task-reference/list_creative_formats.mdx
+++ b/docs/creative/task-reference/list_creative_formats.mdx
@@ -38,7 +38,13 @@ Creative agents advertise stable build routing separately from publisher/product
}
}
}
- ]
+ ],
+ "preview": {
+ "routes": [{
+ "capability_id": "streamhaus_vertical_video",
+ "rendering_origin": "agent_approximation"
+ }]
+ }
}
}
```
diff --git a/docs/creative/task-reference/preview_creative.mdx b/docs/creative/task-reference/preview_creative.mdx
index 43704c797f..a1570e29f5 100644
--- a/docs/creative/task-reference/preview_creative.mdx
+++ b/docs/creative/task-reference/preview_creative.mdx
@@ -89,7 +89,7 @@ Response contains raw HTML:
```
-Only use `output_format: "html"` with trusted creative agents. Direct HTML embedding bypasses iframe sandboxing.
+Treat `preview_html` and `preview_url` as untrusted even when the provider is publisher-designated. Never inject returned HTML into the host DOM. Render HTML via iframe `srcdoc` and URLs in a cross-origin iframe, in both cases with an empty sandbox token set and caller-enforced restrictive CSP. Provider-supplied embedding recommendations are advisory and MUST NOT loosen that policy.
### Batch Preview (Multiple Creatives)
@@ -193,7 +193,9 @@ All modes use a single flat object with `request_type` as the discriminant.
**Required** column values: *Conditional* = single requests and batch items require one of `creative_manifest` or `creative_id`; *Batch* = required when `request_type` is `"batch"`; *Variant* = required when `"variant"`.
-Discover renderers from `get_adcp_capabilities.creative.supported_formats[]` by selecting entries whose `operations` contains `preview` and whose canonical `format` satisfies the manifest. `capability_id` is an agent-local renderer route; it never belongs in the portable manifest. If multiple renderers match, the caller must select one explicitly.
+Discover renderers from `get_adcp_capabilities.creative.supported_formats[]` by selecting entries whose `operations` contains `preview` and whose canonical `format` satisfies the manifest. New 3.2 producers MUST list routable entries in `creative.preview.routes[]`, whose capability-ID set equals the preview-operation capability IDs. Each route declares informational `rendering_origin` as `platform_native` or `agent_approximation`. `capability_id` is an agent-local renderer route; it never belongs in the portable manifest. If multiple renderers match, the caller must select one explicitly.
+
+An agent cannot grant itself authority. Even `platform_native` remains informational unless a matching publisher-origin placement `preview_provider` delegates that endpoint and capability route. Buyers preserve the origin label and follow the [rendering authority and composition rules](/docs/creative/canonical-formats#rendering-authority-and-fallback-order).
### Opt-in asynchronous preview
@@ -360,6 +362,10 @@ These formats have the widest gap between pre-flight and post-flight: a pre-flig
The preview quality tier describes render fidelity, not merely the renderer's relative cost or speed. It is not a validation result, compliance or brand-safety clearance, seller acceptance, or authorization to serve. Buyers use the applicable validation, governance, and seller creative-review workflows for those decisions.
+Quality and authority are independent. `quality_used: "production"` means full fidelity within the selected renderer; it does not turn an undelegated route into publisher-authoritative presentation. Buyer tooling MUST retain quality, rendering origin, and delegation provenance when it records or presents a preview.
+
+Each rendered piece may include `renderer` audit metadata: a stable `renderer_id`, exact semantic `version`, export name, informational `rendering_origin`, and whether the actual output suppresses every tracking, navigation, and remote-asset request. This metadata makes a render reproducible but does not grant authority. Buyers resolve authority only from a matching publisher-origin placement `preview_provider` delegation.
+
- **`draft`** is an iteration render for reviewing creative direction. It MUST preserve the supplied manifest or brief's core concept, content, and non-fidelity constraints, but it MAY use lower-resolution or placeholder assets; approximate layout, typography, color, motion, audio, or interactive behavior; and omit final polish. The protocol does not guarantee that any of those listed fidelity dimensions is final in a draft preview.
- **`production`** is a fidelity-accurate review render. For standard or otherwise deterministic creative, it MUST faithfully render the supplied manifest—or the stored manifest resolved from `creative_id`—and its assets using the serve-time presentation controlled by the selected renderer. In `preview_creative`, material asset substitution or approximation requires `quality_used: "draft"`. It MUST include disclosure and compliance elements required by the manifest and those owned by that rendering layer for the supplied context.
@@ -463,7 +469,7 @@ Preview multiple creatives for a grid layout:
## Key Points
-- Every render's `preview_url` returns an HTML page for iframe embedding
+- Every render's `preview_url` returns an untrusted HTML page for cross-origin iframe embedding with an empty sandbox token set
- Use `output_format: "html"` for grids of 10+ previews (no iframe overhead)
- Batch mode is 5-10x faster than individual requests
- Preview URLs expire only when `expires_at` is present; omitted `expires_at` means no protocol-level expiration
diff --git a/docs/governance/property/adagents.mdx b/docs/governance/property/adagents.mdx
index e67ba08a76..34a60c01ee 100644
--- a/docs/governance/property/adagents.mdx
+++ b/docs/governance/property/adagents.mdx
@@ -162,7 +162,7 @@ The file must be valid JSON with UTF-8 encoding and return HTTP 200 status.
**`placements`** *(optional)*: Canonical placement definitions for the properties in this file
- Products SHOULD reuse these `placement_id` values when declaring `placements`
- Reusing a registered `placement_id` means the product is referring to the same semantic placement, not inventing a different one with the same ID
- - Placement definitions can include `tags`, `property_ids` or `property_tags` for property linkage, `channels`, and `format_options` for creative support
+ - Placement definitions can include `tags`, `property_ids` or `property_tags` for property linkage, `channels`, `format_options` for creative support, a publisher-namespaced `presentation_ref` for offline placement chrome, and `preview_provider` for a publisher-designated AdCP preview agent
- Placements in `adagents.json` are public by definition. Do not publish seller-private placement IDs, source/origin fields, or delivery-system mappings in this file.
- Authorization entries can narrow scope to specific `placement_ids`
- Authorization entries can also use `placement_tags` for governed placement groupings such as `programmatic`, `direct_only`, or `managed_by_riverline`
@@ -187,6 +187,8 @@ At minimum, a public placement should describe:
- the `property_ids` or `property_tags` where it can run
- supported `format_options`, which can reference publisher-owned formats and canonical formats
- optional `channels`
+- optional `presentation_ref` with an HTTPS `uri` and SHA-256 `digest` for publisher-specific chrome or layout metadata
+- optional `preview_provider` mapping placement `format_option_id` values to preview `capability_id` routes on an HTTPS AdCP creative agent
Placement format support is a new 3.1 catalog feature and uses the 3.1+ canonical format-option model only:
@@ -219,6 +221,20 @@ Placement catalog formats describe what the public placement can support. When a
"description": "High-impact homepage sponsorship across the main article rail and top video module.",
"property_ids": ["daily_pulse"],
"channels": ["display", "olv"],
+ "presentation_ref": {
+ "uri": "https://daily-pulse.example/adcp/presentations/homepage-takeover.json",
+ "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ },
+ "preview_provider": {
+ "agent_url": "https://creative.adcontextprotocol.org/mcp",
+ "authority": "publisher_designated",
+ "routes": [
+ {
+ "format_option_id": "publisher_takeover_html5",
+ "capability_id": "preview_display_300x250_html"
+ }
+ ]
+ },
"format_options": [
{ "format_option_id": "publisher_takeover_html5" },
{
@@ -295,6 +311,9 @@ A community mirror:
- Sets **`authorized_agents: []`** — there is no sales agent to authorize, and the mirror MUST NOT fabricate one. An empty array asserts *no sales authorization*; validators MUST NOT read it as deny-all, authorize-all, or a revocation, and MUST still consume the catalog arrays.
- MUST carry at least one non-empty catalog array (`formats`/`properties`/`placements`/`collections`/`signals`) and SHOULD carry a **`catalog_etag`** cache validator (the validator enforces the array, not `catalog_etag`). A file with neither sales authorization nor catalog content is invalid.
+- MAY attach a pinned `reference_renderer` (`runtime: "browser-esm"`, `package`, exact `version`, named `export`, SRI `integrity`, and expected provenance source) to a `formats[]` entry. Such a file requires `catalog_role: "community_format_registry"` and `catalog_etag`; consumers still accept executable references only from the configured AgenticAdvertising.org registry origin. Compatibility binds the named export's tested contract to this format entry's revision; package major versions are independent because one artifact may expose exports for several formats or revisions. Consumers verify tarball integrity plus the attestation source repository, workflow path, and subject digest, then execute the module only in a credential-free, network-disabled worker or opaque-origin realm with time and size limits—never in the host application realm. Non-JavaScript clients use hosted `preview_creative` or show the manifest. On a confirmed vulnerability, registry maintainers MUST rotate `version`, `integrity`, and `catalog_etag` through a reviewed update; consumers that identify the pin as unsafe MUST NOT execute it and instead continue the rendering fallback order.
+- MUST NOT invent publisher `presentation_ref` metadata. That field is publisher-specific and gains its meaning only from a publisher-origin placement declaration.
+- MUST NOT invent or copy a publisher `preview_provider` delegation. Only the publisher-origin placement can grant that scoped authority.
- Sets **`superseded_by`** once the platform publishes its own authoritative `adagents.json`. Buyer SDKs encountering `superseded_by` SHOULD re-fetch from the named URL rather than serving the stale mirror. The mirror SHOULD continue serving with `superseded_by` set for at least one minor release so buyer caches keyed on the mirror URL get an explicit migration signal.
Community contributions use a review-before-publication workflow. Any authenticated organization can submit a conforming catalog with `PUT /api/registry/mirrors/{platform}`. Registry moderators and AgenticAdvertising.org administrators publish immediately; other callers receive `202 Accepted` with an opaque proposal ID, status URL, and content digest. Contributors can follow their proposals through `/api/registry/mirror-proposals`, while moderators approve or reject the exact reviewed digest from the same review surface. Approval returns `409 Conflict` if either the proposal content or its base public mirror changed after review. Pending proposals are stored separately and never appear at `/translated//adagents.json` or in derived publisher records before approval.
@@ -305,7 +324,7 @@ See the worked example at [`static/examples/adagents/community/meta.json`](https
Consumers that need the complete catalog resolution order—publisher-hosted `adagents.json`, then the AgenticAdvertising.org community catalog, then fail closed—should call the registry lookup at `GET /api/registry/publisher?domain=`. The response exposes `discovery_method`, hosting state, registry URL, and property provenance so callers can distinguish publisher assertions from community contributions.
-The lookup's `formats[]` field is a display-oriented, lossy summary. It preserves `format_kind`, `format_option_id`, canonical `params`, and property scoping, but omits fields needed for some validation and compatibility flows, including `format_shape`, `format_schema`, `applies_to_channels`, `v1_format_ref`, and `canonical_formats_only`. Add `include=placements` when computing placement-level eligibility; the response then includes provenance-labeled placement summaries with resolved canonical format options. To validate a custom declaration or inspect fields omitted by either summary, fetch the resolved raw `adagents.json`: use `files.adagents_json.registry_url` for a community catalog and `hosting.resolved_url` or `hosting.expected_url` for a publisher-hosted catalog. Treat a missing raw document or unresolved placement reference as fail-closed.
+The lookup's `formats[]` field is a display-oriented, lossy summary. It preserves `format_kind`, `format_option_id`, canonical `params`, and property scoping, but omits fields needed for some validation, presentation, and compatibility flows, including `format_shape`, `format_schema`, `reference_renderer`, `applies_to_channels`, `v1_format_ref`, and `canonical_formats_only`. Add `include=placements` when computing placement-level eligibility; the response then includes provenance-labeled placement summaries with resolved canonical format options, but consumers needing a placement's full `presentation_ref` or `preview_provider` fetch the raw document. To validate a custom declaration or inspect fields omitted by either summary, fetch the resolved raw `adagents.json`: use `files.adagents_json.registry_url` for a community catalog and `hosting.resolved_url` or `hosting.expected_url` for a publisher-hosted catalog. Treat a missing raw document or unresolved placement reference as fail-closed.
The discovery-layer `validateAdAgents()` API and the `validate_adagents` diagnostic tool validate publisher-origin discovery (`direct`, `authoritative_location`, and `ads.txt` `MANAGERDOMAIN`). They do **not** consult the community catalog. A publisher-origin not-found result is therefore not proof that no community mirror exists. Until the discovery layer explicitly adds that fallback, implementations must not reconstruct the full resolution order on top of `validateAdAgents()`; use the registry lookup.
diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx
index 600a501918..9f30ac0530 100644
--- a/docs/protocol/get_adcp_capabilities.mdx
+++ b/docs/protocol/get_adcp_capabilities.mdx
@@ -922,6 +922,7 @@ Creative protocol capabilities. Only present if `creative` is in `supported_prot
|-------|------|-------------|
| `supports_compliance` | boolean | When `true`, this creative agent can process briefs with compliance requirements and validate them against its canonical supported-format declarations. |
| `supported_formats` | object[] | Canonical creative operation catalog. New 3.2 producers MUST emit a stable `capability_id`, a full canonical `format` declaration, and explicit `operations` (`build`, `validate`, `preview`). Exact publisher support carries `{publisher_domain, format_option_id}` inside `format`; generic capabilities declare a satisfiable canonical parameter envelope. For 3.x compatibility, consumers accept entries without `capability_id` and interpret absent `operations` as `build`; such entries can be matched by contract but not selected through a capability-ID route. Replaces [`list_creative_formats`](/docs/creative/task-reference/list_creative_formats) in 3.2. |
+| `preview` | object | Per-route [`preview_creative`](/docs/creative/task-reference/preview_creative) declaration. `routes[].capability_id` names `supported_formats[]` entries whose `operations` includes `preview`; `rendering_origin` reports `platform_native` or `agent_approximation` per route. This self-description is informational and never grants authority; only a matching publisher placement `preview_provider` delegation does. See [rendering authority](/docs/creative/canonical-formats#rendering-authority-and-fallback-order). |
| `supports_transformers` | boolean | When `true`, this creative agent offers account-scoped transformers — the selectable units of build capability (voices, models, styles) discovered via [`list_transformers`](/docs/creative/task-reference/list_transformers) and selected with `transformer_id` (plus the typed `config` bag) on [`build_creative`](/docs/creative/task-reference/build_creative). When `false` or absent, the agent does not expose transformers; `list_transformers` is unavailable and `build_creative` ignores `transformer_id`/`config`. Pre-call discriminator for routing across creative agents. |
| `supports_refinement` | boolean | When `true`, this creative agent retains produced `build_variant` leaves (for an agent-defined window) and can re-build from one via `refine_from_build_variant_id` on [`build_creative`](/docs/creative/task-reference/build_creative) — applying a natural-language instruction in `message` plus an optional `config` delta, returning new lineage-linked variants. A build-time capability independent of generation/transformation. When `false` or absent, `refine_from_build_variant_id` returns [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature); refine via the transform path (`creative_manifest` + `message`) instead. |
| `refinable_retention_seconds` | integer | When `supports_refinement` is `true`, the **guaranteed-minimum** window (a floor, not a ceiling) during which a produced `build_variant_id` stays refinable via `refine_from_build_variant_id`. A ref within the window SHOULD resolve; the agent MAY retain longer. Omit to leave the window agent-defined (buyers treat refinability as best-effort and handle [`REFERENCE_NOT_FOUND`](/docs/building/verification/compliance-catalog#error-code-reference-not-found)). |
@@ -1780,7 +1781,13 @@ An agent can implement multiple protocols from a single endpoint. This is common
"params": { "width": 300, "height": 250 }
}
}
- ]
+ ],
+ "preview": {
+ "routes": [{
+ "capability_id": "display_image_generator",
+ "rendering_origin": "platform_native"
+ }]
+ }
}
}
```
diff --git a/package.json b/package.json
index 59c0ba857d..75437a9f13 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"deploy:cdn-artifacts-cutover:dry-run": "wrangler deploy --config workers/artifact-cdn/wrangler.cutover.toml --dry-run",
"verify:cdn-artifacts-cutover": "node scripts/verify-cdn-artifacts-cutover.mjs",
"typecheck": "tsc --project server/tsconfig.json --noEmit",
- "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/schema-deprecation-metadata.test.cjs tests/creative-rotation.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs && npm run test:geo-region-targeting",
+ "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/schema-deprecation-metadata.test.cjs tests/creative-rotation.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs && npm run test:geo-region-targeting",
"test:performance-feedback": "node --test --test-force-exit --test-timeout=30000 tests/performance-feedback-contract.test.cjs",
"test:dist-schema-version-ids": "node --test --test-force-exit --test-timeout=30000 tests/dist-schema-version-ids.test.cjs",
"test:examples": "node tests/example-validation-simple.test.cjs && npm run test:tmp-context-merge",
diff --git a/server/src/adagents-manager.ts b/server/src/adagents-manager.ts
index 85330851cb..916efbb201 100644
--- a/server/src/adagents-manager.ts
+++ b/server/src/adagents-manager.ts
@@ -221,6 +221,7 @@ export interface AdAgentsJsonInline {
tag_id?: string;
};
catalog_etag?: string;
+ catalog_role?: 'community_format_registry';
last_updated?: string;
}
@@ -261,6 +262,7 @@ export interface CreateAdAgentsJsonOptions {
placementTags?: Record;
formats?: FormatDefinition[];
catalogEtag?: string;
+ catalogRole?: 'community_format_registry';
signals?: SignalDefinition[];
signalTags?: Record;
}
@@ -718,6 +720,38 @@ export class AdAgentsManager {
});
}
+ const rendererFormats = Array.isArray(data.formats)
+ ? data.formats
+ .map((format: any, index: number) => ({ format, index }))
+ .filter(({ format }: { format: any }) => format?.reference_renderer !== undefined)
+ : [];
+ if (rendererFormats.length > 0) {
+ if (typeof data.catalog_etag !== 'string' || data.catalog_etag.length === 0) {
+ result.errors.push({
+ field: 'catalog_etag',
+ message: 'catalog_etag is required when a format declares reference_renderer',
+ severity: 'error'
+ });
+ }
+ if (data.catalog_role !== 'community_format_registry') {
+ result.errors.push({
+ field: 'catalog_role',
+ message: 'catalog_role must be community_format_registry when a format declares reference_renderer',
+ severity: 'error'
+ });
+ }
+ rendererFormats.forEach(({ format, index }: { format: any; index: number }) => {
+ if (typeof format.format_revision !== 'string'
+ || format.reference_renderer?.format_revision !== format.format_revision) {
+ result.errors.push({
+ field: `formats[${index}].reference_renderer.format_revision`,
+ message: 'reference_renderer.format_revision must equal the enclosing format_revision',
+ severity: 'error'
+ });
+ }
+ });
+ }
+
// Validate signals array if present (for data providers)
if (data.signals !== undefined) {
if (!Array.isArray(data.signals)) {
@@ -1555,6 +1589,7 @@ export class AdAgentsManager {
});
}
+ const placementFormatOptionIds = new Set();
if (placement.format_options && Array.isArray(placement.format_options)) {
placement.format_options.forEach((formatOption: any, formatIndex: number) => {
if (formatOption?.capability_id !== undefined) {
@@ -1577,6 +1612,32 @@ export class AdAgentsManager {
severity: 'error'
});
}
+ if (typeof formatOption?.format_option_id === 'string') {
+ placementFormatOptionIds.add(formatOption.format_option_id);
+ }
+ });
+
+ }
+
+ const previewProvider = placement.preview_provider;
+ if (previewProvider?.routes && Array.isArray(previewProvider.routes)) {
+ const delegatedFormatIds = new Set();
+ previewProvider.routes.forEach((route: any, routeIndex: number) => {
+ if (typeof route?.format_option_id !== 'string' || !placementFormatOptionIds.has(route.format_option_id)) {
+ result.errors.push({
+ field: `placements[${index}].preview_provider.routes[${routeIndex}].format_option_id`,
+ message: `Preview provider route references format_option_id "${String(route?.format_option_id ?? '')}" that is not listed on this placement`,
+ severity: 'error'
+ });
+ } else if (delegatedFormatIds.has(route.format_option_id)) {
+ result.errors.push({
+ field: `placements[${index}].preview_provider.routes[${routeIndex}].format_option_id`,
+ message: `Duplicate preview provider route for format_option_id "${route.format_option_id}"`,
+ severity: 'error'
+ });
+ } else {
+ delegatedFormatIds.add(route.format_option_id);
+ }
});
}
});
@@ -1884,6 +1945,9 @@ export class AdAgentsManager {
if (opts.catalogEtag) {
adagents.catalog_etag = opts.catalogEtag;
}
+ if (opts.catalogRole) {
+ adagents.catalog_role = opts.catalogRole;
+ }
if (opts.formats && opts.formats.length > 0) {
adagents.formats = opts.formats;
@@ -1929,6 +1993,7 @@ export class AdAgentsManager {
authorized_agents: opts.agents,
...(opts.properties && opts.properties.length > 0 ? { properties: opts.properties } : {}),
...(opts.catalogEtag ? { catalog_etag: opts.catalogEtag } : {}),
+ ...(opts.catalogRole ? { catalog_role: opts.catalogRole } : {}),
...(opts.formats && opts.formats.length > 0 ? { formats: opts.formats } : {}),
...(opts.placements && opts.placements.length > 0 ? { placements: opts.placements } : {}),
...(opts.placementTags && Object.keys(opts.placementTags).length > 0 ? { placement_tags: opts.placementTags } : {}),
diff --git a/server/src/capabilities.ts b/server/src/capabilities.ts
index a2d3c89df9..8f4a3ecc51 100644
--- a/server/src/capabilities.ts
+++ b/server/src/capabilities.ts
@@ -42,6 +42,12 @@ export interface CreativeCapabilities {
operations: Array<'build' | 'validate' | 'preview'>;
[key: string]: unknown;
}>;
+ preview?: {
+ routes: Array<{
+ capability_id: string;
+ rendering_origin: 'platform_native' | 'agent_approximation';
+ }>;
+ };
can_generate: boolean;
can_validate: boolean;
can_preview: boolean;
@@ -208,12 +214,55 @@ export async function sanitizeCreativeCapabilities(raw: unknown): Promise entry.operations.includes('build'));
+ const previewCapabilityIds = supportedFormats
+ .filter(entry => entry.operations.includes('preview'))
+ .map(entry => entry.capability_id);
+ const routablePreviewCapabilityIds = previewCapabilityIds.filter((id): id is string => id !== undefined);
+ const rawPreview = block.preview;
+ let preview: CreativeCapabilities['preview'];
+ if (routablePreviewCapabilityIds.length > 0 || rawPreview !== undefined) {
+ if (!rawPreview || typeof rawPreview !== 'object' || Array.isArray(rawPreview)) {
+ throw new Error('creative.preview: required object when supported_formats advertises preview');
+ }
+ const previewBlock = rawPreview as Record;
+ const routes = previewBlock.routes;
+ if (!Array.isArray(routes) || routes.length === 0
+ || routes.some(route => !route || typeof route !== 'object' || Array.isArray(route))) {
+ throw new Error('creative.preview.routes: non-empty route array required');
+ }
+ const sanitizedRoutes = routes.map((rawRoute, index) => {
+ const route = rawRoute as Record;
+ if (typeof route.capability_id !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(route.capability_id)) {
+ throw new Error(`creative.preview.routes[${index}].capability_id: stable identifier required`);
+ }
+ if (!['platform_native', 'agent_approximation'].includes(String(route.rendering_origin))) {
+ throw new Error(`creative.preview.routes[${index}].rendering_origin: expected platform_native or agent_approximation`);
+ }
+ return {
+ capability_id: route.capability_id,
+ rendering_origin: route.rendering_origin as 'platform_native' | 'agent_approximation',
+ };
+ });
+ const declaredIds = sanitizedRoutes.map(route => route.capability_id).sort();
+ if (new Set(declaredIds).size !== declaredIds.length) {
+ throw new Error('creative.preview.routes: capability_id values must be unique');
+ }
+ const advertisedIds = [...routablePreviewCapabilityIds].sort();
+ if (advertisedIds.length !== declaredIds.length
+ || advertisedIds.some((id, index) => id !== declaredIds[index])) {
+ throw new Error('creative.preview.routes: must equal advertised routable preview capability IDs');
+ }
+ preview = {
+ routes: sanitizedRoutes,
+ };
+ }
const declaresBuild = ['supports_generation', 'supports_transformation', 'supports_transformers', 'supports_refinement']
.some(flag => block[flag] === true);
const sanitized: CreativeCapabilities = {
...block,
supported_formats: supportedFormats,
+ ...(preview === undefined ? {} : { preview }),
can_generate: hasBuildCapability || declaresBuild,
can_validate: supportedFormats.some(entry => entry.operations.includes('validate')),
can_preview: supportedFormats.some(entry => entry.operations.includes('preview')),
diff --git a/server/src/creative-agent/index.ts b/server/src/creative-agent/index.ts
index 2861838a06..2f1c2fd1df 100644
--- a/server/src/creative-agent/index.ts
+++ b/server/src/creative-agent/index.ts
@@ -7,19 +7,89 @@
import { Router } from 'express';
import type { Request, Response, NextFunction } from 'express';
-import rateLimit from 'express-rate-limit';
-import { createHash } from 'node:crypto';
+import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
+import { createHash, randomUUID } from 'node:crypto';
+import { createReadStream, createWriteStream } from 'node:fs';
+import { unlink } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { Readable, Transform } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createLogger } from '../logger.js';
import { constantTimeEqual } from '../utils/constant-time-equal.js';
import { createCreativeAgentServer } from './task-handlers.js';
-import { getPreview, cleanExpiredPreviews } from './preview-store.js';
+import {
+ getPreview,
+ getPreviewAssetSource,
+ getOrCreatePreviewAssetDownload,
+ releasePreviewAssetDownload,
+ cleanExpiredPreviews,
+ MAX_PREVIEW_ASSET_BYTES,
+} from './preview-store.js';
import { CommunityMirrorDatabase } from '../db/community-mirror-db.js';
+import { safeFetch } from '../utils/url-security.js';
const logger = createLogger('creative-agent-routes');
const CREATIVE_AGENT_TOKEN = process.env.CREATIVE_AGENT_TOKEN;
const STARTUP_TIME = new Date().toISOString();
+const ALLOWED_PREVIEW_ASSET_TYPES = new Set([
+ 'image/avif', 'image/gif', 'image/jpeg', 'image/png', 'image/webp',
+ 'video/mp4', 'video/quicktime', 'video/webm',
+ 'audio/aac', 'audio/flac', 'audio/mp4', 'audio/mpeg', 'audio/ogg',
+ 'audio/wav', 'audio/webm', 'audio/x-wav',
+]);
+
+class PreviewAssetTooLargeError extends Error {}
+
+async function downloadPreviewAsset(sourceUrl: string) {
+ const upstream = await safeFetch(sourceUrl, {
+ method: 'GET',
+ maxRedirects: 0,
+ headers: { Accept: 'image/avif,image/gif,image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm,audio/*' },
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!upstream.ok || !upstream.body) throw new Error('Preview asset fetch failed');
+
+ const contentType = upstream.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase() ?? '';
+ if (!ALLOWED_PREVIEW_ASSET_TYPES.has(contentType)) {
+ await upstream.body.cancel();
+ throw new TypeError('Unsupported preview asset type');
+ }
+ const declaredLength = Number(upstream.headers.get('content-length') ?? 0);
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_PREVIEW_ASSET_BYTES) {
+ await upstream.body.cancel();
+ throw new PreviewAssetTooLargeError('Preview asset exceeds size limit');
+ }
+
+ // Keep request-controlled asset tokens out of filesystem paths. The store
+ // associates this independently generated filename with the token after the
+ // download completes.
+ const path = join(tmpdir(), `adcp-preview-asset-${randomUUID()}`);
+ let size = 0;
+ const limiter = new Transform({
+ transform(chunk: Buffer, _encoding, callback) {
+ size += chunk.length;
+ callback(size > MAX_PREVIEW_ASSET_BYTES
+ ? new PreviewAssetTooLargeError('Preview asset exceeds size limit')
+ : null, chunk);
+ },
+ });
+ try {
+ // Both the network and file streams honor backpressure. The completed file
+ // is then shared by all requests for this token rather than re-fetched.
+ await pipeline(
+ Readable.from(upstream.body as AsyncIterable),
+ limiter,
+ createWriteStream(path, { flags: 'wx', mode: 0o600 }),
+ );
+ return { path, contentType, size };
+ } catch (error) {
+ await unlink(path).catch(() => {});
+ throw error;
+ }
+}
function setCORSHeaders(res: Response): void {
res.setHeader('Access-Control-Allow-Origin', '*');
@@ -44,6 +114,15 @@ function requireToken(req: Request, res: Response, next: NextFunction): void {
next();
}
+export function previewQuotaKey(req: Request): string {
+ // The reference endpoint has a single optional bearer secret rather than
+ // user accounts. Bind that authenticated credential to the caller's network
+ // identity; anonymous callers are isolated by the same network quota key.
+ const credential = req.headers.authorization ?? 'anonymous';
+ const network = ipKeyGenerator(req.ip ?? req.socket?.remoteAddress ?? 'unknown');
+ return createHash('sha256').update(`${credential}\0${network}`).digest('hex');
+}
+
const CREATIVE_AGENT_HOST = 'creative.adcontextprotocol.org';
/**
@@ -161,6 +240,13 @@ export function createCreativeAgentRouter(): Router {
});
},
});
+ const previewAssetRateLimiter = rateLimit({
+ windowMs: 60 * 1000,
+ max: 30,
+ standardHeaders: true,
+ legacyHeaders: false,
+ validate: { xForwardedForHeader: false, ip: false },
+ });
// MCP endpoint
router.post('/mcp', mcpRateLimiter, requireToken, async (req: Request, res: Response) => {
@@ -169,7 +255,7 @@ export function createCreativeAgentRouter(): Router {
let server: ReturnType | null = null;
try {
const agentBaseUrl = getAgentBaseUrl(req);
- server = createCreativeAgentServer(agentBaseUrl);
+ server = createCreativeAgentServer(agentBaseUrl, previewQuotaKey(req));
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
@@ -215,6 +301,47 @@ export function createCreativeAgentRouter(): Router {
});
// Preview hosting endpoint
+ router.get('/preview-assets/:id', previewAssetRateLimiter, async (req: Request, res: Response) => {
+ res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; sandbox");
+ const tokenExists = getPreviewAssetSource(req.params.id) !== null;
+ const assetDownload = getOrCreatePreviewAssetDownload(
+ req.params.id,
+ MAX_PREVIEW_ASSET_BYTES,
+ sourceUrl => downloadPreviewAsset(sourceUrl),
+ );
+ if (!assetDownload) {
+ res.status(tokenExists ? 503 : 404).send(tokenExists
+ ? 'Preview asset capacity is temporarily unavailable'
+ : 'Preview asset not found or expired');
+ return;
+ }
+ try {
+ const asset = await assetDownload;
+ res.setHeader('Content-Type', asset.contentType);
+ res.setHeader('Content-Length', asset.size);
+ res.setHeader('Cache-Control', 'private, max-age=300');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.status(200);
+ await pipeline(createReadStream(asset.path), res);
+ } catch (error) {
+ logger.warn({ error }, 'Creative agent: preview asset proxy failed');
+ if (!res.headersSent) {
+ res.status(error instanceof PreviewAssetTooLargeError ? 413 : error instanceof TypeError ? 415 : 502)
+ .send(error instanceof PreviewAssetTooLargeError
+ ? 'Preview asset exceeds size limit'
+ : error instanceof TypeError
+ ? 'Unsupported preview asset type'
+ : 'Preview asset fetch failed');
+ } else {
+ res.destroy();
+ }
+ } finally {
+ releasePreviewAssetDownload(req.params.id);
+ }
+ });
+
router.get('/preview/:id', (req: Request, res: Response) => {
const html = getPreview(req.params.id);
if (!html) {
@@ -223,7 +350,7 @@ export function createCreativeAgentRouter(): Router {
}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('X-Frame-Options', 'ALLOWALL');
- res.setHeader('Content-Security-Policy', "default-src 'none'; img-src https: data:; style-src 'unsafe-inline'; frame-ancestors *");
+ res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors *");
res.send(html);
});
diff --git a/server/src/creative-agent/preview-renderer.ts b/server/src/creative-agent/preview-renderer.ts
index 3df119d22c..4b0ed852e4 100644
--- a/server/src/creative-agent/preview-renderer.ts
+++ b/server/src/creative-agent/preview-renderer.ts
@@ -10,27 +10,50 @@
*/
/** An individual asset value from a creative manifest. */
-interface AssetValue {
+export interface AssetValue {
url?: string;
+ proxy_url?: string;
content?: string;
[key: string]: unknown;
}
/** Assets map: asset_id -> asset value. */
-type AssetsMap = Record;
+export type AssetsMap = Record;
-interface ManifestInput {
+export interface ManifestInput {
format_id?: { agent_url?: string; id?: string; width?: number; height?: number; pixel_ratio?: number };
name?: string;
assets?: AssetsMap;
[key: string]: unknown;
}
-interface RenderDimensions {
+export interface PreviewRendererMetadata {
+ renderer_id: string;
+ version: string;
+ export: string;
+ rendering_origin: 'agent_approximation';
+ tracking_suppressed: boolean;
+}
+
+export const REFERENCE_RENDERER_VERSION = '1.0.0-server.1';
+
+export const REFERENCE_RENDERERS: Record> = {
+ image: { renderer_id: 'adcp-reference-image', version: REFERENCE_RENDERER_VERSION, export: 'renderImage' },
+ native_in_feed: { renderer_id: 'adcp-reference-native-in-feed', version: REFERENCE_RENDERER_VERSION, export: 'renderNativeInFeed' },
+ video_vast: { renderer_id: 'adcp-reference-vast', version: REFERENCE_RENDERER_VERSION, export: 'renderVast' },
+};
+
+export interface RenderDimensions {
width: number;
height: number;
}
+export interface PreviewRenderResult {
+ html: string;
+ dimensions: RenderDimensions;
+ renderer: PreviewRendererMetadata;
+}
+
/**
* Get a display value from an asset. URL-based assets use `url`,
* text-based assets use `content`, per the AdCP schema.
@@ -38,7 +61,15 @@ interface RenderDimensions {
function getAssetValue(assets: AssetsMap, assetId: string): string {
const asset = assets[assetId];
if (!asset) return '';
- return asset.url || asset.content || '';
+ return asset.proxy_url || asset.url || asset.content || '';
+}
+
+function firstAssetValue(assets: AssetsMap, ...assetIds: string[]): string {
+ for (const assetId of assetIds) {
+ const value = getAssetValue(assets, assetId);
+ if (value) return value;
+ }
+ return '';
}
function escapeHtml(str: string): string {
@@ -50,21 +81,43 @@ function escapeHtml(str: string): string {
.replace(/'/g, ''');
}
-function wrapInPage(body: string, dims: RenderDimensions, title: string, responsive = false): string {
+function previewAssetOrigins(manifest: ManifestInput): string[] {
+ const origins = Object.values(manifest.assets ?? {}).flatMap(asset => {
+ if (!asset?.proxy_url) return [];
+ try {
+ return [new URL(asset.proxy_url).origin];
+ } catch {
+ return [];
+ }
+ });
+ return [...new Set(origins)];
+}
+
+function wrapInPage(
+ body: string,
+ dims: RenderDimensions,
+ title: string,
+ responsive = false,
+ allowedAssetOrigins: string[] = [],
+): string {
const previewStyle = responsive
? 'width: 100%; max-width: 600px; height: auto; min-height: 400px;'
: `width: ${dims.width}px; height: ${dims.height}px;`;
+ const assetSources = allowedAssetOrigins.map(origin => escapeHtml(origin)).join(' ');
return `
+
+
${escapeHtml(title)}