Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/cli-removed-flag-warnings.md
Original file line number Diff line number Diff line change
@@ -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 <bundle-or-id> 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.
12 changes: 12 additions & 0 deletions .changeset/creative-agent-docs-audio-examples.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions bin/adcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <bundle-or-id> 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 <module>: 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
Expand Down
18 changes: 17 additions & 1 deletion docs/llms.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions scripts/generate-agent-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]> = {
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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
70 changes: 68 additions & 2 deletions skills/build-creative-agent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<vast>...' } }` 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: '<vast>...' } }` 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 }`.
Expand All @@ -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

Expand Down Expand Up @@ -619,6 +621,70 @@ Output can be HTML (`{ content: '<div>...</div>' }`), 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.

### <a name="specialism-creative-generative"></a>creative-generative

Storyboard: `creative_generative`. Takes a brief (`message`) and brand reference (`brand.domain`), generates finished assets.
Expand Down
61 changes: 61 additions & 0 deletions test/lib/cli-removed-flags.test.js
Original file line number Diff line number Diff line change
@@ -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'
);
});
Loading