From adcp-client
Build an AdCP v6.0 creative-template decisioning platform — a stateless creative transform service (TTS, watermarking, format conversion, template fill). Use when the user wants the v6.0 DecisioningPlatform shape; for the lower-level handler-bag API, use `build-creative-agent` instead.
npx claudepluginhub adcontextprotocol/adcp-client --plugin adcp-clientThis skill uses the workspace's default tool permissions.
You're building a **stateless creative transform** service that fits the AdCP `creative-template` specialism: take an input creative manifest + format spec, produce an output creative manifest. No library, no review queue, no persisting state. Examples: TTS audio synthesis, image watermarking, video format conversion, template-based ad generation.
Use when building an AdCP creative agent — an ad server, creative management platform, or any system that accepts, stores, transforms, and serves ad creatives.
Generates platform-specific ad images from campaign-brief.md and brand-profile.json using banana-claude. Supports Meta, Google, TikTok, LinkedIn, YouTube, Microsoft specs.
Generates images from filled prompt templates (1-40) using viostudio.id API (nano-banana-2). Uploads brand reference images (skips UGC templates), submits generation, polls completion, saves result log. Needs VIOSTUDIO_API_KEY and *-prompts.md.
Share bugs, ideas, or general feedback.
You're building a stateless creative transform service that fits the AdCP creative-template specialism: take an input creative manifest + format spec, produce an output creative manifest. No library, no review queue, no persisting state. Examples: TTS audio synthesis, image watermarking, video format conversion, template-based ad generation.
creative-template (stateless transform; not creative-ad-server which is stateful, not creative-generative which is brief-driven — though creative-template and creative-generative share the same CreativeBuilderPlatform interface; pick the specialism ID that matches your behavior)@adcp/sdk 6.0+Wrong skill if:
skills/build-creative-agent/skills/build-creative-agent/ § creative-ad-server'creative-generative' instead of 'creative-template'skills/build-seller-agent/A v6.0 creative-template platform is a single class implementing DecisioningPlatform with a creative field of type CreativeBuilderPlatform. The framework dispatches each tool call to the right method. (CreativeTemplatePlatform and CreativeGenerativePlatform are deprecated aliases of CreativeBuilderPlatform for source compat.)
CreativeManifest.assets is a keyed map, not an arrayEvery example below depends on this — it's the most common day-1 trip-up:
// ✅ CORRECT — assets is { [asset_id]: ImageAsset | AudioAsset | VideoAsset | ... }
const url = req.creative_manifest?.assets?.['source_image']?.url;
// ❌ WRONG — there is no manifest_id; assets is not an array
const url = req.creative_manifest?.assets?.[0]?.url;
Asset values are discriminated by asset_type ('image' | 'audio' | 'video' | 'vast' | 'text' | 'url' | 'html' | ...). TypeScript will narrow them for you when you check the discriminator — no casting needed.
Takes an image asset by id, applies a brand watermark, returns a manifest with the watermarked asset:
import {
AdcpError,
createAdcpServerFromPlatform,
getAsset,
requireAsset,
type DecisioningPlatform,
type AccountStore,
type CreativeBuilderPlatform,
} from '@adcp/sdk/server';
import type {
BuildCreativeRequest,
CreativeManifest,
PreviewCreativeRequest,
PreviewCreativeResponse,
CreativeAsset,
AccountReference,
ImageAsset,
} from '@adcp/sdk/types';
import { serve } from '@adcp/sdk/server';
interface WatermarkConfig {
watermarkUrl: string;
}
interface WatermarkMeta {
brand_id: string;
}
class WatermarkPlatform implements DecisioningPlatform<WatermarkConfig, WatermarkMeta> {
capabilities = {
specialisms: ['creative-template'] as const,
creative_agents: [],
channels: ['display'] as const,
pricingModels: ['cpm'] as const,
config: {
watermarkUrl: 'https://cdn.example.com/brand-watermark.png',
} satisfies WatermarkConfig,
};
// statusMappers + AccountStore.upsert/list are now optional. Stateless
// platforms (creative-template, signal-marketplace proxies) typically
// omit them; framework returns UNSUPPORTED_FEATURE to buyers calling
// sync_accounts / list_accounts on platforms that don't implement them.
accounts: AccountStore<WatermarkMeta> = {
resolve: async (ref: AccountReference) => {
const id = 'account_id' in ref ? ref.account_id : 'wm_acc_default';
return {
id,
name: 'Watermark default', // required by wire Account
status: 'active', // required by wire Account
operator: 'watermark.example.com',
metadata: { brand_id: 'brand_default' },
authInfo: { kind: 'api_key' },
};
},
// upsert / list omitted — stateless platform doesn't manage accounts.
};
creative: CreativeBuilderPlatform<WatermarkMeta> = {
/** Sync transform — fast operation, return result immediately. */
buildCreative: async (req: BuildCreativeRequest): Promise<CreativeManifest> => {
// requireAsset throws AdcpError with field path if missing/wrong type.
// After the call, TS narrows `source` to `ImageAsset` — no cast needed.
const source = requireAsset(req.creative_manifest, 'source_image', 'image');
const watermarkedUrl = await applyWatermark(source.url, this.capabilities.config.watermarkUrl);
const formatId = req.target_format_id;
if (!formatId) {
throw new AdcpError('INVALID_REQUEST', {
recovery: 'correctable',
message: 'target_format_id is required',
field: 'target_format_id',
});
}
const watermarked: ImageAsset = {
asset_type: 'image',
url: watermarkedUrl,
width: source.width,
height: source.height,
};
return {
format_id: formatId,
assets: { watermarked_image: watermarked },
};
},
/** Always sync — preview is just a sandbox URL. */
previewCreative: async (req: PreviewCreativeRequest): Promise<PreviewCreativeResponse> => {
// Soft-form helper — preview is best-effort even if source is missing.
const source = getAsset(req.creative_manifest, 'source_image', 'image');
const sourceUrl = source?.url ?? '';
// PreviewCreativeResponse is a discriminated union by `response_type`.
// Use `'single'` for one preview-per-request (the common case for
// stateless template platforms).
return {
response_type: 'single',
previews: [
{
preview_id: `pv_${Date.now()}`,
input: { name: 'default' },
renders: [
{
render_id: 'r1',
output_format: 'url',
role: 'primary',
preview_url: `https://watermark.example.com/preview?src=${encodeURIComponent(sourceUrl)}`,
},
],
},
],
expires_at: new Date(Date.now() + 3600_000).toISOString(),
};
},
/**
* Stateless template platforms typically auto-approve. Each row is the
* wire `SyncCreativesSuccess.creatives[]` shape: `action` is required
* (CRUD outcome — what your platform did), `status` is optional (review
* state). Stateless transforms use `action: 'unchanged'` since they
* don't persist; review state is `'approved'` since auto-approving.
*/
syncCreatives: async (creatives: CreativeAsset[]) => {
return creatives.map(c => ({
creative_id: c.creative_id ?? `cr_${Math.random()}`,
action: 'unchanged' as const,
status: 'approved' as const,
}));
},
};
}
async function applyWatermark(src: string, mark: string): Promise<string> {
// Real impl calls your imaging service. Stub for the example.
return `${src}?watermark=${encodeURIComponent(mark)}`;
}
// Boot — bind HTTP, dispatch tool calls into the platform.
const platform = new WatermarkPlatform();
const server = createAdcpServerFromPlatform(platform, {
name: 'watermark',
version: '1.0.0',
validation: { requests: 'strict', responses: 'strict' },
});
serve(() => server, { publicUrl: 'https://watermark.example.com' });
That's the entire shape. No as never casts in adopter code — the wire types are typed. Discriminators do narrowing for you. The rest of this skill is the rules around it.
target_format_id is a FormatID object, not a bare string// ❌ WRONG
if (req.target_format_id === 'audio_30s') { ... }
// ✅ CORRECT — FormatID is { id: string; agent_url: string }
if (req.target_format_id?.id === 'audio_30s') { ... }
The wire schema separates format identity (id) from the creative agent that hosts the format definition (agent_url). Always read .id for the literal format identifier.
PreviewCreativeResponse is a discriminated union — pick 'single'// 3 variants by `response_type`: 'single' | 'batch' | 'variant'
// For stateless creative-template platforms, return `'single'`. Always.
return {
response_type: 'single',
previews: [{ preview_id, input: { name: 'default' }, renders: [...] }],
expires_at,
};
batch and variant are for advanced post-flight workflows you don't need. The full union exists because the spec covers ad servers that produce per-impression preview variants — irrelevant for transform platforms. If you're a creative-template platform, always return 'single'.
(See #3268 — proposing to hoist preview_url to the top level for the single-render case.)
CreativeBuilderPlatform has 5 method slots. For each method-pair you implement EXACTLY ONE — sync OR *Task — validatePlatform() will throw at construction if you provide both.
| Slot | Sync variant | HITL *Task variant | Required? |
|---|---|---|---|
| build creative | buildCreative(req, ctx) | buildCreativeTask(taskId, req, ctx) | One required |
| preview creative | previewCreative(req, ctx) | — (always sync) | Required |
| sync creatives | syncCreatives(creatives, ctx) | syncCreativesTask(taskId, creatives, ctx) | One required |
*Task — pick by latency, not by preference| Your operation typically takes... | Pick |
|---|---|
| Under ~5 seconds (image manipulation, simple template fill) | Sync (buildCreative) |
| 10-60 seconds (TTS, audio mixing, video transcode) | Sync is fine — buyer awaits in the request |
| 1-30 minutes (heavy generation, multi-pass rendering) | HITL (buildCreativeTask) — buyer immediately gets a submitted envelope with task_id |
| Unknown / variable | Pick sync; switch to *Task only if observed latency > 30s |
Critical: when you pick HITL (*Task), the buyer cannot poll task status over the wire today (tasks/get integration is post-6.0-rc.1). The framework records terminal state in its task registry, but exposing it to the buyer is preview-incomplete. Default to sync unless your operation truly cannot be awaited.
creative_manifestreq.creative_manifest?.assets?.[asset_id] returns a discriminated union (ImageAsset | AudioAsset | VideoAsset | VASTAsset | TextAsset | URLAsset | HTMLAsset | JavaScriptAsset | WebhookAsset | CSSAsset | DAASTAsset | MarkdownAsset | BriefAsset | CatalogAsset). Use the asset_type discriminator to narrow:
const asset = req.creative_manifest?.assets?.['script'];
if (asset?.asset_type === 'text') {
// TS narrows to TextAsset — `.content`, `.language` available without cast
const scriptText = asset.content;
}
if (asset?.asset_type === 'audio') {
// TS narrows to AudioAsset — `.url`, `.duration_ms`, `.codec` etc.
const audioUrl = asset.url;
}
Field names matter — TextAsset.content (not .text), ImageAsset.url, AudioAsset.url, VideoAsset.url, HTMLAsset.content, URLAsset.url. Use IntelliSense after the discriminator narrows; don't guess.
Likewise when returning a manifest, type the asset value to its concrete shape and TypeScript will validate it against the manifest's union:
const audio: AudioAsset = {
asset_type: 'audio',
url: 'https://cdn.example.com/render.mp3',
duration_ms: 30_000,
container_format: 'mp3',
codec: 'mp3',
};
return {
format_id: req.target_format_id!,
assets: { rendered_audio: audio },
};
Do not write as any or as never on platform code. If you find yourself reaching for those, you almost certainly want to import type the right asset from @adcp/sdk/types and use the discriminator instead.
The asset types are generated from the spec; full list at src/lib/types/tools.generated.ts. Each carries asset_type as a literal-typed discriminator.
getAsset and requireAssetMost platform methods do the same null-check + discriminator-check + extract pattern over and over. The SDK ships two helpers that collapse it:
import { getAsset, requireAsset } from '@adcp/sdk/server';
// Soft form — returns undefined if missing or wrong asset_type
const optionalVoice = getAsset(req.creative_manifest, 'voice', 'text');
// ^^^^^^^^^^^^^ TextAsset | undefined
// Throw form — throws AdcpError('INVALID_REQUEST') with a field path
// if missing or wrong asset_type. Use when the asset is required for
// the platform method to proceed.
const script = requireAsset(req.creative_manifest, 'script', 'text');
// ^^^^^^ TextAsset (never undefined past this line)
await audioStackClient.synthesize({ text: script.content });
Both helpers preserve the discriminator narrowing — script.content types correctly without a cast. requireAsset throws an AdcpError with a precomposed field path (e.g., creative_manifest.assets.script) so the buyer sees actionable feedback. Pass messageOverride if the default doesn't fit.
throw new AdcpError(...)Every method either returns its success type OR throws AdcpError for structured rejection. Generic thrown errors map to SERVICE_UNAVAILABLE with recovery: 'transient'.
buildCreative: async req => {
if (!req.format_id?.id?.startsWith('image_')) {
throw new AdcpError('UNSUPPORTED_FEATURE', {
recovery: 'terminal',
message: 'WatermarkPlatform only supports image_* formats',
field: 'format_id.id',
});
}
if ((req as any).creative_manifest?.assets?.length > 10) {
throw new AdcpError('INVALID_REQUEST', {
recovery: 'correctable',
message: 'Maximum 10 assets per build_creative call',
field: 'creative_manifest.assets',
suggestion: 'Split into multiple requests',
});
}
// ... happy path
};
AdcpError constructor:
new AdcpError(code: ErrorCode | string, options: {
recovery: 'transient' | 'correctable' | 'terminal'; // REQUIRED
message: string; // REQUIRED
field?: string; // path like 'packages[0].targeting'
suggestion?: string; // human-readable fix
retry_after?: number; // seconds; for transient
details?: Record<string, unknown>; // for multi-error pre-flight, etc.
})
Common codes for creative-template: INVALID_REQUEST, UNSUPPORTED_FEATURE, VALIDATION_ERROR, RATE_LIMITED, SERVICE_UNAVAILABLE, CREATIVE_REJECTED. The full vocabulary is in @adcp/sdk/server's ErrorCode type — return any spec code OR your own platform-specific string (agents fall back to recovery classification on unknowns).
The framework consumes idempotency_key on every mutating request before dispatching to your platform method. Replays come back from the framework's idempotency store; you never see duplicate calls for the same (idempotency_key, account) pair.
What you SHOULD do: pass req.idempotency_key to your upstream API's idempotency parameter when you call into GAM / Snap / Meta / your internal services. That makes the dedupe story end-to-end — if the AdCP layer dedupes a request, your upstream platform won't double-charge a CPM either.
buildCreative: async req => {
// Framework already deduped for the (idempotency_key, account) pair.
// Thread the same key into the upstream call so YOUR platform's API
// also dedupes if the same key arrives twice (defensive).
const job = await audioStackClient.synthesize({
text: scriptText,
idempotency_key: req.idempotency_key,
});
return { format_id: req.target_format_id!, assets: { rendered_audio: job.asset } };
};
You do NOT need to maintain your own replay table. The framework owns that.
accounts.resolve(ref) is called by the framework BEFORE any creative method. Whatever you return becomes ctx.account inside your methods. AccountReference is a discriminated union:
type AccountReference =
| { account_id: string; sandbox?: boolean }
| { brand_domain: string; sandbox?: boolean }
| { agency_buyer: { brand_domain: string }; advertiser: { brand_domain: string }; sandbox?: boolean };
Throw AccountNotFoundError (importable from @adcp/sdk/server) when you can't resolve — the framework projects to the wire ACCOUNT_NOT_FOUND envelope.
sandbox: true — the buyer is asking you to validate against your platform without actually transacting. Route reads/writes to your sandbox backend if you have one; otherwise just return realistic-shaped responses without persisting.
import { serve } from '@adcp/sdk/server';
const platform = new WatermarkPlatform();
const server = createAdcpServerFromPlatform(platform, {
name: 'watermark',
version: '1.0.0',
validation: { requests: 'strict', responses: 'strict' },
});
serve(() => server, {
publicUrl: 'https://watermark.example.com',
// For multi-host: pass a function `(host) => server` and branch.
});
createAdcpServerFromPlatform:
validatePlatform() — throws if you advertise a specialism but don't implement it, or define both halves of a method-pairAdcpError-catch + submitted-envelope projection for HITLDecisioningAdcpServer (extends AdcpServer) with getTaskState(taskId) + awaitTask(taskId) for HITL inspectionserve() is unchanged from v5.x; it accepts the server and binds HTTP transport for both MCP and A2A.
capabilities = {
specialisms: ['creative-template'] as const, // single literal in the const tuple
creative_agents: [], // not used by template platforms
channels: ['display', 'video', 'audio'] as const,
pricingModels: ['cpm'] as const,
config: {
/* your platform-specific config */
} satisfies YourConfig,
};
The as const is load-bearing — it preserves the literal types so RequiredPlatformsFor<S> can compile-check that you provide creative: CreativeBuilderPlatform.
my-creative-template-agent/
├── package.json # depends on @adcp/sdk ^5.18.0
├── tsconfig.json # strict: true
├── src/
│ ├── platform.ts # MyPlatform implements DecisioningPlatform
│ └── serve.ts # createAdcpServerFromPlatform + serve()
└── README.md
package.json:
{
"name": "my-creative-template-agent",
"type": "module",
"scripts": { "start": "tsx src/serve.ts" },
"dependencies": { "@adcp/sdk": "^5.18.0" },
"devDependencies": { "tsx": "^4", "typescript": "^5" }
}
The fastest test loop: instantiate your platform, build a server, and dispatch a fake tool call without binding HTTP:
import { AudioStackPlatform } from './platform';
import { createAdcpServerFromPlatform } from '@adcp/sdk/server';
const platform = new AudioStackPlatform();
const server = createAdcpServerFromPlatform(platform, {
name: 'audiostack-test',
version: '0.0.1',
validation: { requests: 'off', responses: 'off' },
});
const result = await server.dispatchTestRequest({
method: 'tools/call',
params: {
name: 'build_creative',
arguments: {
target_format_id: { id: 'audio_30s', agent_url: 'https://x' },
creative_manifest: {
format_id: { id: 'audio_30s', agent_url: 'https://x' },
assets: { script: { asset_type: 'text', text: 'Hello world.' } },
},
account: { account_id: 'test_acc' },
},
},
});
console.log(result.structuredContent);
dispatchTestRequest is the canonical loop for unit-testing platform behavior without HTTP. It's available on DecisioningAdcpServer (the type returned by createAdcpServerFromPlatform). Set validation: { requests: 'off' } while iterating; turn it back to strict for end-to-end tests.
For HITL platforms, server.awaitTask(taskId) settles the background promise; server.getTaskState(taskId) reads terminal status.
❌ Don't import from @adcp/sdk/server for the platform shape. That's the v5.x handler-style API. Use @adcp/sdk/server for v6.0.
❌ Don't use ctx.runAsync(...) or ctx.startTask(...). Those were in earlier preview drops; they're gone in v2.1. The async story is dual-method (xxx vs xxxTask), period.
❌ Don't define both buildCreative and buildCreativeTask. validatePlatform() will throw with a clear diagnostic. Pick one.
❌ Don't return error envelopes manually. Throw AdcpError; the framework projects to the wire shape.
❌ Don't write as never or as any on platform code. The wire types are typed, including creative_manifest.assets[asset_id] as a discriminated union. If you reach for a cast, you're missing an import type or skipping a discriminator check.
❌ Don't treat creative_manifest.assets as an array. It's a keyed map: { [asset_id: string]: ImageAsset | AudioAsset | ... }. Look up by asset_id, not by index.
❌ Don't try to write to the buyer's media_buy_status_changes channel (or any other resource type). Creative-template platforms don't emit lifecycle events; they're stateless.
❌ Don't implement getMediaBuyDelivery / createMediaBuy / etc. Those are sales-shaped tools. Creative-template only implements creative.*.
// From @adcp/sdk/server
import {
AdcpError,
AccountNotFoundError,
createAdcpServerFromPlatform,
// Manifest accessors that preserve discriminator narrowing
getAsset,
requireAsset,
type DecisioningPlatform,
type AccountStore,
type Account,
type CreativeBuilderPlatform,
type CreativeReviewResult,
type RequestContext,
type ErrorCode,
type AdcpStructuredError,
} from '@adcp/sdk/server';
// From @adcp/sdk/types — wire schemas (auto-generated)
import type {
BuildCreativeRequest,
CreativeManifest,
PreviewCreativeRequest,
PreviewCreativeResponse,
CreativeAsset,
AccountReference,
// Asset types for narrowing — pull only the ones you produce/consume
ImageAsset,
AudioAsset,
VideoAsset,
TextAsset,
URLAsset,
HTMLAsset,
VASTAsset,
} from '@adcp/sdk/types';
// From @adcp/sdk/server — HTTP serving
import { serve } from '@adcp/sdk/server';
validatePlatform() threw at construction → check the diagnostic; usually you advertised a specialism without implementing the matching field, or defined both sync and *Task for the same pair.RequiredPlatformsFor<S> constraint → you claimed creative-template but your creative: field doesn't match CreativeBuilderPlatform. Re-check the method signatures.validation: 'strict' config; the request may be failing schema validation before dispatch. Set validation: { requests: 'off' } temporarily to diagnose.For fuller protocol context (request/response shapes, AdCP error vocabulary): read docs/llms.txt. For the v6.0 design rationale: docs/proposals/decisioning-platform-v2-hitl-split.md.