From 89f08a45e3e77d3943f8fd3f5cc16fd86841b088 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 31 May 2026 07:34:55 -0400 Subject: [PATCH 01/14] feat(creative): account-scoped creative transformers + build_creative multiplicity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds creative transformers — the creative analog of a media-buy product: an agent-offered, account-scoped, selectable unit of build capability (a voice, model, style, or director) with a typed config surface and per-account pricing. Makes account-specific render configuration (including custom values like cloned voices that exist only for one credential) discoverable from the agent instead of guessed, hung on a global format, or smuggled through ext. Strictly additive: existing build_creative callers are unaffected (new request fields optional; shipped BuildCreativeSuccess/BuildCreativeMultiSuccess unchanged — a fifth response member is added alongside them). - list_transformers (creative protocol): account-scoped, brief-filterable, paginated discovery; an expand_params mode returns account-scoped enumerable option values on the same tool. - Core schemas transformer.json, transformer-param.json; x-entity 'transformer'. - get_adcp_capabilities -> creative.supports_transformers discriminator. - build_creative request: transformer_id, config (strict-validated typed bag), max_creatives (catalog/item fan-out, distinct from item_limit), max_variants, variant_axis, keep_mode. - build_creative response: BuildCreativeVariantSuccess (creatives[] -> variants[], build_variant_id namespace, per-leaf pricing receipt, items_total/returned, per-item non-atomic via errors[]). - Pricing rides the existing per_unit + inline receipt + report_usage rails. - Deprecates (3.1, removed 4.0; honored through 3.1-3.x) Format.input_format_ids/ output_format_ids/pricing_options and the list_creative_formats i/o filters. - Docs: new list_transformers task ref; extended build_creative; deprecation callouts; spec, capabilities, required-tasks, whats-new-in-3-1; nav + changeset. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/creative-transformers.md | 22 +++ docs.json | 2 + docs/creative/specification.mdx | 12 ++ .../task-reference/build_creative.mdx | 144 ++++++++++++++++- .../task-reference/list_creative_formats.mdx | 12 +- .../task-reference/list_transformers.mdx | 140 ++++++++++++++++ docs/protocol/get_adcp_capabilities.mdx | 1 + docs/protocol/required-tasks.mdx | 1 + docs/reference/whats-new-in-3-1.mdx | 25 +++ static/schemas/source/core/format.json | 9 +- .../source/core/transformer-param.json | 109 +++++++++++++ static/schemas/source/core/transformer.json | 75 +++++++++ .../schemas/source/core/x-entity-types.json | 2 + .../list-creative-formats-request.json | 6 +- .../creative/list-transformers-request.json | 64 ++++++++ .../creative/list-transformers-response.json | 28 ++++ static/schemas/source/index.json | 18 +++ .../media-buy/build-creative-request.json | 56 ++++++- .../media-buy/build-creative-response.json | 150 +++++++++++++++++- .../get-adcp-capabilities-response.json | 5 + 20 files changed, 869 insertions(+), 12 deletions(-) create mode 100644 .changeset/creative-transformers.md create mode 100644 docs/creative/task-reference/list_transformers.mdx create mode 100644 static/schemas/source/core/transformer-param.json create mode 100644 static/schemas/source/core/transformer.json create mode 100644 static/schemas/source/creative/list-transformers-request.json create mode 100644 static/schemas/source/creative/list-transformers-response.json diff --git a/.changeset/creative-transformers.md b/.changeset/creative-transformers.md new file mode 100644 index 0000000000..32e7e9158f --- /dev/null +++ b/.changeset/creative-transformers.md @@ -0,0 +1,22 @@ +--- +"adcontextprotocol": minor +--- + +spec(creative): add `list_transformers` task + account-scoped creative transformers, and extend `build_creative` for transformer selection and variant/catalog multiplicity. + +A **transformer** is the creative analog of a media-buy product: an agent-offered, account-scoped, selectable unit of build capability (a voice, model, style, or director) with a typed configuration surface and per-account pricing. This makes account-specific render configuration — including custom values like cloned voices that exist only for one credential — discoverable from the agent rather than guessed, hung on a global format, or smuggled through `ext`. + +Strictly additive. Existing `build_creative` callers are unaffected (all new request fields are optional; the shipped `BuildCreativeSuccess`/`BuildCreativeMultiSuccess` response shapes are unchanged — a new fifth member is added alongside them). + +New: +- `list_transformers` task (creative protocol): account-scoped, brief-filterable, paginated discovery. An `expand_params` mode returns account-scoped enumerable option **values** (e.g. your configured voices) on the same tool — no separate options endpoint. +- Core schemas `transformer.json` and `transformer-param.json`. +- `get_adcp_capabilities` → `creative.supports_transformers` discriminator. + +`build_creative` extensions: +- Request: `transformer_id` (select one transformer; target format(s) must be a subset of its `output_format_ids`), `config` (typed bag keyed to the transformer's params — agents MUST reject unknown/out-of-range values), `max_creatives` (catalog/item fan-out: N distinct creatives, one per item, with sampling), `max_variants` + `variant_axis` + `keep_mode` (alternatives per creative). +- Response: a new `BuildCreativeVariantSuccess` member — `creatives[]` each carrying `variants[]`, with a `build_variant_id` namespace (distinct from preview `preview_id` and served `variant_id`), per-leaf pricing receipt, and `items_total`/`items_returned`. Best-of-N is variants + `recommended`/`rank`. You pay for all produced variants (`per_unit` × N); a kept variant lazily earns a `creative_id` on trafficking, which flows to `report_usage`. Per-format atomic; per-item non-atomic. + +Pricing rides the existing `per_unit` model + inline receipt + `report_usage` unchanged — transformers carry `pricing_options` (reusing `vendor-pricing-option.json`). + +Deprecations (deprecated in 3.1, removed at 4.0; SDKs MUST keep honoring them through 3.1–3.x): `Format.input_format_ids`, `Format.output_format_ids`, `Format.pricing_options`, and the `input_format_ids`/`output_format_ids` discovery filters on `list_creative_formats` — all superseded by `list_transformers`, which carries each transformer's own I/O signature and pricing. diff --git a/docs.json b/docs.json index d20d6835e2..52db929f97 100644 --- a/docs.json +++ b/docs.json @@ -359,6 +359,7 @@ "docs/creative/task-reference/preview_creative", "docs/creative/task-reference/preview_creative-advanced", "docs/creative/task-reference/list_creative_formats", + "docs/creative/task-reference/list_transformers", "docs/creative/task-reference/list_creatives", "docs/creative/task-reference/sync_creatives", "docs/creative/task-reference/get_creative_delivery" @@ -1556,6 +1557,7 @@ "docs/creative/task-reference/preview_creative", "docs/creative/task-reference/preview_creative-advanced", "docs/creative/task-reference/list_creative_formats", + "docs/creative/task-reference/list_transformers", "docs/creative/task-reference/list_creatives", "docs/creative/task-reference/sync_creatives", "docs/creative/task-reference/get_creative_delivery" diff --git a/docs/creative/specification.mdx b/docs/creative/specification.mdx index 3fe38f740f..6d4fac5fab 100644 --- a/docs/creative/specification.mdx +++ b/docs/creative/specification.mdx @@ -275,6 +275,18 @@ Discover creative formats and their specifications. - Creative agents MAY include references to other creative agents providing additional formats - When filtering by `format_ids`, creative agents MUST return only the requested formats +### list_transformers + +**Reference**: [`list_transformers` task](/docs/creative/task-reference/list_transformers) + +Discover the account-scoped transformers a creative agent offers — the creative analog of media-buy products: agent-offered, selectable units of build capability (voices, models, styles) that you select with `transformer_id` in `build_creative`. Offered only by agents that declare `creative.supports_transformers: true` in `get_adcp_capabilities`. + +**Requirements:** +- Creative agents that set `creative.supports_transformers: true` MUST implement `list_transformers` +- Creative agents MUST resolve transformers, their enumerable option values, and pricing for the calling account — including custom values configured for that account (e.g. cloned voices) +- Creative agents MUST return account-scoped option values inline on `params[].options[]` for each `field` named in `expand_params`, and SHOULD omit them otherwise +- When `include_pricing` is true, creative agents that charge MUST include `pricing_options` (the `per_unit` model) on each transformer + ### build_creative **Reference**: [`build_creative` task](/docs/creative/task-reference/build_creative) diff --git a/docs/creative/task-reference/build_creative.mdx b/docs/creative/task-reference/build_creative.mdx index ed836c9908..093f053903 100644 --- a/docs/creative/task-reference/build_creative.mdx +++ b/docs/creative/task-reference/build_creative.mdx @@ -29,7 +29,13 @@ For information about legacy format IDs and how to reference formats, see [Creat | `target_format_ids` | array | Conditional | Array of format IDs to generate in a single call. Each element is an object with `agent_url` and `id` fields. For 3.1 canonical creative-agent routing, each `id` is an advertised `creative.supported_formats[].capability_id`; legacy named-format IDs remain accepted during the migration window. Mutually exclusive with `target_format_id` — provide exactly one. Returns one manifest per format. | | `brand` | object | No | Brand reference with `domain` field. Resolves brand identity via `/.well-known/brand.json`. Provides brand-level context (colors, logos, tone). | | `quality` | string | No | Quality tier: `"draft"` (fast, lower-fidelity for iteration) or `"production"` (full quality for final delivery). If omitted, the creative agent uses its own default. | -| `item_limit` | integer | No | Maximum number of catalog items to use when generating. Caps generation cost for catalog-driven formats. | +| `item_limit` | integer | No | Maximum number of catalog items to use **within one creative**. Caps generation cost for catalog-driven formats (e.g. a 6-card carousel built from a 1,000-product catalog). Distinct from `max_creatives`, which fans out across items. | +| `transformer_id` | string | No | Select one transformer (discovered via [`list_transformers`](/docs/creative/task-reference/list_transformers)) to perform the build. The requested target format(s) MUST be a subset of that transformer's `output_format_ids`. The transformer's `per_unit` rate is the pricing source for the build. | +| `config` | object | No | Typed configuration bag keyed to the selected transformer's `params[].field` (e.g. `{ "voice": "isaac", "speaking_rate": 1.1 }`). The creative agent MUST reject unknown keys and out-of-range values with field-attributed errors (strict validation) — vendor-specific knobs that are not declared params go in `ext`. Only meaningful with `transformer_id`. | +| `max_creatives` | integer | No | Catalog fan-out axis: produce up to N **distinct creatives, one per catalog item** (a sample — e.g. 5 of 150). Distinct from `item_limit`, which caps items used *within* a single creative. Triggers the `BuildCreativeVariantSuccess` response shape. | +| `max_variants` | integer | No | Number of alternative renders to produce **per creative** (best-of-N). Default `1`. You pay for every produced variant; keeping one or more is a separate trafficking step. | +| `variant_axis` | object | No | Describes the dimension along which variants differ. Object with `dimension` (`voice` \| `theme` \| `best_of_n` \| `transformer_config` \| `custom`), optional `values[]` (explicit values to enumerate along the axis), optional `field` (the `config` param to sweep — required when `dimension` is `transformer_config`), and optional `label`. | +| `keep_mode` | string | No | Advisory hint to the agent on how many variants you intend to keep: `"keep_all"`, `"keep_one"`, or `"keep_some"`. Default `"keep_all"`. Advisory only — you are billed for all produced variants regardless. | | `include_preview` | boolean | No | When true, requests preview renders alongside the manifest. Agents that support this return a `preview` object in the response. Agents that don't simply omit it. | | `preview_inputs` | array | No | Input sets for preview generation when `include_preview` is true. Each entry has `name` (required), optional `macros`, and optional `context_description`. If omitted, the agent generates a single default preview. Only supported with `target_format_id` (single-format) — ignored for multi-format requests. | | `preview_quality` | string | No | Render quality for inline previews: `"draft"` or `"production"`. Independent of the build `quality` — you can build at draft and preview at production, or vice versa. Only used when `include_preview` is true. | @@ -326,6 +332,73 @@ To refine a single format from a multi-format build, call `build_creative` again Multi-format requests with `include_preview: true` generate one default preview per format. Custom `preview_inputs` are only supported with single-format requests. For multi-format builds where you need context-specific previews (device variants, different contexts), use a separate `preview_creative` batch call after building. +### Transformers & variants + +Select a transformer (discovered via [`list_transformers`](/docs/creative/task-reference/list_transformers)) with `transformer_id`, supply a typed `config` keyed to its params, and ask for alternatives with `max_variants`. The agent returns the [variant response](#variant-response): `creatives[]`, each holding a `variants[]` array. Read `recommended` / `rank` to surface the agent's best-of-N pick; traffic the variant(s) you want by their `build_variant_id`. + +Resolutions and quality tiers go on `target_format_ids` (or `quality`), not on `variant_axis` — variants are alternatives for the *same* format. + + + +```javascript test=false +import { testAgent } from '@adcp/sdk/testing'; +import { BuildCreativeResponseSchema } from '@adcp/sdk'; + +const result = await testAgent.buildCreative({ + account: { account_id: 'acct_acme' }, + transformer_id: 'audiostack_voiceover', + config: { voice: 'isaac', speaking_rate: 1.1, mastering_preset: 'podcast' }, + target_format_id: { agent_url: 'https://creative.audiostack.example', id: 'audio_vo' }, + creative_manifest: { + format_id: { agent_url: 'https://creative.audiostack.example', id: 'script' }, + assets: { script: { asset_type: 'text', content: 'Discover the new winter collection.' } }, + }, + max_variants: 3, + variant_axis: { dimension: 'best_of_n', label: 'Read takes' }, + keep_mode: 'keep_one', + idempotency_key: '0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5', +}); + +const parsed = BuildCreativeResponseSchema.parse(result); +for (const creative of parsed.creatives ?? []) { + for (const variant of creative.variants) { + console.log(variant.build_variant_id, variant.rank, variant.recommended, variant.vendor_cost); + } +} +``` + +```python test=false +import asyncio +from adcp.testing import test_agent +from adcp import BuildCreativeResponse + +async def main(): + result = await test_agent.build_creative( + account={"account_id": "acct_acme"}, + transformer_id="audiostack_voiceover", + config={"voice": "isaac", "speaking_rate": 1.1, "mastering_preset": "podcast"}, + target_format_id={"agent_url": "https://creative.audiostack.example", "id": "audio_vo"}, + creative_manifest={ + "format_id": {"agent_url": "https://creative.audiostack.example", "id": "script"}, + "assets": {"script": {"asset_type": "text", "content": "Discover the new winter collection."}}, + }, + max_variants=3, + variant_axis={"dimension": "best_of_n", "label": "Read takes"}, + keep_mode="keep_one", + idempotency_key="0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5", + ) + parsed = BuildCreativeResponse.model_validate(result) + for creative in parsed.creatives or []: + for variant in creative.variants: + print(variant.build_variant_id, variant.rank, variant.recommended, variant.vendor_cost) + +asyncio.run(main()) +``` + + + +You pay for all three takes (`per_unit` × 3); `keep_mode` is advisory. Keeping is a separate trafficking step on the chosen `build_variant_id` — the kept variant then lazily earns a `creative_id` that flows to [`report_usage`](/docs/accounts/tasks/report_usage). + ### Paid build with pricing When the creative agent charges and `account` is provided, the response includes pricing fields. The agent selects the applicable pricing option server-side based on the account's rate card and the work performed — the buyer does not pass `pricing_option_id` in the request. @@ -444,6 +517,75 @@ When the request uses `target_format_ids`, the response contains an array of cre } ``` +### Variant response + +When the request fans out across catalog items (`max_creatives`) or produces alternatives per creative (`max_variants` > 1), the agent returns the `BuildCreativeVariantSuccess` shape. This is a fifth success member alongside the single- and multi-format responses; the `creative_manifest` / `creative_manifests` shapes above are unchanged and still used when you build a single creative with one variant. + +```json +{ + "$schema": "/schemas/media-buy/build-creative-response.json", + "status": "completed", + "creatives": [ + { + "build_creative_id": "bc_card_01", + "catalog_item_ref": { "catalog_type": "product", "item_id": "sku_winter_parka" }, + "variants": [ + { + "build_variant_id": "bv_card01_a", + "creative_manifest": { "format_id": { "agent_url": "https://creative.example.com", "id": "display_300x250" }, "assets": { "...": "..." } }, + "variant_axis_value": "studio", + "recommended": true, + "rank": 1, + "pricing_option_id": "po_per_image", + "vendor_cost": 0.40, + "currency": "USD", + "consumption": { "images_generated": 1 } + }, + { + "build_variant_id": "bv_card01_b", + "creative_manifest": { "format_id": { "agent_url": "https://creative.example.com", "id": "display_300x250" }, "assets": { "...": "..." } }, + "variant_axis_value": "lifestyle", + "recommended": false, + "rank": 2, + "pricing_option_id": "po_per_image", + "vendor_cost": 0.40, + "currency": "USD", + "consumption": { "images_generated": 1 } + } + ] + } + ], + "items_total": 150, + "items_returned": 5, + "vendor_cost": 4.00, + "currency": "USD" +} +``` + +- **creatives[]**: One entry per built creative. With `max_creatives`, there is one entry per sampled catalog item (`catalog_item_ref` identifies which item); without catalog fan-out there is a single entry. +- **creatives[].build_creative_id**: Identifies a built creative within this response. +- **creatives[].catalog_item_ref**: Present on catalog fan-out — an object identifying the source catalog item via `item_id` (plus optional `catalog_type`). +- **creatives[].errors[]**: Present only on a *failed* catalog item — catalog fan-out is non-atomic, so a failed item is returned as a `creatives[]` entry carrying `errors[]` and no `variants[]`, without failing the batch. +- **creatives[].variants[]**: The alternatives produced for this creative. Length is at most `max_variants`. Each variant carries its own complete `creative_manifest`. +- **variants[].build_variant_id**: Identifies a single variant. This is its **own namespace** — never reuse a `preview_id` (a `preview_creative` render) or a served `variant_id` (a delivery-time identifier) here. You traffic a chosen build by passing its `build_variant_id`. +- **variants[].variant_axis_value**: The value of the `variant_axis` dimension this variant represents (e.g. a voice, a theme). +- **variants[].recommended** / **variants[].rank**: Best-of-N signals — `recommended` flags the agent's top pick; `rank` orders the variants. These plus `variant_axis` and `keep_mode` are how best-of-N is expressed. +- **variants[] pricing receipt**: Each variant carries a per-leaf receipt (`pricing_option_id`, `vendor_cost`, `currency`, `consumption`) for the work that produced it. +- **items_total** / **items_returned**: For catalog fan-out, the total eligible catalog items and how many creatives were returned (e.g. "5 of 150"). +- **vendor_cost** / **currency**: Aggregate cost across all produced variants in the response. + + +You pay for **every produced variant** (`per_unit` × N), not just the ones you keep. Keeping is a separate client act of trafficking the chosen `build_variant_id`(s). A kept variant lazily earns a `creative_id` when it is added to the library or first trafficked; that `creative_id` is what flows to [`report_usage`](/docs/accounts/tasks/report_usage). An unkept variant is still billed but never earns a `creative_id`. + + + +**Atomicity differs by axis.** Variant production *per format* is atomic — if one variant for a format fails, that format's build fails. Catalog fan-out (`max_creatives`, per item) is **non-atomic**: individual `creatives[]` entries may succeed while others fail. A failed item is returned as a `creatives[]` entry carrying `errors[]` (and no `variants[]`); the batch still succeeds. + +**Build-time variants are not delivery variants.** The `build_variant_id` here is a pre-delivery handle for an alternative you produced. It is a different namespace from the served `variant_id` in [`get_creative_delivery`](/docs/creative/task-reference/get_creative_delivery), which identifies a creative *execution that was delivered*. A build-time variant becomes a delivery variant only once you keep and traffic it. + +**Resolutions and quality tiers are not variants.** Multiple sizes or quality levels belong on the format axis — pass them as `target_format_ids` (or `quality`), not as `variant_axis` values. Variants are alternatives *for the same format* (different voice, theme, or best-of-N take). + + ### Field descriptions - **creative_manifest**: (single-format) The complete creative manifest ready for use with `sync_creatives` or `preview_creative` diff --git a/docs/creative/task-reference/list_creative_formats.mdx b/docs/creative/task-reference/list_creative_formats.mdx index 84c620b393..92a9afd432 100644 --- a/docs/creative/task-reference/list_creative_formats.mdx +++ b/docs/creative/task-reference/list_creative_formats.mdx @@ -48,8 +48,8 @@ See [list_creative_formats (Sales Agent)](/docs/creative/task-reference/list_cre | `wcag_level` | string | No | Filter to formats meeting at least this WCAG level: `A`, `AA`, `AAA`. See [Accessibility](/docs/creative/accessibility). | | `disclosure_positions` | string[] | No | Filter to formats that support all of these disclosure positions. Matches against `disclosure_capabilities` when present, otherwise falls back to `supported_disclosure_positions`. | | `disclosure_persistence` | string[] | No | Filter to formats whose `disclosure_capabilities` include all of these persistence modes on at least one position. Values: `continuous`, `initial`, `flexible`. | -| `output_format_ids` | FormatID[] | No | Filter to formats whose `output_format_ids` includes any of these. Returns formats that can produce these outputs — inspect their `input_format_ids` to see what inputs they accept. | -| `input_format_ids` | FormatID[] | No | Filter to formats whose `input_format_ids` includes any of these. Returns formats that accept these creatives as input — inspect their `output_format_ids` to see what they can produce. | +| `output_format_ids` | FormatID[] | No | *(deprecated in 3.1)* Filter to formats whose `output_format_ids` includes any of these. Returns formats that can produce these outputs — inspect their `input_format_ids` to see what inputs they accept. Use [`list_transformers`](/docs/creative/task-reference/list_transformers) for build-capability discovery instead. | +| `input_format_ids` | FormatID[] | No | *(deprecated in 3.1)* Filter to formats whose `input_format_ids` includes any of these. Returns formats that accept these creatives as input — inspect their `output_format_ids` to see what they can produce. Use [`list_transformers`](/docs/creative/task-reference/list_transformers) for build-capability discovery instead. | | `pagination` | object | No | Pagination: `max_results` (1-100, default 50) and `cursor` (opaque cursor from previous response) | ### Multi-Render Dimension Filtering @@ -451,6 +451,10 @@ asyncio.run(main()) ### Discover Build Capabilities + +**Deprecated in 3.1.** Build-capability discovery has moved to [`list_transformers`](/docs/creative/task-reference/list_transformers). A transformer is the account-scoped, agent-offered unit of build capability and carries its own `input_format_ids`, `output_format_ids`, and `pricing_options`. The `input_format_ids` and `output_format_ids` filters on this task — and the matching fields on the [Format object](https://adcontextprotocol.org/schemas/v3/core/format.json) — still work during 3.1–3.x but will be removed in 4.0. Query `list_transformers` to discover what an agent can build and what it costs. + + Some formats declare the output formats they can produce via `output_format_ids`. A creative builder (like a multi-publisher template tool) may accept one asset group and produce many publisher-specific formats. A format transformer may accept an existing creative and reformat it. The format schema expresses both sides of the relationship: @@ -579,8 +583,8 @@ Each format includes: | `type` | *(deprecated)* Format type (audio, video, display, dooh). Use `asset_types` filter instead. | | `assets` | Array of all assets with `required` boolean indicating mandatory vs optional | | `renders` | Array of rendered output pieces (dimensions, role) | -| `input_format_ids` | Creative formats this format accepts as input manifests (omitted for formats that work from raw assets) | -| `output_format_ids` | Output formats this format can produce (omitted for formats that produce a single fixed output) | +| `input_format_ids` | *(deprecated in 3.1)* Creative formats this format accepts as input manifests (omitted for formats that work from raw assets). Carried on the transformer instead — see [`list_transformers`](/docs/creative/task-reference/list_transformers). | +| `output_format_ids` | *(deprecated in 3.1)* Output formats this format can produce (omitted for formats that produce a single fixed output). Carried on the transformer instead — see [`list_transformers`](/docs/creative/task-reference/list_transformers). | ### Asset Roles diff --git a/docs/creative/task-reference/list_transformers.mdx b/docs/creative/task-reference/list_transformers.mdx new file mode 100644 index 0000000000..a24200db88 --- /dev/null +++ b/docs/creative/task-reference/list_transformers.mdx @@ -0,0 +1,140 @@ +--- +title: list_transformers +description: "list_transformers discovers account-scoped creative transformers — the agent-offered, selectable units of build capability (voices, models, styles) used by build_creative." +"og:title": "AdCP — list_transformers" +testable: true +--- + + +Discover the **transformers** a creative agent offers for your account. A transformer is the creative analog of a media-buy product: an agent-offered, account-scoped, selectable unit of build capability (a voice, a model, a style, a director) with a typed configuration surface and per-account pricing. You discover transformers here, then select one with `transformer_id` in [`build_creative`](/docs/creative/task-reference/build_creative). + +**Response Time**: ~1 second (account-scoped lookup) + +**Authentication**: Account-scoped. Transformers, their enumerable option values, and pricing are resolved for the calling credential — including custom values you configured (e.g. cloned voices) that exist only for your account. + +**Request Schema**: [`/schemas/v3/creative/list-transformers-request.json`](https://adcontextprotocol.org/schemas/v3/creative/list-transformers-request.json) +**Response Schema**: [`/schemas/v3/creative/list-transformers-response.json`](https://adcontextprotocol.org/schemas/v3/creative/list-transformers-response.json) + +Offered only by agents that set `creative.supports_transformers: true` in [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities). + +## Why transformers + +The set of render knobs a creative agent exposes — and their legal values — is **account-specific and dynamic**. Your configured voices aren't a global enum or a list you hold; the *agent* knows them, and the set changes when you add one. So discovery flows agent → buyer, the same way `get_products` surfaces account-scoped inventory. `list_transformers` is that discovery surface for creative build capability. + +The agent chooses granularity: a distinct voice or model may be its own transformer, or a single transformer may expose `voice`/`model` as an enumerable `config` param. Either way you use the same call — list transformers, expand a param if you want its values. + +## Request Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `transformer_ids` | string[] | No | Return only these specific transformer IDs | +| `input_format_ids` | FormatID[] | No | Filter to transformers that accept any of these formats as input | +| `output_format_ids` | FormatID[] | No | Filter to transformers that can produce any of these output formats | +| `name_search` | string | No | Search transformers by name (case-insensitive partial match) | +| `brief` | string | No | Natural-language brief to rank/filter transformers and their option values (e.g. "warm female Spanish-language voiceover"). Curates to intent rather than returning the full set. | +| `expand_params` | string[] | No | Param `field` names for which to return account-scoped option **values** inline on `params[].options[]`. Omit for the lean default (descriptors only). | +| `include_pricing` | boolean | No | Include `pricing_options` on each transformer. Requires `account`. | +| `account` | AccountRef | Conditional | Required when `include_pricing` is true. Transformers are account-scoped regardless. | +| `pagination` | object | No | `max_results` and `cursor` (opaque cursor from previous response) | + +### The `expand` mode + +By default the response returns each transformer's param **schemas** with small closed enums inlined (e.g. `mastering_preset`). To get the **values** of an account-scoped enumerable param (e.g. your voices), name it in `expand_params`. Values are brief-filtered and paginated per param via `options_cursor` — so "warm Spanish female voice" narrows a 300-voice catalog to a handful instead of dumping all of them. There is no separate options endpoint; value enumeration is a mode of this one tool. + +## Response + +| Field | Description | +|-------|-------------| +| `transformers` | Array of transformer descriptors (see below) | +| `errors` | Optional array of task-specific errors and warnings | +| `pagination` | Pagination cursor when more transformers are available | + +Each transformer carries: + +| Field | Description | +|-------|-------------| +| `transformer_id` | Stable id; pass to `build_creative` `transformer_id` | +| `name` | Human-readable name | +| `input_format_ids` / `output_format_ids` | What it accepts and produces. A `build_creative` target MUST be a subset of `output_format_ids`. Empty `input_format_ids` means it builds from a brief (pure generation). | +| `params` | Config knobs (see [Transformer Param](https://adcontextprotocol.org/schemas/v3/core/transformer-param.json)): `field`, `type`, `value_source` (`inline`/`range`/`enumerable`), `allowed_values`/`minimum`/`maximum`, `options[]`+`options_cursor` (when expanded), `default` | +| `pricing_options` | Per-account rate card (when `include_pricing`). Uses the `per_unit` model (e.g. $/generated image, $/second). The applied option is echoed per-leaf on the `build_creative` response and reconciled via `report_usage`. | + +See [Transformer schema](https://adcontextprotocol.org/schemas/v3/core/transformer.json) for the complete object. + +## Common Scenarios + +### Discover voices for a brief, with values + + + +```javascript test=false +import { testAgent } from '@adcp/sdk/testing'; +import { ListTransformersResponseSchema } from '@adcp/sdk'; + +const result = await testAgent.listTransformers({ + account: { account_id: 'acct_acme' }, + brief: 'warm female Spanish-language voiceover', + output_format_ids: [{ agent_url: 'https://creative.audiostack.example', id: 'audio_vo' }], + expand_params: ['voice'], + include_pricing: true, +}); + +const parsed = ListTransformersResponseSchema.parse(result); +for (const t of parsed.transformers) { + console.log(t.transformer_id, t.name); + const voice = t.params?.find((p) => p.field === 'voice'); + for (const opt of voice?.options ?? []) console.log(' voice:', opt.value, opt.metadata); +} +``` + +```python test=false +import asyncio +from adcp.testing import test_agent +from adcp import ListTransformersResponse + +async def main(): + result = await test_agent.list_transformers( + account={"account_id": "acct_acme"}, + brief="warm female Spanish-language voiceover", + output_format_ids=[{"agent_url": "https://creative.audiostack.example", "id": "audio_vo"}], + expand_params=["voice"], + include_pricing=True, + ) + parsed = ListTransformersResponse.model_validate(result) + for t in parsed.transformers: + print(t.transformer_id, t.name) + +asyncio.run(main()) +``` + + + +### Then build with the selected transformer + +Pass the chosen `transformer_id` and a typed `config` (keyed by each param's `field`) to [`build_creative`](/docs/creative/task-reference/build_creative): + +```json test=false +{ + "transformer_id": "audiostack_voiceover", + "config": { "voice": "isaac", "speaking_rate": 1.1, "mastering_preset": "podcast" }, + "creative_manifest": { "format_id": { "agent_url": "https://creative.audiostack.example", "id": "script" }, "assets": { "script": { "asset_type": "text", "content": "Discover the new winter collection." } } }, + "target_format_id": { "agent_url": "https://creative.audiostack.example", "id": "audio_vo" }, + "account": { "account_id": "acct_acme" }, + "idempotency_key": "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5" +} +``` + +## Error Handling + +| Code | Meaning | Recovery | +|------|---------|----------| +| `AUTHENTICATION_REQUIRED` | `include_pricing` requested without a resolvable account | Supply `account` / authenticate | +| `INVALID_INPUT` | Malformed filter or pagination cursor | Correct the request | + +Unknown `expand_params` (a `field` no transformer exposes) are ignored, not an error — the param simply returns no `options`. + +## Learn More + +- [build_creative](/docs/creative/task-reference/build_creative) — select a transformer and produce creatives (incl. variants) +- [get_adcp_capabilities](/docs/protocol/get_adcp_capabilities) — `creative.supports_transformers` discriminator +- [Vendor pricing](https://adcontextprotocol.org/schemas/v3/core/vendor-pricing-option.json) — the `per_unit` rate model diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 18fcfc11b4..31df1c05c3 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -383,6 +383,7 @@ Creative protocol capabilities. Only present if `creative` is in `supported_prot | Field | Type | Description | |-------|------|-------------| | `supports_compliance` | boolean | When `true`, this creative agent can process briefs with compliance requirements (`required_disclosures`, `prohibited_claims`) and will validate that disclosures can be satisfied by the target format. Use the `disclosure_positions` filter on `list_creative_formats` to find compatible formats. | +| `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. | | `bills_through_adcp` | boolean | When `true`, this creative agent bills through the AdCP rate-card surface — `list_creatives` returns `pricing_options` (with `include_pricing=true` and an authenticated account), `build_creative` populates `pricing_option_id` and `vendor_cost`, and `report_usage` accepts records against the rate card. When `false` or absent, the agent bills out of band (flat license, SaaS contract, bundled enterprise agreement); buyers should skip pricing fields and tolerate `report_usage` returning `accepted: 0` with `BILLING_OUT_OF_BAND` errors. Pre-call discriminator for routing across creative agents. | ### governance diff --git a/docs/protocol/required-tasks.mdx b/docs/protocol/required-tasks.mdx index 8a0a2e7318..b5fe797a3d 100644 --- a/docs/protocol/required-tasks.mdx +++ b/docs/protocol/required-tasks.mdx @@ -66,6 +66,7 @@ Orchestrators are not MCP/A2A servers — they call sales agent tasks. Conforman | Task | Requirement | Notes | |------|------------|-------| | `list_creative_formats` | Required | Format discovery with technical specs | +| `list_transformers` | Conditional | Required when the agent advertises `creative.supports_transformers: true` | | `build_creative` | Optional | Generation, transformation, or library retrieval | | `preview_creative` | Optional | Preview rendering | | `list_creatives` | Conditional | Required when `has_creative_library: true` | diff --git a/docs/reference/whats-new-in-3-1.mdx b/docs/reference/whats-new-in-3-1.mdx index ff7dba93cc..f02c59531c 100644 --- a/docs/reference/whats-new-in-3-1.mdx +++ b/docs/reference/whats-new-in-3-1.mdx @@ -29,6 +29,7 @@ This page is the curated adopter overview for the 3.1 minor release. Need the fu - **You can pin a release and stop fighting drift.** Release-precision `adcp_version` (`"3.1"` at GA, exact pre-releases such as `"3.1-rc.4"` during validation) on every request; sellers advertise their full `supported_versions` set and echo what they actually served. SDK constructor pins are now a real thing. - **Sub-brands self-publish.** A brand can publish its own canonical `brand.json` on its own domain while the corporate house declares ownership via a portfolio pointer — same reciprocal pattern as IAB's `ads.txt` / `sellers.json`. - **Creative formats have a canonical vocabulary.** 12 canonical `format_kind` values + a publisher catalog discovery surface + a projection-ref mechanism. Creative agents also advertise buildable canonical outputs through `creative.supported_formats[]`; targetable entries SHOULD include stable `capability_id` values for `build_creative` routing. +- **You can discover and select creative transformers.** A new `list_transformers` task surfaces the account-scoped, agent-offered build units — voices, models, styles — the creative analog of media-buy products, with an `expand_params` mode that returns the enumerable option values (e.g. your configured voices) on the same tool. `build_creative` selects one with `transformer_id`, configures it with a typed `config` bag (strict validation — unknown keys and out-of-range values are rejected with field-attributed errors; vendor knobs go in `ext`), and fans out across catalog items (`max_creatives`) and alternatives (`max_variants` + `variant_axis`, with `keep_mode` advisory). A new `BuildCreativeVariantSuccess` response member carries per-variant manifests, recommendation/rank, and a per-leaf pricing receipt. Pricing moves onto the transformer (`pricing_options` `per_unit`), settled via `report_usage`. - **Video inventory can declare placement semantics.** Products and placements can declare OpenRTB-aligned `video_placement_types` so buyers can distinguish instream, accompanying-content, interstitial, and standalone video. - **Verification badges are version-scoped.** Public 3.1 badge issuance can run alongside active 3.0 badges; buyers pinned to a release read the matching badge version. - **Action discovery and proposals are mechanical.** Products advertise `allowed_actions[]`; media buys carry `available_actions[]`; proposals use `proposal_status` to say whether finalize is still required before `create_media_buy(proposal_id)`. @@ -52,6 +53,7 @@ Plus a long tail of error-code clarity, auth tightening, idempotency rules, TMP | **Package signal targeting** | No grouped buy-time surface for seller-offered named signals; storefronts overloaded audience fields or brief text | `targeting_overlay.signal_targeting_groups`: top-level `operator: "all"` with child `any` include groups and `none` exclusion groups. Product `signal_targeting_rules` declares selection mode, direct vs seller-planned resolution, and group limits | | **Wholesale feed webhooks** | None | Account-level webhooks registered via `sync_accounts.accounts[].notification_configs[]` carry `product.*` / `signal.*` / `wholesale_feed.bulk_change` change payloads with `applies_to.scope`; standard webhook signing and SSRF guards apply | | **Creative formats** | Format-by-name with per-publisher variants | 12 canonical `format_kind` values + publisher catalog discovery (`adagents.json formats[]`) + `v1_format_ref` for dual emission + size flexibility (fixed / multi-size / responsive); creative agents advertise buildable outputs in `creative.supported_formats[]` and SHOULD include `capability_id` on targetable entries | +| **Creative transformers** | `build_creative` built to a target format; render knobs implicit; format-attached `Format.input_format_ids` / `output_format_ids` / `pricing_options` | `list_transformers` discovers account-scoped build units (voices/models/styles) with an `expand_params` option-enumeration mode; `build_creative` selects one via `transformer_id`, takes a strictly-validated typed `config`, and fans out with `max_creatives` (per-catalog-item) + `max_variants`/`variant_axis` + advisory `keep_mode`; new `BuildCreativeVariantSuccess` member returns per-variant manifests, recommendation/`rank`, and per-leaf pricing receipts; rate lives on `transformer.pricing_options` (`per_unit`), settled via `report_usage`. The shipped `BuildCreativeSuccess` / `BuildCreativeMultiSuccess` are unchanged | | **Version negotiation** | Integer `adcp_major_version` per request | Release-precision `adcp_version` (e.g. `"3.1"` at GA or exact prereleases like `"3.1-rc.4"`) + `adcp.supported_versions` advertisement + envelope echo. Integer field remains as backwards-compatible legacy | | **Optimization goals** | `event` + `metric` kinds, vendor-agnostic | New `vendor_metric` kind — bind goals to vendor-attested metrics; `vendor_metric_optimization` per-product capability; three-precondition rejection rule | | **Capability declarations** | Per-protocol basics | New: `media_buy.buying_modes` for wholesale products, `signals.discovery_modes` for wholesale signals, `wholesale_feed_versioning`, `wholesale_feed_webhooks`, `supported_optimization_metrics`, `supported_target_kinds`, `media_buy.frequency_capping`, `media_buy.propagation_surfaces`, `creative.bills_through_adcp`, `capabilities.idempotency.in_flight_max_seconds` | @@ -159,6 +161,28 @@ Live in 3.1, additive over 3.0. Products carry `format_options[]`: a list of `Pr → Spec: [Canonical formats](/docs/creative/canonical-formats) · PRs [#3307](https://github.com/adcontextprotocol/adcp/pull/3307), [#4770](https://github.com/adcontextprotocol/adcp/pull/4770) +### Creative transformers — discover build capability, select, fan out, variant + +3.1 introduces **transformers**: the creative analog of media-buy products. A transformer is an agent-offered, account-scoped, selectable unit of build capability — a voice, a model, a style, a director — with a typed configuration surface and per-account pricing. The set is account-specific and dynamic (your configured voices aren't a global enum), so discovery flows agent → buyer the same way `get_products` surfaces account-scoped inventory. + +- **`list_transformers`** is the new discovery surface. It returns the transformers a creative agent offers for your account, each with `input_format_ids` / `output_format_ids`, a typed param schema, and (with `include_pricing`) a `per_unit` rate card. Its `expand_params` mode returns the account-scoped enumerable option values for a param — your actual configured voices, for example — on the same tool, instead of forcing you to hold a stale local list. Offered only by agents that set `creative.supports_transformers: true` in `get_adcp_capabilities`. + +- **`build_creative` selects and configures a transformer.** Pass `transformer_id` to pick one — the target format(s) MUST be a subset of its `output_format_ids` — and a typed `config` bag keyed to the transformer's params. Validation is strict: the agent MUST reject unknown keys and out-of-range values with field-attributed errors; vendor-specific knobs go in `ext`. + +- **Two fan-out axes.** `max_creatives` is the item/catalog axis: N distinct creatives, one per catalog item ("5 of 150" sampling) — distinct from `item_limit`, which caps the items used *within* one creative. `max_variants` (default 1) produces alternatives per creative along a `variant_axis` (`voice` | `theme` | `best_of_n` | `transformer_config` | `custom`, with optional `values[]` and `label`). `keep_mode` (`keep_all` | `keep_one` | `keep_some`) is advisory. Resolutions and quality levels are the **format** axis (`target_format_ids`), not variants. + +- **New `BuildCreativeVariantSuccess` response member** (the 5th success shape) carries `creatives[]`, each `{ build_creative_id, catalog_item_ref?, variants[] }`; each variant is `{ build_variant_id, creative_manifest, variant_axis_value?, recommended, rank?, ... }` with a per-leaf pricing receipt (`pricing_option_id` + `vendor_cost` + `currency` + `consumption`). Top-level `items_total` / `items_returned` plus an aggregate `vendor_cost`. The shipped `BuildCreativeSuccess` / `BuildCreativeMultiSuccess` are **unchanged**. + +- **Best-of-N is variants + `keep_mode` + `recommended`/`rank`.** `build_variant_id` is its own namespace — never reuse `preview_id` (preview renders) or served `variant_id` (delivery). You **pay for all produced variants** (`per_unit` × N); keeping is a client act of trafficking the chosen `build_variant_id`(s). A kept variant lazily earns a `creative_id` (added to the library / first trafficked) which flows to `report_usage`. Per-**format** generation is atomic; per-**item** (catalog fan-out) is non-atomic. + +- **Pricing moves onto the transformer.** The rate lives on `transformer.pricing_options` (`per_unit`), echoed inline as a per-leaf receipt on `build_creative`, and settled via `report_usage`. `Format.pricing_options` is **deprecated** in favor of `transformer.pricing_options`. + + +**Deprecations (3.1, removed in 4.0).** `Format.input_format_ids`, `Format.output_format_ids`, and `Format.pricing_options`, plus the `input_format_ids` / `output_format_ids` filters on `list_creative_formats`, are deprecated and all redirect to `list_transformers`. SDKs honor them through 3.1–3.x; they are removed at 4.0. Migrate format-attached input/output/pricing reads to `list_transformers`. + + +→ Spec: [`list_transformers`](/docs/creative/task-reference/list_transformers) · [`build_creative`](/docs/creative/task-reference/build_creative) · [`get_adcp_capabilities` § `creative.supports_transformers`](/docs/protocol/get_adcp_capabilities) + ### Release-precision version negotiation — pin your release Every request and response now carries `adcp_version` (release-precision: `"3.1"` at GA, exact pre-releases like `"3.1-rc.4"` during validation); sellers advertise their full `supported_versions` set on `get_adcp_capabilities` and echo the release they actually served at the envelope root. SDKs pin via a constructor option (`adcpVersion: "3.1"` JS, `adcp_version="3.1"` Python, `WithAdcpVersion("3.1")` Go at GA; exact prerelease strings before GA) and emit both the new string and the integer `adcp_major_version` mirror for compatibility with sellers that only read the legacy field. The integer remains functional through all of 3.x — additive ship, no required changes for 3.0-conformant agents. `VERSION_UNSUPPORTED` is typed with `error.data.supported_versions[]` echoed so retry doesn't require an out-of-band lookup. @@ -278,6 +302,7 @@ Normative tightenings landed as the spec settled through beta and RC. Mostly low | A sales / signals agent serving wholesale feed mirrors at scale | Declare `wholesale_feed_webhooks.supported: true` and support webhook registration through `sync_accounts.accounts[].notification_configs[]`, with standard account-level webhook signing, endpoint proof-of-control before activation, SSRF guards, account/caller authorization checks, and the notification-config fan-out cap. Keep `event_types[]` consistent with declared repair reads: `product.*` requires wholesale `get_products`, `signal.*` requires wholesale `get_signals`. The webhook emitter MUST include the actual changed product/signal payload or bulk-change summary and apply the same per-caller scope filter as your wholesale task at event-emission time — multi-tenant agents that can't reliably scope events per-principal MUST NOT declare the capability. | | A measurement vendor (attention, brand lift, emissions, retail) | Publish your experimental `measurement.metrics[]` catalog at your AdCP agent and declare `experimental_features: ["measurement.core"]`. Sellers declaring `vendor_metric_optimization` per product can now bind optimization goals to your `(vendor, metric_id)` pairs. | | A creative agent | Declare `capabilities.creative.bills_through_adcp` honestly. If you bill out of band, reject `report_usage` calls with `BILLING_OUT_OF_BAND` rather than silently accepting. | +| A creative agent offering transformers | Declare `creative.supports_transformers: true` and serve `list_transformers` (including the `expand_params` option-enumeration mode). On `build_creative`, validate the typed `config` strictly — reject unknown keys and out-of-range values with field-attributed errors, route vendor knobs through `ext`, and verify each target format is a subset of the transformer's `output_format_ids`. Return `BuildCreativeVariantSuccess` with per-leaf pricing receipts when fanning out across catalog items or variants. Move rate cards from `Format.pricing_options` to `transformer.pricing_options` and settle through `report_usage`. | | Maintaining SDK fixtures, compliance mirrors, or release packaging | Keep compliance vector and test-kit references inside the packaged `/compliance/{version}/` tree. Treat missing bundle-relative references as release blockers. | | An SDK author | Pin `published_version` to the published 3.1 artifact you bundle; emit `adcp_version` (release-precision string) plus `adcp_major_version` (integer mirror); normalize semver values to release-precision before wire emission (`"3.1.0-rc.4"` → `"3.1-rc.4"`); surface `VERSION_UNSUPPORTED` as a typed error rather than auto-downshifting. | diff --git a/static/schemas/source/core/format.json b/static/schemas/source/core/format.json index de1262c1f4..ab8716e312 100644 --- a/static/schemas/source/core/format.json +++ b/static/schemas/source/core/format.json @@ -554,14 +554,16 @@ }, "input_format_ids": { "type": "array", - "description": "Array of format IDs this format accepts as input creative manifests. When present, indicates this format can take existing creatives in these formats as input. Omit for formats that work from raw assets (images, text, etc.) rather than existing creatives.", + "deprecated": true, + "description": "**DEPRECATED in 3.1. Removed at 4.0.** Use `list_transformers` instead — a transformer declares its own `input_format_ids`/`output_format_ids`, so build capability is a property of the transformer (the unit you select and that carries pricing), not a relationship hung on a format. Discover build capability via `list_transformers` (optionally filtered by `input_format_ids`/`output_format_ids`).\n\nMigration: sellers that expressed transform capability by hanging `input_format_ids` on a format SHOULD declare a transformer via `list_transformers` instead. Buyers SHOULD discover build capability via `list_transformers` rather than filtering formats.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* array of format IDs this format accepts as input creative manifests; when present, indicates this format can take existing creatives in these formats as input. SDKs reading 3.1 catalogs MUST continue to honor this field when present; 4.0+ SDKs MAY reject it. New code SHOULD NOT emit this field.", "items": { "$ref": "/schemas/core/format-id.json" } }, "output_format_ids": { "type": "array", - "description": "Array of format IDs that this format can produce as output. When present, indicates this format can build creatives in these output formats (e.g., a multi-publisher template format might produce standard display formats across many publishers). Omit for formats that produce a single fixed output (the format itself).", + "deprecated": true, + "description": "**DEPRECATED in 3.1. Removed at 4.0.** Use `list_transformers` instead — a transformer declares its own `output_format_ids`, so what a builder can produce is a property of the transformer, not a relationship hung on a format. Discover via `list_transformers`.\n\nMigration: sellers that expressed multi-output build capability (e.g. a multi-publisher template) by hanging `output_format_ids` on a format SHOULD declare a transformer via `list_transformers` instead.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* array of format IDs this format can produce as output; when present, indicates this format can build creatives in these output formats. SDKs reading 3.1 catalogs MUST continue to honor this field when present; 4.0+ SDKs MAY reject it. New code SHOULD NOT emit this field.", "items": { "$ref": "/schemas/core/format-id.json" } @@ -666,7 +668,8 @@ }, "pricing_options": { "type": "array", - "description": "Pricing options for this format. Used by transformation and generation agents that charge per format adapted, per image generated, or per unit of work. Present when the request included include_pricing=true and account. Ad servers and library-based agents expose pricing on list_creatives instead.", + "deprecated": true, + "description": "**DEPRECATED in 3.1. Removed at 4.0.** Use `transformer.pricing_options` (via `list_transformers`) instead — pricing belongs on the transformer (the unit selected and billed), exactly as it belongs on a media-buy product. Once formats only describe output shape, format-level pricing is vestigial.\n\nMigration: transformation/generation agents that charged via `format.pricing_options` SHOULD move the same `vendor-pricing-option` entries onto the corresponding transformer. The applied option is echoed per-leaf on the build_creative response and reconciled via report_usage, unchanged.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* pricing options for this format, used by transformation/generation agents that charge per format adapted, per image generated, or per unit of work; present when the request included include_pricing=true and account. SDKs reading 3.1 catalogs MUST continue to honor this field when present; 4.0+ SDKs MAY reject it. New code SHOULD NOT emit this field.", "items": { "$ref": "/schemas/core/vendor-pricing-option.json" }, diff --git a/static/schemas/source/core/transformer-param.json b/static/schemas/source/core/transformer-param.json new file mode 100644 index 0000000000..d422d6c4de --- /dev/null +++ b/static/schemas/source/core/transformer-param.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/transformer-param.json", + "title": "Transformer Param", + "description": "Descriptor for one configuration knob a transformer exposes. Returned inside transformer.json `params[]` from list_transformers. The descriptor declares the knob's shape; the buyer supplies its value in build_creative `config` keyed by `field`. For `value_source: enumerable` params (e.g. account-specific voices), the legal `options[]` are account-scoped and dynamic — they are returned only when the param's `field` is named in the list_transformers `expand_params` request, and may be paginated via `options_cursor`.", + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "The config key. Buyers set the value under this exact key in build_creative `config`." + }, + "type": { + "type": "string", + "enum": ["string", "number", "integer", "boolean"], + "description": "JSON type of the value the buyer supplies for this field." + }, + "value_source": { + "type": "string", + "enum": ["inline", "range", "enumerable"], + "description": "Where the legal values come from. `inline` — a small closed set listed in `allowed_values` (e.g. mastering_preset). `range` — a numeric interval bounded by `minimum`/`maximum` (e.g. speaking_rate). `enumerable` — an account-scoped, dynamic set (e.g. voices, including custom/cloned ones) resolved per-credential; values appear in `options[]` only when expanded." + }, + "allowed_values": { + "type": "array", + "description": "The closed set of legal values. Present when `value_source` is `inline`.", + "items": {}, + "minItems": 1 + }, + "minimum": { + "type": "number", + "description": "Inclusive lower bound. Present when `value_source` is `range`." + }, + "maximum": { + "type": "number", + "description": "Inclusive upper bound. Present when `value_source` is `range`." + }, + "options": { + "type": "array", + "description": "Account-scoped legal values for an `enumerable` param. Populated ONLY when this param's `field` was named in the list_transformers `expand_params` request — otherwise omitted (the buyer enumerates on demand). Brief-filtered and paginated; use `options_cursor` for the next page.", + "items": { + "type": "object", + "properties": { + "value": { + "description": "The value the buyer passes in `config` for this field." + }, + "label": { + "type": "string", + "description": "Human-readable label for this option." + }, + "metadata": { + "type": "object", + "description": "Option-specific attributes the buyer can filter or display (e.g. for a voice: language, gender, provider, custom).", + "additionalProperties": true + } + }, + "required": ["value"], + "additionalProperties": true + } + }, + "options_cursor": { + "type": "string", + "description": "Opaque pagination cursor for this param's `options[]`. Present when more option values are available than were returned. Pass back to list_transformers (scoped to this transformer + field) to fetch the next page." + }, + "default": { + "description": "The value applied when the buyer omits this field from `config`. Type matches `type`." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the buyer MUST supply this field in `config`. When false and no `default` is declared, the agent chooses." + }, + "description": { + "type": "string", + "description": "Human-readable explanation of what this knob does." + } + }, + "required": ["field", "type", "value_source"], + "additionalProperties": true, + "examples": [ + { + "field": "voice", + "type": "string", + "value_source": "enumerable", + "default": "sara", + "description": "Narration voice, including account-specific custom/cloned voices.", + "options": [ + { "value": "isaac", "label": "Isaac", "metadata": { "language": "en-US", "gender": "male", "provider": "audiostack" } }, + { "value": "ceo_clone_2026", "label": "CEO (custom)", "metadata": { "language": "en-US", "custom": true } } + ], + "options_cursor": "eyJvZmZzZXQiOjI1fQ==" + }, + { + "field": "mastering_preset", + "type": "string", + "value_source": "inline", + "allowed_values": ["broadcast", "podcast", "music"], + "default": "broadcast", + "description": "Audio mastering profile applied to the final mix." + }, + { + "field": "speaking_rate", + "type": "number", + "value_source": "range", + "minimum": 0.5, + "maximum": 2.0, + "default": 1.0, + "description": "Narration speed multiplier." + } + ] +} diff --git a/static/schemas/source/core/transformer.json b/static/schemas/source/core/transformer.json new file mode 100644 index 0000000000..7cdbc44d38 --- /dev/null +++ b/static/schemas/source/core/transformer.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/transformer.json", + "title": "Transformer", + "description": "An agent-offered, account-scoped, selectable unit of creative build capability — the creative analog of a media-buy product. A transformer maps input formats to output formats and exposes typed configuration `params[]`. Discovered via list_transformers and selected by `transformer_id` in build_creative. The agent chooses granularity: a distinct voice or model may be its own transformer, or a single transformer may expose `voice`/`model` as an enumerable param. Whether something is a distinct transformer vs. a param is the agent's call — surface separately-priced or capability-distinct alternatives as transformers; keep scalar dials as params.", + "type": "object", + "properties": { + "transformer_id": { + "type": "string", + "x-entity": "transformer", + "description": "Stable identifier for this transformer within the agent. Pass to build_creative `transformer_id` to select it." + }, + "name": { + "type": "string", + "description": "Human-readable transformer name (e.g. 'Voiceover — Isaac', 'Veo 3 text-to-video')." + }, + "description": { + "type": "string", + "description": "Plain-text explanation of what this transformer produces and how." + }, + "metadata": { + "type": "object", + "description": "Transformer-specific attributes a buyer can filter or display (e.g. provider, modality, language).", + "additionalProperties": true + }, + "input_format_ids": { + "type": "array", + "description": "Formats this transformer accepts as input. Empty or omitted means it builds from a brief / raw assets (pure generation) rather than transforming an existing creative.", + "items": { "$ref": "/schemas/core/format-id.json" } + }, + "output_format_ids": { + "type": "array", + "description": "Formats this transformer can produce. A build_creative request's target format(s) MUST be a subset of these.", + "items": { "$ref": "/schemas/core/format-id.json" }, + "minItems": 1 + }, + "params": { + "type": "array", + "description": "Configuration knobs this transformer exposes. The buyer supplies values in build_creative `config`, keyed by each param's `field`. Enumerable param values (e.g. account-specific voices) are returned only when requested via list_transformers `expand_params`.", + "items": { "$ref": "/schemas/core/transformer-param.json" } + }, + "pricing_options": { + "type": "array", + "description": "Per-account rate-card options for using this transformer. Present when the list_transformers request set include_pricing=true with an account. The applied option is echoed back per-leaf on the build_creative response and reconciled via report_usage.", + "items": { "$ref": "/schemas/core/vendor-pricing-option.json" }, + "minItems": 1 + } + }, + "required": ["transformer_id", "name", "output_format_ids"], + "additionalProperties": true, + "examples": [ + { + "transformer_id": "audiostack_voiceover", + "name": "Voiceover", + "description": "Script-to-audio voiceover with account-configured voices.", + "metadata": { "provider": "audiostack", "modality": "audio" }, + "input_format_ids": [{ "agent_url": "https://creative.audiostack.example", "id": "script" }], + "output_format_ids": [{ "agent_url": "https://creative.audiostack.example", "id": "audio_vo" }], + "params": [ + { "field": "voice", "type": "string", "value_source": "enumerable", "default": "sara", "description": "Narration voice, incl. custom voices." }, + { "field": "mastering_preset", "type": "string", "value_source": "inline", "allowed_values": ["broadcast", "podcast", "music"], "default": "broadcast" }, + { "field": "speaking_rate", "type": "number", "value_source": "range", "minimum": 0.5, "maximum": 2.0, "default": 1.0 } + ], + "pricing_options": [ + { + "pricing_option_id": "vo_per_second_standard", + "model": "per_unit", + "unit": "second", + "unit_price": 0.05, + "currency": "USD" + } + ] + } + ] +} diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index c4cb2d79bf..9d01db349a 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -18,6 +18,7 @@ "vendor_pricing_option", "creative", "creative_format", + "transformer", "audience", "signal", "signal_activation_id", @@ -52,6 +53,7 @@ "vendor_pricing_option": "A pricing tier offered by a vendor agent (rights agent, signals agent, creative agent, governance agent) for its own services. `pricing_option_id` via `core/vendor-pricing-option.json`, also surfaced in `brand/acquire-rights-*`, `signals/activate-signal-request`, `media-buy/build-creative-response`, and `creative/get-creative-features-response`. Scoped to the issuing agent; not interchangeable with `product_pricing_option`.", "creative": "A creative asset (library entry, buyer-assigned). `creative_id` across creative/*, brand/creative-approval-*, and media-buy/package-request.", "creative_format": "A format spec identified by the composite of `agent_url` + `id` (see core/format-id.json).", + "transformer": "An account-scoped creative build capability offered by a creative agent (the creative analog of a product) — a voice, model, style, or director with typed config params and per-account pricing. `transformer_id` via `core/transformer.json`, discovered in `creative/list-transformers-response` and selected in `media-buy/build-creative-request`. Scoped to the issuing creative agent.", "audience": "A buyer-managed audience (CRM, lookalike seed, suppression). `audience_id` in media-buy/sync-audiences-request.", "signal": "A data signal. New discovery, activation, product targeting, and buy-time surfaces use `signal_ref`; legacy `signal_id` objects are deprecated. Opaque activation ids use `signal_activation_id` instead.", "signal_activation_id": "Opaque identifier used to select or activate a signal from a signals agent or seller-offered signal feed. `signal_agent_segment_id` in signals/* schemas and product signal targeting option declarations. Scoped to the agent that issued it; not interchangeable with `signal`.", diff --git a/static/schemas/source/creative/list-creative-formats-request.json b/static/schemas/source/creative/list-creative-formats-request.json index 91fbeed528..fffb6897cf 100644 --- a/static/schemas/source/creative/list-creative-formats-request.json +++ b/static/schemas/source/creative/list-creative-formats-request.json @@ -110,7 +110,8 @@ }, "output_format_ids": { "type": "array", - "description": "Filter to formats whose output_format_ids includes any of these format IDs. Returns formats that can produce these outputs — inspect each result's input_format_ids to see what inputs they accept.", + "deprecated": true, + "description": "**DEPRECATED in 3.1.** Discover build capability via `list_transformers` (filter its `output_format_ids`) instead — build capability is a property of transformers, not a relationship between formats. *Legacy:* filter to formats whose `output_format_ids` includes any of these format IDs.", "items": { "$ref": "/schemas/core/format-id.json" }, @@ -118,7 +119,8 @@ }, "input_format_ids": { "type": "array", - "description": "Filter to formats whose input_format_ids includes any of these format IDs. Returns formats that accept these creatives as input — inspect each result's output_format_ids to see what they can produce.", + "deprecated": true, + "description": "**DEPRECATED in 3.1.** Discover build capability via `list_transformers` (filter its `input_format_ids`) instead. *Legacy:* filter to formats whose `input_format_ids` includes any of these format IDs.", "items": { "$ref": "/schemas/core/format-id.json" }, diff --git a/static/schemas/source/creative/list-transformers-request.json b/static/schemas/source/creative/list-transformers-request.json new file mode 100644 index 0000000000..c96132f701 --- /dev/null +++ b/static/schemas/source/creative/list-transformers-request.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/creative/list-transformers-request.json", + "title": "List Transformers Request (Creative Agent)", + "description": "Request parameters for discovering account-scoped creative transformers offered by this creative agent. Brief-filterable and paginated. Transformers are the creative analog of media-buy products: agent-offered, account-scoped, selectable units of build capability. Use `expand_params` to additionally return account-scoped option VALUES (e.g. the buyer's configured voices) for named enumerable params.", + "type": "object", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" }, + { + "if": { + "properties": { "include_pricing": { "const": true } }, + "required": ["include_pricing"] + }, + "then": { "required": ["account"] } + } + ], + "properties": { + "transformer_ids": { + "type": "array", + "description": "Return only these specific transformer IDs.", + "items": { "type": "string", "x-entity": "transformer" }, + "minItems": 1 + }, + "input_format_ids": { + "type": "array", + "description": "Filter to transformers that accept any of these formats as input.", + "items": { "$ref": "/schemas/core/format-id.json" }, + "minItems": 1 + }, + "output_format_ids": { + "type": "array", + "description": "Filter to transformers that can produce any of these output formats.", + "items": { "$ref": "/schemas/core/format-id.json" }, + "minItems": 1 + }, + "name_search": { + "type": "string", + "description": "Search transformers by name (case-insensitive partial match)." + }, + "brief": { + "type": "string", + "description": "Natural-language brief used to rank and filter transformers (and their enumerable option values when expanded) — e.g. 'warm female Spanish-language voiceover'. Curates to intent rather than returning the full set, the way get_products curates inventory." + }, + "expand_params": { + "type": "array", + "description": "Param `field` names for which to return account-scoped option VALUES inline on each transformer's `params[].options[]`. Omit to return param descriptors without enumerated values (the lean default). Paginated per-param via `options_cursor`.", + "items": { "type": "string" }, + "minItems": 1 + }, + "include_pricing": { + "type": "boolean", + "default": false, + "description": "Include `pricing_options` on each transformer. Requires `account`." + }, + "account": { + "$ref": "/schemas/core/account-ref.json", + "description": "Account reference. Transformers are account-scoped — the returned set, the enumerable option values, and (with include_pricing) the rate card are all resolved for this credential." + }, + "pagination": { "$ref": "/schemas/core/pagination-request.json" }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/creative/list-transformers-response.json b/static/schemas/source/creative/list-transformers-response.json new file mode 100644 index 0000000000..84ff46c7d9 --- /dev/null +++ b/static/schemas/source/creative/list-transformers-response.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/creative/list-transformers-response.json", + "title": "List Transformers Response (Creative Agent)", + "description": "Response payload for list_transformers — account-scoped transformer descriptors matching the query, with optional per-param enumerated option values (when expanded) and per-account pricing (when requested).", + "type": "object", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" }, + { "$ref": "/schemas/core/protocol-envelope.json" } + ], + "properties": { + "transformers": { + "type": "array", + "description": "Transformer descriptors matching the query.", + "items": { "$ref": "/schemas/core/transformer.json" } + }, + "errors": { + "type": "array", + "description": "Task-specific errors and warnings.", + "items": { "$ref": "/schemas/core/error.json" } + }, + "pagination": { "$ref": "/schemas/core/pagination-response.json" }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["transformers"], + "additionalProperties": true +} diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 237232ffe9..871f5bb7ed 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -410,6 +410,14 @@ "$ref": "/schemas/core/creative-consumption.json", "description": "Structured consumption details returned by build_creative when a paid creative agent computes cost. Well-known fields for tokens, images, renders, and processing time." }, + "transformer": { + "$ref": "/schemas/core/transformer.json", + "description": "An agent-offered, account-scoped, selectable unit of creative build capability (the creative analog of a media-buy product). Maps input formats to output formats and exposes typed config params. Discovered via list_transformers, selected by transformer_id in build_creative." + }, + "transformer-param": { + "$ref": "/schemas/core/transformer-param.json", + "description": "Descriptor for one configuration knob a transformer exposes (field, type, value_source inline|range|enumerable, allowed values/range/account-scoped options, default)." + }, "property-id": { "$ref": "/schemas/core/property-id.json", "description": "Identifier for a publisher property - lowercase alphanumeric with underscores only" @@ -1256,6 +1264,16 @@ "description": "Response payload with full format definitions - this is the authoritative source for format specifications" } }, + "list-transformers": { + "request": { + "$ref": "/schemas/creative/list-transformers-request.json", + "description": "Request parameters for discovering account-scoped creative transformers (the creative analog of products), with optional brief filtering, per-param option expansion, and pricing" + }, + "response": { + "$ref": "/schemas/creative/list-transformers-response.json", + "description": "Response payload with transformer descriptors — input/output formats, typed config params, account-scoped enumerable option values when expanded, and per-account pricing" + } + }, "get-creative-delivery": { "request": { "$ref": "/schemas/creative/get-creative-delivery-request.json", diff --git a/static/schemas/source/media-buy/build-creative-request.json b/static/schemas/source/media-buy/build-creative-request.json index 3ceff4a6dc..1b201f8238 100644 --- a/static/schemas/source/media-buy/build-creative-request.json +++ b/static/schemas/source/media-buy/build-creative-request.json @@ -50,6 +50,60 @@ "minItems": 1, "description": "Array of format IDs to generate in a single call. Mutually exclusive with target_format_id. The creative agent produces one manifest per format. Each format definition specifies its own required input assets and output structure." }, + "transformer_id": { + "type": "string", + "x-entity": "transformer", + "description": "Selects an account-scoped transformer (discovered via list_transformers) to perform the build. One transformer per call. When present, the build uses this transformer and target_format_id/target_format_ids select which of its outputs to produce — they MUST be a subset of the transformer's output_format_ids. Render configuration goes in `config`." + }, + "config": { + "type": "object", + "description": "Typed render configuration for the selected transformer, keyed by each param's `field` (from the transformer's params[] in list_transformers). Example: { \"voice\": \"isaac\", \"speaking_rate\": 1.1, \"mastering_preset\": \"podcast\" }. The agent MUST validate `config` against the transformer's live params for this account and reject unrecognized keys and out-of-range / non-enumerated values with a field-attributed error (e.g. `config.voice`) rather than silently ignoring them — config drives a paid render. Genuinely vendor-specific or experimental knobs not declared as params belong in `ext`, not here. (The schema leaves this object open because legal keys are dynamic per transformer; strict validation is a normative agent obligation.)", + "additionalProperties": true + }, + "max_creatives": { + "type": "integer", + "minimum": 1, + "description": "Caps how many DISTINCT creatives to produce along the catalog/item fan-out axis — one creative per catalog item. Use it to sample a large catalog (e.g. send 150 job openings, set max_creatives: 5 to preview five). Distinct from item_limit, which caps how many catalog items a SINGLE creative consumes (DCO-style). Omitted with a catalog input means one creative per item up to the catalog/format bound; omitted without a catalog collapses to a single creative. Large fan-outs may return asynchronously." + }, + "max_variants": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Caps how many ALTERNATIVES to produce per creative (different voices, themes, best-of-N, etc.). Default 1 preserves single-output behavior. Each variant is a real, independently-billed build (you pay for all produced); the buyer keeps one or many. When variant_axis.values[] is provided, its length is authoritative over max_variants. Resolutions/quality tiers are NOT variants — request them as additional target formats." + }, + "variant_axis": { + "type": "object", + "description": "Declares the dimension along which variants differ. When `values` is provided, the agent produces exactly one variant per value (e.g. an A/B of two voices). When only `dimension` is provided, the agent chooses up to max_variants variants along that dimension (e.g. best-of-N, themes).", + "properties": { + "dimension": { + "type": "string", + "enum": ["voice", "theme", "best_of_n", "transformer_config", "custom"], + "description": "What varies across variants. `transformer_config` varies a named config param (name it in `field`); `best_of_n` lets the agent explore and rank; `custom` is agent-defined (describe in `label`)." + }, + "field": { + "type": "string", + "description": "The transformer `config` param `field` this axis sweeps. REQUIRED when `dimension` is `transformer_config`; `values[]` are then interpreted as values for `config[field]`. Omit for other dimensions." + }, + "values": { + "type": "array", + "description": "Caller-fixed set of values for the axis (e.g. [\"isaac\", \"sara\"] for dimension `voice`). When present, len(values) is the variant count and is authoritative over max_variants.", + "items": {}, + "minItems": 1 + }, + "label": { + "type": "string", + "description": "Human-readable description of the axis, especially for `custom`." + } + }, + "required": ["dimension"], + "additionalProperties": true + }, + "keep_mode": { + "type": "string", + "enum": ["keep_all", "keep_one", "keep_some"], + "default": "keep_all", + "description": "Advisory hint for how the buyer intends to use the variants. `keep_one` (best-of-N) and `keep_some` signal the agent to set `recommended`/`rank` on returned variants. Advisory only — it does not change what is returned or billed; every produced variant is returned and charged. Keeping is a client act of trafficking the chosen build_variant_id(s)." + }, "account": { "$ref": "/schemas/core/account-ref.json", "description": "Account reference for pricing and billing. When present, the creative agent applies account-specific pricing from the rate card, records the build against the account for billing, and can enforce account-level quotas or entitlements. Required by creative agents that charge for their services." @@ -65,7 +119,7 @@ "item_limit": { "type": "integer", "minimum": 1, - "description": "Maximum number of catalog items to use when generating. When a catalog asset contains more items than this limit, the creative agent selects the top items based on relevance or catalog ordering. When item_limit exceeds the format's max_items, the creative agent SHOULD use the lesser of the two. Ignored when the manifest contains no catalog assets." + "description": "Maximum number of catalog items a SINGLE creative consumes when generating (DCO-style — e.g. how many items fill one carousel/feed creative). When a catalog asset contains more items than this limit, the creative agent selects the top items based on relevance or catalog ordering. When item_limit exceeds the format's max_items, the creative agent SHOULD use the lesser of the two. Ignored when the manifest contains no catalog assets. Distinct from `max_creatives`, which fans OUT across catalog items to produce one distinct creative per item." }, "include_preview": { "type": "boolean", diff --git a/static/schemas/source/media-buy/build-creative-response.json b/static/schemas/source/media-buy/build-creative-response.json index 66032124aa..7e67f252ca 100644 --- a/static/schemas/source/media-buy/build-creative-response.json +++ b/static/schemas/source/media-buy/build-creative-response.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/build-creative-response.json", "title": "Build Creative Response", - "description": "Response payload for build_creative. Exactly one of four shapes: (1) synchronous single-format success — creative_manifest issued in-line (target_format_id request); (2) synchronous multi-format success — creative_manifests issued in-line (target_format_ids request); (3) terminal failure — an errors array; (4) submitted task envelope — status 'submitted' with task_id when the build is queued (e.g., slow generative or multi-minute LLM pipeline). The submitted branch MAY carry advisory errors for non-blocking warnings; terminal failures belong in the error branch. These four shapes are mutually exclusive — a response has exactly one.", + "description": "Response payload for build_creative. Exactly one of five shapes: (1) synchronous single-format success — creative_manifest issued in-line (target_format_id request); (2) synchronous multi-format success — creative_manifests issued in-line (target_format_ids request); (3) multiplicity success — creatives[] each carrying variants[], returned when the request used transformer_id with item (max_creatives) and/or variant (max_variants) fan-out; (4) terminal failure — an errors array; (5) submitted task envelope — status 'submitted' with task_id when the build is queued (e.g., slow generative or multi-minute LLM pipeline). The submitted branch MAY carry advisory errors for non-blocking warnings; terminal failures belong in the error branch. These five shapes are mutually exclusive — a response has exactly one.", "type": "object", "allOf": [ { @@ -272,6 +272,144 @@ ], "additionalProperties": true }, + { + "title": "BuildCreativeVariantSuccess", + "description": "Multiplicity success response. Returned when the request used item fan-out (max_creatives) and/or variant fan-out (max_variants / variant_axis), with or without a transformer_id. Carries creatives[] — one entry per produced creative (per catalog item when fanning out) — each holding variants[] alternatives. Every produced variant is a real, independently-billed build (you pay for all produced); the buyer keeps one or many by trafficking the chosen build_variant_id(s). Mutually exclusive with the other success/error/submitted shapes. Per-FORMAT remains atomic within a (creative) group; per-ITEM (catalog fan-out) is non-atomic — a failed item is reported via that creative's errors[] and does not fail the batch.", + "type": "object", + "properties": { + "creatives": { + "type": "array", + "description": "One entry per produced creative. With catalog fan-out, one entry per catalog item (bounded/sampled by max_creatives).", + "items": { + "type": "object", + "properties": { + "build_creative_id": { + "type": "string", + "description": "Build-time handle for this produced creative within this response. Distinct from a library creative_id — a build_creative_id is not yet persisted/servable; it acquires a creative_id when a chosen variant is trafficked or added to the library (lazy promotion)." + }, + "catalog_item_ref": { + "type": "object", + "description": "When this creative was produced by fanning out over a catalog, identifies the source item.", + "properties": { + "catalog_type": { + "type": "string", + "description": "The catalog type the item came from." + }, + "item_id": { + "type": "string", + "description": "Identifier of the catalog item this creative was built for." + } + }, + "required": ["item_id"], + "additionalProperties": true + }, + "variants": { + "type": "array", + "description": "Alternatives produced for this creative (voices, themes, best-of-N, etc.). At least one. Each is an independently-tagged, independently-billed build.", + "items": { + "type": "object", + "properties": { + "build_variant_id": { + "type": "string", + "description": "Build-time handle for this variant. Its OWN namespace — MUST NOT reuse a preview_id (preview renders) or a served variant_id (delivery). Reference it to traffic/keep this variant; on first trafficking it earns a library creative_id, which is what flows into report_usage." + }, + "creative_manifest": { + "$ref": "/schemas/core/creative-manifest.json", + "description": "The generated creative for this variant. Carries its own format_id." + }, + "variant_axis_value": { + "description": "The value of the variant axis that produced this variant (e.g. the voice id, theme name, or config value). Lets the buyer correlate the variant to its A/B cell." + }, + "recommended": { + "type": "boolean", + "description": "Set when keep_mode was keep_one/keep_some — flags the agent's recommended pick(s). Advisory." + }, + "rank": { + "type": "integer", + "minimum": 1, + "description": "Agent's ranking of this variant (1 = best) when it scored alternatives (best-of-N). Advisory." + }, + "pricing_option_id": { + "type": "string", + "x-entity": "vendor_pricing_option", + "description": "Which rate-card pricing option was applied for THIS variant leaf. Pass in report_usage after promotion." + }, + "vendor_cost": { + "type": "number", + "minimum": 0, + "description": "Cost incurred for this variant leaf, denominated in currency." + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code for vendor_cost." + }, + "consumption": { + "$ref": "/schemas/core/creative-consumption.json", + "description": "Structured consumption for this variant leaf. Informational; vendor_cost is the billing source of truth." + } + }, + "required": ["build_variant_id", "creative_manifest"], + "additionalProperties": true + }, + "minItems": 1 + }, + "errors": { + "type": "array", + "description": "Per-creative errors when this catalog item failed to build. Present only on failed items; does not fail the batch (per-item non-atomic). A failed entry carries errors[] and no variants[]; a successful entry carries variants[] and SHOULD NOT carry errors.", + "items": { "$ref": "/schemas/core/error.json" }, + "minItems": 1 + } + }, + "anyOf": [ + { "required": ["variants"] }, + { "required": ["errors"] } + ], + "additionalProperties": true + }, + "minItems": 1 + }, + "items_total": { + "type": "integer", + "minimum": 0, + "description": "Total catalog items eligible for the build (before max_creatives sampling). Lets the buyer see that creatives[] is a sample of a larger set." + }, + "items_returned": { + "type": "integer", + "minimum": 0, + "description": "Number of creatives returned in creatives[] (after max_creatives sampling)." + }, + "vendor_cost": { + "type": "number", + "minimum": 0, + "description": "Aggregate cost across all variant leaves, denominated in currency. MUST equal the sum of the per-leaf vendor_cost values (leaves are the source of truth)." + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code for the aggregate vendor_cost." + }, + "sandbox": { + "type": "boolean", + "description": "When true, this response contains simulated data from sandbox mode." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the earliest generated asset URL expires across all variants. Re-build after this time to get fresh URLs." + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "creatives" + ], + "additionalProperties": true + }, { "title": "BuildCreativeError", "description": "Error response — creative generation failed. Multi-format requests (`target_format_ids[]`) are atomic per `BuildCreativeMultiSuccess` semantics: a single format failing means the entire batch returns an error response, not a partial success. Buyers diagnose per-format failures by reading the `errors[]` array's per-format attribution (see the `errors` field description).", @@ -308,6 +446,11 @@ "creative_manifests" ] }, + { + "required": [ + "creatives" + ] + }, { "properties": { "status": { @@ -371,6 +514,11 @@ "required": [ "creative_manifests" ] + }, + { + "required": [ + "creatives" + ] } ] } diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 1ba080edde..581aa2989a 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -1091,6 +1091,11 @@ "description": "When true, this agent can transform or resize existing manifests via build_creative. The buyer provides a creative_manifest and a target_format_id, and the agent adapts the creative to the new format.", "default": false }, + "supports_transformers": { + "type": "boolean", + "description": "When true, this agent exposes account-scoped creative transformers via list_transformers (the creative analog of media-buy products) and accepts transformer_id + config on build_creative. Buyers SHOULD call list_transformers to discover available transformers, their typed config params (and account-scoped enumerable option values via expand_params), and pricing. When false or absent, the agent does not offer the transformer surface.", + "default": false + }, "supported_formats": { "type": "array", "description": "Canonical-formats path: format declarations describing which canonical formats this creative agent can produce via `build_creative`. Each entry uses the same `ProductFormatDeclaration` shape as a product's inline `format_options[i]` — `format_kind` discriminator + `params` (canonical's parameter schema including `slots`, dimensions, durations, codecs, character limits, platform_extensions, tracking_extensions). Replaces the v1 `list_creative_formats` discovery surface for creative agents.", From 0372f8c2706b9cf0c48cb9f11b9ac3972184c82a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 31 May 2026 09:13:55 -0400 Subject: [PATCH 02/14] feat(creative): pin build_variant_id as the leaf-level lineage anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens the build_variant_id this PR already introduces, per the back-half follow-on plan (#5219 review): it is the leaf-level anchor everything downstream (refinement parentage, build→delivery learning, QA re-rolls) keys on, so getting its identity and billing semantics right now avoids re-pointing later. - Register x-entity `build_variant`; tag the build_variant_id property with it. - State normatively: minted per produced variant; distinct from served variant_id and preview_id and from the call-level build_creative_id; lineage anchors on the leaf, not the call. - Billing: an untrafficked leaf has no report_usage key — billed via the inline per-leaf vendor_cost only; report_usage reconciliation applies once the leaf lazily earns a creative_id on trafficking. Co-Authored-By: Claude Opus 4.8 (1M context) --- static/schemas/source/core/x-entity-types.json | 2 ++ static/schemas/source/media-buy/build-creative-response.json | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index 9d01db349a..aa10ac510b 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -19,6 +19,7 @@ "creative", "creative_format", "transformer", + "build_variant", "audience", "signal", "signal_activation_id", @@ -54,6 +55,7 @@ "creative": "A creative asset (library entry, buyer-assigned). `creative_id` across creative/*, brand/creative-approval-*, and media-buy/package-request.", "creative_format": "A format spec identified by the composite of `agent_url` + `id` (see core/format-id.json).", "transformer": "An account-scoped creative build capability offered by a creative agent (the creative analog of a product) — a voice, model, style, or director with typed config params and per-account pricing. `transformer_id` via `core/transformer.json`, discovered in `creative/list-transformers-response` and selected in `media-buy/build-creative-request`. Scoped to the issuing creative agent.", + "build_variant": "A single produced creative variant leaf from build_creative — the leaf-level lineage anchor. `build_variant_id` on `media-buy/build-creative-response` BuildCreativeVariantSuccess `creatives[].variants[]`. Distinct from a served `variant_id` (delivery) and a `preview_id` (preview renders), and distinct from the call-level grouping `build_creative_id`. A build_variant lazily earns a durable `creative_id` only when trafficked; lineage joins (refinement parentage, build→delivery learning) key on this leaf id.", "audience": "A buyer-managed audience (CRM, lookalike seed, suppression). `audience_id` in media-buy/sync-audiences-request.", "signal": "A data signal. New discovery, activation, product targeting, and buy-time surfaces use `signal_ref`; legacy `signal_id` objects are deprecated. Opaque activation ids use `signal_activation_id` instead.", "signal_activation_id": "Opaque identifier used to select or activate a signal from a signals agent or seller-offered signal feed. `signal_agent_segment_id` in signals/* schemas and product signal targeting option declarations. Scoped to the agent that issued it; not interchangeable with `signal`.", diff --git a/static/schemas/source/media-buy/build-creative-response.json b/static/schemas/source/media-buy/build-creative-response.json index 7e67f252ca..520709638a 100644 --- a/static/schemas/source/media-buy/build-creative-response.json +++ b/static/schemas/source/media-buy/build-creative-response.json @@ -311,7 +311,8 @@ "properties": { "build_variant_id": { "type": "string", - "description": "Build-time handle for this variant. Its OWN namespace — MUST NOT reuse a preview_id (preview renders) or a served variant_id (delivery). Reference it to traffic/keep this variant; on first trafficking it earns a library creative_id, which is what flows into report_usage." + "x-entity": "build_variant", + "description": "Build-time handle for this produced variant — the leaf-level lineage anchor for the creative. Minted per produced variant. Its OWN namespace — MUST NOT reuse a `preview_id` (preview renders) or a served `variant_id` (delivery). Lineage and joins (refinement parentage, build→delivery learning, QA re-rolls) anchor on this leaf id, NOT on the call-level `build_creative_id`. A leaf lazily earns a durable `creative_id` only when trafficked / added to the library; that `creative_id` is what flows into `report_usage`. An untrafficked leaf has no `report_usage` key — it is billed via the inline per-leaf `vendor_cost` only, and `report_usage` reconciliation applies once the leaf earns a `creative_id`." }, "creative_manifest": { "$ref": "/schemas/core/creative-manifest.json", From f1bd802a1f1b68a3edd12474778f9a8cf8424476 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 31 May 2026 09:38:54 -0400 Subject: [PATCH 03/14] feat(creative): conversational refinement on build_creative Folds the back-half refinement capability into #5219 (per the follow-on plan). Refinement re-builds a prior produced variant with free-form direction via the open `message` field; it is build_creative + one input, not a new task. - Request: refine_from_build_variant_id (re-build from a prior leaf, NL message + config delta, NEW lineage-linked variants; never a mutation). transformer_id and target inherited from the parent; a differing value is INVALID_REQUEST. Composes with max_variants/variant_axis; mutually exclusive with max_creatives. Unknown/no-longer-retained ref -> REFERENCE_NOT_FOUND, error.field=refine_from_build_variant_id. - Capability: creative.supports_refinement (agent retains leaves for an agent-defined window) -- a get_adcp_capabilities boolean, consistent with supports_transformers/generation/transformation. - Response: parent_build_variant_id on the variant leaf (single parent -> chain); AI-derivative attribution rides the manifest's existing provenance. - Docs + capability row + changeset updated. Adversarially reviewed; fixed 7 findings (dangling per-leaf expires_at; details.entity -> error.field; transformer/target inheritance conflict; max_creatives back-ref; example fix; four-modes title). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/creative-transformers.md | 4 +++ .../task-reference/build_creative.mdx | 28 +++++++++++++++++-- docs/protocol/get_adcp_capabilities.mdx | 1 + .../media-buy/build-creative-request.json | 11 ++++++-- .../media-buy/build-creative-response.json | 5 ++++ .../get-adcp-capabilities-response.json | 5 ++++ 6 files changed, 49 insertions(+), 5 deletions(-) diff --git a/.changeset/creative-transformers.md b/.changeset/creative-transformers.md index 32e7e9158f..55f87f6184 100644 --- a/.changeset/creative-transformers.md +++ b/.changeset/creative-transformers.md @@ -17,6 +17,10 @@ New: - Request: `transformer_id` (select one transformer; target format(s) must be a subset of its `output_format_ids`), `config` (typed bag keyed to the transformer's params — agents MUST reject unknown/out-of-range values), `max_creatives` (catalog/item fan-out: N distinct creatives, one per item, with sampling), `max_variants` + `variant_axis` + `keep_mode` (alternatives per creative). - Response: a new `BuildCreativeVariantSuccess` member — `creatives[]` each carrying `variants[]`, with a `build_variant_id` namespace (distinct from preview `preview_id` and served `variant_id`), per-leaf pricing receipt, and `items_total`/`items_returned`. Best-of-N is variants + `recommended`/`rank`. You pay for all produced variants (`per_unit` × N); a kept variant lazily earns a `creative_id` on trafficking, which flows to `report_usage`. Per-format atomic; per-item non-atomic. +`build_variant` lineage + refinement: +- `build_variant_id` is now the leaf-level lineage anchor (`x-entity: build_variant`): minted per produced variant, distinct from the call-level `build_creative_id`, lazily earning a durable `creative_id` only on trafficking. Untrafficked leaves are billed via the inline per-leaf `vendor_cost` only; `report_usage` reconciliation applies once a leaf earns a `creative_id`. +- Conversational refinement: `build_creative` gains `refine_from_build_variant_id` — re-build a prior leaf with a natural-language instruction in `message` plus an optional `config` delta, returning new lineage-linked variants (each with `parent_build_variant_id`); never a mutation. Composes with `max_variants`/`variant_axis`, mutually exclusive with `max_creatives`. Gated by the new `get_adcp_capabilities` → `creative.supports_refinement` discriminator (`UNSUPPORTED_FEATURE` when unsupported; `REFERENCE_NOT_FOUND` for an unknown/expired ref). + Pricing rides the existing `per_unit` model + inline receipt + `report_usage` unchanged — transformers carry `pricing_options` (reusing `vendor-pricing-option.json`). Deprecations (deprecated in 3.1, removed at 4.0; SDKs MUST keep honoring them through 3.1–3.x): `Format.input_format_ids`, `Format.output_format_ids`, `Format.pricing_options`, and the `input_format_ids`/`output_format_ids` discovery filters on `list_creative_formats` — all superseded by `list_transformers`, which carries each transformer's own I/O signature and pricing. diff --git a/docs/creative/task-reference/build_creative.mdx b/docs/creative/task-reference/build_creative.mdx index 093f053903..52c66a9daf 100644 --- a/docs/creative/task-reference/build_creative.mdx +++ b/docs/creative/task-reference/build_creative.mdx @@ -32,10 +32,11 @@ For information about legacy format IDs and how to reference formats, see [Creat | `item_limit` | integer | No | Maximum number of catalog items to use **within one creative**. Caps generation cost for catalog-driven formats (e.g. a 6-card carousel built from a 1,000-product catalog). Distinct from `max_creatives`, which fans out across items. | | `transformer_id` | string | No | Select one transformer (discovered via [`list_transformers`](/docs/creative/task-reference/list_transformers)) to perform the build. The requested target format(s) MUST be a subset of that transformer's `output_format_ids`. The transformer's `per_unit` rate is the pricing source for the build. | | `config` | object | No | Typed configuration bag keyed to the selected transformer's `params[].field` (e.g. `{ "voice": "isaac", "speaking_rate": 1.1 }`). The creative agent MUST reject unknown keys and out-of-range values with field-attributed errors (strict validation) — vendor-specific knobs that are not declared params go in `ext`. Only meaningful with `transformer_id`. | -| `max_creatives` | integer | No | Catalog fan-out axis: produce up to N **distinct creatives, one per catalog item** (a sample — e.g. 5 of 150). Distinct from `item_limit`, which caps items used *within* a single creative. Triggers the `BuildCreativeVariantSuccess` response shape. | +| `max_creatives` | integer | No | Catalog fan-out axis: produce up to N **distinct creatives, one per catalog item** (a sample — e.g. 5 of 150). Distinct from `item_limit`, which caps items used *within* a single creative. Mutually exclusive with `refine_from_build_variant_id`. Triggers the `BuildCreativeVariantSuccess` response shape. | | `max_variants` | integer | No | Number of alternative renders to produce **per creative** (best-of-N). Default `1`. You pay for every produced variant; keeping one or more is a separate trafficking step. | | `variant_axis` | object | No | Describes the dimension along which variants differ. Object with `dimension` (`voice` \| `theme` \| `best_of_n` \| `transformer_config` \| `custom`), optional `values[]` (explicit values to enumerate along the axis), optional `field` (the `config` param to sweep — required when `dimension` is `transformer_config`), and optional `label`. | | `keep_mode` | string | No | Advisory hint to the agent on how many variants you intend to keep: `"keep_all"`, `"keep_one"`, or `"keep_some"`. Default `"keep_all"`. Advisory only — you are billed for all produced variants regardless. | +| `refine_from_build_variant_id` | string | No | Refine a prior produced variant: re-build from its `build_variant_id` applying the NL instruction in `message` plus any `config` delta, returning **new** lineage-linked variants (never a mutation). `transformer_id` and target format(s) are inherited from the parent. Composes with `max_variants`/`variant_axis`; mutually exclusive with `max_creatives`. Requires `creative.supports_refinement` — otherwise `UNSUPPORTED_FEATURE`; an unknown/no-longer-retained ref is `REFERENCE_NOT_FOUND` (`error.field` = `refine_from_build_variant_id`). See [Refinement](#refinement). | | `include_preview` | boolean | No | When true, requests preview renders alongside the manifest. Agents that support this return a `preview` object in the response. Agents that don't simply omit it. | | `preview_inputs` | array | No | Input sets for preview generation when `include_preview` is true. Each entry has `name` (required), optional `macros`, and optional `context_description`. If omitted, the agent generates a single default preview. Only supported with `target_format_id` (single-format) — ignored for multi-format requests. | | `preview_quality` | string | No | Render quality for inline previews: `"draft"` or `"production"`. Independent of the build `quality` — you can build at draft and preview at production, or vice versa. Only used when `include_preview` is true. | @@ -567,7 +568,8 @@ When the request fans out across catalog items (`max_creatives`) or produces alt - **creatives[].catalog_item_ref**: Present on catalog fan-out — an object identifying the source catalog item via `item_id` (plus optional `catalog_type`). - **creatives[].errors[]**: Present only on a *failed* catalog item — catalog fan-out is non-atomic, so a failed item is returned as a `creatives[]` entry carrying `errors[]` and no `variants[]`, without failing the batch. - **creatives[].variants[]**: The alternatives produced for this creative. Length is at most `max_variants`. Each variant carries its own complete `creative_manifest`. -- **variants[].build_variant_id**: Identifies a single variant. This is its **own namespace** — never reuse a `preview_id` (a `preview_creative` render) or a served `variant_id` (a delivery-time identifier) here. You traffic a chosen build by passing its `build_variant_id`. +- **variants[].build_variant_id**: Identifies a single variant — the leaf-level **lineage anchor**. This is its **own namespace** — never reuse a `preview_id` (a `preview_creative` render) or a served `variant_id` (a delivery-time identifier) here. You traffic a chosen build by passing its `build_variant_id`. +- **variants[].parent_build_variant_id**: Present only on a refined variant ([Refinement](#refinement)) — the `build_variant_id` of the source it was refined from. Absent for first-generation builds. - **variants[].variant_axis_value**: The value of the `variant_axis` dimension this variant represents (e.g. a voice, a theme). - **variants[].recommended** / **variants[].rank**: Best-of-N signals — `recommended` flags the agent's top pick; `rank` orders the variants. These plus `variant_axis` and `keep_mode` are how best-of-N is expressed. - **variants[] pricing receipt**: Each variant carries a per-leaf receipt (`pricing_option_id`, `vendor_cost`, `currency`, `consumption`) for the work that produced it. @@ -586,6 +588,28 @@ You pay for **every produced variant** (`per_unit` × N), not just the ones you **Resolutions and quality tiers are not variants.** Multiple sizes or quality levels belong on the format axis — pass them as `target_format_ids` (or `quality`), not as `variant_axis` values. Variants are alternatives *for the same format* (different voice, theme, or best-of-N take). +### Refinement + +Conversational refinement re-builds a prior variant with free-form direction — "make it warmer", "tighten the CTA". The typed `config` surface is closed by design, so free-form intent rides the **open** surface that already exists: the `message` field (whose description is already *"For refinement, this describes the desired changes"*). Refinement is therefore not a new task — it's `build_creative` with one extra input: + +- Pass `refine_from_build_variant_id` (a prior leaf's `build_variant_id`) plus your instruction in `message` and any `config` delta. +- The agent re-builds from that leaf and returns **new** variants, each with `parent_build_variant_id` set to the source. A refinement is **never a mutation** — the parent leaf is unchanged, and the new leaf earns its own `build_variant_id` (and, on trafficking, its own `creative_id`). +- `transformer_id` and the target format(s) are **inherited** from the parent; you don't repeat them. Passing a `transformer_id` or target format that differs from the parent's is an `INVALID_REQUEST`. `config` is applied as a **delta** over the parent's config. +- Composes with `max_variants` / `variant_axis` (e.g. "three warmer takes" → three refined leaves), but **not** with `max_creatives` / catalog fan-out — you refine one produced creative, not a catalog. +- Requires the agent to advertise `creative.supports_refinement: true` (it retains produced leaves for an agent-defined window). Agents that retain nothing return `UNSUPPORTED_FEATURE`; refine a buyer-held manifest instead via the transform path (`creative_manifest` + `message`). An unknown or no-longer-retained ref returns `REFERENCE_NOT_FOUND` with `error.field` set to `refine_from_build_variant_id`. + +AI-derivative attribution rides the manifest's existing `provenance`; `parent_build_variant_id` carries only the lineage edge (refinements chain into a tree). + +```json test=false +{ + "refine_from_build_variant_id": "bv_card01_a", + "message": "Warmer lighting, and move the logo to the lower-right.", + "max_variants": 3, + "account": { "account_id": "acct_acme" }, + "idempotency_key": "7f2a1b3c-4d5e-6f70-8192-a3b4c5d6e7f8" +} +``` + ### Field descriptions - **creative_manifest**: (single-format) The complete creative manifest ready for use with `sync_creatives` or `preview_creative` diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 31df1c05c3..ef27cd6b81 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -384,6 +384,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 (`required_disclosures`, `prohibited_claims`) and will validate that disclosures can be satisfied by the target format. Use the `disclosure_positions` filter on `list_creative_formats` to find compatible formats. | | `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`; refine via the transform path (`creative_manifest` + `message`) instead. | | `bills_through_adcp` | boolean | When `true`, this creative agent bills through the AdCP rate-card surface — `list_creatives` returns `pricing_options` (with `include_pricing=true` and an authenticated account), `build_creative` populates `pricing_option_id` and `vendor_cost`, and `report_usage` accepts records against the rate card. When `false` or absent, the agent bills out of band (flat license, SaaS contract, bundled enterprise agreement); buyers should skip pricing fields and tolerate `report_usage` returning `accepted: 0` with `BILLING_OUT_OF_BAND` errors. Pre-call discriminator for routing across creative agents. | ### governance diff --git a/static/schemas/source/media-buy/build-creative-request.json b/static/schemas/source/media-buy/build-creative-request.json index 1b201f8238..30360aa960 100644 --- a/static/schemas/source/media-buy/build-creative-request.json +++ b/static/schemas/source/media-buy/build-creative-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/build-creative-request.json", "title": "Build Creative Request", - "description": "Request to transform, generate, or retrieve a creative manifest. Supports three modes: (1) generation from a brief or seed assets, (2) transformation of an existing manifest, (3) retrieval from a creative library by creative_id. Produces target manifest(s) in the specified format(s). Provide either target_format_id for a single format or target_format_ids for multiple formats.", + "description": "Request to transform, generate, refine, or retrieve a creative manifest. Supports four modes: (1) generation from a brief or seed assets, (2) transformation of an existing manifest, (3) refinement of a prior produced variant via refine_from_build_variant_id (re-build with a natural-language message + config delta), (4) retrieval from a creative library by creative_id. Produces target manifest(s) in the specified format(s). Provide either target_format_id for a single format or target_format_ids for multiple formats.", "type": "object", "allOf": [ { @@ -57,13 +57,18 @@ }, "config": { "type": "object", - "description": "Typed render configuration for the selected transformer, keyed by each param's `field` (from the transformer's params[] in list_transformers). Example: { \"voice\": \"isaac\", \"speaking_rate\": 1.1, \"mastering_preset\": \"podcast\" }. The agent MUST validate `config` against the transformer's live params for this account and reject unrecognized keys and out-of-range / non-enumerated values with a field-attributed error (e.g. `config.voice`) rather than silently ignoring them — config drives a paid render. Genuinely vendor-specific or experimental knobs not declared as params belong in `ext`, not here. (The schema leaves this object open because legal keys are dynamic per transformer; strict validation is a normative agent obligation.)", + "description": "Typed render configuration for the selected transformer, keyed by each param's `field` (from the transformer's params[] in list_transformers). Example: { \"voice\": \"isaac\", \"speaking_rate\": 1.1, \"mastering_preset\": \"podcast\" }. The agent MUST validate `config` against the transformer's live params for this account and reject unrecognized keys and out-of-range / non-enumerated values with a field-attributed error (e.g. `config.voice`) rather than silently ignoring them — config drives a paid render. Genuinely vendor-specific or experimental knobs not declared as params belong in `ext`, not here. (The schema leaves this object open because legal keys are dynamic per transformer; strict validation is a normative agent obligation.) When `refine_from_build_variant_id` is set, `config` is applied as a DELTA over the parent leaf's config.", "additionalProperties": true }, + "refine_from_build_variant_id": { + "type": "string", + "x-entity": "build_variant", + "description": "Refine a previously produced variant: re-build from the referenced `build_variant_id`, applying the natural-language instruction in `message` and any `config` delta, and return NEW lineage-linked variant(s) — each with `parent_build_variant_id` set to this id. A refinement is never a mutation; the parent leaf is unchanged. The `transformer_id` and target format(s) are inherited from the parent and need not be repeated; passing a `transformer_id` or `target_format_id`/`target_format_ids` that differs from the parent's is rejected with `INVALID_REQUEST`. Composes with `max_variants` / `variant_axis` (produces N refined alternatives), but is mutually exclusive with `max_creatives` / catalog fan-out (you refine one prior creative, not a catalog). Requires the agent to advertise `creative.supports_refinement: true` in get_adcp_capabilities; agents that do not retain prior builds reject with `UNSUPPORTED_FEATURE`. A ref that is unknown or no longer retained (agents retain produced leaves for an agent-defined window) is rejected with `REFERENCE_NOT_FOUND`, with `error.field` set to `refine_from_build_variant_id`. To refine a buyer-held manifest when the agent retains nothing, use the transform path instead (`creative_manifest` + `message`)." + }, "max_creatives": { "type": "integer", "minimum": 1, - "description": "Caps how many DISTINCT creatives to produce along the catalog/item fan-out axis — one creative per catalog item. Use it to sample a large catalog (e.g. send 150 job openings, set max_creatives: 5 to preview five). Distinct from item_limit, which caps how many catalog items a SINGLE creative consumes (DCO-style). Omitted with a catalog input means one creative per item up to the catalog/format bound; omitted without a catalog collapses to a single creative. Large fan-outs may return asynchronously." + "description": "Caps how many DISTINCT creatives to produce along the catalog/item fan-out axis — one creative per catalog item. Use it to sample a large catalog (e.g. send 150 job openings, set max_creatives: 5 to preview five). Distinct from item_limit, which caps how many catalog items a SINGLE creative consumes (DCO-style). Omitted with a catalog input means one creative per item up to the catalog/format bound; omitted without a catalog collapses to a single creative. Large fan-outs may return asynchronously. Mutually exclusive with `refine_from_build_variant_id` (refinement targets one prior creative, not a catalog fan-out)." }, "max_variants": { "type": "integer", diff --git a/static/schemas/source/media-buy/build-creative-response.json b/static/schemas/source/media-buy/build-creative-response.json index 520709638a..27f562c574 100644 --- a/static/schemas/source/media-buy/build-creative-response.json +++ b/static/schemas/source/media-buy/build-creative-response.json @@ -314,6 +314,11 @@ "x-entity": "build_variant", "description": "Build-time handle for this produced variant — the leaf-level lineage anchor for the creative. Minted per produced variant. Its OWN namespace — MUST NOT reuse a `preview_id` (preview renders) or a served `variant_id` (delivery). Lineage and joins (refinement parentage, build→delivery learning, QA re-rolls) anchor on this leaf id, NOT on the call-level `build_creative_id`. A leaf lazily earns a durable `creative_id` only when trafficked / added to the library; that `creative_id` is what flows into `report_usage`. An untrafficked leaf has no `report_usage` key — it is billed via the inline per-leaf `vendor_cost` only, and `report_usage` reconciliation applies once the leaf earns a `creative_id`." }, + "parent_build_variant_id": { + "type": "string", + "x-entity": "build_variant", + "description": "When this variant was produced by refining a prior build (request `refine_from_build_variant_id`), the source leaf's `build_variant_id` — establishing refinement lineage (a leaf may itself be refined, forming a chain). Absent for first-generation builds. AI-derivative attribution rides the manifest's existing `provenance`; this field carries only the lineage edge." + }, "creative_manifest": { "$ref": "/schemas/core/creative-manifest.json", "description": "The generated creative for this variant. Carries its own format_id." diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 581aa2989a..bcfc6209c9 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -1096,6 +1096,11 @@ "description": "When true, this agent exposes account-scoped creative transformers via list_transformers (the creative analog of media-buy products) and accepts transformer_id + config on build_creative. Buyers SHOULD call list_transformers to discover available transformers, their typed config params (and account-scoped enumerable option values via expand_params), and pricing. When false or absent, the agent does not offer the transformer surface.", "default": false }, + "supports_refinement": { + "type": "boolean", + "description": "When true, this agent retains produced build_variant leaves for an agent-defined retention window and can re-build from one via build_creative's refine_from_build_variant_id — applying a natural-language instruction in message plus an optional config delta, returning new lineage-linked variants. A build-time agent capability independent of generation/transformation. When false or absent, refine_from_build_variant_id is rejected with UNSUPPORTED_FEATURE; buyers refine instead via the transform path (creative_manifest + message).", + "default": false + }, "supported_formats": { "type": "array", "description": "Canonical-formats path: format declarations describing which canonical formats this creative agent can produce via `build_creative`. Each entry uses the same `ProductFormatDeclaration` shape as a product's inline `format_options[i]` — `format_kind` discriminator + `params` (canonical's parameter schema including `slots`, dimensions, durations, codecs, character limits, platform_extensions, tracking_extensions). Replaces the v1 `list_creative_formats` discovery surface for creative agents.", From 5114ea63c9be9ea37f5d4529c25b6bdea21ae9e2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 31 May 2026 09:53:53 -0400 Subject: [PATCH 04/14] docs(creative): add creative-transformers migration guide + fix error codes/report_usage contradiction Persona/scenario review of #5219 flagged the migration surface as warranted by the repo's own convention (media-buy-status.mdx exists for a single-field deprecation; this PR deprecates a larger surface through a new task). - New docs/reference/migration/creative-transformers.mdx (mirrors media-buy-status structure): Format.input_format_ids/output_format_ids/pricing_options + the list_creative_formats discovery filters -> list_transformers; before/after; SDK honor-window table; and the three SILENT-degradation hazards (discovery-read going progressively empty, multi-publisher per-output pricing loss, best-of-N spend under-counting). Wired into both current docs.json migration groups and linked from the whats-new Warning. - Fix non-canonical error codes in list_transformers.mdx (AUTHENTICATION_REQUIRED -> AUTH_MISSING, INVALID_INPUT -> INVALID_REQUEST; verified against error-code.json). - Resolve the report_usage.mdx contradiction: a build_variant leaf earns a creative_id only on trafficking; discarded fan-out/best-of-N leaves are billed via the inline per-leaf vendor_cost, not report_usage. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs.json | 2 + docs/accounts/tasks/report_usage.mdx | 2 +- .../task-reference/list_transformers.mdx | 4 +- .../migration/creative-transformers.mdx | 86 +++++++++++++++++++ docs/reference/whats-new-in-3-1.mdx | 2 +- 5 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 docs/reference/migration/creative-transformers.mdx diff --git a/docs.json b/docs.json index 52db929f97..a9b3d7423d 100644 --- a/docs.json +++ b/docs.json @@ -93,6 +93,7 @@ "docs/reference/migration/pricing", "docs/reference/migration/geo-targeting", "docs/reference/migration/creatives", + "docs/reference/migration/creative-transformers", "docs/reference/migration/catalogs", "docs/reference/migration/optimization-goals", "docs/reference/migration/brand-identity", @@ -1299,6 +1300,7 @@ "docs/reference/migration/pricing", "docs/reference/migration/geo-targeting", "docs/reference/migration/creatives", + "docs/reference/migration/creative-transformers", "docs/reference/migration/catalogs", "docs/reference/migration/optimization-goals", "docs/reference/migration/brand-identity", diff --git a/docs/accounts/tasks/report_usage.mdx b/docs/accounts/tasks/report_usage.mdx index 8a54be96f9..2cf548b903 100644 --- a/docs/accounts/tasks/report_usage.mdx +++ b/docs/accounts/tasks/report_usage.mdx @@ -35,7 +35,7 @@ Each record requires `account`, `vendor_cost`, and `currency`. Additional fields | `impressions` | number | Signals: Yes | Impressions delivered | | `media_spend` | number | percent_of_media: Yes | Media spend for percent-of-media cost verification | | `signal_agent_segment_id` | string | Signals: Yes | Signal identifier from `get_signals` | -| `creative_id` | string | Creative: Yes | Creative identifier from `build_creative` or `list_creatives`. Links usage to a specific creative for billing verification. | +| `creative_id` | string | Creative: Yes | Creative identifier from `build_creative` or `list_creatives`. Links usage to a specific creative for billing verification. A `build_creative` variant leaf earns a `creative_id` only when trafficked/added to the library — discarded best-of-N or fan-out variants are never reported here; their charge is the inline per-leaf `vendor_cost` on the `build_creative` response (the authoritative record for untrafficked leaves). | | `property_list_id` | string | Property lists: Yes | Property list identifier from `list_property_lists`. Links usage to a specific property list for billing verification. | ## Response diff --git a/docs/creative/task-reference/list_transformers.mdx b/docs/creative/task-reference/list_transformers.mdx index a24200db88..e1a63f4bca 100644 --- a/docs/creative/task-reference/list_transformers.mdx +++ b/docs/creative/task-reference/list_transformers.mdx @@ -128,8 +128,8 @@ Pass the chosen `transformer_id` and a typed `config` (keyed by each param's `fi | Code | Meaning | Recovery | |------|---------|----------| -| `AUTHENTICATION_REQUIRED` | `include_pricing` requested without a resolvable account | Supply `account` / authenticate | -| `INVALID_INPUT` | Malformed filter or pagination cursor | Correct the request | +| `AUTH_MISSING` | `include_pricing` requested without a resolvable account | Supply `account` / authenticate | +| `INVALID_REQUEST` | Malformed filter or pagination cursor | Correct the request | Unknown `expand_params` (a `field` no transformer exposes) are ignored, not an error — the param simply returns no `options`. diff --git a/docs/reference/migration/creative-transformers.mdx b/docs/reference/migration/creative-transformers.mdx new file mode 100644 index 0000000000..f57b30c7b3 --- /dev/null +++ b/docs/reference/migration/creative-transformers.mdx @@ -0,0 +1,86 @@ +--- +title: "Migrating to creative transformers (3.1)" +description: "Move build-capability discovery and creative pricing off Format fields onto transformers (list_transformers). Additive in 3.1; deprecated Format fields removed at 4.0." +"og:title": "AdCP — Migrating to creative transformers" +--- + +# Migrating to creative transformers (3.1) + +AdCP 3.1 introduces **transformers** — the creative analog of a media-buy product: an agent-offered, account-scoped, selectable unit of build capability (a voice, model, style, or director) with a typed configuration surface and per-account pricing, discovered via [`list_transformers`](/docs/creative/task-reference/list_transformers) and selected with `transformer_id` on [`build_creative`](/docs/creative/task-reference/build_creative). + +Because a transformer carries **its own input/output formats and its own pricing**, three things that 3.0 hung on the *format* are now redundant and are deprecated: + +- `Format.input_format_ids` / `Format.output_format_ids` +- `Format.pricing_options` +- the `input_format_ids` / `output_format_ids` discovery **filters** on `list_creative_formats` + +**Nothing breaks if you do nothing in 3.1.** All four are `deprecated: true` but still validate and still function through the 3.1–3.x line. Act before 4.0. The hazards below are about *silent degradation* over the window, not hard breaks. + +## What changed + +| Surface | 3.0 | 3.1 | Removed | +|---|---|---|---| +| Build-capability discovery | filter `list_creative_formats` on `input_format_ids` / `output_format_ids`; read `Format.input_format_ids` / `output_format_ids` | discover via `list_transformers` (each transformer declares its own I/O signature) | 4.0 | +| Creative/transform pricing | `Format.pricing_options` (per format) | `transformer.pricing_options` (per transformer; `include_pricing` + `account` on `list_transformers`) | 4.0 | +| `list_creative_formats` `input_format_ids` / `output_format_ids` filters | supported | `deprecated: true` — use `list_transformers` filters + `brief` | 4.0 | + +## Migrate + +### Format I/O signature → transformer + +A transformer declares the formats it accepts and produces directly, so build capability is a property of the *selectable unit*, not a relationship hung on a format. + +**Before (3.0)** — a transform format declares what it consumes/produces: +```json test=false +{ + "format_id": { "agent_url": "https://creative.example", "id": "resize_to_meta" }, + "input_format_ids": [{ "agent_url": "https://creative.example", "id": "display_master" }], + "output_format_ids": [{ "agent_url": "https://creative.example", "id": "meta_reels_9x16" }] +} +``` + +**After (3.1)** — declare a transformer (discovered via `list_transformers`): +```json test=false +{ + "transformer_id": "resize_to_meta", + "name": "Resize to Meta Reels", + "input_format_ids": [{ "agent_url": "https://creative.example", "id": "display_master" }], + "output_format_ids": [{ "agent_url": "https://creative.example", "id": "meta_reels_9x16" }], + "params": [], + "pricing_options": [ { "pricing_option_id": "per_format", "model": "per_unit", "unit": "format", "unit_price": 0.10, "currency": "USD" } ] +} +``` + +### Format pricing → transformer pricing + +`Format.pricing_options` moves to `transformer.pricing_options` (same `vendor-pricing-option` shape; `per_unit` is typical). The applied option is echoed per-leaf on the `build_creative` response and reconciled via `report_usage`, unchanged. + +> **Per-output pricing does not survive the move unchanged.** `Format.pricing_options` could price each output format differently; `transformer.pricing_options` is one rate card for the whole transformer with no edge to a specific `output_format_id`. A multi-publisher template that priced outputs differently must **split into one transformer per price point** (e.g. one transformer per publisher), not a single transformer with many outputs. + +### `list_creative_formats` discovery filters → `list_transformers` + +Stop filtering `list_creative_formats` on `input_format_ids` / `output_format_ids` to find "what can build X". Use `list_transformers` — filter on its `input_format_ids` / `output_format_ids`, narrow with `brief`, and expand account-scoped option values with `expand_params`. + +## What breaks silently if you do nothing + +These do not throw — which is exactly why they are the reason this guide exists. + +1. **Discovery-read degradation (buyers).** If your buyer/storyboard learns build capability *only* by reading `Format.input_format_ids` / `output_format_ids` or by filtering `list_creative_formats` on them, you keep working against a 3.1 seller but see **progressively emptier results** as sellers stop emitting the deprecated fields and move capability onto transformers. No error — just fewer and fewer options. **The field outlives its data.** Move discovery to `list_transformers`. + +2. **Per-output pricing loss (multi-publisher template sellers).** See the pricing note above — a template that priced outputs differently silently mis-states pricing after a naïve `Format.pricing_options` → `transformer.pricing_options` lift. Split into per-price-point transformers. + +3. **Fan-out / best-of-N spend under-counting (billing pipelines).** With `max_variants` / `max_creatives`, every produced variant is billed, but only a **trafficked** leaf lazily earns a `creative_id`. Discarded best-of-N leaves are billed via the **inline per-leaf `vendor_cost`** on the `build_creative` response *only* — they never earn a `creative_id` and so never appear in `report_usage`. A pipeline that reconciles spend solely from `report_usage` (the 3.0 invariant "every billed unit reconciles via `creative_id`") will **under-count** by every discarded variant. Ingest the inline per-leaf receipts as the authoritative record for untrafficked leaves. + +## SDK behavior and timeline + +| Version | Deprecated fields | +|---|---| +| 3.1 | `deprecated: true`; still emitted and honored. SDKs MUST continue to read them. | +| 3.1–3.x | Honored throughout. New code SHOULD NOT emit them. | +| 4.0+ | SDKs MAY reject. Removed. | + +## See also + +- [`list_transformers`](/docs/creative/task-reference/list_transformers) — discover transformers, their params, option values, and pricing +- [`build_creative`](/docs/creative/task-reference/build_creative) — select a transformer, configure, multiply, refine +- [What's new in 3.1](/docs/reference/whats-new-in-3-1) diff --git a/docs/reference/whats-new-in-3-1.mdx b/docs/reference/whats-new-in-3-1.mdx index f02c59531c..d1688c7d4c 100644 --- a/docs/reference/whats-new-in-3-1.mdx +++ b/docs/reference/whats-new-in-3-1.mdx @@ -178,7 +178,7 @@ Live in 3.1, additive over 3.0. Products carry `format_options[]`: a list of `Pr - **Pricing moves onto the transformer.** The rate lives on `transformer.pricing_options` (`per_unit`), echoed inline as a per-leaf receipt on `build_creative`, and settled via `report_usage`. `Format.pricing_options` is **deprecated** in favor of `transformer.pricing_options`. -**Deprecations (3.1, removed in 4.0).** `Format.input_format_ids`, `Format.output_format_ids`, and `Format.pricing_options`, plus the `input_format_ids` / `output_format_ids` filters on `list_creative_formats`, are deprecated and all redirect to `list_transformers`. SDKs honor them through 3.1–3.x; they are removed at 4.0. Migrate format-attached input/output/pricing reads to `list_transformers`. +**Deprecations (3.1, removed in 4.0).** `Format.input_format_ids`, `Format.output_format_ids`, and `Format.pricing_options`, plus the `input_format_ids` / `output_format_ids` filters on `list_creative_formats`, are deprecated and all redirect to `list_transformers`. SDKs honor them through 3.1–3.x; they are removed at 4.0. Migrate format-attached input/output/pricing reads to `list_transformers`. Full migration (incl. the discovery-degradation, per-output-pricing, and best-of-N spend hazards): [Migration › Creative transformers](/docs/reference/migration/creative-transformers). → Spec: [`list_transformers`](/docs/creative/task-reference/list_transformers) · [`build_creative`](/docs/creative/task-reference/build_creative) · [`get_adcp_capabilities` § `creative.supports_transformers`](/docs/protocol/get_adcp_capabilities) From c6e4d2fc66fee0a1c8313102640f55e002f286f6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 31 May 2026 13:13:29 -0400 Subject: [PATCH 05/14] feat(creative): resolve the four pre-GA review items on build_creative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the persona/scenario review of #5219: 1. Response dispatch is unmissable. BuildCreativeVariantSuccess now states normatively that sending max_creatives / max_variants>1 / variant_axis / refine_from returns creatives[] with NO fallback to creative_manifest(s); a mirrors it in build_creative.mdx. 2. Request exclusivity is schema-enforced (was prose-only): allOf `not` guards for target_format_id XOR target_format_ids, and refine_from_build_variant_id NOT-with max_creatives — mirroring the include_pricing->account if/then pattern in the sibling request schemas. 3. Per-param option pagination is consumable: list_transformers gains expand_pagination [{transformer_id, field, options_cursor}] so the options_cursor the response promises can be passed back, scoped per (transformer, param). expand_params now returns the first page. 4. Single builds are refinable: BuildCreativeSuccess carries an optional build_variant_id (when the agent supports refinement) usable as refine_from_build_variant_id; multi-format builds refine via the variant shape (documented). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/creative/task-reference/build_creative.mdx | 8 +++++++- .../task-reference/list_transformers.mdx | 5 +++-- .../creative/list-transformers-request.json | 17 ++++++++++++++++- .../media-buy/build-creative-request.json | 8 ++++++++ .../media-buy/build-creative-response.json | 9 +++++++-- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/creative/task-reference/build_creative.mdx b/docs/creative/task-reference/build_creative.mdx index 52c66a9daf..d9aedbbb7a 100644 --- a/docs/creative/task-reference/build_creative.mdx +++ b/docs/creative/task-reference/build_creative.mdx @@ -520,7 +520,11 @@ When the request uses `target_format_ids`, the response contains an array of cre ### Variant response -When the request fans out across catalog items (`max_creatives`) or produces alternatives per creative (`max_variants` > 1), the agent returns the `BuildCreativeVariantSuccess` shape. This is a fifth success member alongside the single- and multi-format responses; the `creative_manifest` / `creative_manifests` shapes above are unchanged and still used when you build a single creative with one variant. +When the request uses `max_creatives`, `max_variants` > 1, `variant_axis`, or `refine_from_build_variant_id`, the agent returns the `BuildCreativeVariantSuccess` shape. This is a fifth success member alongside the single- and multi-format responses; the `creative_manifest` / `creative_manifests` shapes are unchanged and still used when you build one creative with one variant. + + +**No fallback.** If you send any of `max_creatives`, `max_variants > 1`, `variant_axis`, or `refine_from_build_variant_id`, you **must** handle `creatives[]` — you will not get `creative_manifest`/`creative_manifests` back. There is no automatic downgrade to the single/multi shapes. + ```json { @@ -598,6 +602,8 @@ Conversational refinement re-builds a prior variant with free-form direction — - Composes with `max_variants` / `variant_axis` (e.g. "three warmer takes" → three refined leaves), but **not** with `max_creatives` / catalog fan-out — you refine one produced creative, not a catalog. - Requires the agent to advertise `creative.supports_refinement: true` (it retains produced leaves for an agent-defined window). Agents that retain nothing return `UNSUPPORTED_FEATURE`; refine a buyer-held manifest instead via the transform path (`creative_manifest` + `message`). An unknown or no-longer-retained ref returns `REFERENCE_NOT_FOUND` with `error.field` set to `refine_from_build_variant_id`. +Any build can be refined, not just variant builds: a single-format `BuildCreativeSuccess` carries an optional `build_variant_id` (present when the agent supports refinement) that you pass as `refine_from_build_variant_id`. Multi-format builds that need per-output refinable leaves should request the variant shape (`max_variants`), since the bare `creative_manifests[]` array carries no leaf ids. + AI-derivative attribution rides the manifest's existing `provenance`; `parent_build_variant_id` carries only the lineage edge (refinements chain into a tree). ```json test=false diff --git a/docs/creative/task-reference/list_transformers.mdx b/docs/creative/task-reference/list_transformers.mdx index e1a63f4bca..6c34691ca2 100644 --- a/docs/creative/task-reference/list_transformers.mdx +++ b/docs/creative/task-reference/list_transformers.mdx @@ -32,14 +32,15 @@ The agent chooses granularity: a distinct voice or model may be its own transfor | `output_format_ids` | FormatID[] | No | Filter to transformers that can produce any of these output formats | | `name_search` | string | No | Search transformers by name (case-insensitive partial match) | | `brief` | string | No | Natural-language brief to rank/filter transformers and their option values (e.g. "warm female Spanish-language voiceover"). Curates to intent rather than returning the full set. | -| `expand_params` | string[] | No | Param `field` names for which to return account-scoped option **values** inline on `params[].options[]`. Omit for the lean default (descriptors only). | +| `expand_params` | string[] | No | Param `field` names for which to return the **first page** of account-scoped option **values** inline on `params[].options[]`. Omit for the lean default (descriptors only). | +| `expand_pagination` | object[] | No | Fetch the **next page** of a specific param's options, scoped per `{ transformer_id, field, options_cursor }` (cursor from a prior response's `params[].options_cursor`). Use once you hold a cursor, instead of `expand_params`. | | `include_pricing` | boolean | No | Include `pricing_options` on each transformer. Requires `account`. | | `account` | AccountRef | Conditional | Required when `include_pricing` is true. Transformers are account-scoped regardless. | | `pagination` | object | No | `max_results` and `cursor` (opaque cursor from previous response) | ### The `expand` mode -By default the response returns each transformer's param **schemas** with small closed enums inlined (e.g. `mastering_preset`). To get the **values** of an account-scoped enumerable param (e.g. your voices), name it in `expand_params`. Values are brief-filtered and paginated per param via `options_cursor` — so "warm Spanish female voice" narrows a 300-voice catalog to a handful instead of dumping all of them. There is no separate options endpoint; value enumeration is a mode of this one tool. +By default the response returns each transformer's param **schemas** with small closed enums inlined (e.g. `mastering_preset`). To get the **values** of an account-scoped enumerable param (e.g. your voices), name it in `expand_params` — that returns the **first page** on `params[].options[]`. When a param's values are truncated, its `params[].options_cursor` is set; fetch the next page by passing `{ transformer_id, field, options_cursor }` in **`expand_pagination`** (each `(transformer, param)` pages independently). Values are brief-filtered — "warm Spanish female voice" narrows a 300-voice catalog to a handful instead of dumping all of them. There is no separate options endpoint; value enumeration (and its pagination) is a mode of this one tool. ## Response diff --git a/static/schemas/source/creative/list-transformers-request.json b/static/schemas/source/creative/list-transformers-request.json index c96132f701..a79e4cfd87 100644 --- a/static/schemas/source/creative/list-transformers-request.json +++ b/static/schemas/source/creative/list-transformers-request.json @@ -43,10 +43,25 @@ }, "expand_params": { "type": "array", - "description": "Param `field` names for which to return account-scoped option VALUES inline on each transformer's `params[].options[]`. Omit to return param descriptors without enumerated values (the lean default). Paginated per-param via `options_cursor`.", + "description": "Param `field` names for which to return the FIRST page of account-scoped option VALUES inline on each transformer's `params[].options[]`. Omit to return param descriptors without enumerated values (the lean default). When a param's options are truncated, its `params[].options_cursor` is set — fetch the next page via `expand_pagination` (below).", "items": { "type": "string" }, "minItems": 1 }, + "expand_pagination": { + "type": "array", + "description": "Fetch the NEXT page of a specific param's account-scoped options, using the `options_cursor` a prior response returned for that `(transformer, param)`. Scoped per `(transformer_id, field)` so multiple params can be paged independently. Use this instead of `expand_params` once you hold a cursor.", + "items": { + "type": "object", + "properties": { + "transformer_id": { "type": "string", "x-entity": "transformer", "description": "The transformer whose param options to page." }, + "field": { "type": "string", "description": "The param `field` to page." }, + "options_cursor": { "type": "string", "description": "Opaque cursor from that param's prior `params[].options_cursor`." } + }, + "required": ["transformer_id", "field", "options_cursor"], + "additionalProperties": true + }, + "minItems": 1 + }, "include_pricing": { "type": "boolean", "default": false, diff --git a/static/schemas/source/media-buy/build-creative-request.json b/static/schemas/source/media-buy/build-creative-request.json index 30360aa960..5743c441de 100644 --- a/static/schemas/source/media-buy/build-creative-request.json +++ b/static/schemas/source/media-buy/build-creative-request.json @@ -7,6 +7,14 @@ "allOf": [ { "$ref": "/schemas/core/version-envelope.json" + }, + { + "$comment": "target_format_id and target_format_ids are mutually exclusive (single vs multi).", + "not": { "required": ["target_format_id", "target_format_ids"] } + }, + { + "$comment": "Refinement targets one prior creative, not a catalog fan-out.", + "not": { "required": ["refine_from_build_variant_id", "max_creatives"] } } ], "x-mutates-state": true, diff --git a/static/schemas/source/media-buy/build-creative-response.json b/static/schemas/source/media-buy/build-creative-response.json index 27f562c574..fd52174bfd 100644 --- a/static/schemas/source/media-buy/build-creative-response.json +++ b/static/schemas/source/media-buy/build-creative-response.json @@ -15,13 +15,18 @@ "oneOf": [ { "title": "BuildCreativeSuccess", - "description": "Single-format success response. Returned when the request used target_format_id.", + "description": "Single-format success response. Returned when the request used target_format_id (without fan-out). Carries an optional build_variant_id so a single build is refinable; multi-format builds that need per-output refinable leaves should request the variant shape (max_variants).", "type": "object", "properties": { "creative_manifest": { "$ref": "/schemas/core/creative-manifest.json", "description": "The generated or transformed creative manifest" }, + "build_variant_id": { + "type": "string", + "x-entity": "build_variant", + "description": "Leaf handle for this produced creative — present when the agent supports refinement (creative.supports_refinement). Pass it as refine_from_build_variant_id to refine this build. Same namespace as BuildCreativeVariantSuccess leaves; distinct from served variant_id / preview_id. Lazily earns a creative_id on trafficking." + }, "sandbox": { "type": "boolean", "description": "When true, this response contains simulated data from sandbox mode." @@ -274,7 +279,7 @@ }, { "title": "BuildCreativeVariantSuccess", - "description": "Multiplicity success response. Returned when the request used item fan-out (max_creatives) and/or variant fan-out (max_variants / variant_axis), with or without a transformer_id. Carries creatives[] — one entry per produced creative (per catalog item when fanning out) — each holding variants[] alternatives. Every produced variant is a real, independently-billed build (you pay for all produced); the buyer keeps one or many by trafficking the chosen build_variant_id(s). Mutually exclusive with the other success/error/submitted shapes. Per-FORMAT remains atomic within a (creative) group; per-ITEM (catalog fan-out) is non-atomic — a failed item is reported via that creative's errors[] and does not fail the batch.", + "description": "Multiplicity success response. Returned WHENEVER the request used max_creatives, max_variants > 1, variant_axis, or refine_from_build_variant_id — with or without a transformer_id. **There is no fallback to the single/multi-format shapes: a client that sends any of those inputs MUST handle this `creatives[]` shape; it will not receive `creative_manifest`/`creative_manifests`.** Carries creatives[] — one entry per produced creative (per catalog item when fanning out) — each holding variants[] alternatives. Every produced variant is a real, independently-billed build (you pay for all produced); the buyer keeps one or many by trafficking the chosen build_variant_id(s). Mutually exclusive with the other success/error/submitted shapes. Per-FORMAT remains atomic within a (creative) group; per-ITEM (catalog fan-out) is non-atomic — a failed item is reported via that creative's errors[] and does not fail the batch.", "type": "object", "properties": { "creatives": { From 47b4cc6f116780ff2150aa503d57fdb9587c986c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 31 May 2026 13:54:26 -0400 Subject: [PATCH 06/14] spec(creative): transformer refinement-retention + fan-out multiplicity discriminators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on #3 (keystone) from the persona/scenario review: give buyers pre-call discriminators so they know what an agent supports before sending, rather than probing and handling failures. Additive/optional; the surface the spend-control and conformance follow-ons read. - get_adcp_capabilities creative.refinable_retention_seconds (guaranteed-minimum refinable window — machine-readable floor for the prior prose-only "agent-defined window"). - get_adcp_capabilities creative.multiplicity {supports_catalog_fanout + max_creatives_limit, supports_variants + max_variants_limit, variant_dimensions[]}. Over-limit fan-out is clamped (items_returned < items_total), not rejected. - transformer.multiplicity narrows the agent-level object per transformer (ceilings <= agent, dimensions subset of agent). - build_creative docs note the clamp behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../transformer-capability-discriminators.md | 12 ++++++ .../task-reference/build_creative.mdx | 4 +- docs/protocol/get_adcp_capabilities.mdx | 2 + static/schemas/source/core/transformer.json | 17 ++++++++ .../get-adcp-capabilities-response.json | 41 +++++++++++++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 .changeset/transformer-capability-discriminators.md diff --git a/.changeset/transformer-capability-discriminators.md b/.changeset/transformer-capability-discriminators.md new file mode 100644 index 0000000000..06009905aa --- /dev/null +++ b/.changeset/transformer-capability-discriminators.md @@ -0,0 +1,12 @@ +--- +"adcontextprotocol": minor +--- + +spec(creative): add pre-call discriminators for creative-transformer refinement retention and fan-out multiplicity. + +Lets a buyer agent know — before sending — what a creative agent supports, instead of probing and handling failures. Additive and optional (all fields default to "unsupported / unbounded"), and the keystone the spend-control and conformance follow-ons build on. + +- `get_adcp_capabilities` → `creative.refinable_retention_seconds` (integer): the guaranteed-minimum window a produced `build_variant_id` stays refinable. Replaces the prose-only "agent-defined window" with a machine-readable floor; omit to keep it agent-defined. +- `get_adcp_capabilities` → `creative.multiplicity` (object): `supports_catalog_fanout` + `max_creatives_limit`, `supports_variants` + `max_variants_limit`, and `variant_dimensions[]`. Over-limit `max_creatives`/`max_variants` are **clamped** to the ceilings (shortfall via `items_returned` < `items_total`), not rejected — consistent with `item_limit`'s "use the lesser" rule. Absent means no fan-out. +- `transformer.json` → optional `multiplicity` that narrows the agent-level object per transformer (ceilings ≤ agent, `variant_dimensions` ⊆ agent). +- `build_creative` docs note the clamp behavior on `max_creatives`/`max_variants`. diff --git a/docs/creative/task-reference/build_creative.mdx b/docs/creative/task-reference/build_creative.mdx index d9aedbbb7a..10589c8320 100644 --- a/docs/creative/task-reference/build_creative.mdx +++ b/docs/creative/task-reference/build_creative.mdx @@ -32,8 +32,8 @@ For information about legacy format IDs and how to reference formats, see [Creat | `item_limit` | integer | No | Maximum number of catalog items to use **within one creative**. Caps generation cost for catalog-driven formats (e.g. a 6-card carousel built from a 1,000-product catalog). Distinct from `max_creatives`, which fans out across items. | | `transformer_id` | string | No | Select one transformer (discovered via [`list_transformers`](/docs/creative/task-reference/list_transformers)) to perform the build. The requested target format(s) MUST be a subset of that transformer's `output_format_ids`. The transformer's `per_unit` rate is the pricing source for the build. | | `config` | object | No | Typed configuration bag keyed to the selected transformer's `params[].field` (e.g. `{ "voice": "isaac", "speaking_rate": 1.1 }`). The creative agent MUST reject unknown keys and out-of-range values with field-attributed errors (strict validation) — vendor-specific knobs that are not declared params go in `ext`. Only meaningful with `transformer_id`. | -| `max_creatives` | integer | No | Catalog fan-out axis: produce up to N **distinct creatives, one per catalog item** (a sample — e.g. 5 of 150). Distinct from `item_limit`, which caps items used *within* a single creative. Mutually exclusive with `refine_from_build_variant_id`. Triggers the `BuildCreativeVariantSuccess` response shape. | -| `max_variants` | integer | No | Number of alternative renders to produce **per creative** (best-of-N). Default `1`. You pay for every produced variant; keeping one or more is a separate trafficking step. | +| `max_creatives` | integer | No | Catalog fan-out axis: produce up to N **distinct creatives, one per catalog item** (a sample — e.g. 5 of 150). Distinct from `item_limit`, which caps items used *within* a single creative. Mutually exclusive with `refine_from_build_variant_id`. Triggers the `BuildCreativeVariantSuccess` response shape. Supported only when the agent advertises `creative.multiplicity.supports_catalog_fanout`; values above `max_creatives_limit` are **clamped** (shortfall via `items_returned` < `items_total`), not rejected. | +| `max_variants` | integer | No | Number of alternative renders to produce **per creative** (best-of-N). Default `1`. You pay for every produced variant; keeping one or more is a separate trafficking step. Supported only when the agent advertises `creative.multiplicity.supports_variants`; values above `max_variants_limit` are clamped. | | `variant_axis` | object | No | Describes the dimension along which variants differ. Object with `dimension` (`voice` \| `theme` \| `best_of_n` \| `transformer_config` \| `custom`), optional `values[]` (explicit values to enumerate along the axis), optional `field` (the `config` param to sweep — required when `dimension` is `transformer_config`), and optional `label`. | | `keep_mode` | string | No | Advisory hint to the agent on how many variants you intend to keep: `"keep_all"`, `"keep_one"`, or `"keep_some"`. Default `"keep_all"`. Advisory only — you are billed for all produced variants regardless. | | `refine_from_build_variant_id` | string | No | Refine a prior produced variant: re-build from its `build_variant_id` applying the NL instruction in `message` plus any `config` delta, returning **new** lineage-linked variants (never a mutation). `transformer_id` and target format(s) are inherited from the parent. Composes with `max_variants`/`variant_axis`; mutually exclusive with `max_creatives`. Requires `creative.supports_refinement` — otherwise `UNSUPPORTED_FEATURE`; an unknown/no-longer-retained ref is `REFERENCE_NOT_FOUND` (`error.field` = `refine_from_build_variant_id`). See [Refinement](#refinement). | diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index ef27cd6b81..daedfb8812 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -385,6 +385,8 @@ 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 (`required_disclosures`, `prohibited_claims`) and will validate that disclosures can be satisfied by the target format. Use the `disclosure_positions` filter on `list_creative_formats` to find compatible formats. | | `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`; 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`). | +| `multiplicity` | object | Pre-call fan-out discriminators so a buyer knows before sending `max_creatives`/`max_variants`: `supports_catalog_fanout` + `max_creatives_limit`, `supports_variants` + `max_variants_limit`, and `variant_dimensions[]` (which `variant_axis.dimension` values are supported). Over-limit requests are **clamped** to the ceilings (shortfall shown via `items_returned` < `items_total`), not rejected. Absent means no fan-out — `build_creative` produces a single creative. Individual transformers may narrow this via `transformer.multiplicity`. | | `bills_through_adcp` | boolean | When `true`, this creative agent bills through the AdCP rate-card surface — `list_creatives` returns `pricing_options` (with `include_pricing=true` and an authenticated account), `build_creative` populates `pricing_option_id` and `vendor_cost`, and `report_usage` accepts records against the rate card. When `false` or absent, the agent bills out of band (flat license, SaaS contract, bundled enterprise agreement); buyers should skip pricing fields and tolerate `report_usage` returning `accepted: 0` with `BILLING_OUT_OF_BAND` errors. Pre-call discriminator for routing across creative agents. | ### governance diff --git a/static/schemas/source/core/transformer.json b/static/schemas/source/core/transformer.json index 7cdbc44d38..48189cd102 100644 --- a/static/schemas/source/core/transformer.json +++ b/static/schemas/source/core/transformer.json @@ -44,6 +44,23 @@ "description": "Per-account rate-card options for using this transformer. Present when the list_transformers request set include_pricing=true with an account. The applied option is echoed back per-leaf on the build_creative response and reconciled via report_usage.", "items": { "$ref": "/schemas/core/vendor-pricing-option.json" }, "minItems": 1 + }, + "multiplicity": { + "type": "object", + "description": "Optional per-transformer fan-out limits that NARROW the agent-level get_adcp_capabilities `creative.multiplicity`. Same shape as the agent-level object. When present, this transformer's authoritative; its ceilings (max_creatives_limit / max_variants_limit) MUST NOT exceed the agent ceilings, and its variant_dimensions MUST be a subset of the agent's. Omit to inherit the agent-level capability unchanged.", + "properties": { + "supports_catalog_fanout": { "type": "boolean", "default": false, "description": "Whether this transformer accepts max_creatives." }, + "max_creatives_limit": { "type": "integer", "minimum": 1, "description": "Per-transformer ceiling on max_creatives (≤ the agent ceiling)." }, + "supports_variants": { "type": "boolean", "default": false, "description": "Whether this transformer accepts max_variants > 1 / variant_axis." }, + "max_variants_limit": { "type": "integer", "minimum": 1, "description": "Per-transformer ceiling on max_variants (≤ the agent ceiling)." }, + "variant_dimensions": { + "type": "array", + "description": "Variant axis dimensions this transformer supports (⊆ the agent's).", + "items": { "type": "string", "enum": ["voice", "theme", "best_of_n", "transformer_config", "custom"] }, + "uniqueItems": true + } + }, + "additionalProperties": true } }, "required": ["transformer_id", "name", "output_format_ids"], diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index bcfc6209c9..752c9f4cd3 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -1101,6 +1101,47 @@ "description": "When true, this agent retains produced build_variant leaves for an agent-defined retention window and can re-build from one via build_creative's refine_from_build_variant_id — applying a natural-language instruction in message plus an optional config delta, returning new lineage-linked variants. A build-time agent capability independent of generation/transformation. When false or absent, refine_from_build_variant_id is rejected with UNSUPPORTED_FEATURE; buyers refine instead via the transform path (creative_manifest + message).", "default": false }, + "refinable_retention_seconds": { + "type": "integer", + "minimum": 0, + "description": "When supports_refinement is true, the GUARANTEED-MINIMUM window (a floor, not a ceiling) during which a produced build_variant_id remains refinable via refine_from_build_variant_id: a ref within this window from production SHOULD resolve; the agent MAY retain longer. Omit when the retention window is agent-defined and not advertised — buyers then treat refinability as best-effort and handle REFERENCE_NOT_FOUND." + }, + "multiplicity": { + "type": "object", + "description": "Pre-call discriminators for build_creative fan-out, so a buyer knows BEFORE sending max_creatives / max_variants whether this agent supports them and the ceilings. Over-limit requests are CLAMPED to these ceilings (the agent produces up to the limit and signals the shortfall via items_returned < items_total on BuildCreativeVariantSuccess), not rejected — consistent with item_limit's 'use the lesser' rule. Absent means no fan-out: build_creative produces a single creative and max_creatives/max_variants>1 are not supported.", + "properties": { + "supports_catalog_fanout": { + "type": "boolean", + "default": false, + "description": "When true, build_creative accepts max_creatives (one distinct creative per catalog item)." + }, + "max_creatives_limit": { + "type": "integer", + "minimum": 1, + "description": "Ceiling on max_creatives. Omitted means no advertised ceiling." + }, + "supports_variants": { + "type": "boolean", + "default": false, + "description": "When true, build_creative accepts max_variants > 1 / variant_axis (alternatives per creative)." + }, + "max_variants_limit": { + "type": "integer", + "minimum": 1, + "description": "Ceiling on max_variants. Omitted means no advertised ceiling." + }, + "variant_dimensions": { + "type": "array", + "description": "Which variant_axis.dimension values this agent supports.", + "items": { + "type": "string", + "enum": ["voice", "theme", "best_of_n", "transformer_config", "custom"] + }, + "uniqueItems": true + } + }, + "additionalProperties": true + }, "supported_formats": { "type": "array", "description": "Canonical-formats path: format declarations describing which canonical formats this creative agent can produce via `build_creative`. Each entry uses the same `ProductFormatDeclaration` shape as a product's inline `format_options[i]` — `format_kind` discriminator + `params` (canonical's parameter schema including `slots`, dimensions, durations, codecs, character limits, platform_extensions, tracking_extensions). Replaces the v1 `list_creative_formats` discovery surface for creative agents.", From ce25f93b09d33361b9b7a5b3bd72de8cc471b1e3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 31 May 2026 14:21:09 -0400 Subject: [PATCH 07/14] spec(creative): build_creative spend controls (max_spend cap + estimate dry-run) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on #1 (the highest-risk gap from the persona review): fan-out (max_creatives × max_variants) + refinement produce many independently-billed leaves, and per_unit gives a rate but not the unit count up front — so an autonomous buyer had no protocol brake on spend. Both additions optional, gated by creative.supports_spend_controls. - Request mode:"estimate" -> new BuildCreativeEstimate response member (6th oneOf arm): dry run, produces/bills nothing, returns a cost_low/cost_high band with basis (fixed/estimated_units/cpm_deferred) computed against the request inputs. Advisory in this revision. - Request max_spend {amount,currency}: hard per-call ceiling. Agent stops before the next leaf would exceed it and returns the partial BuildCreativeVariantSuccess with budget_status:"capped" + advisory BUDGET_CAP_REACHED in errors[] (every returned leaf real/billed; items_returned < items_total). First-leaf-over-cap -> terminal BUDGET_CAP_REACHED; currency mismatch -> INVALID_REQUEST. - New error code BUDGET_CAP_REACHED (enum + enumDescriptions + enumMetadata + drift disposition held-for-next-minor/3.1), distinct from BUDGET_EXCEEDED/EXHAUSTED. - New capability creative.supports_spend_controls (default false). Deferred to WG: binding estimates; refinement-LOOP bound as protocol session budget vs buyer responsibility (documented buyer-side for now). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/build-creative-spend-controls.md | 14 +++ .../task-reference/build_creative.mdx | 15 ++++ docs/protocol/get_adcp_capabilities.mdx | 1 + scripts/error-code-drift-dispositions.json | 5 ++ static/schemas/source/enums/error-code.json | 6 ++ .../media-buy/build-creative-request.json | 18 +++- .../media-buy/build-creative-response.json | 88 ++++++++++++++++++- .../get-adcp-capabilities-response.json | 5 ++ 8 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 .changeset/build-creative-spend-controls.md diff --git a/.changeset/build-creative-spend-controls.md b/.changeset/build-creative-spend-controls.md new file mode 100644 index 0000000000..c87f182fe1 --- /dev/null +++ b/.changeset/build-creative-spend-controls.md @@ -0,0 +1,14 @@ +--- +"adcontextprotocol": minor +--- + +spec(creative): add build_creative spend controls — `max_spend` cap + `mode: "estimate"` dry-run. + +Follow-on from the persona/scenario review: fan-out (`max_creatives` × `max_variants`) and refinement produce many independently-billed leaves, and `per_unit` pricing gives a rate but not the unit count in advance — so an autonomous buyer had no protocol brake on spend. Both additions are optional and gated by a new `creative.supports_spend_controls` capability. + +- **`mode: "estimate"`** (request) → new `BuildCreativeEstimate` response shape (6th `oneOf` member): a dry run that produces and bills nothing and returns a `cost_low`/`cost_high` band computed against the request's actual inputs, with `basis` (`fixed` exact / `estimated_units` / `cpm_deferred`) and an optional per-leaf breakdown. Advisory/non-binding in this revision. +- **`max_spend: { amount, currency }`** (request) → a hard per-call ceiling: the agent stops before the next leaf would exceed it and returns the partial `BuildCreativeVariantSuccess` with new `budget_status: "capped"` and an advisory `BUDGET_CAP_REACHED` in `errors[]` (every returned leaf real and billed; `items_returned` < `items_total`). First-leaf-over-cap → terminal `BUDGET_CAP_REACHED`; currency mismatch → `INVALID_REQUEST`. +- New error code **`BUDGET_CAP_REACHED`** (distinct from `BUDGET_EXCEEDED`/`BUDGET_EXHAUSTED`), in both `enumDescriptions` and `enumMetadata`. +- New capability **`creative.supports_spend_controls`** (default false). + +Deferred to the working group (flagged, not omitted): whether an estimate can be **binding**, and whether a refinement-**loop** bound is a protocol-level session budget vs. a buyer responsibility (documented as buyer-side for now). diff --git a/docs/creative/task-reference/build_creative.mdx b/docs/creative/task-reference/build_creative.mdx index 10589c8320..2a9e7e0c86 100644 --- a/docs/creative/task-reference/build_creative.mdx +++ b/docs/creative/task-reference/build_creative.mdx @@ -37,6 +37,8 @@ For information about legacy format IDs and how to reference formats, see [Creat | `variant_axis` | object | No | Describes the dimension along which variants differ. Object with `dimension` (`voice` \| `theme` \| `best_of_n` \| `transformer_config` \| `custom`), optional `values[]` (explicit values to enumerate along the axis), optional `field` (the `config` param to sweep — required when `dimension` is `transformer_config`), and optional `label`. | | `keep_mode` | string | No | Advisory hint to the agent on how many variants you intend to keep: `"keep_all"`, `"keep_one"`, or `"keep_some"`. Default `"keep_all"`. Advisory only — you are billed for all produced variants regardless. | | `refine_from_build_variant_id` | string | No | Refine a prior produced variant: re-build from its `build_variant_id` applying the NL instruction in `message` plus any `config` delta, returning **new** lineage-linked variants (never a mutation). `transformer_id` and target format(s) are inherited from the parent. Composes with `max_variants`/`variant_axis`; mutually exclusive with `max_creatives`. Requires `creative.supports_refinement` — otherwise `UNSUPPORTED_FEATURE`; an unknown/no-longer-retained ref is `REFERENCE_NOT_FOUND` (`error.field` = `refine_from_build_variant_id`). See [Refinement](#refinement). | +| `mode` | string | No | `"execute"` (default) produces and bills. `"estimate"` is a **dry run** — produces and bills nothing, returns a `BuildCreativeEstimate` cost band (`cost_low`/`cost_high`) computed against this request's inputs. Requires `creative.supports_spend_controls`. See [Spend controls](#spend-controls). | +| `max_spend` | object | No | Hard per-call spend ceiling `{ amount, currency }`. The agent produces leaves until the next would exceed `amount`, then stops and returns the partial result with `budget_status: "capped"`. Requires `creative.supports_spend_controls`. Caps one call — bound refinement loops buyer-side. See [Spend controls](#spend-controls). | | `include_preview` | boolean | No | When true, requests preview renders alongside the manifest. Agents that support this return a `preview` object in the response. Agents that don't simply omit it. | | `preview_inputs` | array | No | Input sets for preview generation when `include_preview` is true. Each entry has `name` (required), optional `macros`, and optional `context_description`. If omitted, the agent generates a single default preview. Only supported with `target_format_id` (single-format) — ignored for multi-format requests. | | `preview_quality` | string | No | Render quality for inline previews: `"draft"` or `"production"`. Independent of the build `quality` — you can build at draft and preview at production, or vice versa. Only used when `include_preview` is true. | @@ -616,6 +618,19 @@ AI-derivative attribution rides the manifest's existing `provenance`; `parent_bu } ``` +### Spend controls + +Fan-out and refinement can produce many independently-billed leaves (`max_creatives` × `max_variants`), and `per_unit` pricing gives a *rate* but not the *unit count* in advance (a 6-second voiceover and a 60-second one cost 10× at the same rate). Two opt-in controls, both gated by `creative.supports_spend_controls`: + +- **Estimate first (`mode: "estimate"`).** A dry run: the agent produces and bills nothing and returns a `BuildCreativeEstimate` with a `cost_low`/`cost_high` band (and `basis`: `fixed` = exact, `estimated_units` = generative projection, `cpm_deferred` = cost accrues at serve). The band is the load-bearing piece — the seller derives it from your actual inputs, so you don't have to guess unit counts. +- **Cap the call (`max_spend: { amount, currency }`).** A hard stop: the agent produces leaves until the next would push aggregate `vendor_cost` over `amount`, then returns the partial `BuildCreativeVariantSuccess` with `budget_status: "capped"` and an advisory `BUDGET_CAP_REACHED` in `errors[]` — every returned leaf is real and billed, nothing produced is discarded, `items_returned` < `items_total` shows the shortfall. If even the first leaf would exceed the cap, the call fails with a terminal `BUDGET_CAP_REACHED`. `currency` must match the rate card (no FX) or the request is rejected `INVALID_REQUEST` (`error.field: max_spend.currency`). + +They compose: estimate to get `cost_high`, then execute with `max_spend` = `cost_high` × a safety margin. `max_spend` caps a **single call**; to bound an autonomous **refinement loop**, track aggregate `vendor_cost` across calls and stop issuing them (a buyer responsibility in this revision — a protocol-level session budget is deferred to the working group). + + +`max_spend` and `mode: "estimate"` require the agent to advertise `creative.supports_spend_controls`; otherwise they're rejected with `UNSUPPORTED_FEATURE`. They're only meaningful with `bills_through_adcp: true` (an out-of-band biller has no AdCP cost to cap against). + + ### Field descriptions - **creative_manifest**: (single-format) The complete creative manifest ready for use with `sync_creatives` or `preview_creative` diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index daedfb8812..1e53f3257d 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -387,6 +387,7 @@ Creative protocol capabilities. Only present if `creative` is in `supported_prot | `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`; 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`). | | `multiplicity` | object | Pre-call fan-out discriminators so a buyer knows before sending `max_creatives`/`max_variants`: `supports_catalog_fanout` + `max_creatives_limit`, `supports_variants` + `max_variants_limit`, and `variant_dimensions[]` (which `variant_axis.dimension` values are supported). Over-limit requests are **clamped** to the ceilings (shortfall shown via `items_returned` < `items_total`), not rejected. Absent means no fan-out — `build_creative` produces a single creative. Individual transformers may narrow this via `transformer.multiplicity`. | +| `supports_spend_controls` | boolean | When `true`, `build_creative` honors a per-call `max_spend` ceiling (returns a partial paid build with `budget_status: "capped"` + a `BUDGET_CAP_REACHED` advisory rather than overspending) and supports `mode: "estimate"` dry-runs (a projected cost band, producing/billing nothing). When `false` or absent, both are rejected with `UNSUPPORTED_FEATURE`. Meaningful only with `bills_through_adcp: true`. | | `bills_through_adcp` | boolean | When `true`, this creative agent bills through the AdCP rate-card surface — `list_creatives` returns `pricing_options` (with `include_pricing=true` and an authenticated account), `build_creative` populates `pricing_option_id` and `vendor_cost`, and `report_usage` accepts records against the rate card. When `false` or absent, the agent bills out of band (flat license, SaaS contract, bundled enterprise agreement); buyers should skip pricing fields and tolerate `report_usage` returning `accepted: 0` with `BILLING_OUT_OF_BAND` errors. Pre-call discriminator for routing across creative agents. | ### governance diff --git a/scripts/error-code-drift-dispositions.json b/scripts/error-code-drift-dispositions.json index 341ef37ce1..1f91ed2408 100644 --- a/scripts/error-code-drift-dispositions.json +++ b/scripts/error-code-drift-dispositions.json @@ -1,6 +1,11 @@ { "$comment": "Disposition registry for error codes that are present in the current source enum but absent from the 3.0.x maintenance branch. Read by scripts/lint-error-code-drift.cjs. Every code in the AHEAD set (main \\ 3.0.x) MUST have an entry here; codes missing from this file fail the lint. Policy: 3.0.x is wire-stable. Adding a new enum value is a wire change (a 3.0.x receiver decoding a 3.1 sender's error.code cannot match against an enum it doesn't carry, and JSON Schema enum is closed by default), so the default disposition for any new code is 'held-for-next-minor' with target_version='3.1'. 'held-for-next-major' (target_version='4.0') is for codes that should defer past 3.x \u2014 typically because the surrounding subsystem is being reworked. 'backport-pending' is reserved for prose-only or doc-comment-only changes to a code that already exists on 3.0.x; the lint rejects backport-pending entries whose code is not on 3.0.x. Valid `disposition` values: 'backport-pending', 'held-for-next-minor', 'held-for-next-major', 'unclassified'. `target_version` (required for held-*) is a 'MAJOR.MINOR' string. When a code lands on 3.0.x its entry SHOULD be removed; the lint flags stale entries.", "dispositions": { + "BUDGET_CAP_REACHED": { + "disposition": "held-for-next-minor", + "target_version": "3.1", + "note": "build_creative spend controls — emitted when a per-call max_spend ceiling stops production early (advisory on a partial BuildCreativeVariantSuccess, or terminal when even the first leaf exceeds the cap). New 3.1 creative-transformer surface; wire change — held for 3.1." + }, "ACTION_NOT_ALLOWED": { "disposition": "held-for-next-minor", "target_version": "3.1", diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 8ead3abb39..e9d6057574 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -32,6 +32,7 @@ "GOVERNANCE_DENIED", "BUDGET_EXHAUSTED", "BUDGET_EXCEEDED", + "BUDGET_CAP_REACHED", "CONFLICT", "IDEMPOTENCY_CONFLICT", "IDEMPOTENCY_EXPIRED", @@ -116,6 +117,7 @@ "GOVERNANCE_DENIED": "A registered governance agent denied the transaction. Sellers MUST place the denial in the task's structured rejection arm when one exists (e.g., `acquire_rights` → `AcquireRightsRejected`, `creative_approval` → `CreativeRejected`); otherwise in `errors[]` + `adcp_error`. Buyers MUST dispatch on the response's discriminated `status` first and fall back to `errors[].code` / `adcp_error.code` only when no rejection arm exists for that task. The buyer may restructure the buy (e.g., reduce budget, split into smaller transactions), escalate to human spending authority, or contact the governance agent for details. Recovery: correctable.\n\nWire placement (full guidance). Governance denial is a structured business outcome, not a system error — the governance call SUCCEEDED and the agent returned a denial verdict. Two cases:\n\n1. Task response defines a structured rejection arm. The arm IS the canonical denial shape. The seller populates `reason` (human-readable, propagating governance findings) and `suggestions` (optional) and does NOT additionally emit `GOVERNANCE_DENIED` in `errors[]` or `adcp_error`. The rejection arms enforce this at the schema layer: e.g., `AcquireRightsRejected` and `CreativeRejected` both declare `not: { required: [errors] }`, so dual-emission is already a schema violation. The code does not appear on the wire when the rejection arm is used. Transport-level success markers MUST NOT be flipped (HTTP 200, MCP `isError: false`, A2A `succeeded`) — the task ran successfully and produced a structured response.\n\n2. Task response has no rejection arm (e.g., `create_media_buy` returns Success / Error / Submitted arms only). The seller populates `errors[].code: GOVERNANCE_DENIED` in the payload AND `adcp_error.code: GOVERNANCE_DENIED` on the envelope per the two-layer model in `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`. Transport-level failure markers DO flip in this case (HTTP 4xx, MCP `isError: true`, A2A `failed`) — the task could not produce a success artifact.\n\nThe rule generalizes to any current or future task whose response defines a discriminated rejection arm. In either placement, sellers SHOULD propagate governance findings verbatim — buyers' recovery decisions depend on what specifically was rejected. `GOVERNANCE_DENIED` is reserved for verdicts received from a reachable governance agent; if the governance call itself failed (timeout, network, config error), use `GOVERNANCE_UNAVAILABLE` instead.", "BUDGET_EXHAUSTED": "Account or campaign budget has been fully spent. Distinct from BUDGET_TOO_LOW (rejected at submission). Recovery: terminal (buyer must add funds or increase budget cap).", "BUDGET_EXCEEDED": "Operation would exceed the allocated budget for the media buy or package. Distinct from BUDGET_EXHAUSTED (already spent) and BUDGET_TOO_LOW (below minimum). Recovery: correctable (reduce requested amount or increase budget allocation).", + "BUDGET_CAP_REACHED": "build_creative stopped producing early because the next leaf would exceed the request's max_spend ceiling. Normally a SUCCESSFUL partial build (BuildCreativeVariantSuccess with budget_status: 'capped' and an advisory BUDGET_CAP_REACHED entry — every returned leaf is real and billed); returned as a terminal error only when even the first leaf would exceed the cap (no partial possible). Distinct from BUDGET_EXCEEDED (would exceed a media-buy/package allocation — a rejection) and BUDGET_EXHAUSTED (already spent). Recovery: correctable (raise max_spend, or reduce max_creatives/max_variants).", "CREATIVE_DEADLINE_EXCEEDED": "Creative change submitted after the package's creative_deadline. Distinct from CREATIVE_REJECTED (content policy failure). Recovery: correctable (check creative_deadline via get_media_buys before submitting changes, or negotiate a deadline extension with the seller).", "CONFLICT": "Concurrent modification detected. The resource was modified by another request between read and write. Recovery: transient (re-read the resource and retry with current state).", "IDEMPOTENCY_CONFLICT": "An earlier request with the same idempotency_key was processed with a different canonical payload within the seller's replay window. Distinct from CONFLICT (concurrent write) — this indicates the client reused a key across semantically different requests. Recovery: correctable (use a fresh UUID v4 for the new request, or resend the exact original payload to get the cached response).", @@ -282,6 +284,10 @@ "recovery": "correctable", "suggestion": "reduce requested amount or increase budget allocation" }, + "BUDGET_CAP_REACHED": { + "recovery": "correctable", + "suggestion": "raise max_spend, or reduce max_creatives/max_variants, to produce the remaining items" + }, "CONFLICT": { "recovery": "transient", "suggestion": "re-read the resource and retry with current state" diff --git a/static/schemas/source/media-buy/build-creative-request.json b/static/schemas/source/media-buy/build-creative-request.json index 5743c441de..62071680a4 100644 --- a/static/schemas/source/media-buy/build-creative-request.json +++ b/static/schemas/source/media-buy/build-creative-request.json @@ -73,10 +73,26 @@ "x-entity": "build_variant", "description": "Refine a previously produced variant: re-build from the referenced `build_variant_id`, applying the natural-language instruction in `message` and any `config` delta, and return NEW lineage-linked variant(s) — each with `parent_build_variant_id` set to this id. A refinement is never a mutation; the parent leaf is unchanged. The `transformer_id` and target format(s) are inherited from the parent and need not be repeated; passing a `transformer_id` or `target_format_id`/`target_format_ids` that differs from the parent's is rejected with `INVALID_REQUEST`. Composes with `max_variants` / `variant_axis` (produces N refined alternatives), but is mutually exclusive with `max_creatives` / catalog fan-out (you refine one prior creative, not a catalog). Requires the agent to advertise `creative.supports_refinement: true` in get_adcp_capabilities; agents that do not retain prior builds reject with `UNSUPPORTED_FEATURE`. A ref that is unknown or no longer retained (agents retain produced leaves for an agent-defined window) is rejected with `REFERENCE_NOT_FOUND`, with `error.field` set to `refine_from_build_variant_id`. To refine a buyer-held manifest when the agent retains nothing, use the transform path instead (`creative_manifest` + `message`)." }, + "mode": { + "type": "string", + "enum": ["execute", "estimate"], + "default": "execute", + "description": "`execute` (default) produces and bills the creative(s). `estimate` is a DRY RUN: the agent produces nothing and bills nothing, and returns a BuildCreativeEstimate with a projected cost band (cost_low/cost_high) computed against THIS request's actual inputs (script length, brief, catalog size, max_creatives × max_variants) — the band the buyer cannot derive itself, since per_unit gives the rate but not the unit count. Requires the agent to advertise `creative.supports_spend_controls`; otherwise rejected with `UNSUPPORTED_FEATURE`." + }, + "max_spend": { + "type": "object", + "description": "Hard per-call spend ceiling. The agent produces leaves until the NEXT leaf would push the run's aggregate vendor_cost over `amount`, then STOPS and returns the partial BuildCreativeVariantSuccess produced so far with `budget_status: \"capped\"` (every returned leaf is real, trafficable, and billed — nothing produced is discarded). If even the first leaf would exceed the cap, the call fails with BUDGET_CAP_REACHED. `currency` MUST match the rate card's currency (the agent does not FX-convert) or the request is rejected with INVALID_REQUEST (error.field `max_spend.currency`). Requires `creative.supports_spend_controls`. Caps a SINGLE call — to bound a refinement loop, track aggregate vendor_cost across calls and stop issuing them (buyer responsibility in v1).", + "properties": { + "amount": { "type": "number", "minimum": 0, "description": "Maximum aggregate vendor_cost to incur on this call, in `currency`." }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$", "description": "ISO 4217 currency; MUST match the rate card." } + }, + "required": ["amount", "currency"], + "additionalProperties": true + }, "max_creatives": { "type": "integer", "minimum": 1, - "description": "Caps how many DISTINCT creatives to produce along the catalog/item fan-out axis — one creative per catalog item. Use it to sample a large catalog (e.g. send 150 job openings, set max_creatives: 5 to preview five). Distinct from item_limit, which caps how many catalog items a SINGLE creative consumes (DCO-style). Omitted with a catalog input means one creative per item up to the catalog/format bound; omitted without a catalog collapses to a single creative. Large fan-outs may return asynchronously. Mutually exclusive with `refine_from_build_variant_id` (refinement targets one prior creative, not a catalog fan-out)." + "description": "Caps how many DISTINCT creatives to produce along the catalog/item fan-out axis — one creative per catalog item. Use it to sample a large catalog (e.g. send 150 job openings, set max_creatives: 5 to preview five). Distinct from item_limit, which caps how many catalog items a SINGLE creative consumes (DCO-style). Omitted with a catalog input means one creative per item up to the catalog/format bound; omitted without a catalog collapses to a single creative. Large fan-outs may return asynchronously. Mutually exclusive with `refine_from_build_variant_id` (refinement targets one prior creative, not a catalog fan-out). Supported only when the agent advertises `creative.multiplicity.supports_catalog_fanout`; values above `max_creatives_limit` are clamped. Pair with `max_spend` to bound the bill of a large fan-out." }, "max_variants": { "type": "integer", diff --git a/static/schemas/source/media-buy/build-creative-response.json b/static/schemas/source/media-buy/build-creative-response.json index fd52174bfd..c4e2ed0f53 100644 --- a/static/schemas/source/media-buy/build-creative-response.json +++ b/static/schemas/source/media-buy/build-creative-response.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/build-creative-response.json", "title": "Build Creative Response", - "description": "Response payload for build_creative. Exactly one of five shapes: (1) synchronous single-format success — creative_manifest issued in-line (target_format_id request); (2) synchronous multi-format success — creative_manifests issued in-line (target_format_ids request); (3) multiplicity success — creatives[] each carrying variants[], returned when the request used transformer_id with item (max_creatives) and/or variant (max_variants) fan-out; (4) terminal failure — an errors array; (5) submitted task envelope — status 'submitted' with task_id when the build is queued (e.g., slow generative or multi-minute LLM pipeline). The submitted branch MAY carry advisory errors for non-blocking warnings; terminal failures belong in the error branch. These five shapes are mutually exclusive — a response has exactly one.", + "description": "Response payload for build_creative. Exactly one of six shapes: (1) synchronous single-format success — creative_manifest issued in-line (target_format_id request); (2) synchronous multi-format success — creative_manifests issued in-line (target_format_ids request); (3) multiplicity success — creatives[] each carrying variants[], returned when the request used item (max_creatives) and/or variant (max_variants) fan-out; (4) dry-run estimate — mode 'estimate' returns a BuildCreativeEstimate cost band and produces/bills nothing; (5) terminal failure — an errors array; (6) submitted task envelope — status 'submitted' with task_id when the build is queued (e.g., slow generative or multi-minute LLM pipeline). The submitted branch MAY carry advisory errors for non-blocking warnings; terminal failures belong in the error branch. These six shapes are mutually exclusive — a response has exactly one.", "type": "object", "allOf": [ { @@ -400,6 +400,17 @@ "pattern": "^[A-Z]{3}$", "description": "ISO 4217 currency code for the aggregate vendor_cost." }, + "budget_status": { + "type": "string", + "enum": ["complete", "capped"], + "default": "complete", + "description": "`complete` (default; absent == complete for back-compat) means the agent produced everything requested. `capped` means a `max_spend` ceiling stopped production early — `items_returned` < `items_total` shows the shortfall, every returned leaf is real/billed, and an advisory `BUDGET_CAP_REACHED` entry appears in `errors[]`. This is a successful partial build, not a failure." + }, + "errors": { + "type": "array", + "description": "Advisory (non-terminal) entries on an otherwise-successful build — e.g. a `BUDGET_CAP_REACHED` notice when `budget_status` is `capped`. Terminal failures use the BuildCreativeError shape, not this field.", + "items": { "$ref": "/schemas/core/error.json" } + }, "sandbox": { "type": "boolean", "description": "When true, this response contains simulated data from sandbox mode." @@ -421,6 +432,71 @@ ], "additionalProperties": true }, + { + "title": "BuildCreativeEstimate", + "description": "Dry-run estimate. Returned when the request set mode:\"estimate\". The agent produced NOTHING and billed NOTHING; it returns a projected cost band computed against this request's actual inputs (script length, brief, catalog size, max_creatives × max_variants) — the band the buyer cannot derive itself because per_unit gives the rate but not the unit count. Advisory and non-binding in this revision. Mutually exclusive with the success/error/submitted shapes (discriminated by mode:\"estimate\" + the estimate object).", + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "estimate", + "description": "Echoes the request mode; discriminates this dry-run envelope from the producing shapes." + }, + "estimate": { + "type": "object", + "description": "Projected cost for what mode:\"execute\" would have produced.", + "properties": { + "items_total": { "type": "integer", "minimum": 0, "description": "Catalog items eligible (before max_creatives sampling)." }, + "items_to_produce": { "type": "integer", "minimum": 0, "description": "Distinct creatives that would be produced (after max_creatives)." }, + "variants_per_item": { "type": "integer", "minimum": 1, "description": "Alternatives per creative (max_variants)." }, + "leaves_total": { "type": "integer", "minimum": 0, "description": "Total billable leaves = items_to_produce × variants_per_item." }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$", "description": "ISO 4217 currency for the cost band." }, + "cost_low": { "type": "number", "minimum": 0, "description": "Low end of the projected aggregate vendor_cost." }, + "cost_high": { "type": "number", "minimum": 0, "description": "High end. For basis \"fixed\" this equals cost_low (exact)." }, + "cost_expected": { "type": "number", "minimum": 0, "description": "Optional point estimate within the band." }, + "basis": { + "type": "string", + "enum": ["fixed", "estimated_units", "cpm_deferred"], + "description": "`fixed` = per-format flat pricing (cost_low == cost_high, exact). `estimated_units` = generative per_unit where the seller projects a unit range (band reflects the uncertainty in seconds/images/tokens). `cpm_deferred` = CPM-priced; build-time cost is 0 and cost accrues at serve time (mirrors the vendor_cost:0 CPM convention)." + }, + "per_leaf": { + "type": "array", + "description": "Optional per-leaf breakdown of the estimate.", + "items": { + "type": "object", + "properties": { + "catalog_item_ref": { "type": "object", "additionalProperties": true }, + "variant_axis_value": {}, + "pricing_option_id": { "type": "string", "x-entity": "vendor_pricing_option" }, + "cost_low": { "type": "number", "minimum": 0 }, + "cost_high": { "type": "number", "minimum": 0 }, + "consumption_estimate": { "$ref": "/schemas/core/creative-consumption.json" } + }, + "additionalProperties": true + } + } + }, + "required": ["currency", "cost_low", "cost_high", "basis"], + "additionalProperties": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp after which this estimate's inputs/prices may no longer hold." + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "mode", + "estimate" + ], + "additionalProperties": true + }, { "title": "BuildCreativeError", "description": "Error response — creative generation failed. Multi-format requests (`target_format_ids[]`) are atomic per `BuildCreativeMultiSuccess` semantics: a single format failing means the entire batch returns an error response, not a partial success. Buyers diagnose per-format failures by reading the `errors[]` array's per-format attribution (see the `errors` field description).", @@ -462,6 +538,11 @@ "creatives" ] }, + { + "required": [ + "estimate" + ] + }, { "properties": { "status": { @@ -530,6 +611,11 @@ "required": [ "creatives" ] + }, + { + "required": [ + "estimate" + ] } ] } diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 752c9f4cd3..f7ab0546d1 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -1101,6 +1101,11 @@ "description": "When true, this agent retains produced build_variant leaves for an agent-defined retention window and can re-build from one via build_creative's refine_from_build_variant_id — applying a natural-language instruction in message plus an optional config delta, returning new lineage-linked variants. A build-time agent capability independent of generation/transformation. When false or absent, refine_from_build_variant_id is rejected with UNSUPPORTED_FEATURE; buyers refine instead via the transform path (creative_manifest + message).", "default": false }, + "supports_spend_controls": { + "type": "boolean", + "default": false, + "description": "When true, build_creative honors a per-call `max_spend` ceiling (producing partial paid results and returning budget_status:\"capped\" + a BUDGET_CAP_REACHED advisory rather than overspending) AND supports mode:\"estimate\" dry-runs (a projected cost band, producing/billing nothing). When false or absent, max_spend / mode:estimate are rejected with UNSUPPORTED_FEATURE. Out-of-band billers (bills_through_adcp:false) have no AdCP cost truth to cap against, so this is meaningful only alongside bills_through_adcp:true." + }, "refinable_retention_seconds": { "type": "integer", "minimum": 0, From 2189ad440bd2a7da01110dc4f80e819bbb4f6fae Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 31 May 2026 14:35:31 -0400 Subject: [PATCH 08/14] fix(creative): address F1 spend-controls review (leaf-granular cap shortfall, CPM caveat, estimate docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of dc3b9e4871; fixed the confirmed findings: - MAJOR: budget_status claimed items_returned < items_total shows the cap shortfall, but the cap is leaf-granular and items_* count catalog items — wrong for variant-only fan-out and collides with normal max_creatives sampling. Added leaves_total/leaves_returned to BuildCreativeVariantSuccess; budget_status now points at the leaf shortfall + the BUDGET_CAP_REACHED advisory as authoritative. - max_spend bounds only build-time vendor_cost; noted that CPM (cpm_deferred) builds are 0 at build time so the cap never engages (use max_creatives). - Documented the BuildCreativeEstimate response (new "### Estimate response" section + example) and added budget_status/leaves/errors to the variant field list; fixed the example's missing envelope status. - Corrected "fifth success member" -> "third success shape (oneOf 3 of 6)". - "buyer responsibility in v1" -> "in this revision". - Refreshed scripts/oneof-discriminators.baseline.json (was stale at variants:4; now reflects the 6-member oneOf). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../task-reference/build_creative.mdx | 39 +++++++++++++++++-- scripts/oneof-discriminators.baseline.json | 12 +++--- .../media-buy/build-creative-request.json | 2 +- .../media-buy/build-creative-response.json | 12 +++++- 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/docs/creative/task-reference/build_creative.mdx b/docs/creative/task-reference/build_creative.mdx index 2a9e7e0c86..dd38a25e61 100644 --- a/docs/creative/task-reference/build_creative.mdx +++ b/docs/creative/task-reference/build_creative.mdx @@ -522,7 +522,7 @@ When the request uses `target_format_ids`, the response contains an array of cre ### Variant response -When the request uses `max_creatives`, `max_variants` > 1, `variant_axis`, or `refine_from_build_variant_id`, the agent returns the `BuildCreativeVariantSuccess` shape. This is a fifth success member alongside the single- and multi-format responses; the `creative_manifest` / `creative_manifests` shapes are unchanged and still used when you build one creative with one variant. +When the request uses `max_creatives`, `max_variants` > 1, `variant_axis`, or `refine_from_build_variant_id`, the agent returns the `BuildCreativeVariantSuccess` shape — the third success shape (`oneOf` member 3 of 6) alongside the single- and multi-format responses, which are unchanged and still used when you build one creative with one variant. **No fallback.** If you send any of `max_creatives`, `max_variants > 1`, `variant_axis`, or `refine_from_build_variant_id`, you **must** handle `creatives[]` — you will not get `creative_manifest`/`creative_manifests` back. There is no automatic downgrade to the single/multi shapes. @@ -580,7 +580,10 @@ When the request uses `max_creatives`, `max_variants` > 1, `variant_axis`, or `r - **variants[].recommended** / **variants[].rank**: Best-of-N signals — `recommended` flags the agent's top pick; `rank` orders the variants. These plus `variant_axis` and `keep_mode` are how best-of-N is expressed. - **variants[] pricing receipt**: Each variant carries a per-leaf receipt (`pricing_option_id`, `vendor_cost`, `currency`, `consumption`) for the work that produced it. - **items_total** / **items_returned**: For catalog fan-out, the total eligible catalog items and how many creatives were returned (e.g. "5 of 150"). +- **leaves_total** / **leaves_returned**: Leaf-granular counts — total leaves the request would produce vs. how many were actually produced/billed. When `budget_status` is `capped`, `leaves_returned` < `leaves_total` is the shortfall (works even for variant-only fan-out with no catalog). - **vendor_cost** / **currency**: Aggregate cost across all produced variants in the response. +- **budget_status**: `complete` (default; absent == complete) or `capped` — a `max_spend` ceiling stopped production early (a successful partial build, not a failure). See [Spend controls](#spend-controls). +- **errors[]**: Advisory (non-terminal) entries on an otherwise-successful build — e.g. a `BUDGET_CAP_REACHED` notice when `budget_status` is `capped`. You pay for **every produced variant** (`per_unit` × N), not just the ones you keep. Keeping is a separate client act of trafficking the chosen `build_variant_id`(s). A kept variant lazily earns a `creative_id` when it is added to the library or first trafficked; that `creative_id` is what flows to [`report_usage`](/docs/accounts/tasks/report_usage). An unkept variant is still billed but never earns a `creative_id`. @@ -623,14 +626,44 @@ AI-derivative attribution rides the manifest's existing `provenance`; `parent_bu Fan-out and refinement can produce many independently-billed leaves (`max_creatives` × `max_variants`), and `per_unit` pricing gives a *rate* but not the *unit count* in advance (a 6-second voiceover and a 60-second one cost 10× at the same rate). Two opt-in controls, both gated by `creative.supports_spend_controls`: - **Estimate first (`mode: "estimate"`).** A dry run: the agent produces and bills nothing and returns a `BuildCreativeEstimate` with a `cost_low`/`cost_high` band (and `basis`: `fixed` = exact, `estimated_units` = generative projection, `cpm_deferred` = cost accrues at serve). The band is the load-bearing piece — the seller derives it from your actual inputs, so you don't have to guess unit counts. -- **Cap the call (`max_spend: { amount, currency }`).** A hard stop: the agent produces leaves until the next would push aggregate `vendor_cost` over `amount`, then returns the partial `BuildCreativeVariantSuccess` with `budget_status: "capped"` and an advisory `BUDGET_CAP_REACHED` in `errors[]` — every returned leaf is real and billed, nothing produced is discarded, `items_returned` < `items_total` shows the shortfall. If even the first leaf would exceed the cap, the call fails with a terminal `BUDGET_CAP_REACHED`. `currency` must match the rate card (no FX) or the request is rejected `INVALID_REQUEST` (`error.field: max_spend.currency`). +- **Cap the call (`max_spend: { amount, currency }`).** A hard stop: the agent produces leaves until the next would push aggregate `vendor_cost` over `amount`, then returns the partial `BuildCreativeVariantSuccess` with `budget_status: "capped"` and an advisory `BUDGET_CAP_REACHED` in `errors[]` — every returned leaf is real and billed, nothing produced is discarded. The leaf-granular shortfall is `leaves_returned` < `leaves_total` (not `items_returned`/`items_total`, which count catalog items and don't capture a variant-only or mid-item cap); the `BUDGET_CAP_REACHED` advisory is the authoritative cap signal. If even the first leaf would exceed the cap, the call fails with a terminal `BUDGET_CAP_REACHED`. `currency` must match the rate card (no FX) or the request is rejected `INVALID_REQUEST` (`error.field: max_spend.currency`). `max_spend` bounds only **build-time** `vendor_cost` — CPM-priced builds (`basis: cpm_deferred`) are 0 at build time and accrue at serve, so the cap never engages; bound a CPM fan-out with `max_creatives` instead. -They compose: estimate to get `cost_high`, then execute with `max_spend` = `cost_high` × a safety margin. `max_spend` caps a **single call**; to bound an autonomous **refinement loop**, track aggregate `vendor_cost` across calls and stop issuing them (a buyer responsibility in this revision — a protocol-level session budget is deferred to the working group). +They compose: estimate to get `cost_high`, then execute with `max_spend` = `cost_high` × a safety margin (CPM builds excepted — their `cost_high` is 0 at build time). `max_spend` caps a **single call**; to bound an autonomous **refinement loop**, track aggregate `vendor_cost` across calls and stop issuing them (a buyer responsibility in this revision — a protocol-level session budget is deferred to the working group). `max_spend` and `mode: "estimate"` require the agent to advertise `creative.supports_spend_controls`; otherwise they're rejected with `UNSUPPORTED_FEATURE`. They're only meaningful with `bills_through_adcp: true` (an out-of-band biller has no AdCP cost to cap against). +### Estimate response + +A `mode: "estimate"` request returns the `BuildCreativeEstimate` shape (`oneOf` member 4 of 6) — produced and billed nothing, just a projected cost band: + +```json test=false +{ + "$schema": "/schemas/media-buy/build-creative-response.json", + "status": "completed", + "mode": "estimate", + "estimate": { + "items_total": 150, + "items_to_produce": 5, + "variants_per_item": 3, + "leaves_total": 15, + "currency": "USD", + "cost_low": 6.00, + "cost_high": 9.00, + "cost_expected": 7.50, + "basis": "estimated_units" + }, + "expires_at": "2026-06-01T00:00:00Z" +} +``` + +- **estimate.leaves_total** = `items_to_produce` × `variants_per_item` — the number of billable leaves `mode: "execute"` would produce. +- **estimate.cost_low / cost_high / cost_expected**: the projected aggregate cost band. The seller derives it from your actual inputs. +- **estimate.basis**: `fixed` (per-format flat — `cost_low == cost_high`, exact), `estimated_units` (generative `per_unit`; the band reflects unit-count uncertainty), or `cpm_deferred` (CPM — build-time cost 0, accrues at serve, so the band is 0). +- **estimate.per_leaf** (optional): per-leaf breakdown. +- Estimates are **advisory / non-binding** in this revision (binding quotes are deferred to the working group). + ### Field descriptions - **creative_manifest**: (single-format) The complete creative manifest ready for use with `sync_creatives` or `preview_creative` diff --git a/scripts/oneof-discriminators.baseline.json b/scripts/oneof-discriminators.baseline.json index 8f76cac6eb..b5ad990fd0 100644 --- a/scripts/oneof-discriminators.baseline.json +++ b/scripts/oneof-discriminators.baseline.json @@ -84,7 +84,7 @@ "brand/verify-brand-claim-response.json##/oneOf": { "kind": "narrowable", "variants": 2, - "note": "0:[claim_type,verification_status] | 1:[errors]" + "note": "0:[claim_type,verification_status,signed_response] | 1:[errors]" }, "brand/verify-brand-claims-response.json##/definitions/result_entry/oneOf": { "kind": "narrowable", @@ -94,7 +94,7 @@ "brand/verify-brand-claims-response.json##/oneOf": { "kind": "narrowable", "variants": 2, - "note": "0:[results] | 1:[errors]" + "note": "0:[results,signed_response] | 1:[errors]" }, "compliance/comply-test-controller-response.json##/oneOf": { "kind": "dangerous", @@ -253,13 +253,13 @@ }, "media-buy/build-creative-response.json##/oneOf": { "kind": "narrowable", - "variants": 4, - "note": "0:[creative_manifest] | 1:[creative_manifests] | 2:[errors] | 3:[status,task_id]" + "variants": 6, + "note": "0:[creative_manifest] | 1:[creative_manifests] | 2:[creatives] | 3:[mode,estimate] | 4:[errors] | 5:[status,task_id]" }, "media-buy/create-media-buy-response.json##/oneOf": { "kind": "narrowable", "variants": 3, - "note": "0:[media_buy_id,packages] | 1:[errors] | 2:[status,task_id]" + "note": "0:[media_buy_id,confirmed_at,revision,packages] | 1:[errors] | 2:[status,task_id]" }, "media-buy/get-media-buy-delivery-request.json##/properties/reporting_dimensions/properties/geo/properties/system/oneOf": { "kind": "dangerous", @@ -309,7 +309,7 @@ "media-buy/update-media-buy-response.json##/oneOf": { "kind": "narrowable", "variants": 3, - "note": "0:[media_buy_id] | 1:[errors] | 2:[status,task_id]" + "note": "0:[media_buy_id,revision] | 1:[errors] | 2:[status,task_id]" }, "pricing-options/cpv-option.json##/properties/parameters/properties/view_threshold/oneOf": { "kind": "dangerous", diff --git a/static/schemas/source/media-buy/build-creative-request.json b/static/schemas/source/media-buy/build-creative-request.json index 62071680a4..7abe46b952 100644 --- a/static/schemas/source/media-buy/build-creative-request.json +++ b/static/schemas/source/media-buy/build-creative-request.json @@ -81,7 +81,7 @@ }, "max_spend": { "type": "object", - "description": "Hard per-call spend ceiling. The agent produces leaves until the NEXT leaf would push the run's aggregate vendor_cost over `amount`, then STOPS and returns the partial BuildCreativeVariantSuccess produced so far with `budget_status: \"capped\"` (every returned leaf is real, trafficable, and billed — nothing produced is discarded). If even the first leaf would exceed the cap, the call fails with BUDGET_CAP_REACHED. `currency` MUST match the rate card's currency (the agent does not FX-convert) or the request is rejected with INVALID_REQUEST (error.field `max_spend.currency`). Requires `creative.supports_spend_controls`. Caps a SINGLE call — to bound a refinement loop, track aggregate vendor_cost across calls and stop issuing them (buyer responsibility in v1).", + "description": "Hard per-call spend ceiling. The agent produces leaves until the NEXT leaf would push the run's aggregate vendor_cost over `amount`, then STOPS and returns the partial BuildCreativeVariantSuccess produced so far with `budget_status: \"capped\"` (every returned leaf is real, trafficable, and billed — nothing produced is discarded; the leaf shortfall is `leaves_returned` < `leaves_total`). If even the first leaf would exceed the cap, the call fails with BUDGET_CAP_REACHED. `currency` MUST match the rate card's currency (the agent does not FX-convert) or the request is rejected with INVALID_REQUEST (error.field `max_spend.currency`). Requires `creative.supports_spend_controls`. Caps a SINGLE call — to bound a refinement loop, track aggregate vendor_cost across calls and stop issuing them (buyer responsibility in this revision). max_spend bounds only build-time vendor_cost: CPM-priced builds (estimate basis `cpm_deferred`) have build-time vendor_cost 0 and accrue at serve time, so max_spend never engages for them — bound a CPM fan-out with max_creatives instead.", "properties": { "amount": { "type": "number", "minimum": 0, "description": "Maximum aggregate vendor_cost to incur on this call, in `currency`." }, "currency": { "type": "string", "pattern": "^[A-Z]{3}$", "description": "ISO 4217 currency; MUST match the rate card." } diff --git a/static/schemas/source/media-buy/build-creative-response.json b/static/schemas/source/media-buy/build-creative-response.json index c4e2ed0f53..6de4e23cdb 100644 --- a/static/schemas/source/media-buy/build-creative-response.json +++ b/static/schemas/source/media-buy/build-creative-response.json @@ -390,6 +390,16 @@ "minimum": 0, "description": "Number of creatives returned in creatives[] (after max_creatives sampling)." }, + "leaves_total": { + "type": "integer", + "minimum": 0, + "description": "Total leaves the request would have produced (≈ items_to_produce × variants_per_item). Present when a max_spend cap may have stopped production short. Counts LEAVES, not catalog items — so it expresses a shortfall even for a variant-only fan-out with no catalog." + }, + "leaves_returned": { + "type": "integer", + "minimum": 0, + "description": "Number of leaves actually produced and billed across creatives[].variants[]. When budget_status is 'capped', leaves_returned < leaves_total is the leaf-granular shortfall signal (items_returned/items_total are catalog-item counts and do not capture a mid-item or variant-only cap)." + }, "vendor_cost": { "type": "number", "minimum": 0, @@ -404,7 +414,7 @@ "type": "string", "enum": ["complete", "capped"], "default": "complete", - "description": "`complete` (default; absent == complete for back-compat) means the agent produced everything requested. `capped` means a `max_spend` ceiling stopped production early — `items_returned` < `items_total` shows the shortfall, every returned leaf is real/billed, and an advisory `BUDGET_CAP_REACHED` entry appears in `errors[]`. This is a successful partial build, not a failure." + "description": "`complete` (default; absent == complete for back-compat) means the agent produced everything requested. `capped` means a `max_spend` ceiling stopped production early: every returned leaf is real/billed, and an advisory `BUDGET_CAP_REACHED` entry in `errors[]` is the authoritative cap signal. The leaf-granular shortfall is `leaves_returned` < `leaves_total` (do NOT rely on items_returned < items_total — that is also the normal max_creatives-sampling signal and does not capture a mid-item or variant-only cap). This is a successful partial build, not a failure." }, "errors": { "type": "array", From f312f63b75a89c0cd5ecfcf372b13bf7414e850f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 31 May 2026 14:41:01 -0400 Subject: [PATCH 09/14] spec(creative): generative-encoding safe additions (free_text params + per-output pricing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The additive half of the generative-agent encodings follow-on (Veo/Imagen). The two normative rules (count owned by max_variants; aspect_ratio on the format axis) are left to the WG. - transformer-param value_source gains "free_text" (open string, e.g. negative_prompt) + optional max_length; description states count knobs are NOT params. - vendor-pricing-option gains optional applies_to_output_format_ids so a transformer can price different outputs differently (multi-publisher template). Additive/inert for signals/governance — flagged for shared-schema owner ack. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/generative-encoding-safe-additions.md | 10 ++++++++++ docs/creative/task-reference/list_transformers.mdx | 4 ++-- static/schemas/source/core/transformer-param.json | 9 +++++++-- static/schemas/source/core/vendor-pricing-option.json | 6 ++++++ 4 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 .changeset/generative-encoding-safe-additions.md diff --git a/.changeset/generative-encoding-safe-additions.md b/.changeset/generative-encoding-safe-additions.md new file mode 100644 index 0000000000..39c6ce3ded --- /dev/null +++ b/.changeset/generative-encoding-safe-additions.md @@ -0,0 +1,10 @@ +--- +"adcontextprotocol": minor +--- + +spec(creative): generative-encoding safe additions — `free_text` params + per-output transformer pricing. + +The additive half of the generative-agent (Veo/Imagen) encodings follow-on. The two *normative* rules it pairs with — generation count is owned by `max_variants`/`max_creatives` (never a config param), and `aspect_ratio` rides the format axis — are intentionally left to the working group; only the safe schema bits land here. + +- `transformer-param.json` `value_source` gains **`free_text`** (an open buyer-authored string with no closed set — e.g. a `negative_prompt` or style note; `type` MUST be `string`, the closed-set fields MUST be absent) plus an optional **`max_length`**. The description also states that count/quantity knobs MUST NOT be params (count rides `max_variants`/`max_creatives`). +- `vendor-pricing-option.json` gains optional **`applies_to_output_format_ids`** so one creative transformer can price different outputs differently (e.g. a multi-publisher template charging per publisher format); an unscoped option is the default. Additive and inert for non-creative vendors (signals/governance) — **flagged for shared-schema owner ack**. diff --git a/docs/creative/task-reference/list_transformers.mdx b/docs/creative/task-reference/list_transformers.mdx index 6c34691ca2..be4b903320 100644 --- a/docs/creative/task-reference/list_transformers.mdx +++ b/docs/creative/task-reference/list_transformers.mdx @@ -57,8 +57,8 @@ Each transformer carries: | `transformer_id` | Stable id; pass to `build_creative` `transformer_id` | | `name` | Human-readable name | | `input_format_ids` / `output_format_ids` | What it accepts and produces. A `build_creative` target MUST be a subset of `output_format_ids`. Empty `input_format_ids` means it builds from a brief (pure generation). | -| `params` | Config knobs (see [Transformer Param](https://adcontextprotocol.org/schemas/v3/core/transformer-param.json)): `field`, `type`, `value_source` (`inline`/`range`/`enumerable`), `allowed_values`/`minimum`/`maximum`, `options[]`+`options_cursor` (when expanded), `default` | -| `pricing_options` | Per-account rate card (when `include_pricing`). Uses the `per_unit` model (e.g. $/generated image, $/second). The applied option is echoed per-leaf on the `build_creative` response and reconciled via `report_usage`. | +| `params` | Config knobs (see [Transformer Param](https://adcontextprotocol.org/schemas/v3/core/transformer-param.json)): `field`, `type`, `value_source` (`inline`/`range`/`enumerable`/`free_text`), `allowed_values`/`minimum`/`maximum`, `options[]`+`options_cursor` (when expanded), `max_length` (for `free_text`), `default`. `free_text` is an open buyer-authored string (e.g. a `negative_prompt`); a param MUST NOT be a generation-count knob — count rides `max_variants`/`max_creatives`. | +| `pricing_options` | Per-account rate card (when `include_pricing`). Uses the `per_unit` model (e.g. $/generated image, $/second). A pricing option may carry `applies_to_output_format_ids` to price different outputs differently (e.g. a multi-publisher template per publisher format); an unscoped option is the default. The applied option is echoed per-leaf on the `build_creative` response and reconciled via `report_usage`. | See [Transformer schema](https://adcontextprotocol.org/schemas/v3/core/transformer.json) for the complete object. diff --git a/static/schemas/source/core/transformer-param.json b/static/schemas/source/core/transformer-param.json index d422d6c4de..17b878aaa7 100644 --- a/static/schemas/source/core/transformer-param.json +++ b/static/schemas/source/core/transformer-param.json @@ -16,8 +16,13 @@ }, "value_source": { "type": "string", - "enum": ["inline", "range", "enumerable"], - "description": "Where the legal values come from. `inline` — a small closed set listed in `allowed_values` (e.g. mastering_preset). `range` — a numeric interval bounded by `minimum`/`maximum` (e.g. speaking_rate). `enumerable` — an account-scoped, dynamic set (e.g. voices, including custom/cloned ones) resolved per-credential; values appear in `options[]` only when expanded." + "enum": ["inline", "range", "enumerable", "free_text"], + "description": "Where the legal values come from. `inline` — a small closed set listed in `allowed_values` (e.g. mastering_preset). `range` — a numeric interval bounded by `minimum`/`maximum` (e.g. speaking_rate). `enumerable` — an account-scoped, dynamic set (e.g. voices, including custom/cloned ones) resolved per-credential; values appear in `options[]` only when expanded. `free_text` — an open buyer-authored string with no closed/enumerable set (e.g. a negative_prompt or style note for a generative agent); `type` MUST be `string` and `allowed_values`/`minimum`/`maximum`/`options`/`options_cursor` MUST be absent. NOTE: a transformer-param MUST NOT be a generation-count knob (sample_count/n/num_images/count) — output count is owned by `max_variants`/`max_creatives`, never config." + }, + "max_length": { + "type": "integer", + "minimum": 1, + "description": "Optional maximum character length for a `free_text` param. Omit for no declared limit." }, "allowed_values": { "type": "array", diff --git a/static/schemas/source/core/vendor-pricing-option.json b/static/schemas/source/core/vendor-pricing-option.json index 6d6ffa7725..c3ecb5b9d6 100644 --- a/static/schemas/source/core/vendor-pricing-option.json +++ b/static/schemas/source/core/vendor-pricing-option.json @@ -11,6 +11,12 @@ "type": "string", "description": "Opaque identifier for this pricing option, unique within the vendor agent. Pass this in report_usage to identify which pricing option was applied.", "x-entity": "vendor_pricing_option" + }, + "applies_to_output_format_ids": { + "type": "array", + "description": "Creative transformers only: scopes this pricing option to specific output formats, so one transformer can price different outputs differently (e.g. a multi-publisher template charging per publisher format). When present, the option applies only to leaves whose format matches one of these; an unscoped option (this field absent) is the default for any output. Inert for non-creative vendors (signals/governance), which omit it.", + "items": { "$ref": "/schemas/core/format-id.json" }, + "minItems": 1 } }, "required": ["pricing_option_id"] From 66b0921433e8f6ffc2df5958263ed55bf40f53f5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 31 May 2026 18:53:45 -0400 Subject: [PATCH 10/14] spec(creative): address Nas review (build_variant_id on report_usage, keep_mode echo) + UNPRICEABLE_OUTPUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #5219 + WG decision: - Nas: report_usage usage records carry creative_id but no link to the specific build leaf for audit (pricing_option_id isn't unique across leaves). Added optional build_variant_id to report-usage-request usage[] item. - Nas: keep_mode is advisory with no response echo, so a buyer billed for N after sending keep_one had no paper trail. Added keep_mode_applied echo on BuildCreativeVariantSuccess (mirrors the get-products refinement_applied pattern). - WG5 (decided: hard error, no fallback): added UNPRICEABLE_OUTPUT error code (enum + descriptions + metadata + drift disposition) — a build targeting an output no transformer.pricing_options entry covers (and no unscoped default) is rejected, never billed at a guessed rate. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/accounts/tasks/report_usage.mdx | 1 + docs/creative/task-reference/build_creative.mdx | 3 ++- docs/creative/task-reference/list_transformers.mdx | 2 +- scripts/error-code-drift-dispositions.json | 5 +++++ static/schemas/source/account/report-usage-request.json | 5 +++++ static/schemas/source/core/vendor-pricing-option.json | 2 +- static/schemas/source/enums/error-code.json | 6 ++++++ .../schemas/source/media-buy/build-creative-response.json | 5 +++++ 8 files changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/accounts/tasks/report_usage.mdx b/docs/accounts/tasks/report_usage.mdx index 2cf548b903..9b7cc8521a 100644 --- a/docs/accounts/tasks/report_usage.mdx +++ b/docs/accounts/tasks/report_usage.mdx @@ -36,6 +36,7 @@ Each record requires `account`, `vendor_cost`, and `currency`. Additional fields | `media_spend` | number | percent_of_media: Yes | Media spend for percent-of-media cost verification | | `signal_agent_segment_id` | string | Signals: Yes | Signal identifier from `get_signals` | | `creative_id` | string | Creative: Yes | Creative identifier from `build_creative` or `list_creatives`. Links usage to a specific creative for billing verification. A `build_creative` variant leaf earns a `creative_id` only when trafficked/added to the library — discarded best-of-N or fan-out variants are never reported here; their charge is the inline per-leaf `vendor_cost` on the `build_creative` response (the authoritative record for untrafficked leaves). | +| `build_variant_id` | string | No | When the reported `creative_id` was promoted from a specific `build_creative` variant leaf, the source `build_variant_id` — lets reconciliation link this record back to the exact produced leaf for audit (`pricing_option_id` alone is not unique across leaves). Omit for creatives with no build-variant lineage. | | `property_list_id` | string | Property lists: Yes | Property list identifier from `list_property_lists`. Links usage to a specific property list for billing verification. | ## Response diff --git a/docs/creative/task-reference/build_creative.mdx b/docs/creative/task-reference/build_creative.mdx index dd38a25e61..65ade6ee9f 100644 --- a/docs/creative/task-reference/build_creative.mdx +++ b/docs/creative/task-reference/build_creative.mdx @@ -35,7 +35,7 @@ For information about legacy format IDs and how to reference formats, see [Creat | `max_creatives` | integer | No | Catalog fan-out axis: produce up to N **distinct creatives, one per catalog item** (a sample — e.g. 5 of 150). Distinct from `item_limit`, which caps items used *within* a single creative. Mutually exclusive with `refine_from_build_variant_id`. Triggers the `BuildCreativeVariantSuccess` response shape. Supported only when the agent advertises `creative.multiplicity.supports_catalog_fanout`; values above `max_creatives_limit` are **clamped** (shortfall via `items_returned` < `items_total`), not rejected. | | `max_variants` | integer | No | Number of alternative renders to produce **per creative** (best-of-N). Default `1`. You pay for every produced variant; keeping one or more is a separate trafficking step. Supported only when the agent advertises `creative.multiplicity.supports_variants`; values above `max_variants_limit` are clamped. | | `variant_axis` | object | No | Describes the dimension along which variants differ. Object with `dimension` (`voice` \| `theme` \| `best_of_n` \| `transformer_config` \| `custom`), optional `values[]` (explicit values to enumerate along the axis), optional `field` (the `config` param to sweep — required when `dimension` is `transformer_config`), and optional `label`. | -| `keep_mode` | string | No | Advisory hint to the agent on how many variants you intend to keep: `"keep_all"`, `"keep_one"`, or `"keep_some"`. Default `"keep_all"`. Advisory only — you are billed for all produced variants regardless. | +| `keep_mode` | string | No | Advisory hint to the agent on how many variants you intend to keep: `"keep_all"`, `"keep_one"`, or `"keep_some"`. Default `"keep_all"`. Advisory only — you are billed for all produced variants regardless. The response echoes `keep_mode_applied` so you have confirmation the hint was received (audit trail for billing disputes). | | `refine_from_build_variant_id` | string | No | Refine a prior produced variant: re-build from its `build_variant_id` applying the NL instruction in `message` plus any `config` delta, returning **new** lineage-linked variants (never a mutation). `transformer_id` and target format(s) are inherited from the parent. Composes with `max_variants`/`variant_axis`; mutually exclusive with `max_creatives`. Requires `creative.supports_refinement` — otherwise `UNSUPPORTED_FEATURE`; an unknown/no-longer-retained ref is `REFERENCE_NOT_FOUND` (`error.field` = `refine_from_build_variant_id`). See [Refinement](#refinement). | | `mode` | string | No | `"execute"` (default) produces and bills. `"estimate"` is a **dry run** — produces and bills nothing, returns a `BuildCreativeEstimate` cost band (`cost_low`/`cost_high`) computed against this request's inputs. Requires `creative.supports_spend_controls`. See [Spend controls](#spend-controls). | | `max_spend` | object | No | Hard per-call spend ceiling `{ amount, currency }`. The agent produces leaves until the next would exceed `amount`, then stops and returns the partial result with `budget_status: "capped"`. Requires `creative.supports_spend_controls`. Caps one call — bound refinement loops buyer-side. See [Spend controls](#spend-controls). | @@ -582,6 +582,7 @@ When the request uses `max_creatives`, `max_variants` > 1, `variant_axis`, or `r - **items_total** / **items_returned**: For catalog fan-out, the total eligible catalog items and how many creatives were returned (e.g. "5 of 150"). - **leaves_total** / **leaves_returned**: Leaf-granular counts — total leaves the request would produce vs. how many were actually produced/billed. When `budget_status` is `capped`, `leaves_returned` < `leaves_total` is the shortfall (works even for variant-only fan-out with no catalog). - **vendor_cost** / **currency**: Aggregate cost across all produced variants in the response. +- **keep_mode_applied**: Echoes the `keep_mode` the agent applied (present when the request set it). `keep_mode` is advisory and doesn't change what's produced or billed, so this is your confirmation the hint was received — the audit trail for a "I asked for keep_one but was billed for N" dispute. - **budget_status**: `complete` (default; absent == complete) or `capped` — a `max_spend` ceiling stopped production early (a successful partial build, not a failure). See [Spend controls](#spend-controls). - **errors[]**: Advisory (non-terminal) entries on an otherwise-successful build — e.g. a `BUDGET_CAP_REACHED` notice when `budget_status` is `capped`. diff --git a/docs/creative/task-reference/list_transformers.mdx b/docs/creative/task-reference/list_transformers.mdx index be4b903320..3b381b806e 100644 --- a/docs/creative/task-reference/list_transformers.mdx +++ b/docs/creative/task-reference/list_transformers.mdx @@ -58,7 +58,7 @@ Each transformer carries: | `name` | Human-readable name | | `input_format_ids` / `output_format_ids` | What it accepts and produces. A `build_creative` target MUST be a subset of `output_format_ids`. Empty `input_format_ids` means it builds from a brief (pure generation). | | `params` | Config knobs (see [Transformer Param](https://adcontextprotocol.org/schemas/v3/core/transformer-param.json)): `field`, `type`, `value_source` (`inline`/`range`/`enumerable`/`free_text`), `allowed_values`/`minimum`/`maximum`, `options[]`+`options_cursor` (when expanded), `max_length` (for `free_text`), `default`. `free_text` is an open buyer-authored string (e.g. a `negative_prompt`); a param MUST NOT be a generation-count knob — count rides `max_variants`/`max_creatives`. | -| `pricing_options` | Per-account rate card (when `include_pricing`). Uses the `per_unit` model (e.g. $/generated image, $/second). A pricing option may carry `applies_to_output_format_ids` to price different outputs differently (e.g. a multi-publisher template per publisher format); an unscoped option is the default. The applied option is echoed per-leaf on the `build_creative` response and reconciled via `report_usage`. | +| `pricing_options` | Per-account rate card (when `include_pricing`). Uses the `per_unit` model (e.g. $/generated image, $/second). A pricing option may carry `applies_to_output_format_ids` to price different outputs differently (e.g. a multi-publisher template per publisher format); an unscoped option is the default, and an output matching no option (with no unscoped default) is rejected `UNPRICEABLE_OUTPUT` (no fallback). The applied option is echoed per-leaf on the `build_creative` response and reconciled via `report_usage`. | See [Transformer schema](https://adcontextprotocol.org/schemas/v3/core/transformer.json) for the complete object. diff --git a/scripts/error-code-drift-dispositions.json b/scripts/error-code-drift-dispositions.json index 1f91ed2408..097f2b01bc 100644 --- a/scripts/error-code-drift-dispositions.json +++ b/scripts/error-code-drift-dispositions.json @@ -6,6 +6,11 @@ "target_version": "3.1", "note": "build_creative spend controls — emitted when a per-call max_spend ceiling stops production early (advisory on a partial BuildCreativeVariantSuccess, or terminal when even the first leaf exceeds the cap). New 3.1 creative-transformer surface; wire change — held for 3.1." }, + "UNPRICEABLE_OUTPUT": { + "disposition": "held-for-next-minor", + "target_version": "3.1", + "note": "Creative transformers per-output pricing (transformer.pricing_options applies_to_output_format_ids) — emitted when a build targets an output no pricing option covers and none is unscoped. New 3.1 creative-transformer surface; wire change — held for 3.1." + }, "ACTION_NOT_ALLOWED": { "disposition": "held-for-next-minor", "target_version": "3.1", diff --git a/static/schemas/source/account/report-usage-request.json b/static/schemas/source/account/report-usage-request.json index 7702d9d81f..5f9c158c53 100644 --- a/static/schemas/source/account/report-usage-request.json +++ b/static/schemas/source/account/report-usage-request.json @@ -80,6 +80,11 @@ "description": "Creative identifier from build_creative or list_creatives. Required for creative agents. Links usage records to specific creatives for billing verification.", "x-entity": "creative" }, + "build_variant_id": { + "type": "string", + "x-entity": "build_variant", + "description": "Optional. When the reported creative_id was promoted from a specific build_creative variant leaf (a fan-out or best-of-N produce), the source build_variant_id — lets billing reconciliation link this usage record back to the exact produced leaf for audit (pricing_option_id alone is not unique across leaves). Omit for creatives with no build-variant lineage." + }, "property_list_id": { "type": "string", "description": "Property list identifier from list_property_lists. Required for property list agents. Links usage records to specific property lists for billing verification.", diff --git a/static/schemas/source/core/vendor-pricing-option.json b/static/schemas/source/core/vendor-pricing-option.json index c3ecb5b9d6..7fc4bc3885 100644 --- a/static/schemas/source/core/vendor-pricing-option.json +++ b/static/schemas/source/core/vendor-pricing-option.json @@ -14,7 +14,7 @@ }, "applies_to_output_format_ids": { "type": "array", - "description": "Creative transformers only: scopes this pricing option to specific output formats, so one transformer can price different outputs differently (e.g. a multi-publisher template charging per publisher format). When present, the option applies only to leaves whose format matches one of these; an unscoped option (this field absent) is the default for any output. Inert for non-creative vendors (signals/governance), which omit it.", + "description": "Creative transformers only: scopes this pricing option to specific output formats, so one transformer can price different outputs differently (e.g. a multi-publisher template charging per publisher format). When present, the option applies only to leaves whose format matches one of these; an unscoped option (this field absent) is the default for any output. A build targeting an output that matches no scoped option AND has no unscoped default is rejected with UNPRICEABLE_OUTPUT — never billed at a guessed rate (no silent fallback). Inert for non-creative vendors (signals/governance), which omit it.", "items": { "$ref": "/schemas/core/format-id.json" }, "minItems": 1 } diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index e9d6057574..dc750e2a65 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -20,6 +20,7 @@ "CREATIVE_REJECTED", "CREATIVE_VALUE_NOT_ALLOWED", "UNSUPPORTED_FEATURE", + "UNPRICEABLE_OUTPUT", "UNSUPPORTED_GRANULARITY", "UNSUPPORTED_PROVISIONING", "AUDIENCE_TOO_SMALL", @@ -105,6 +106,7 @@ "CREATIVE_REJECTED": "Creative failed content policy review. For deadline violations, see CREATIVE_DEADLINE_EXCEEDED. Recovery: correctable (revise the creative per the seller's advertising_policies).", "CREATIVE_VALUE_NOT_ALLOWED": "A submitted text-asset value is not in the format's declared `allowed_values` list. Distinct from `CREATIVE_REJECTED` (generic content-policy failure) by being a closed-set constraint violation that the buyer can resolve mechanically without policy interpretation — the seller has published the complete list of acceptable values on the format, and any value outside that list is rejected by definition. The seller MUST set `error.field` to the offending asset's path within the manifest (e.g., `creatives[0].creative_manifest.assets[0].value` or the field name declared by the format) and SHOULD include the format's `allowed_values` array in `error.details.allowed_values` so the buyer agent can re-prompt its LLM with constrained sampling. Recovery: correctable (select a value from `allowed_values` and resubmit).", "UNSUPPORTED_FEATURE": "A requested feature or field is not supported by this seller. Recovery: correctable (check get_adcp_capabilities and remove unsupported fields).", + "UNPRICEABLE_OUTPUT": "A creative transformer build targets an output format that no pricing option covers — no transformer.pricing_options entry has a matching applies_to_output_format_ids and none is unscoped. The build is rejected rather than billed at a guessed rate (no silent fallback). Recovery: correctable (target an output the transformer prices, or have the seller add a pricing option for it).", "UNSUPPORTED_GRANULARITY": "The requested `time_granularity` on `get_media_buy_delivery` is not in the product's declared `reporting_capabilities.windowed_pull_granularities`. Distinct from `UNSUPPORTED_FEATURE` (generic capability mismatch) by being narrowly about reporting-window granularity — the buyer asked for hourly pull-recovery on a product that only honors daily pulls, for example. Sellers MAY echo the declared set in `error.details.supported_granularities` when the caller is authorized to read the product's reporting capabilities — the same set is already available via `get_adcp_capabilities`, so the echo is a convenience, not load-bearing. Sellers MUST NOT echo a granularity set the caller could not otherwise read (per-product capability views vary by buyer entitlement). The `error.field` SHOULD point at `time_granularity`. Buyers that need higher-frequency recovery than the seller's pull set supports MUST rely on the webhook channel as primary at that frequency — the seller's `available_reporting_frequencies` may legitimately exceed `windowed_pull_granularities` (e.g., a stream-tap webhook on Kafka with warehouse pulls only at daily). Recovery: correctable (re-issue at a granularity in the declared set, or omit `time_granularity` to fall back to cumulative date-range pulls).", "UNSUPPORTED_PROVISIONING": "The seller does not support the `sync_accounts` mode the entry requested. Returned per-entry in the `sync_accounts` response when (a) an entry keyed by the natural-key trio (`brand` + `operator` + `billing`) is sent to a seller that does not provision accounts via AdCP — typical for account-id namespaces where accounts are pre-provisioned out of band or discovered via `list_accounts`; or (b) an entry keyed by `account` (AccountRef) is sent to a seller that has not implemented the settings-update mode. Distinct from `UNSUPPORTED_FEATURE` (generic capability mismatch) by being narrowly about which of the two `sync_accounts` modes the seller implements. The two modes are mutually exclusive per-entry — the seller MUST NOT silently downgrade or upgrade between them. Sellers MAY declare which modes they implement via `get_adcp_capabilities` (forward-looking — capability declaration shape is open). Recovery: correctable (re-issue with the entry shape the seller supports; for account-id namespaces that expose settings updates, that means `account: {account_id: ...}` entries; for sellers that only provision via natural key that means the flat `brand+operator+billing` shape). The `error.field` SHOULD point at the entry index where the unsupported shape was found.", "AUDIENCE_TOO_SMALL": "Audience segment is below the minimum required size for targeting. Recovery: correctable (broaden targeting or upload more audience members).", @@ -236,6 +238,10 @@ "recovery": "correctable", "suggestion": "check get_adcp_capabilities and remove unsupported fields" }, + "UNPRICEABLE_OUTPUT": { + "recovery": "correctable", + "suggestion": "target an output format the transformer prices, or have the seller add a pricing option covering it" + }, "UNSUPPORTED_GRANULARITY": { "recovery": "correctable", "suggestion": "pick a granularity from the product's reporting_capabilities.windowed_pull_granularities (via get_adcp_capabilities) and resubmit, or omit time_granularity to fall back to cumulative date-range pulls" diff --git a/static/schemas/source/media-buy/build-creative-response.json b/static/schemas/source/media-buy/build-creative-response.json index 6de4e23cdb..1171c0f647 100644 --- a/static/schemas/source/media-buy/build-creative-response.json +++ b/static/schemas/source/media-buy/build-creative-response.json @@ -410,6 +410,11 @@ "pattern": "^[A-Z]{3}$", "description": "ISO 4217 currency code for the aggregate vendor_cost." }, + "keep_mode_applied": { + "type": "string", + "enum": ["keep_all", "keep_one", "keep_some"], + "description": "Echoes the `keep_mode` the agent applied (mirrors the request hint). Present when the request set `keep_mode`. `keep_mode` is advisory — it does not change what is produced or billed (you pay for every leaf in `variants[]`) — so this echo is the buyer's confirmation that the hint was received, the audit paper trail for a 'I asked for keep_one but was billed for N' dispute. Whether the agent acted on it shows in the `recommended`/`rank` it set on the leaves." + }, "budget_status": { "type": "string", "enum": ["complete", "capped"], From f3c747595a2119664d60732513753d844c8108a1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 31 May 2026 19:54:37 -0400 Subject: [PATCH 11/14] =?UTF-8?q?feat(creative):=20F4=20=E2=80=94=20creati?= =?UTF-8?q?ve-transformers=20conformance=20storyboard=20+=20live=20referen?= =?UTF-8?q?ce-agent=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the creative-transformers conformance specialism and makes it a LIVE test by implementing the transformer surface in the reference training agent. Reference agent (server/src/training-agent): - list_transformers: new custom tool on /creative + /creative-builder (account-scoped catalog, brief filtering, expand_params/expand_pagination option paging with a dedicated cursor namespace, include_pricing requires account). Gated off 3.0-compat like validate_input. - build_creative transformer/variant/refinement branch: strict config validation against the selected transformer's params (unknown key / out-of-range / inline / enumerable / free_text max_length / required -> INVALID_REQUEST field=config.); target_format_id subset check; single transformer build -> BuildCreativeSuccess + build_variant_id; max_variants / variant_axis -> BuildCreativeVariantSuccess (clamped to the advertised max_variants_limit, shortfall via leaves_returned INVALID_REQUEST for include_pricing-without-account; target subset + free_text max_length + required-param validation; malformed option-cursor rejection; transformer cursor namespace. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/training-agent/task-handlers.ts | 397 +++++++++++++++- .../tenants/creative-builder.ts | 2 + server/src/training-agent/tenants/creative.ts | 2 + .../tenants/list-transformers-tool.ts | 46 ++ server/src/training-agent/tenants/router.ts | 13 +- .../tenants/tenant-smoke.test.ts | 16 +- .../training-agent/tenants/tool-catalog.ts | 5 +- server/tests/manual/run-storyboards.ts | 19 +- .../creative-transformers/index.yaml | 430 ++++++++++++++++++ static/schemas/source/enums/specialism.json | 2 + 10 files changed, 922 insertions(+), 10 deletions(-) create mode 100644 server/src/training-agent/tenants/list-transformers-tool.ts create mode 100644 static/compliance/source/specialisms/creative-transformers/index.yaml diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 4b1594d465..8879873ff5 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -3176,6 +3176,258 @@ export async function handleListCreativeFormats(args: ToolArgs, _ctx: TrainingCo }; } +// ── Transformers (list_transformers + build_creative transformer path) ────── +// +// A transformer is the creative analog of a media-buy product: an +// agent-offered, account-scoped, selectable unit of build capability +// (a voice, a model, a style) with a typed config surface. The reference +// agent exposes one static transformer; real agents resolve account-scoped +// option values (e.g. cloned voices) per credential. Enumerable option VALUES +// are returned only when the param's field is named in expand_params. + +interface TransformerParamOption { + value: unknown; + label?: string; + metadata?: Record; +} + +interface TransformerParam { + field: string; + type: 'string' | 'number' | 'integer' | 'boolean'; + value_source: 'inline' | 'range' | 'enumerable' | 'free_text'; + allowed_values?: unknown[]; + minimum?: number; + maximum?: number; + max_length?: number; + default?: unknown; + required?: boolean; + description?: string; +} + +interface TrainingTransformer { + transformer_id: string; + name: string; + description?: string; + metadata?: Record; + input_format_ids?: FormatID[]; + output_format_ids: FormatID[]; + params: TransformerParam[]; + pricing_options?: Array>; + multiplicity?: Record; + // Full account-scoped option pool for enumerable params, surfaced on + // params[].options[] only when expand_params names the field. + enumerableOptions?: Record; +} + +// The agent advertises this as multiplicity.max_variants_limit and enforces it +// on build_creative — a variant request above it is clamped (not rejected), +// signalling the shortfall via leaves_returned < leaves_total. +const TRANSFORMER_MAX_VARIANTS_LIMIT = 10; + +function getTransformers(): TrainingTransformer[] { + const agentUrl = getAgentUrl(); + return [ + { + transformer_id: 'audiostack_voiceover', + name: 'Voiceover', + description: 'Script-to-audio voiceover with account-configured voices.', + metadata: { provider: 'audiostack', modality: 'audio' }, + input_format_ids: [{ agent_url: agentUrl, id: 'script' }], + output_format_ids: [{ agent_url: agentUrl, id: 'audio_vo' }], + params: [ + { field: 'voice', type: 'string', value_source: 'enumerable', default: 'sara', description: 'Narration voice, incl. account-specific custom/cloned voices.' }, + { field: 'mastering_preset', type: 'string', value_source: 'inline', allowed_values: ['broadcast', 'podcast', 'music'], default: 'broadcast', description: 'Audio mastering profile applied to the final mix.' }, + { field: 'speaking_rate', type: 'number', value_source: 'range', minimum: 0.5, maximum: 2.0, default: 1.0, description: 'Narration speed multiplier.' }, + { field: 'pronunciation_note', type: 'string', value_source: 'free_text', max_length: 280, description: 'Optional free-text pronunciation/style guidance.' }, + ], + pricing_options: [ + { pricing_option_id: 'vo_per_second_standard', model: 'per_unit', unit: 'second', unit_price: 0.05, currency: 'USD' }, + ], + multiplicity: { supports_catalog_fanout: false, supports_variants: true, max_variants_limit: TRANSFORMER_MAX_VARIANTS_LIMIT, variant_dimensions: ['voice', 'best_of_n', 'transformer_config'] }, + enumerableOptions: { + voice: [ + { value: 'sara', label: 'Sara', metadata: { language: 'en-US', gender: 'female', provider: 'audiostack' } }, + { value: 'isaac', label: 'Isaac', metadata: { language: 'en-US', gender: 'male', provider: 'audiostack' } }, + { value: 'mateo', label: 'Mateo', metadata: { language: 'es-ES', gender: 'male', provider: 'audiostack' } }, + { value: 'ceo_clone_2026', label: 'CEO (custom)', metadata: { language: 'en-US', custom: true } }, + ], + }, + }, + ]; +} + +interface ListTransformersArgs { + transformer_ids?: string[]; + input_format_ids?: FormatID[]; + output_format_ids?: FormatID[]; + name_search?: string; + brief?: string; + expand_params?: string[]; + expand_pagination?: Array<{ transformer_id?: string; field?: string; options_cursor?: string }>; + include_pricing?: boolean; + account?: unknown; + pagination?: { max_results?: number; cursor?: string }; +} + +export async function handleListTransformers(args: ToolArgs, _ctx: TrainingContext): Promise { + const req = args as unknown as ListTransformersArgs; + + // Pricing is account-scoped — the request schema makes account conditionally + // required when include_pricing is true. + if (req.include_pricing) { + const account = req.account as { account_id?: string; brand?: { domain?: string } } | undefined; + const hasAccount = !!(account && (account.account_id || account.brand?.domain)); + if (!hasAccount) { + return { errors: [{ code: 'INVALID_REQUEST', message: 'account is required when include_pricing is true.', field: 'account', recovery: 'correctable' }] }; + } + } + + let transformers = getTransformers(); + if (req.transformer_ids?.length) { + const want = new Set(req.transformer_ids); + transformers = transformers.filter(t => want.has(t.transformer_id)); + } + if (req.output_format_ids?.length) { + const want = new Set(req.output_format_ids.map(f => f.id)); + transformers = transformers.filter(t => t.output_format_ids.some(f => want.has(f.id))); + } + if (req.input_format_ids?.length) { + const want = new Set(req.input_format_ids.map(f => f.id)); + transformers = transformers.filter(t => (t.input_format_ids ?? []).some(f => want.has(f.id))); + } + if (req.name_search) { + const needle = req.name_search.toLowerCase(); + transformers = transformers.filter(t => t.name.toLowerCase().includes(needle)); + } + + // expand_params surfaces the FIRST page of a field's option values; + // expand_pagination fetches the NEXT page of a specific (transformer, field) + // via its options_cursor (from a prior response). + const OPTION_PAGE_SIZE = 25; + const expand = new Set(req.expand_params ?? []); + const optionCursorByKey = new Map(); + for (const ep of req.expand_pagination ?? []) { + if (!ep.field) continue; + expand.add(ep.field); + if (ep.options_cursor !== undefined) { + // Reject a malformed/foreign option cursor rather than silently restart at page 1. + if (decodeTransformerOptionCursor(ep.options_cursor) === null) { + return { errors: [{ code: 'INVALID_REQUEST', message: 'expand_pagination.options_cursor is malformed', field: 'expand_pagination', recovery: 'correctable' }] }; + } + optionCursorByKey.set(`${ep.transformer_id ?? ''}::${ep.field}`, ep.options_cursor); + } + } + const briefNeedle = req.brief?.toLowerCase(); + + const shaped = transformers.map(t => { + const params = t.params.map(p => { + if (p.value_source === 'enumerable' && expand.has(p.field)) { + let options = t.enumerableOptions?.[p.field] ?? []; + // Brief-filter enumerable values (e.g. "spanish" narrows a voice catalog). + if (briefNeedle) { + const filtered = options.filter(o => JSON.stringify(o).toLowerCase().includes(briefNeedle)); + if (filtered.length > 0) options = filtered; + } + // Page the (filtered) option set per (transformer, field). + const cursor = optionCursorByKey.get(`${t.transformer_id}::${p.field}`) ?? optionCursorByKey.get(`::${p.field}`); + const optOffset = cursor ? (decodeTransformerOptionCursor(cursor) ?? 0) : 0; + const optEnd = Math.min(optOffset + OPTION_PAGE_SIZE, options.length); + const moreOptions = optEnd < options.length; + return { + ...p, + options: options.slice(optOffset, optEnd), + ...(moreOptions && { options_cursor: encodeTransformerOptionCursor(optEnd) }), + }; + } + return p; + }); + return { + transformer_id: t.transformer_id, + name: t.name, + ...(t.description && { description: t.description }), + ...(t.metadata && { metadata: t.metadata }), + ...(t.input_format_ids && { input_format_ids: t.input_format_ids }), + output_format_ids: t.output_format_ids, + params, + ...(t.multiplicity && { multiplicity: t.multiplicity }), + ...(req.include_pricing && t.pricing_options && { pricing_options: t.pricing_options }), + }; + }); + + const requestedMax = req.pagination?.max_results; + const maxResults = Math.min(typeof requestedMax === 'number' ? requestedMax : 50, 100); + const offset = decodeTransformerCursor(req.pagination?.cursor); + if (offset === null) { + return { errors: [{ code: 'INVALID_REQUEST', message: 'pagination.cursor is malformed', field: 'pagination.cursor', recovery: 'correctable' }] }; + } + const pageEnd = Math.min(offset + maxResults, shaped.length); + const page = shaped.slice(offset, pageEnd); + const hasMore = pageEnd < shaped.length; + + return { + transformers: page, + pagination: { + has_more: hasMore, + total_count: shaped.length, + ...(hasMore && { cursor: encodeTransformerCursor(pageEnd) }), + }, + }; +} + +/** + * Strict-validate a build_creative `config` against the selected transformer's + * params. The request schema leaves config open (legal keys are dynamic per + * transformer), so rejecting unknown keys / out-of-range values is a normative + * AGENT obligation, not schema validation. Returns the first violation, or null. + */ +function validateTransformerConfig( + transformer: TrainingTransformer, + config: Record | undefined, +): { code: string; message: string; field: string; recovery: 'correctable' } | null { + if (!config || typeof config !== 'object') return null; + const byField = new Map(transformer.params.map(p => [p.field, p] as const)); + for (const [key, value] of Object.entries(config)) { + const param = byField.get(key); + if (!param) { + return { code: 'INVALID_REQUEST', message: `Unknown config key "${key}" for transformer "${transformer.transformer_id}". Vendor-specific knobs belong in ext.`, field: `config.${key}`, recovery: 'correctable' }; + } + if (param.value_source === 'inline') { + if (!Array.isArray(param.allowed_values) || !param.allowed_values.includes(value)) { + return { code: 'INVALID_REQUEST', message: `config.${key} must be one of: ${(param.allowed_values ?? []).join(', ')}.`, field: `config.${key}`, recovery: 'correctable' }; + } + } else if (param.value_source === 'range') { + const n = typeof value === 'number' ? value : Number.NaN; + if (Number.isNaN(n) || (typeof param.minimum === 'number' && n < param.minimum) || (typeof param.maximum === 'number' && n > param.maximum)) { + return { code: 'INVALID_REQUEST', message: `config.${key} must be a number in [${param.minimum}, ${param.maximum}].`, field: `config.${key}`, recovery: 'correctable' }; + } + } else if (param.value_source === 'enumerable') { + const opts = transformer.enumerableOptions?.[key] ?? []; + if (opts.length > 0 && !opts.some(o => o.value === value)) { + return { code: 'INVALID_REQUEST', message: `config.${key} "${String(value)}" is not an available value for this account.`, field: `config.${key}`, recovery: 'correctable' }; + } + } else if (param.value_source === 'free_text') { + // No closed/enumerable set, but max_length (when declared) is enforced. + if (typeof param.max_length === 'number' && typeof value === 'string' && value.length > param.max_length) { + return { code: 'INVALID_REQUEST', message: `config.${key} exceeds the maximum length of ${param.max_length} characters.`, field: `config.${key}`, recovery: 'correctable' }; + } + } + } + // Required params the buyer omitted (no default chosen by the caller). + for (const param of transformer.params) { + if (param.required && !(param.field in config)) { + return { code: 'INVALID_REQUEST', message: `config.${param.field} is required for transformer "${transformer.transformer_id}".`, field: `config.${param.field}`, recovery: 'correctable' }; + } + } + return null; +} + +function transformerManifest(target: FormatID, label: string): AdcpCreativeManifest { + return { + format_id: { agent_url: target.agent_url ?? getAgentUrl(), id: target.id }, + assets: buildHtmlAssets(label), + } as AdcpCreativeManifest; +} + function defaultTargetsForManifest(manifest: ValidateInputArgs['manifest']): ValidateInputTarget[] { if (typeof manifest?.format_kind === 'string') { return [{ kind: 'canonical', id: manifest.format_kind }]; @@ -5214,6 +5466,22 @@ function decodeCreativeCursor(cursor: string | undefined): number | null { return decodeOffsetCursor('creatives', cursor); } +// Transformer list + per-param option cursors get their own kinds so a cursor +// minted by one endpoint can't be replayed onto another (the kind prefix is +// the cross-endpoint guard — see pagination.ts). +function encodeTransformerCursor(offset: number): string { + return encodeOffsetCursor('transformers', offset); +} +function decodeTransformerCursor(cursor: string | undefined): number | null { + return decodeOffsetCursor('transformers', cursor); +} +function encodeTransformerOptionCursor(offset: number): string { + return encodeOffsetCursor('transformer_options', offset); +} +function decodeTransformerOptionCursor(cursor: string | undefined): number | null { + return decodeOffsetCursor('transformer_options', cursor); +} + /** Sandbox rate card: returns CPM pricing based on account and creative format. */ function getCreativePricing(account: { account_id?: string }, creative: import('./types.js').CreativeState) { // Two sandbox rate cards: "premium" accounts get lower CPM @@ -5658,9 +5926,20 @@ export async function handleGetAdcpCapabilities(args: ToolArgs, ctx: TrainingCon supports_transformation: true, supports_compliance: false, has_creative_library: true, - bills_through_adcp: creativeBillsThroughAdcp(ctx), - supported_formats: supportedCanonicalFormatsCapability(), - canonical_catalog_version: '3.1', + ...(includeThreeOneFields(ctx) ? { + bills_through_adcp: creativeBillsThroughAdcp(ctx), + supported_formats: supportedCanonicalFormatsCapability(), + canonical_catalog_version: '3.1', + supports_transformers: true, + supports_refinement: true, + refinable_retention_seconds: 3600, + multiplicity: { + supports_catalog_fanout: false, + supports_variants: true, + max_variants_limit: TRANSFORMER_MAX_VARIANTS_LIMIT, + variant_dimensions: ['voice', 'theme', 'best_of_n', 'transformer_config', 'custom'], + }, + } : {}), }, account: { require_operator_auth: false, @@ -6240,6 +6519,14 @@ interface BuildCreativeArgs { package_id?: string; quality?: 'draft' | 'production'; message?: string; + transformer_id?: string; + config?: Record; + max_creatives?: number; + max_variants?: number; + variant_axis?: { dimension?: string; field?: string; values?: unknown[]; label?: string }; + keep_mode?: 'keep_all' | 'keep_one' | 'keep_some'; + refine_from_build_variant_id?: string; + idempotency_key?: string; } type ResolvedBuildTarget = { @@ -6355,6 +6642,110 @@ export async function handleBuildCreative(args: ToolArgs, ctx: TrainingContext): ? [req.target_format_id] : []; + // Transformer / multiplicity / refinement path. Engaged whenever the request + // selects a transformer or asks for the variant shape (max_variants > 1, + // variant_axis, or refine_from_build_variant_id). Bypasses the format-catalog + // gate: a transformer's target is one of ITS output_format_ids, echoed into + // the produced manifest rather than resolved against the static catalog. + const wantsVariantShape = (typeof req.max_variants === 'number' && req.max_variants > 1) + || !!req.variant_axis + || !!req.refine_from_build_variant_id; + if (req.transformer_id || wantsVariantShape) { + const idemSeed = (typeof req.idempotency_key === 'string' ? req.idempotency_key : 'build') + .replace(/[^A-Za-z0-9]/g, '').slice(0, 12) || 'build'; + + let transformer: TrainingTransformer | undefined; + if (req.transformer_id) { + transformer = getTransformers().find(t => t.transformer_id === req.transformer_id); + if (!transformer) { + return buildCreativeCompleted({ errors: [{ code: 'INVALID_REQUEST', message: `Unknown transformer_id "${req.transformer_id}". Discover transformers via list_transformers.`, field: 'transformer_id', recovery: 'correctable' }] }); + } + const configError = validateTransformerConfig(transformer, req.config); + if (configError) { + return buildCreativeCompleted({ errors: [configError] }); + } + // A build_creative target MUST be a subset of the transformer's outputs. + if (req.target_format_id && !transformer.output_format_ids.some(f => f.id === req.target_format_id!.id)) { + return buildCreativeCompleted({ errors: [{ code: 'INVALID_REQUEST', message: `target_format_id "${req.target_format_id.id}" is not an output format of transformer "${req.transformer_id}".`, field: 'target_format_id', recovery: 'correctable' }] }); + } + } + + // Refinement requires the agent to advertise supports_refinement (3.1 surface). + if (req.refine_from_build_variant_id && !canonicalBuildsEnabled) { + return buildCreativeCompleted({ errors: [{ code: 'UNSUPPORTED_FEATURE', message: 'This agent does not retain prior builds for refinement. Drop refine_from_build_variant_id and resend, or use the transform path (creative_manifest + message).', field: 'refine_from_build_variant_id', recovery: 'correctable' }] }); + } + + const target: FormatID = req.target_format_id + ?? transformer?.output_format_ids?.[0] + ?? { agent_url: agentUrl, id: 'audio_vo' }; + + // Single-format, non-variant transformer build → BuildCreativeSuccess, + // carrying a build_variant_id so the result is itself refinable. + if (!wantsVariantShape && req.transformer_id) { + return buildCreativeCompleted({ + creative_manifest: transformerManifest(target, ``), + build_variant_id: `bv_${idemSeed}_0`, + ...(governanceContext && { governance_context: governanceContext }), + }); + } + + // Variant shape (max_variants / variant_axis / refinement) → + // BuildCreativeVariantSuccess. variant_axis.values length is authoritative + // over max_variants when present. + const axisValues = req.variant_axis?.values; + const requestedVariantCount = Array.isArray(axisValues) && axisValues.length > 0 + ? axisValues.length + : (typeof req.max_variants === 'number' && req.max_variants > 1 ? req.max_variants : 1); + // Clamp to the advertised ceiling (never allocate an unbounded fan-out from + // caller input); the shortfall shows as leaves_returned < leaves_total. + const variantCount = Math.min(Math.max(requestedVariantCount, 1), TRANSFORMER_MAX_VARIANTS_LIMIT); + const isRefine = !!req.refine_from_build_variant_id; + const keepMode = req.keep_mode; + + const variants = Array.from({ length: variantCount }, (_unused, i) => { + const leaf: Record = { + build_variant_id: `bv_${idemSeed}_${i}`, + creative_manifest: transformerManifest(target, ``), + }; + if (Array.isArray(axisValues) && axisValues[i] !== undefined) { + leaf.variant_axis_value = axisValues[i]; + } + if (isRefine) { + leaf.parent_build_variant_id = req.refine_from_build_variant_id; + } + // keep_one/keep_some are advisory: flag the agent's pick(s) via + // recommended/rank without changing what is produced or billed. + if (keepMode === 'keep_one' || keepMode === 'keep_some') { + leaf.rank = i + 1; + if (i === 0) leaf.recommended = true; + } + return leaf; + }); + + const variantResponse = buildCreativeCompleted({ + creatives: [{ build_creative_id: `bc_${idemSeed}`, variants }], + items_total: 1, + items_returned: 1, + leaves_total: requestedVariantCount, + leaves_returned: variantCount, + ...(keepMode && { keep_mode_applied: keepMode }), + budget_status: 'complete', + ...(governanceContext && { governance_context: governanceContext }), + }); + // BuildCreativeVariantSuccess (creatives[].variants[]) is in the wire + // schema (build-creative-response.json) ahead of the SDK's published + // BuildCreativeResponse union type. Responses are not framework-validated + // (registry.ts validation.responses:'off'), so emit the wire shape; the + // storyboard response_schema check is the guard. + return variantResponse as unknown as BuildCreativeResponse & { + pricing_option_id?: string; + vendor_cost?: number; + currency?: string; + consumption?: Record; + governance_context?: string; + }; + } + // Mode 1: Library retrieval (creative_id) if (req.creative_id) { const creative = session.creatives.get(req.creative_id) ?? getComplianceCreative(req.creative_id); diff --git a/server/src/training-agent/tenants/creative-builder.ts b/server/src/training-agent/tenants/creative-builder.ts index 99f4a160dc..6098fe8ad0 100644 --- a/server/src/training-agent/tenants/creative-builder.ts +++ b/server/src/training-agent/tenants/creative-builder.ts @@ -11,6 +11,7 @@ import { getTenantSigningMaterial } from './signing.js'; import { buildCreativeComplyConfig } from './comply.js'; import { listAccountsTool } from './account-tools.js'; import { validateInputTool } from './validate-input-tool.js'; +import { listTransformersTool } from './list-transformers-tool.js'; import type { TrainingContext } from '../types.js'; const TENANT_ID = 'creative-builder'; @@ -37,6 +38,7 @@ export function buildCreativeBuilderTenantConfig(host: string, options: { storyb creativeBillsThroughAdcp: false, ...(options.storyboardCompat && { storyboardCompat: options.storyboardCompat }), }), + list_transformers: listTransformersTool(), }), }, complyTest: buildCreativeComplyConfig(), diff --git a/server/src/training-agent/tenants/creative.ts b/server/src/training-agent/tenants/creative.ts index 7dc92ce2ef..1530db08ae 100644 --- a/server/src/training-agent/tenants/creative.ts +++ b/server/src/training-agent/tenants/creative.ts @@ -9,6 +9,7 @@ import { buildCreativeComplyConfig } from './comply.js'; import { listAccountsTool } from './account-tools.js'; import { reportUsageTool } from './report-usage-tool.js'; import { validateInputTool } from './validate-input-tool.js'; +import { listTransformersTool } from './list-transformers-tool.js'; import type { TrainingContext } from '../types.js'; const TENANT_ID = 'creative'; @@ -36,6 +37,7 @@ export function buildCreativeTenantConfig(host: string, options: { storyboardCom creativeBillsThroughAdcp: false, ...(options.storyboardCompat && { storyboardCompat: options.storyboardCompat }), }), + list_transformers: listTransformersTool(), }), }, complyTest: buildCreativeComplyConfig(), diff --git a/server/src/training-agent/tenants/list-transformers-tool.ts b/server/src/training-agent/tenants/list-transformers-tool.ts new file mode 100644 index 0000000000..55685d4fb8 --- /dev/null +++ b/server/src/training-agent/tenants/list-transformers-tool.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { customToolFor } from './custom-tool-helper.js'; +import { handleListTransformers } from '../task-handlers.js'; + +const FORMAT_ID_SCHEMA = z.object({ + agent_url: z.string(), + id: z.string(), +}).passthrough(); + +const ACCOUNT_REF_SCHEMA = z.object({ + account_id: z.string().optional(), + brand: z.object({ domain: z.string() }).passthrough().optional(), + operator: z.string().optional(), +}).passthrough(); + +const LIST_TRANSFORMERS_SCHEMA = { + transformer_ids: z.array(z.string()).optional(), + input_format_ids: z.array(FORMAT_ID_SCHEMA).optional(), + output_format_ids: z.array(FORMAT_ID_SCHEMA).optional(), + name_search: z.string().optional(), + brief: z.string().optional(), + expand_params: z.array(z.string()).optional(), + expand_pagination: z.array(z.object({ + transformer_id: z.string().optional(), + field: z.string().optional(), + options_cursor: z.string().optional(), + }).passthrough()).optional(), + include_pricing: z.boolean().optional(), + account: ACCOUNT_REF_SCHEMA.optional(), + pagination: z.object({ + max_results: z.number().optional(), + cursor: z.string().optional(), + }).passthrough().optional(), +}; + +export function listTransformersTool() { + return customToolFor( + 'list_transformers', + 'Discover account-scoped creative transformers (voices, models, render configs) and their typed config params, with optional enumerated option values (expand_params) and per-account pricing (include_pricing).', + LIST_TRANSFORMERS_SCHEMA, + handleListTransformers, + { + annotations: { readOnlyHint: true, idempotentHint: true }, + }, + ); +} diff --git a/server/src/training-agent/tenants/router.ts b/server/src/training-agent/tenants/router.ts index 8d9643beee..866daa84fb 100644 --- a/server/src/training-agent/tenants/router.ts +++ b/server/src/training-agent/tenants/router.ts @@ -547,13 +547,22 @@ function projectSalesCapabilities( ? adcp.supported_versions : [...TRAINING_AGENT_SUPPORTED_RELEASE_VERSIONS], }; - if (tenantId === 'creative' && storyboardCompat?.version !== '3.0') { + if ((tenantId === 'creative' || tenantId === 'creative-builder') && storyboardCompat?.version !== '3.0') { const creative = structured.creative && typeof structured.creative === 'object' ? structured.creative : {}; structured.creative = { ...creative, - bills_through_adcp: false, + ...(tenantId === 'creative' ? { bills_through_adcp: false } : {}), + supports_transformers: true, + supports_refinement: true, + refinable_retention_seconds: 3600, + multiplicity: { + supports_catalog_fanout: false, + supports_variants: true, + max_variants_limit: 10, + variant_dimensions: ['voice', 'theme', 'best_of_n', 'transformer_config', 'custom'], + }, }; } if (tenantId === 'sales') { diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index 8419cd7b03..411dce45ce 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -526,13 +526,19 @@ describe('tenant routing smoke', () => { const capabilitiesBody = await callTenantTool(url, 2, 'get_adcp_capabilities', {}) as { result?: { structuredContent?: { creative?: Record } }; }; - expect(capabilitiesBody.result?.structuredContent?.creative ?? {}).not.toHaveProperty('bills_through_adcp'); + const creative = capabilitiesBody.result?.structuredContent?.creative ?? {}; + expect(creative).not.toHaveProperty('bills_through_adcp'); + // The transformer capability flags ride the same 3.0 gate and must also be absent. + expect(creative).not.toHaveProperty('supports_transformers'); + expect(creative).not.toHaveProperty('supports_refinement'); + expect(creative).not.toHaveProperty('refinable_retention_seconds'); + expect(creative).not.toHaveProperty('multiplicity'); } finally { await close(); } }, 15000); - it('advertises creative billing discriminator on the current creative tenant', async () => { + it('advertises creative billing + transformer discriminators on the current creative tenant', async () => { const { baseUrl, close } = await bootServer(); try { const url = `${baseUrl}/creative/mcp`; @@ -540,7 +546,11 @@ describe('tenant routing smoke', () => { const capabilitiesBody = await callTenantTool(url, 2, 'get_adcp_capabilities', {}) as { result?: { structuredContent?: { creative?: Record } }; }; - expect(capabilitiesBody.result?.structuredContent?.creative?.bills_through_adcp).toBe(false); + const creative = capabilitiesBody.result?.structuredContent?.creative ?? {}; + expect(creative.bills_through_adcp).toBe(false); + expect(creative.supports_transformers).toBe(true); + expect(creative.supports_refinement).toBe(true); + expect((creative.multiplicity as { supports_variants?: boolean } | undefined)?.supports_variants).toBe(true); } finally { await close(); } diff --git a/server/src/training-agent/tenants/tool-catalog.ts b/server/src/training-agent/tenants/tool-catalog.ts index d497321bed..1f151cafd2 100644 --- a/server/src/training-agent/tenants/tool-catalog.ts +++ b/server/src/training-agent/tenants/tool-catalog.ts @@ -53,6 +53,9 @@ export const TOOL_CATALOG: Readonly> = { // (they live on `CreativeAdServerPlatform`); the /creative-builder tenant // no longer advertises them in tools/list. validate_input: ['sales', 'creative', 'creative-builder'], + // list_transformers (account-scoped transformer discovery) rides customTools + // on the creative tenants, gated off 3.0-compat like validate_input. + list_transformers: ['creative', 'creative-builder'], list_creatives: ['sales', 'creative'], sync_creatives: ['sales', 'creative', 'creative-builder'], build_creative: ['sales', 'creative', 'creative-builder'], @@ -109,7 +112,7 @@ export function toolsForTenant( .filter(([, tenants]) => tenants.includes(tenantId)) .map(([tool]) => tool) .filter(tool => !( - tool === 'validate_input' + (tool === 'validate_input' || tool === 'list_transformers') && ( options.storyboardCompat?.version === '3.0' || options.adcpVersion?.startsWith('3.0') diff --git a/server/tests/manual/run-storyboards.ts b/server/tests/manual/run-storyboards.ts index 0800b69664..92e79c49e0 100644 --- a/server/tests/manual/run-storyboards.ts +++ b/server/tests/manual/run-storyboards.ts @@ -143,7 +143,24 @@ const KNOWN_FAILING_STORYBOARDS: ReadonlyMap = new Map([ * upstream issue and skipping the whole storyboard would lose passing * coverage. Track every entry with a linked issue. */ -const KNOWN_FAILING_STEPS: ReadonlyMap = new Map([]); +const KNOWN_FAILING_STEPS: ReadonlyMap = new Map([ + [ + 'creative_transformers/list_transformers', + 'list_transformers is a new task in @adcp/sdk 8.1.0-beta.19: the storyboard runner dispatches and grades tasks via a task-name registry (storyboard/task-map.js + TOOL_RESPONSE_SCHEMAS) that does not yet include it, so the runner cannot invoke or schema-grade the response. The reference agent implements list_transformers (custom tool on /creative + /creative-builder), and the build_creative transformer path it feeds is graded live by the rest of this storyboard. Remove this entry when the SDK ships the list_transformers task.', + ], + [ + 'creative_transformers/list_transformers_expand', + 'Same blocker as creative_transformers/list_transformers — list_transformers is not yet in the SDK task-map/TOOL_RESPONSE_SCHEMAS. Remove when the SDK ships the task.', + ], + [ + 'creative_transformers/build_variants', + 'The build_creative BuildCreativeVariantSuccess arm (creatives[]/variants[]/build_variant_id) added in #5219 is not yet in @adcp/sdk 8.1.0-beta.19\'s build_creative response validator (TOOL_RESPONSE_SCHEMAS knows only the creative_manifest/creative_manifests arms), so the runner rejects the valid variant response. The reference agent produces the variant shape correctly. Remove this entry when the SDK ships the build_creative variant arm.', + ], + [ + 'creative_transformers/refine_variant', + 'Same blocker as creative_transformers/build_variants — refinement returns the BuildCreativeVariantSuccess (creatives[]) shape, not yet in the SDK build_creative response validator. Remove when the SDK ships the variant arm.', + ], +]); const THREE_ZERO_COMPAT_KNOWN_FAILING_STEPS: ReadonlyMap = new Map([ [ diff --git a/static/compliance/source/specialisms/creative-transformers/index.yaml b/static/compliance/source/specialisms/creative-transformers/index.yaml new file mode 100644 index 0000000000..96b9804c1a --- /dev/null +++ b/static/compliance/source/specialisms/creative-transformers/index.yaml @@ -0,0 +1,430 @@ +id: creative_transformers +version: "1.0.0" +title: "Account-scoped creative transformer agent" +protocol: creative +category: creative_transformers +summary: "Agent that exposes account-scoped transformers (voices, models, render configs) via list_transformers, then builds and refines creatives by transformer_id + typed config — with variant fan-out and strict config validation." +required_tools: + - list_transformers + - build_creative + +# Cross-step assertion (adcp#2664). status.monotonic rejects resource status +# transitions observed across steps that aren't on the spec lifecycle graph. +# +# Cross-resource assertion (adcp#2859). impairment.coherence verifies that +# creative.rejected transitions propagate to any non-terminal media buy that +# references the creative. Grades not_applicable until #2860 wires the +# cross-resource exercise. Transformer storyboards, like generative ones, +# typically don't surface a media-buy snapshot read, so this specialism may +# stay not_applicable more often than its peers even after #2860. +invariants: + - status.monotonic + - impairment.coherence + +narrative: | + You run a creative agent whose render capability is account-specific: the + buyer's configured voices, cloned/custom voices, generative models, and + render presets. The legal set of render knobs — and their values — is not a + global enum the buyer holds; the agent knows them, and the set changes when + the buyer adds one. So discovery flows agent → buyer, exactly the way + get_products surfaces account-scoped inventory. + + You expose that capability as discoverable TRANSFORMERS (the creative analog + of a media-buy product), each with a typed config surface, selected by + transformer_id with a typed `config` on build_creative. + + This storyboard walks the transformer flow: capability discovery, transformer + discovery, expanding an account-scoped enumerable param's values, building + with a selected transformer + config, producing A/B variants along an axis, + refining a produced variant (lineage), and rejecting an out-of-range config + value (strict validation — a normative agent obligation, since the wire schema + leaves config open). + +agent: + interaction_model: stateless_transform + capabilities: + - supports_transformers + examples: + - "AudioStack (account voices)" + - "Generative model agents (Veo, image models)" + +caller: + role: buyer_agent + example: "Scope3 (DSP)" + +prerequisites: + description: | + The caller needs an account (transformers, their enumerable option values, + and pricing are resolved per-credential) and a brand identity. The test kit + provides a sample brand and account. + test_kit: "test-kits/acme-outdoor.yaml" + +phases: + - id: capability_discovery + title: "Capability discovery" + narrative: | + The buyer calls get_adcp_capabilities and confirms the agent advertises + the transformer surface (creative.supports_transformers) before + discovering transformers. + steps: + - id: get_capabilities + title: "Confirm the transformer surface is offered" + narrative: | + A transformer-capable agent declares creative.supports_transformers: + true. Buyers gate the list_transformers / transformer-config build + path on this discriminator. + task: get_adcp_capabilities + schema_ref: "protocol/get-adcp-capabilities-request.json" + response_schema_ref: "protocol/get-adcp-capabilities-response.json" + doc_ref: "/protocol/get_adcp_capabilities" + comply_scenario: capability_discovery + stateful: false + expected: | + Return capabilities declaring creative in supported_protocols with + creative.supports_transformers: true. + sample_request: + context: + correlation_id: "creative_transformers--get_capabilities" + validations: + - check: response_schema + description: "Response matches get-adcp-capabilities-response.json schema" + - check: field_present + path: "supported_protocols" + description: "Agent declares supported protocols" + - check: field_value + path: "creative.supports_transformers" + value: true + description: "Agent advertises the account-scoped transformer surface" + - check: field_present + path: "context" + description: "Response echoes back the context object" + - check: field_value + path: "context.correlation_id" + value: "creative_transformers--get_capabilities" + description: "Context correlation_id returned unchanged" + + - id: discover_transformers + title: "Discover transformers" + narrative: | + The buyer asks what transformers this agent offers for their account, + optionally narrowing with a natural-language brief. Each transformer + carries a stable transformer_id, the formats it produces, and its typed + config params. + steps: + - id: list_transformers + title: "List account-scoped transformers" + narrative: | + The buyer sends a brief ("warm female Spanish-language voiceover") and + their account. The agent returns matching transformers — each with a + transformer_id to select in build_creative, output_format_ids, and the + config params it exposes. + task: list_transformers + schema_ref: "creative/list-transformers-request.json" + response_schema_ref: "creative/list-transformers-response.json" + doc_ref: "/creative/task-reference/list_transformers" + comply_scenario: creative_sync + stateful: false + expected: | + Return transformers[] — each with transformer_id, name, + output_format_ids, and params[] descriptors (field, type, + value_source). Curate to the brief rather than dumping the full set. + sample_request: + brief: "warm female Spanish-language voiceover" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + context: + correlation_id: "creative_transformers--list_transformers" + context_outputs: + - name: transformer_id + path: "transformers[0].transformer_id" + validations: + - check: response_schema + description: "Response matches list-transformers-response.json schema" + - check: field_present + path: "transformers" + description: "Response contains a transformers array" + - check: field_present + path: "transformers[0].transformer_id" + description: "Each transformer carries a stable transformer_id" + - check: field_present + path: "transformers[0].output_format_ids" + description: "Each transformer declares the formats it produces" + - check: field_present + path: "context" + description: "Response echoes back the context object" + - check: field_value + path: "context.correlation_id" + value: "creative_transformers--list_transformers" + description: "Context correlation_id returned unchanged" + + - id: discover_transformer_options + title: "Expand an enumerable param's values" + narrative: | + The default list_transformers response returns param SCHEMAS. To get the + account-scoped VALUES of an enumerable param (e.g. the buyer's voices), + the buyer names the field in expand_params — the agent returns the first + page of option values on params[].options[], brief-filtered. + steps: + - id: list_transformers_expand + title: "Expand the voice param's account-scoped values" + narrative: | + The buyer re-queries the selected transformer with + expand_params: ["voice"]. The agent returns the voice option values + available to this account (including any custom/cloned voices). + task: list_transformers + schema_ref: "creative/list-transformers-request.json" + response_schema_ref: "creative/list-transformers-response.json" + doc_ref: "/creative/task-reference/list_transformers" + comply_scenario: creative_sync + stateful: false + expected: | + Return the transformer with the voice param's options[] populated + (value + optional label/metadata), brief-filtered. + sample_request: + transformer_ids: + - "audiostack_voiceover" + expand_params: + - "voice" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + context: + correlation_id: "creative_transformers--list_transformers_expand" + validations: + - check: response_schema + description: "Response matches list-transformers-response.json schema" + - check: field_present + path: "transformers[0].params[0].field" + description: "Param descriptors present (options[] populated only for the expanded enumerable field)" + - check: field_present + path: "context" + description: "Response echoes back the context object" + - check: field_value + path: "context.correlation_id" + value: "creative_transformers--list_transformers_expand" + description: "Context correlation_id returned unchanged" + + - id: build_with_transformer + title: "Build with a transformer + config" + narrative: | + The buyer selects the discovered transformer and passes a typed config + keyed by each param's field. The agent builds the creative using that + transformer and returns a single-format manifest. + steps: + - id: build_transformer + title: "Build a voiceover with a selected voice" + narrative: | + The buyer passes transformer_id, a config (voice / mastering_preset / + speaking_rate), and a target format that is a subset of the + transformer's output_format_ids. The agent returns a single creative + manifest. + task: build_creative + schema_ref: "media-buy/build-creative-request.json" + response_schema_ref: "media-buy/build-creative-response.json" + doc_ref: "/creative/task-reference/build_creative" + comply_scenario: creative_flow + stateful: false + expected: | + Return a BuildCreativeSuccess: creative_manifest with a format_id + matching the requested target, plus an optional build_variant_id so + the build is itself refinable. + sample_request: + message: "Read this script in a warm tone." + transformer_id: "audiostack_voiceover" + config: + voice: "isaac" + mastering_preset: "podcast" + speaking_rate: 1.1 + target_format_id: + agent_url: "https://your-agent.example.com" + id: "audio_vo" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + idempotency_key: "$generate:uuid_v4#creative_transformers_build_with_transformer_build_transformer" + context: + correlation_id: "creative_transformers--build_transformer" + validations: + - check: response_schema + description: "Response matches build-creative-response.json schema" + - check: field_present + path: "creative_manifest.format_id" + description: "Single-format success — manifest carries the target format_id" + - check: field_present + path: "context" + description: "Response echoes back the context object" + - check: field_value + path: "context.correlation_id" + value: "creative_transformers--build_transformer" + description: "Context correlation_id returned unchanged" + + - id: variant_fanout + title: "Produce variants along an axis" + narrative: | + The buyer A/Bs two voices in one call: max_variants with a variant_axis + fixing the values. The agent returns the variant shape — creatives[] each + carrying variants[], one independently-tagged leaf per axis value. Each + leaf is a real, independently-billed build; keep_mode is advisory. + steps: + - id: build_variants + title: "Build two voice variants" + narrative: | + The buyer requests two variants along the voice axis (isaac, sara) + with keep_mode keep_one. The agent returns creatives[0].variants[] + with a build_variant_id per leaf and echoes keep_mode_applied. + task: build_creative + schema_ref: "media-buy/build-creative-request.json" + response_schema_ref: "media-buy/build-creative-response.json" + doc_ref: "/creative/task-reference/build_creative" + comply_scenario: creative_flow + stateful: false + expected: | + Return a BuildCreativeVariantSuccess: creatives[] with variants[], + each leaf carrying a build_variant_id, plus keep_mode_applied echoing + the request hint. + sample_request: + message: "A/B two voices for the same script." + transformer_id: "audiostack_voiceover" + max_variants: 2 + variant_axis: + dimension: "voice" + values: + - "isaac" + - "sara" + keep_mode: "keep_one" + target_format_id: + agent_url: "https://your-agent.example.com" + id: "audio_vo" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + idempotency_key: "$generate:uuid_v4#creative_transformers_variant_fanout_build_variants" + context: + correlation_id: "creative_transformers--build_variants" + context_outputs: + - name: parent_build_variant_id + path: "creatives[0].variants[0].build_variant_id" + validations: + - check: response_schema + description: "Response matches build-creative-response.json schema" + - check: field_present + path: "creatives[0].variants[0].build_variant_id" + description: "Variant shape — each leaf carries a build_variant_id" + - check: field_value + path: "keep_mode_applied" + value: "keep_one" + description: "Agent echoes the advisory keep_mode hint it received" + - check: field_present + path: "context" + description: "Response echoes back the context object" + - check: field_value + path: "context.correlation_id" + value: "creative_transformers--build_variants" + description: "Context correlation_id returned unchanged" + + - id: refinement_lineage + title: "Refine a produced variant" + narrative: | + The buyer refines one produced leaf: re-build from its build_variant_id + with a natural-language instruction and a config delta. The agent returns + a NEW lineage-linked variant whose parent_build_variant_id points back to + the source leaf. Refinement is never a mutation — the parent is unchanged. + steps: + - id: refine_variant + title: "Slow down the chosen variant" + narrative: | + The buyer passes refine_from_build_variant_id (the captured leaf) plus + a config delta (speaking_rate). The transformer and target are + inherited from the parent. The agent returns a new variant with + parent_build_variant_id set. + task: build_creative + schema_ref: "media-buy/build-creative-request.json" + response_schema_ref: "media-buy/build-creative-response.json" + doc_ref: "/creative/task-reference/build_creative" + comply_scenario: creative_flow + stateful: true + expected: | + Return a BuildCreativeVariantSuccess whose leaf carries a new + build_variant_id and a parent_build_variant_id linking back to the + refined source leaf. + sample_request: + message: "Slow it down slightly." + refine_from_build_variant_id: "$context.parent_build_variant_id" + config: + speaking_rate: 0.9 + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + idempotency_key: "$generate:uuid_v4#creative_transformers_refinement_lineage_refine_variant" + context: + correlation_id: "creative_transformers--refine_variant" + validations: + - check: response_schema + description: "Response matches build-creative-response.json schema" + - check: field_present + path: "creatives[0].variants[0].build_variant_id" + description: "New lineage-linked leaf" + - check: field_present + path: "creatives[0].variants[0].parent_build_variant_id" + description: "Lineage edge back to the refined parent leaf" + - check: field_present + path: "context" + description: "Response echoes back the context object" + - check: field_value + path: "context.correlation_id" + value: "creative_transformers--refine_variant" + description: "Context correlation_id returned unchanged" + + - id: config_strict_rejection + title: "Reject an out-of-range config value" + narrative: | + config drives a paid render, so the agent MUST validate it against the + selected transformer's live params and reject out-of-range / unrecognized + values with a field-attributed error — never silently ignore them. The + wire schema leaves config open (legal keys are dynamic per transformer), + so this rejection is a normative AGENT obligation, not schema validation: + the payload is well-formed; the value is semantically illegal. + steps: + - id: reject_out_of_range_config + title: "speaking_rate above the param's maximum is rejected" + narrative: | + The buyer sends speaking_rate 9.9 — a schema-valid number, but outside + the transformer's declared range. The agent rejects with + INVALID_REQUEST attributed to config.speaking_rate. + task: build_creative + schema_ref: "media-buy/build-creative-request.json" + response_schema_ref: "media-buy/build-creative-response.json" + doc_ref: "/creative/task-reference/build_creative" + comply_scenario: creative_flow + stateful: false + expect_error: true + negative_path: payload_well_formed + expected: | + Reject with INVALID_REQUEST — the payload is schema-valid (config is + an open object) but speaking_rate is outside the transformer's range. + error.field SHOULD identify config.speaking_rate. + sample_request: + message: "Read this script." + transformer_id: "audiostack_voiceover" + config: + speaking_rate: 9.9 + target_format_id: + agent_url: "https://your-agent.example.com" + id: "audio_vo" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + idempotency_key: "$generate:uuid_v4#creative_transformers_config_strict_rejection_reject_out_of_range_config" + context: + correlation_id: "creative_transformers--reject_out_of_range_config" + validations: + - check: error_code + value: "INVALID_REQUEST" + description: "Out-of-range config value is rejected, not silently applied" diff --git a/static/schemas/source/enums/specialism.json b/static/schemas/source/enums/specialism.json index bc632b7409..a65cbe39ac 100644 --- a/static/schemas/source/enums/specialism.json +++ b/static/schemas/source/enums/specialism.json @@ -17,6 +17,7 @@ "creative-ad-server", "creative-generative", "creative-template", + "creative-transformers", "governance-aware-seller", "governance-delivery-monitor", "governance-spend-authority", @@ -40,6 +41,7 @@ "creative-ad-server": "Creative ad server with tag-based delivery", "creative-generative": "Generative creative agent producing assets on demand", "creative-template": "Creative template and transformation agent", + "creative-transformers": "Account-scoped creative transformer agent — discoverable voices/models/render configs selected via list_transformers and built/refined by transformer_id + config (the creative analog of products)", "governance-aware-seller": "Seller agent that composes with the buyer's campaign-governance agent after baseline sync_governance registration — calls check_governance before confirming spend, and propagates approvals, conditions, and denials unchanged. Optional claim for the full governance-check loop.", "governance-delivery-monitor": "Campaign delivery monitoring with drift detection", "governance-spend-authority": "Conditional spend approval and human-in-the-loop governance", From f979bf7a5caaff51f076d9586df95d7e671f05ce Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 1 Jun 2026 04:47:19 -0400 Subject: [PATCH 12/14] fix(creative): add creative-transformers to ADCP_SPECIALISMS (taxonomy sync) F4 added `creative-transformers` to enums/specialism.json but not to the ADCP_SPECIALISMS mirror in server/src/services/adcp-taxonomy.ts, so server/tests/unit/adcp-taxonomy.test.ts (the enum-sync guard) failed in the TypeScript Build job: "expected [20] to deeply equal [21]". The precommit hook only runs `--dir tests/`, not `server/tests/`, so it didn't catch this locally. Adds the value to both the AdcpSpecialism type union and the ADCP_SPECIALISMS array. registry.ts imports the constant (no separate list) and SPECIALISM_STATUSES loads dynamically, so no other edits needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/services/adcp-taxonomy.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/services/adcp-taxonomy.ts b/server/src/services/adcp-taxonomy.ts index eb25b5a599..640a09842e 100644 --- a/server/src/services/adcp-taxonomy.ts +++ b/server/src/services/adcp-taxonomy.ts @@ -94,6 +94,7 @@ export type AdcpSpecialism = | 'creative-ad-server' | 'creative-generative' | 'creative-template' + | 'creative-transformers' | 'governance-aware-seller' | 'governance-delivery-monitor' | 'governance-spend-authority' @@ -117,6 +118,7 @@ export const ADCP_SPECIALISMS: readonly AdcpSpecialism[] = [ 'creative-ad-server', 'creative-generative', 'creative-template', + 'creative-transformers', 'governance-aware-seller', 'governance-delivery-monitor', 'governance-spend-authority', From 86c7de0b179a9eaa7e520048cc71553e9da06049 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 1 Jun 2026 04:57:58 -0400 Subject: [PATCH 13/14] fix(creative): regenerate OpenAPI registry for creative-transformers specialism Adding creative-transformers to ADCP_SPECIALISMS flows through registry.ts's verified_specialisms z.enum into the generated static/openapi/registry.yaml. The TypeScript Build job's OpenAPI-freshness check (npm run build:openapi + git diff) failed once the taxonomy test passed and the job reached that step. Regenerated; adds the single enum value. Co-Authored-By: Claude Opus 4.8 (1M context) --- static/openapi/registry.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/static/openapi/registry.yaml b/static/openapi/registry.yaml index e63ddbc487..ecb1b879da 100644 --- a/static/openapi/registry.yaml +++ b/static/openapi/registry.yaml @@ -1626,6 +1626,7 @@ components: - creative-ad-server - creative-generative - creative-template + - creative-transformers - governance-aware-seller - governance-delivery-monitor - governance-spend-authority From 6ab8e5cde29b8955221014fc7b516b47335064f9 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 2 Jun 2026 09:23:15 -0400 Subject: [PATCH 14/14] spec(creative): fold voice synthesis anchor into transformers --- .../task-reference/build_creative.mdx | 4 +- .../creative-transformers/index.yaml | 7 +-- static/schemas/source/core/transformer.json | 45 +++++++++++++++++++ .../media-buy/build-creative-response.json | 6 +-- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/docs/creative/task-reference/build_creative.mdx b/docs/creative/task-reference/build_creative.mdx index 65ade6ee9f..eaf5659074 100644 --- a/docs/creative/task-reference/build_creative.mdx +++ b/docs/creative/task-reference/build_creative.mdx @@ -569,11 +569,11 @@ When the request uses `max_creatives`, `max_variants` > 1, `variant_axis`, or `r } ``` -- **creatives[]**: One entry per built creative. With `max_creatives`, there is one entry per sampled catalog item (`catalog_item_ref` identifies which item); without catalog fan-out there is a single entry. +- **creatives[]**: One entry per built creative group. With `max_creatives`, there is one entry per sampled catalog item (`catalog_item_ref` identifies which item); without catalog fan-out there is a single entry. Treat this as the produced group set; choose among alternatives inside each group's `variants[]`. - **creatives[].build_creative_id**: Identifies a built creative within this response. - **creatives[].catalog_item_ref**: Present on catalog fan-out — an object identifying the source catalog item via `item_id` (plus optional `catalog_type`). - **creatives[].errors[]**: Present only on a *failed* catalog item — catalog fan-out is non-atomic, so a failed item is returned as a `creatives[]` entry carrying `errors[]` and no `variants[]`, without failing the batch. -- **creatives[].variants[]**: The alternatives produced for this creative. Length is at most `max_variants`. Each variant carries its own complete `creative_manifest`. +- **creatives[].variants[]**: The choose-among alternatives produced for this creative group. Length is at most `max_variants`. Each variant carries its own complete `creative_manifest`. - **variants[].build_variant_id**: Identifies a single variant — the leaf-level **lineage anchor**. This is its **own namespace** — never reuse a `preview_id` (a `preview_creative` render) or a served `variant_id` (a delivery-time identifier) here. You traffic a chosen build by passing its `build_variant_id`. - **variants[].parent_build_variant_id**: Present only on a refined variant ([Refinement](#refinement)) — the `build_variant_id` of the source it was refined from. Absent for first-generation builds. - **variants[].variant_axis_value**: The value of the `variant_axis` dimension this variant represents (e.g. a voice, a theme). diff --git a/static/compliance/source/specialisms/creative-transformers/index.yaml b/static/compliance/source/specialisms/creative-transformers/index.yaml index 96b9804c1a..36cafdae32 100644 --- a/static/compliance/source/specialisms/creative-transformers/index.yaml +++ b/static/compliance/source/specialisms/creative-transformers/index.yaml @@ -266,9 +266,10 @@ phases: title: "Produce variants along an axis" narrative: | The buyer A/Bs two voices in one call: max_variants with a variant_axis - fixing the values. The agent returns the variant shape — creatives[] each - carrying variants[], one independently-tagged leaf per axis value. Each - leaf is a real, independently-billed build; keep_mode is advisory. + fixing the values. The agent returns the variant shape — creatives[] is + the produced creative group set, and variants[] is the choose-among set + within each group, with one independently-tagged leaf per axis value. + Each leaf is a real, independently-billed build; keep_mode is advisory. steps: - id: build_variants title: "Build two voice variants" diff --git a/static/schemas/source/core/transformer.json b/static/schemas/source/core/transformer.json index 48189cd102..0f248758b8 100644 --- a/static/schemas/source/core/transformer.json +++ b/static/schemas/source/core/transformer.json @@ -23,6 +23,44 @@ "description": "Transformer-specific attributes a buyer can filter or display (e.g. provider, modality, language).", "additionalProperties": true }, + "voice_synthesis_ref": { + "type": "array", + "description": "Optional discovery/audit anchors for voice transformers provisioned from brand-agent voice_synthesis entries. Informational only: these references help buyers match a discovered transformer to brand/rights-agent provenance, but they do not assert build-time authorization or require the creative agent to perform rights-token validation.", + "items": { + "type": "object", + "properties": { + "brand_agent": { + "type": "object", + "description": "Brand agent that exposed the referenced voice_synthesis entry.", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "MCP endpoint URL of the brand agent." + }, + "id": { + "type": "string", + "description": "Agent identifier." + } + }, + "required": ["url", "id"], + "additionalProperties": true + }, + "voice_id": { + "type": "string", + "description": "voice_synthesis.voice_id from the brand agent." + }, + "rights_id": { + "type": "string", + "x-entity": "rights_grant", + "description": "Optional rights offering or grant identifier associated with this provisioned voice. This is provenance metadata only, not a build_creative rights token." + } + }, + "required": ["brand_agent", "voice_id"], + "additionalProperties": true + }, + "minItems": 1 + }, "input_format_ids": { "type": "array", "description": "Formats this transformer accepts as input. Empty or omitted means it builds from a brief / raw assets (pure generation) rather than transforming an existing creative.", @@ -71,6 +109,13 @@ "name": "Voiceover", "description": "Script-to-audio voiceover with account-configured voices.", "metadata": { "provider": "audiostack", "modality": "audio" }, + "voice_synthesis_ref": [ + { + "brand_agent": { "url": "https://brand.novabrands.example/mcp", "id": "nova_brand_agent" }, + "voice_id": "sara", + "rights_id": "rgt_sara_voice_2026" + } + ], "input_format_ids": [{ "agent_url": "https://creative.audiostack.example", "id": "script" }], "output_format_ids": [{ "agent_url": "https://creative.audiostack.example", "id": "audio_vo" }], "params": [ diff --git a/static/schemas/source/media-buy/build-creative-response.json b/static/schemas/source/media-buy/build-creative-response.json index 1171c0f647..1044682bc8 100644 --- a/static/schemas/source/media-buy/build-creative-response.json +++ b/static/schemas/source/media-buy/build-creative-response.json @@ -279,12 +279,12 @@ }, { "title": "BuildCreativeVariantSuccess", - "description": "Multiplicity success response. Returned WHENEVER the request used max_creatives, max_variants > 1, variant_axis, or refine_from_build_variant_id — with or without a transformer_id. **There is no fallback to the single/multi-format shapes: a client that sends any of those inputs MUST handle this `creatives[]` shape; it will not receive `creative_manifest`/`creative_manifests`.** Carries creatives[] — one entry per produced creative (per catalog item when fanning out) — each holding variants[] alternatives. Every produced variant is a real, independently-billed build (you pay for all produced); the buyer keeps one or many by trafficking the chosen build_variant_id(s). Mutually exclusive with the other success/error/submitted shapes. Per-FORMAT remains atomic within a (creative) group; per-ITEM (catalog fan-out) is non-atomic — a failed item is reported via that creative's errors[] and does not fail the batch.", + "description": "Multiplicity success response. Returned WHENEVER the request used max_creatives, max_variants > 1, variant_axis, or refine_from_build_variant_id — with or without a transformer_id. **There is no fallback to the single/multi-format shapes: a client that sends any of those inputs MUST handle this `creatives[]` shape; it will not receive `creative_manifest`/`creative_manifests`.** Carries creatives[] — one entry per produced creative group (per catalog item when fanning out) — each holding variants[] alternatives. The envelope semantics are fixed: creatives[] is the set of creative groups the call produced; variants[] is the choose-among set within each group. Every produced variant is a real, independently-billed build (you pay for all produced); the buyer keeps one or many by trafficking the chosen build_variant_id(s). Mutually exclusive with the other success/error/submitted shapes. Per-FORMAT remains atomic within a (creative) group; per-ITEM (catalog fan-out) is non-atomic — a failed item is reported via that creative's errors[] and does not fail the batch.", "type": "object", "properties": { "creatives": { "type": "array", - "description": "One entry per produced creative. With catalog fan-out, one entry per catalog item (bounded/sampled by max_creatives).", + "description": "One entry per produced creative group. With catalog fan-out, one entry per catalog item (bounded/sampled by max_creatives). This array is the produced group set; use variants[] inside each group for choose-among alternatives.", "items": { "type": "object", "properties": { @@ -310,7 +310,7 @@ }, "variants": { "type": "array", - "description": "Alternatives produced for this creative (voices, themes, best-of-N, etc.). At least one. Each is an independently-tagged, independently-billed build.", + "description": "Choose-among alternatives produced for this creative group (voices, themes, best-of-N, etc.). At least one. Each is an independently-tagged, independently-billed build.", "items": { "type": "object", "properties": {