| name | build-decisioning-creative-template |
| description | Build an AdCP creative-template decisioning platform — a stateless creative transform service (TTS, watermarking, format conversion, template fill). Use when the user wants the typed `DecisioningPlatform` shape; for fork-an-adapter starting points, see `build-creative-agent`. |
Build a Creative-Template Decisioning Platform
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.
When this skill applies
- User wants a creative-template service against the typed
DecisioningPlatform surface
- Specialism:
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)
- SDK package:
@adcp/sdk
Wrong skill if:
- User wants to fork a worked adapter →
skills/build-creative-agent/
- User wants stateful creative library/ad-server →
skills/build-creative-agent/ § creative-ad-server
- User wants brief-to-creative generation → same skill as this one (Builder covers both); declare
'creative-generative' instead of 'creative-template'
- User wants to sell media inventory →
skills/build-seller-agent/
The whole shape (read this first)
A 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.)
Key fact: CreativeManifest.assets is a keyed map, not an array
Every example below depends on this — it's the most common day-1 trip-up:
const url = req.creative_manifest?.assets?.['source_image']?.url;
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.
Minimal worked example — image watermark service
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,
};
accounts: AccountStore<WatermarkMeta> = {
resolve: async (ref: AccountReference) => {
const id = 'account_id' in ref ? ref.account_id : 'wm_acc_default';
return {
id,
name: 'Watermark default',
status: 'active',
operator: 'watermark.example.com',
metadata: { brand_id: 'brand_default' },
authInfo: { kind: 'api_key' },
};
},
};
creative: CreativeBuilderPlatform<WatermarkMeta> = {
buildCreative: async (req: BuildCreativeRequest): Promise<CreativeManifest> => {
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 },
};
},
previewCreative: async (req: PreviewCreativeRequest): Promise<PreviewCreativeResponse> => {
const source = getAsset(req.creative_manifest, 'source_image', 'image');
const sourceUrl = source?.url ?? '';
return {
status: 'completed',
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(),
};
},
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> {
return `${src}?watermark=${encodeURIComponent(mark)}`;
}
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.
Two wire shapes that trip people up
target_format_id is a FormatID object, not a bare string
if (req.target_format_id === 'audio_30s') { ... }
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'
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.)
The interface you implement
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 |
Sync vs *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.
Reading typed assets out of creative_manifest
req.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') {
const scriptText = asset.content;
}
if (asset?.asset_type === 'audio') {
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.
Helpers — getAsset and requireAsset
Most 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';
const optionalVoice = getAsset(req.creative_manifest, 'voice', 'text');
const script = requireAsset(req.creative_manifest, 'script', 'text');
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.
Errors — 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',
});
}
};
AdcpError constructor:
new AdcpError(code: ErrorCode | string, options: {
recovery: 'transient' | 'correctable' | 'terminal';
message: string;
field?: string;
suggestion?: string;
retry_after?: number;
details?: Record<string, unknown>;
})
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).
Idempotency — the framework dedupes; you thread the key downstream
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 => {
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.
Account resolution
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.
Serving the agent
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',
});
createAdcpServerFromPlatform:
- Calls
validatePlatform() — throws if you advertise a specialism but don't implement it, or define both halves of a method-pair
- Wraps each method with
AdcpError-catch + submitted-envelope projection for HITL
- Returns a
DecisioningAdcpServer (extends AdcpServer) with getTaskState(taskId) + awaitTask(taskId) for HITL inspection
serve() accepts the server and binds HTTP transport for both MCP and A2A.
Capabilities — declare what you support
capabilities = {
specialisms: ['creative-template'] as const,
creative_agents: [],
channels: ['display', 'video', 'audio'] as const,
pricingModels: ['cpm'] as const,
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.
Scaffolding — minimum viable project
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" }
}
Testing your platform
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.
What NOT to do
❌ Don't use ctx.runAsync(...) or ctx.startTask(...). 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.*.
Reference: imports cheat sheet
import {
AdcpError,
AccountNotFoundError,
createAdcpServerFromPlatform,
getAsset,
requireAsset,
type DecisioningPlatform,
type AccountStore,
type Account,
type CreativeBuilderPlatform,
type CreativeReviewResult,
type RequestContext,
type ErrorCode,
type AdcpStructuredError,
} from '@adcp/sdk/server';
import type {
BuildCreativeRequest,
CreativeManifest,
PreviewCreativeRequest,
PreviewCreativeResponse,
CreativeAsset,
AccountReference,
ImageAsset,
AudioAsset,
VideoAsset,
TextAsset,
URLAsset,
HTMLAsset,
VASTAsset,
} from '@adcp/sdk/types';
import { serve } from '@adcp/sdk/server';
When you're stuck
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.
- TS compiler complains about
RequiredPlatformsFor<S> constraint → you claimed creative-template but your creative: field doesn't match CreativeBuilderPlatform. Re-check the method signatures.
- Wire request doesn't reach your method → check the framework's
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.