diff --git a/.changeset/cli-removed-flag-warnings.md b/.changeset/cli-removed-flag-warnings.md new file mode 100644 index 000000000..dc201880c --- /dev/null +++ b/.changeset/cli-removed-flag-warnings.md @@ -0,0 +1,19 @@ +--- +'@adcp/client': patch +--- + +cli: warn on removed flags instead of silently ignoring + +`--platform-type` was removed from the SDK in 5.1 (`comply()` throws when it's passed programmatically), but the CLI was still capturing and silently dropping the flag. Third-party CI scripts that pass it today believe they're filtering agent selection when they aren't. + +`adcp storyboard run` (and its `adcp comply` deprecated alias) now emits a stderr warning naming the flag, the version it was removed in, and the migration path: + +``` +[warn] --platform-type was removed in 5.1.0 and is being ignored. +Agent selection is now driven by get_adcp_capabilities (supported_protocols + specialisms). +Pass --storyboards to target a specific bundle. +``` + +Non-breaking — execution continues. Warnings are suppressed under `--json` to keep stdout as pure JSON. Detection covers both space-separated (`--platform-type value`) and equals (`--platform-type=value`) forms. + +The `REMOVED_FLAGS` map in `bin/adcp.js` is a single location to extend as we deprecate additional flags. diff --git a/.changeset/creative-agent-docs-audio-examples.md b/.changeset/creative-agent-docs-audio-examples.md new file mode 100644 index 000000000..512ad9532 --- /dev/null +++ b/.changeset/creative-agent-docs-audio-examples.md @@ -0,0 +1,12 @@ +--- +'@adcp/client': patch +--- + +docs(creative-agent): louder build_creative response-shape callouts, add audio creative-template example + +Makes discoverability of existing SDK surface better for creative agents: + +- `docs/llms.txt` — new "Watch out:" blocks on `build_creative`, `preview_creative`, and `list_creative_formats` that point at `buildCreativeResponse`/`buildCreativeMultiResponse`/typed asset factories and flag the audio-formats `renders` gotcha. Driven by a data map in `scripts/generate-agent-docs.ts`. +- `skills/build-creative-agent/SKILL.md` — cross-cutting pitfalls now mention `audioAsset` and spell out that platform-native top-level fields (`tag_url`, `creative_id`, `media_type`) are invalid responses. Adds an Audio subsection under `creative-template` covering format declaration (`type: 'audio'`, `renders: [{ role, duration_seconds }]`), async render pipelines, and a handler example using `buildCreativeResponse` + `audioAsset`. + +No library code changes — the factories and response helpers already shipped in prior releases. diff --git a/bin/adcp.js b/bin/adcp.js index e31206fc7..b6b1f825b 100755 --- a/bin/adcp.js +++ b/bin/adcp.js @@ -1354,10 +1354,40 @@ async function handleStoryboardShow(args) { } } +// Flags that used to be accepted but have been removed from the SDK/CLI. +// Silent-ignore hides real divergence between author intent and runtime behavior +// (observed in third-party CI scripts that still pass `--platform-type`). +// Each entry: { since: version-string, hint: what-to-do-instead }. +// +// NOTE: `parseAgentOptions` still captures `--platform-type`'s value (line ~602) +// solely so the value doesn't leak into `positionalArgs`. That capture + this +// warning are both necessary — removing the capture would make a bare +// `--platform-type X` turn `X` into an agent alias lookup. +const REMOVED_FLAGS = { + '--platform-type': { + since: '5.1.0', + hint: 'Agent selection is now driven by get_adcp_capabilities (supported_protocols + specialisms). Pass --storyboards to target a specific bundle.', + }, +}; + +// Scan `args` for removed flags and emit a stderr warning for each one found. +// Writes to stderr unconditionally — stderr does not corrupt `--json` stdout, +// and the CI logs where these warnings need to land capture both streams. +// Non-breaking — execution continues. +function warnRemovedFlags(args) { + for (const [flag, meta] of Object.entries(REMOVED_FLAGS)) { + if (args.includes(flag) || args.some(a => a.startsWith(`${flag}=`))) { + console.error(`DEPRECATED: ${flag} was removed in ${meta.since} and is being ignored. ${meta.hint}`); + } + } +} + async function handleStoryboardRun(args) { const opts = parseAgentOptions(args); const { authToken, protocolFlag, jsonOutput, dryRun, positionalArgs, file: filePath, localAgent, format } = opts; + warnRemovedFlags(args); + // --local-agent : spin the agent up in-process, seed fixtures, // run storyboards, tear down. Collapses the 300-line seller-side // bootstrap into one command. See `runAgainstLocalAgent` in diff --git a/docs/llms.txt b/docs/llms.txt index fb18317c5..d3745f5b8 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,6 +1,6 @@ # Ad Context Protocol (AdCP) -> Generated at: 2026-04-22 +> Generated at: 2026-04-23 > Library: @adcp/client v5.13.0 > AdCP major version: 3 > Canonical URL: https://adcontextprotocol.github.io/adcp-client/llms.txt @@ -265,6 +265,10 @@ Request parameters for discovering format IDs and creative agents supported by t - Required: `formats: object[]` - Optional: `creative_agents: object[]`, `errors: object[]`, `pagination: Pagination Response`, `sandbox: boolean`, `context: Context` +**Watch out:** +- Each `renders[]` entry needs `role` + exactly one of `dimensions` (object) OR `parameters_from_format_id: true`. Top-level `{ width, height }` fails — wrap in `dimensions`. +- Audio formats (`type: "audio"`) have no width/height — declare `renders: [{ role: "primary", duration_seconds: N }]` so storyboard `field_present formats[0].renders` validations still pass. + #### `create_media_buy` Request parameters for creating a media buy. @@ -391,6 +395,11 @@ Request parameters for AI-powered creative generation. - Required: `creative_manifest: Creative Manifest` - Optional: `sandbox: boolean`, `expires_at: string`, `preview: object`, `preview_error: Error`, `pricing_option_id: string`, `vendor_cost: number`, `currency: string`, `consumption: Creative Consumption`, +1 more +**Watch out:** +- Response is ALWAYS `{ creative_manifest }` (single) or `{ creative_manifests }` (multi). Platform-native fields at the top level (`tag_url`, `creative_id`, `media_type`) are invalid. +- Use `buildCreativeResponse({ creative_manifest })` / `buildCreativeMultiResponse({ creative_manifests })` from `@adcp/client/server` to enforce the shape at compile time. +- Each asset under `creative_manifest.assets` needs an `asset_type` discriminator — use the factories: `imageAsset`, `videoAsset`, `audioAsset`, `htmlAsset`, `urlAsset`, `textAsset` (or `Asset.image(...)`). + #### `preview_creative` Request parameters for generating creative previews. @@ -403,6 +412,9 @@ Request parameters for generating creative previews. - Required: `response_type: 'single'`, `previews: object[]`, `expires_at: string` - Optional: `interactive_url: string`, `context: Context` +**Watch out:** +- Each `renders[]` entry is a oneOf on `output_format` — use `urlRender({...})`, `htmlRender({...})`, or `bothRender({...})` to inject the discriminator and require the matching `preview_url`/`preview_html` field. + #### `list_creative_formats` Request parameters for discovering creative formats from this creative agent. @@ -414,6 +426,10 @@ Request parameters for discovering creative formats from this creative agent. - Required: `formats: object[]` - Optional: `creative_agents: object[]`, `errors: object[]`, `pagination: Pagination Response`, `context: Context` +**Watch out:** +- Each `renders[]` entry needs `role` + exactly one of `dimensions` (object) OR `parameters_from_format_id: true`. Top-level `{ width, height }` fails — wrap in `dimensions`. +- Audio formats (`type: "audio"`) have no width/height — declare `renders: [{ role: "primary", duration_seconds: N }]` so storyboard `field_present formats[0].renders` validations still pass. + #### `get_creative_delivery` Request parameters for retrieving creative delivery data with variant-level breakdowns. diff --git a/package-lock.json b/package-lock.json index f26b59e98..170e6b9e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@adcp/client", - "version": "5.11.0", + "version": "5.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@adcp/client", - "version": "5.11.0", + "version": "5.13.0", "license": "Apache-2.0", "dependencies": { "ajv": "^8.18.0", diff --git a/scripts/generate-agent-docs.ts b/scripts/generate-agent-docs.ts index 3b09665fb..2e52039de 100644 --- a/scripts/generate-agent-docs.ts +++ b/scripts/generate-agent-docs.ts @@ -43,6 +43,25 @@ const OPERATION_DOMAINS = ['trusted-match'] as const; // Skip internal/sandbox-only tools const SKIP_TOOLS = new Set(['comply-test-controller']); +// Short, high-signal "watch out" notes appended below the Response block for +// specific tools. Kept here so regenerating llms.txt from the schema index +// still carries the operational lessons that bit real integrators. +// Keep each entry under ~5 lines — llms.txt is a scan surface, not a tutorial. +const TOOL_GOTCHAS: Record = { + build_creative: [ + 'Response is ALWAYS `{ creative_manifest }` (single) or `{ creative_manifests }` (multi). Platform-native fields at the top level (`tag_url`, `creative_id`, `media_type`) are invalid.', + 'Use `buildCreativeResponse({ creative_manifest })` / `buildCreativeMultiResponse({ creative_manifests })` from `@adcp/client/server` to enforce the shape at compile time.', + 'Each asset under `creative_manifest.assets` needs an `asset_type` discriminator — use the factories: `imageAsset`, `videoAsset`, `audioAsset`, `htmlAsset`, `urlAsset`, `textAsset` (or `Asset.image(...)`).', + ], + preview_creative: [ + 'Each `renders[]` entry is a oneOf on `output_format` — use `urlRender({...})`, `htmlRender({...})`, or `bothRender({...})` to inject the discriminator and require the matching `preview_url`/`preview_html` field.', + ], + list_creative_formats: [ + 'Each `renders[]` entry needs `role` + exactly one of `dimensions` (object) OR `parameters_from_format_id: true`. Top-level `{ width, height }` fails — wrap in `dimensions`.', + 'Audio formats (`type: "audio"`) have no width/height — declare `renders: [{ role: "primary", duration_seconds: N }]` so storyboard `field_present formats[0].renders` validations still pass.', + ], +}; + // GitHub Pages base URL for published docs const DOCS_BASE_URL = 'https://adcontextprotocol.github.io/adcp-client'; @@ -716,6 +735,15 @@ function generateLlmsTxt( } ln(); } + + const gotchas = TOOL_GOTCHAS[tool.name]; + if (gotchas?.length) { + ln(`**Watch out:**`); + for (const note of gotchas) { + ln(`- ${note}`); + } + ln(); + } } // Deep dive links for this domain @@ -1180,6 +1208,18 @@ function main() { `Found ${tools.length} tools, ${errorCodes.length} error codes, ${storyboards.length} storyboards, ${scenarios.length} test scenarios` ); + // Fail loudly if TOOL_GOTCHAS grows stale. A tool rename would otherwise + // silently drop its "Watch out:" block from llms.txt with no CI signal. + const knownToolNames = new Set(tools.map(t => t.name)); + const orphanGotchas = Object.keys(TOOL_GOTCHAS).filter(name => !knownToolNames.has(name)); + if (orphanGotchas.length > 0) { + console.error( + `ERROR: TOOL_GOTCHAS references unknown tool(s): ${orphanGotchas.join(', ')}. ` + + `A tool was renamed or removed — update TOOL_GOTCHAS in scripts/generate-agent-docs.ts.` + ); + process.exit(1); + } + const llmsTxt = generateLlmsTxt(index, tools, errorCodes, storyboards, scenarios); const typeSummary = generateTypeSummary(index, tools); diff --git a/skills/build-creative-agent/SKILL.md b/skills/build-creative-agent/SKILL.md index b152558fe..aff2a1093 100644 --- a/skills/build-creative-agent/SKILL.md +++ b/skills/build-creative-agent/SKILL.md @@ -93,7 +93,8 @@ What happens when a creative is synced: > **Cross-cutting pitfalls matrix runs keep catching:** > > - `capabilities.specialisms` is `string[]` of enum ids (e.g. `['creative-ad-server']`), NOT `[{id, version}]` objects. -> - `build_creative` response is `{ creative_manifest: { format_id, assets } }`. Each asset in `creative_manifest.assets` requires an `asset_type` discriminator — use the typed factories (`imageAsset({...})`, `videoAsset({...})`, `htmlAsset({...})`, `urlAsset({...})`) so the discriminator is injected for you; a plain `{ serving_tag: { content: '...' } }` fails validation. +> - `build_creative` response is `{ creative_manifest: { format_id, assets } }` (single) or `{ creative_manifests: [...] }` (multi). Platform-native fields at the top level (`tag_url`, `creative_id`, `media_type`) are **invalid** — use `buildCreativeResponse({ creative_manifest })` / `buildCreativeMultiResponse({ creative_manifests })` from `@adcp/client/server` to lock the shape at compile time. +> - Each asset in `creative_manifest.assets` requires an `asset_type` discriminator. Use the typed factories (`imageAsset`, `videoAsset`, `audioAsset`, `htmlAsset`, `urlAsset`, `textAsset`) so the discriminator is injected for you; a plain `{ serving_tag: { content: '...' } }` fails validation. > - `preview_creative` renders have the same pattern — each `renders[]` entry is a oneOf on `output_format`. Use `urlRender({...})`, `htmlRender({...})`, or `bothRender({...})` to inject the discriminator and require the matching `preview_url` / `preview_html` field automatically. > - `get_creative_delivery` requires **top-level `currency: string`** (ISO 4217), in addition to any per-row spend fields. `reporting_period/start` and `/end` are ISO 8601 **date-time** strings (`new Date().toISOString()`), not date-only. > - `videoAsset({...})` requires `width` + `height` per GA (previously optional). Set realistic pixel values — `{ url, width: 1920, height: 1080 }`. @@ -113,8 +114,9 @@ Asset values use type-specific shapes, not a generic `asset_type` discriminator: - Image: `{ url: string, width: number, height: number, format: string }` - Video: `{ url: string, duration_ms: number, format: string }` +- Audio: `{ url: string, container_format: string, codec: string, duration_ms: number, channels?: string, sampling_rate_hz?: number }` - HTML: `{ content: string }` (not `{ html: string }`) -- Text: `{ text: string }` +- Text: `{ content: string }` (not `{ text: string }` — the field is `content`, same as HTML) ### Context and Ext Passthrough @@ -619,6 +621,70 @@ Output can be HTML (`{ content: '
...
' }`), JavaScript tag (`{ content `list_creative_formats` accepts filter params (`type`, `max_width`, `max_height`). Return an empty array — not an error — when nothing matches. +#### Audio creative-template (TTS / mix / master) + +Audio creative agents (AudioStack, ElevenLabs, Resemble) fit the `creative-template` archetype — stateless transform from an inline manifest to a rendered audio file. Three things differ from display: + +1. **No width/height.** Declare `renders: [{ role: 'primary', duration_seconds: N }]` — the `renders` array is still required by the `discover_formats` storyboard validation, but audio formats surface duration instead of dimensions. +2. **Async render pipelines.** TTS → mix → master is typically minutes long. Don't block the `build_creative` call waiting for the pipeline; the platform-native SDK (AudioStack's 300s poll window, etc.) belongs inside a task worker. If the platform's API returns quickly, build synchronously; otherwise return the task envelope and emit a `creative_review` completion webhook (see the [Webhooks](#webhooks-for-async-review-pipelines) section above for the wiring). +3. **Inputs are text assets keyed by `asset_id`.** The buyer sends `creative_manifest.assets.script` (a `TextAsset` with `content: string`) — read `inputManifest.assets.script?.content`, not `.text`. + +Format declaration: + +```typescript +listCreativeFormats: async () => ({ + formats: [{ + format_id: { agent_url: AGENT_URL, id: 'audio_ad_30s' }, + name: 'Audio Ad — 30s', + type: 'audio' as const, + renders: [{ role: 'primary', duration_seconds: 30 }], + assets: [ + { asset_id: 'script', asset_type: 'text', required: true, item_type: 'individual', description: 'Ad script (~70-75 words for a 30s read)' }, + { asset_id: 'voice', asset_type: 'text', required: false, item_type: 'individual', description: 'TTS voice name (e.g. "sara", "isaac")' }, + { asset_id: 'music_template', asset_type: 'text', required: false, item_type: 'individual', description: 'Music-bed template; omit for voice-only' }, + ], + }], +}), +``` + +Handler — inline manifest in, rendered audio out: + +```typescript +import { buildCreativeResponse, audioAsset } from '@adcp/client/server'; + +buildCreative: async (params) => { + const inputManifest = params.creative_manifest; // already inline — no lookup + const targetFid = params.target_format_id ?? inputManifest.format_id; + + // Read inputs from the inline manifest's assets (TextAsset.content, not .text) + const script = inputManifest.assets.script?.content ?? ''; + const voice = inputManifest.assets.voice?.content; + const musicTemplate = inputManifest.assets.music_template?.content; + + // Platform pipeline (script → speech → mix). Wrap in a task worker if long-running. + const rendered = await renderAudio({ script, voice, musicTemplate }); + + return buildCreativeResponse({ + creative_manifest: { + format_id: targetFid, + assets: { + audio: audioAsset({ + url: rendered.url, + container_format: 'mp3', + codec: 'mp3', + duration_ms: rendered.durationMs, + channels: 'stereo', + sampling_rate_hz: 44100, + }), + }, + }, + sandbox: params.account?.sandbox === true, + }); +}, +``` + +Common trap — returning platform-native fields (`{ tag_url, creative_id, media_type }`) at the top level instead of wrapping in `creative_manifest`. The wire schema rejects it; `buildCreativeResponse` catches it at compile time. + ### creative-generative Storyboard: `creative_generative`. Takes a brief (`message`) and brand reference (`brand.domain`), generates finished assets. diff --git a/test/lib/cli-removed-flags.test.js b/test/lib/cli-removed-flags.test.js new file mode 100644 index 000000000..a7f9acf7f --- /dev/null +++ b/test/lib/cli-removed-flags.test.js @@ -0,0 +1,61 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const CLI = path.resolve(__dirname, '../../bin/adcp.js'); + +// Every spawn gets a timeout so a regression that accidentally reaches a live +// agent path (runFullAssessment doesn't honor --dry-run) fails fast instead of +// hanging CI. +function runCli(args) { + return spawnSync('node', [CLI, ...args], { encoding: 'utf8', timeout: 10_000 }); +} + +// Call the command with no agent arg so `handleStoryboardRun` exits at the +// "Usage:" check (exit 2). `warnRemovedFlags` fires at the top of the handler, +// before that check — so we get the warning without any network call. + +test('--platform-type emits a deprecation warning on stderr under storyboard run', () => { + const result = runCli(['storyboard', 'run', '--platform-type', 'creative_transformer']); + assert.strictEqual(result.status, 2, `expected exit 2 (usage), got ${result.status}. stderr: ${result.stderr}`); + assert.match( + result.stderr, + /DEPRECATED: --platform-type was removed in 5\.1\.0/, + `expected removed-flag warning on stderr, got: ${result.stderr}` + ); + assert.match(result.stderr, /get_adcp_capabilities/); +}); + +test('--platform-type warning still reaches stderr under --json (stdout stays pure JSON)', () => { + const result = runCli(['storyboard', 'run', '--platform-type', 'creative_transformer', '--json']); + // Warning must reach stderr so CI log streams capture it. stderr never + // pollutes stdout JSON, so --json is not a reason to suppress. + assert.match(result.stderr, /DEPRECATED: --platform-type was removed/); +}); + +test('--platform-type=value form is also detected', () => { + const result = runCli(['storyboard', 'run', '--platform-type=creative_transformer']); + assert.match(result.stderr, /DEPRECATED: --platform-type was removed/); +}); + +test('adcp comply (deprecated alias) still surfaces removed-flag warnings', () => { + const result = runCli(['comply', '--platform-type', 'creative_transformer']); + assert.match(result.stderr, /DEPRECATED: --platform-type was removed/); +}); + +test('no warning when --platform-type is absent', () => { + const result = runCli(['storyboard', 'run']); + assert.doesNotMatch(result.stderr, /removed in 5\.1\.0/); +}); + +test('warning is advisory — exit status reflects the real command outcome, not the warning', () => { + // No agent arg → exit 2 (usage). Adding --platform-type must not change that. + const withFlag = runCli(['storyboard', 'run', '--platform-type', 'creative_transformer']); + const withoutFlag = runCli(['storyboard', 'run']); + assert.strictEqual( + withFlag.status, + withoutFlag.status, + 'removed-flag warning must not alter exit status — it is advisory' + ); +});