diff --git a/.changeset/canonical-format-sample-render-url.md b/.changeset/canonical-format-sample-render-url.md
new file mode 100644
index 0000000000..6078aa38b9
--- /dev/null
+++ b/.changeset/canonical-format-sample-render-url.md
@@ -0,0 +1,5 @@
+---
+"adcontextprotocol": minor
+---
+
+Add `sample_render_url` to canonical format declarations and registry summaries so catalogs can link to human-facing sample renders without implying buyer-asset preview, validation, creative approval, or live delivery fidelity.
diff --git a/server/public/publisher-home.html b/server/public/publisher-home.html
index 45b7890167..157a54f5b6 100644
--- a/server/public/publisher-home.html
+++ b/server/public/publisher-home.html
@@ -447,6 +447,14 @@
border-radius: var(--radius-lg);
background: var(--color-bg-card);
overflow: hidden;
+ cursor: pointer;
+ transition: var(--transition-all);
+ }
+ .format-card:hover,
+ .format-card:focus {
+ border-color: var(--color-brand);
+ box-shadow: var(--shadow-sm);
+ outline: none;
}
.format-preview {
display: grid;
@@ -547,6 +555,130 @@
color: var(--color-text-secondary);
overflow-wrap: anywhere;
}
+ .format-card__actions {
+ display: flex;
+ gap: var(--space-2);
+ margin-top: var(--space-3);
+ }
+ .format-card__sample-state {
+ font-size: var(--text-xs);
+ font-weight: var(--font-semibold);
+ color: var(--color-brand);
+ }
+ .format-dialog {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: grid;
+ place-items: center;
+ padding: var(--space-4);
+ background: var(--modal-backdrop);
+ }
+ .format-dialog__panel {
+ width: min(720px, 100%);
+ max-height: min(760px, calc(100vh - 32px));
+ overflow: auto;
+ border-radius: var(--radius-lg);
+ border: var(--border-1) solid var(--color-border);
+ background: var(--color-bg-card);
+ box-shadow: var(--shadow-xl);
+ }
+ .format-dialog__header {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ display: flex;
+ justify-content: space-between;
+ gap: var(--space-4);
+ align-items: flex-start;
+ padding: var(--space-5);
+ border-bottom: var(--border-1) solid var(--color-border-light);
+ background: var(--color-bg-card);
+ }
+ .format-dialog__title {
+ margin: 0;
+ font-size: var(--text-xl);
+ font-weight: var(--font-bold);
+ color: var(--color-text-heading);
+ }
+ .format-dialog__subtitle {
+ margin-top: 2px;
+ font-family: var(--font-mono);
+ font-size: var(--text-xs);
+ color: var(--color-text-secondary);
+ overflow-wrap: anywhere;
+ }
+ .format-dialog__close {
+ border: var(--border-1) solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: var(--color-bg-subtle);
+ color: var(--color-text);
+ cursor: pointer;
+ font-size: var(--text-lg);
+ line-height: 1;
+ padding: var(--space-2);
+ }
+ .format-dialog__body {
+ padding: var(--space-5);
+ }
+ .format-dialog__preview {
+ margin-bottom: var(--space-5);
+ }
+ .format-dialog__actions {
+ display: flex;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+ margin-bottom: var(--space-5);
+ }
+ .format-dialog__primary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 38px;
+ padding: 0 var(--space-4);
+ border-radius: var(--radius-md);
+ background: var(--color-brand);
+ color: var(--color-bg-card);
+ text-decoration: none;
+ font-weight: var(--font-semibold);
+ }
+ .format-dialog__empty {
+ margin: 0;
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+ }
+ .format-dialog__section {
+ margin-top: var(--space-5);
+ }
+ .format-dialog__section h4 {
+ margin: 0 0 var(--space-3);
+ font-size: var(--text-sm);
+ font-weight: var(--font-semibold);
+ color: var(--color-text-heading);
+ }
+ .format-detail-list {
+ display: grid;
+ gap: var(--space-2);
+ margin: 0;
+ }
+ .format-detail-list div {
+ display: grid;
+ grid-template-columns: minmax(120px, 0.35fr) minmax(0, 1fr);
+ gap: var(--space-3);
+ padding: var(--space-2) 0;
+ border-bottom: var(--border-1) solid var(--color-border-light);
+ }
+ .format-detail-list dt {
+ color: var(--color-text-secondary);
+ font-size: var(--text-xs);
+ font-weight: var(--font-semibold);
+ }
+ .format-detail-list dd {
+ margin: 0;
+ font-family: var(--font-mono);
+ font-size: var(--text-xs);
+ overflow-wrap: anywhere;
+ }
@media (max-width: 760px) {
.publisher-profile__grid {
@@ -568,6 +700,10 @@
.publisher-formats__intro {
margin-top: var(--space-2);
}
+ .format-detail-list div {
+ grid-template-columns: 1fr;
+ gap: var(--space-1);
+ }
}
/* Contextual relationship line — single line, dismissible, no
@@ -758,6 +894,16 @@
Build adagents.json
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
+ function safeHttpsUrl(raw) {
+ if (!raw) return '';
+ try {
+ const url = new URL(String(raw));
+ return url.protocol === 'https:' ? url.href : '';
+ } catch (_) {
+ return '';
+ }
+ }
+
function getDomain() {
const path = window.location.pathname;
const m = path.match(/^\/publisher\/(.+?)\/?$/);
@@ -1297,6 +1443,43 @@ ${escapeHtml(name)}
return chips.slice(0, 5);
}
+ function formatParamValue(value) {
+ if (value === null || value === undefined) return '';
+ if (Array.isArray(value) || typeof value === 'object') {
+ return JSON.stringify(value);
+ }
+ return String(value);
+ }
+
+ function renderFormatParams(params) {
+ const entries = Object.entries(params || {})
+ .filter(([, value]) => value !== undefined && value !== null)
+ .slice(0, 32);
+ if (!entries.length) return 'No canonical parameters published.
';
+ return `${
+ entries.map(([key, value]) => `
+
- ${escapeHtml(compactIdentifier(key))}
+ - ${escapeHtml(formatParamValue(value))}
+ `).join('')
+ }
`;
+ }
+
+ function renderFormatDetailList(format, scope) {
+ const rows = [
+ ['Format kind', compactKind(format.format_kind)],
+ ['Format option ID', format.format_option_id || 'not published'],
+ ['Scope', scope],
+ ['Seller preference', format.seller_preference || 'not set'],
+ ['Experimental', format.experimental ? 'yes' : 'no'],
+ ];
+ return `${
+ rows.map(([label, value]) => `
+
- ${escapeHtml(label)}
+ - ${escapeHtml(value)}
+ `).join('')
+ }
`;
+ }
+
function previewShape(format) {
const kind = format.format_kind || '';
const params = format.params || {};
@@ -1336,6 +1519,131 @@ ${escapeHtml(name)}
`;
}
+ function formatScopeLabel(format, props) {
+ const propertyNames = new Map(
+ props
+ .filter((p) => p.id)
+ .map((p) => [p.id, p.name || p.id])
+ );
+ const scopedIds = Array.isArray(format.applies_to_property_ids) ? format.applies_to_property_ids : [];
+ const scopedTags = Array.isArray(format.applies_to_property_tags) ? format.applies_to_property_tags : [];
+ if (scopedIds.length) {
+ return `Applies to ${scopedIds.map((id) => propertyNames.get(id) || compactIdentifier(id)).join(', ')}`;
+ }
+ if (scopedTags.length) {
+ return `Applies to ${scopedTags.map((tag) => compactIdentifier(tag)).join(', ')} properties`;
+ }
+ return 'Applies to all listed properties';
+ }
+
+ let formatDialogTrigger = null;
+
+ function closeFormatDialog(options = {}) {
+ const { restoreFocus = true } = options;
+ const existing = document.getElementById('format-detail-dialog');
+ if (existing) existing.remove();
+ document.removeEventListener('keydown', handleFormatDialogKeydown);
+ if (restoreFocus && formatDialogTrigger instanceof HTMLElement) {
+ formatDialogTrigger.focus();
+ }
+ formatDialogTrigger = null;
+ }
+
+ function handleFormatDialogKeydown(event) {
+ if (event.key === 'Escape') {
+ closeFormatDialog();
+ return;
+ }
+ if (event.key !== 'Tab') return;
+ const dialog = document.getElementById('format-detail-dialog');
+ if (!dialog) return;
+ const focusable = Array.from(dialog.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'))
+ .filter((el) => el instanceof HTMLElement);
+ if (!focusable.length) {
+ event.preventDefault();
+ return;
+ }
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+
+ function showFormatDetails(format, scope, trigger) {
+ closeFormatDialog({ restoreFocus: false });
+ formatDialogTrigger = trigger instanceof HTMLElement ? trigger : null;
+ const sampleRenderUrl = safeHttpsUrl(format.sample_render_url);
+ const title = format.display_name || format.format_option_id || compactKind(format.format_kind);
+ const chips = formatChips(format);
+ const dialog = document.createElement('div');
+ dialog.id = 'format-detail-dialog';
+ dialog.className = 'format-dialog';
+ dialog.innerHTML = `
+
+ `;
+ dialog.addEventListener('click', (event) => {
+ if (event.target === dialog) closeFormatDialog();
+ });
+ dialog.querySelector('.format-dialog__close').addEventListener('click', closeFormatDialog);
+ document.addEventListener('keydown', handleFormatDialogKeydown);
+ document.body.appendChild(dialog);
+ dialog.querySelector('.format-dialog__close').focus();
+ }
+
+ function bindFormatCards(formats, props) {
+ const grid = document.getElementById('publisher-formats-grid');
+ if (!grid) return;
+ grid.querySelectorAll('.format-card[data-format-index]').forEach((card) => {
+ const open = () => {
+ const index = Number(card.getAttribute('data-format-index'));
+ const format = formats[index];
+ if (!format) return;
+ showFormatDetails(format, formatScopeLabel(format, props), card);
+ };
+ card.addEventListener('click', (event) => {
+ if (event.target instanceof Element && event.target.closest('a')) return;
+ open();
+ });
+ card.addEventListener('keydown', (event) => {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ event.preventDefault();
+ open();
+ });
+ });
+ }
+
function renderFormats(formats, props, agents) {
const intro = document.getElementById('publisher-formats-intro');
const grid = document.getElementById('publisher-formats-grid');
@@ -1351,32 +1659,27 @@ ${escapeHtml(name)}
grid.innerHTML = `${note}No creative format declarations recorded yet.
`;
return;
}
- const propertyNames = new Map(
- props
- .filter((p) => p.id)
- .map((p) => [p.id, p.name || p.id])
- );
- grid.innerHTML = note + formats.map((format) => {
+ grid.innerHTML = note + formats.map((format, index) => {
const chips = formatChips(format);
- const scopedIds = Array.isArray(format.applies_to_property_ids) ? format.applies_to_property_ids : [];
- const scopedTags = Array.isArray(format.applies_to_property_tags) ? format.applies_to_property_tags : [];
- const scope = scopedIds.length
- ? `Applies to ${scopedIds.map((id) => escapeHtml(propertyNames.get(id) || compactIdentifier(id))).join(', ')}`
- : scopedTags.length
- ? `Applies to ${scopedTags.map((tag) => escapeHtml(compactIdentifier(tag))).join(', ')} properties`
- : 'Applies to all listed properties';
- return `
+ const scope = formatScopeLabel(format, props);
+ const sampleRenderUrl = safeHttpsUrl(format.sample_render_url);
+ const title = format.display_name || format.format_option_id || compactKind(format.format_kind);
+ return `
${renderFormatPreview(format)}
-
+
${format.format_option_id ? `${escapeHtml(compactIdentifier(format.format_option_id))} · ` : ''}${escapeHtml(compactKind(format.format_kind))}
${chips.length ? `
${
chips.map((chip) => `${escapeHtml(chip)}`).join('')
}
` : ''}
-
${scope}
+
${escapeHtml(scope)}
+ ${sampleRenderUrl ? `
+ Sample render available
+
` : ''}
`;
}).join('');
+ bindFormatCards(formats, props);
}
// ─── On-file: adagents.json + brand.json file cards ─────────
diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts
index 187524e7d5..7a16f05b4c 100644
--- a/server/src/routes/registry-api.ts
+++ b/server/src/routes/registry-api.ts
@@ -151,6 +151,7 @@ type PublisherFormatSummary = {
format_option_id?: string;
display_name: string;
format_kind: string;
+ sample_render_url?: string;
params?: Record;
applies_to_property_ids?: string[];
applies_to_property_tags?: string[];
@@ -168,6 +169,17 @@ function stringOrUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
+function httpsUrlOrUndefined(value: unknown): string | undefined {
+ const raw = stringOrUndefined(value);
+ if (!raw) return undefined;
+ try {
+ const url = new URL(raw);
+ return url.protocol === "https:" ? url.href : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
function stringArray(value: unknown, cap = 8): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0).slice(0, cap)
@@ -301,6 +313,7 @@ function summarizeFormats(
format_option_id: optionId,
display_name: displayName,
format_kind: formatKind,
+ sample_render_url: httpsUrlOrUndefined(format.sample_render_url),
params,
applies_to_property_ids: appliesToPropertyIds,
applies_to_property_tags: appliesToPropertyTags,
diff --git a/server/src/schemas/registry.ts b/server/src/schemas/registry.ts
index 45c05a9958..aac08ad90e 100644
--- a/server/src/schemas/registry.ts
+++ b/server/src/schemas/registry.ts
@@ -910,6 +910,10 @@ const PublisherFormatSummarySchema = z.object({
format_option_id: z.string().optional().openapi({ description: "Stable format option identifier from adagents.json `formats[]`." }),
display_name: z.string().openapi({ description: "Human-readable format label for catalog and publisher UI display." }),
format_kind: z.string().openapi({ description: "Canonical format discriminator, such as `image`, `video_hosted`, `native_in_feed`, or `custom`." }),
+ sample_render_url: HttpsUrlSchema.optional().openapi({
+ description:
+ "Optional HTTPS page where a human can inspect a sample render of this catalog format using publisher- or registry-provided example assets. Informational only; this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, or live delivery guarantee.",
+ }),
params: z.record(z.string(), z.unknown()).optional().openapi({ description: "Canonical format params from the publisher's adagents.json declaration." }),
applies_to_property_ids: z.array(z.string()).optional().openapi({ description: "Property IDs this format applies to; absent means all properties." }),
applies_to_property_tags: z.array(z.string()).optional().openapi({ description: "Property tags this format applies to; absent means all properties." }),
diff --git a/static/openapi/registry.yaml b/static/openapi/registry.yaml
index 6eb1257cc3..be2f4fbc69 100644
--- a/static/openapi/registry.yaml
+++ b/static/openapi/registry.yaml
@@ -1058,6 +1058,10 @@ components:
format_kind:
type: string
description: Canonical format discriminator, such as `image`, `video_hosted`, `native_in_feed`, or `custom`.
+ sample_render_url:
+ type: string
+ pattern: ^https:\/\/
+ description: Optional HTTPS page where a human can inspect a sample render of this catalog format using publisher- or registry-provided example assets. Informational only; this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, or live delivery guarantee.
params:
type: object
additionalProperties: {}
diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json
index d143dfbff8..c1064ad43e 100644
--- a/static/schemas/source/core/product-format-declaration.json
+++ b/static/schemas/source/core/product-format-declaration.json
@@ -20,6 +20,12 @@
"type": "string",
"description": "Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn."
},
+ "sample_render_url": {
+ "type": "string",
+ "format": "uri",
+ "pattern": "^https://",
+ "description": "Optional human-facing rendered example for this format declaration. Used by registry/catalog UIs to let buyers inspect the unit shape (for example, an AgenticAdvertising.org-translated Snap unit) before constructing a creative manifest. Uses publisher- or registry-provided sample assets, not buyer-submitted creative assets. Informational only: this URL is not a renderer endpoint, validation oracle, creative approval, or guarantee of how submitted creative will render in live delivery. Sellers SHOULD keep the URL stable while the declaration is active."
+ },
"applies_to_channels": {
"type": "array",
"items": { "$ref": "/schemas/enums/channels.json" },
diff --git a/tests/placement-catalog-schema.test.cjs b/tests/placement-catalog-schema.test.cjs
index 20d62d3b87..23cc41971a 100644
--- a/tests/placement-catalog-schema.test.cjs
+++ b/tests/placement-catalog-schema.test.cjs
@@ -413,6 +413,7 @@ test('format options can be referenced by publisher domain or product-local ID',
validateDeclaration({
publisher_domain: 'daily-pulse.example',
format_option_id: 'homepage_image',
+ sample_render_url: 'https://creative.adcontextprotocol.org/translated/snap/samples/story',
format_kind: 'image',
params: {
width: 300,
@@ -423,6 +424,19 @@ test('format options can be referenced by publisher domain or product-local ID',
JSON.stringify(validateDeclaration.errors, null, 2)
);
+ assert.equal(
+ validateDeclaration({
+ format_option_id: 'homepage_image',
+ sample_render_url: 'http://creative.adcontextprotocol.org/translated/snap/samples/story',
+ format_kind: 'image',
+ params: {
+ width: 300,
+ height: 250
+ }
+ }),
+ false
+ );
+
assert.equal(
validateDeclaration({
format_option_id: 'seller_takeover_image',