一键导入
build-seller-agent
Use when building an AdCP seller agent — a publisher, SSP, or retail media network that sells advertising inventory to buyer agents.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Use when building an AdCP seller agent — a publisher, SSP, or retail media network that sells advertising inventory to buyer agents.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Interact with AdCP (Ad Context Protocol) advertising agents over MCP or A2A protocols. Use when the user wants to call AdCP tools (get_products, create_media_buy, sync_creatives, etc.), discover an agent's capabilities, run protocol compliance tests, look up brands or properties in the AdCP registry, manage saved agent aliases, or debug agent responses. NOT for general HTTP/REST API calls. Requires @adcp/sdk (npm).
Wire-level invariants for any AdCP buyer call — idempotency_key replay semantics, account `oneOf` variants, async `status:'submitted'`+`task_id` polling, error recovery from `adcp_error.issues[]`. Load before any per-protocol task skill (adcp-media-buy, adcp-creative, adcp-signals, adcp-governance, adcp-si, adcp-brand) when calling an AdCP agent as a buyer.
Use when building an AdCP creative agent — an ad server, creative management platform, or any system that accepts, stores, transforms, and serves ad creatives.
Build an AdCP sales agent (publisher / SSP / retail-media network). Implement 5 functions, throw 8 typed errors, run it. Framework handles idempotency, HITL, signing, multi-tenant, schema validation.
Use when building an AdCP sponsored intelligence agent — a brand-side conversational AI experience that an LLM host (ChatGPT, Claude, Perplexity, Arc) can hand off to.
Use when building an AdCP seller agent — a publisher, SSP, or retail media network that sells advertising inventory to buyer agents.
| name | build-seller-agent |
| description | Use when building an AdCP seller agent — a publisher, SSP, or retail media network that sells advertising inventory to buyer agents. |
A seller agent receives briefs from buyers, returns products with pricing, accepts media buys, manages creatives, and reports delivery. The business model — what you sell, how you price it, and whether humans approve deals — shapes every implementation decision. Determine that first.
get_products, create_media_buy, or the media buy protocolNot this skill:
docs/getting-started.md)skills/build-signals-agent/Determine these five things. Ask the user — don't guess.
delivery_type: "guaranteed", may require async approval (submitted → pending_approval → confirmed)delivery_type: "non_guaranteed", buyer sets bid_price, instant activationMany sellers support both — different products can have different delivery types.
Get specific inventory. Each product needs:
product_id, name, descriptionpublisher_properties — at least one { publisher_domain: 'example.com', selection_type: 'all' } (discriminated union: 'all' | 'by_id' with property_ids | 'by_tag' with tags)format_ids — array of { agent_url: string, id: string } referencing creative formatsdelivery_type — 'guaranteed' or 'non_guaranteed'pricing_options — at least one (see below)reporting_capabilities — { available_reporting_frequencies: ['daily'], expected_delay_minutes: 240, timezone: 'UTC', supports_webhooks: false, available_metrics: ['impressions', 'spend', 'clicks'], date_range_support: 'date_range' }channels — use as const to avoid string[] inference: channels: ['display', 'olv'] as constPricing models (all require pricing_option_id and currency):
cpm — { pricing_option_id: 'cpm-1', pricing_model: "cpm", fixed_price: 12.00, currency: "USD" }cpc — { pricing_option_id: 'cpc-1', pricing_model: "cpc", fixed_price: 1.50, currency: "USD" }{ pricing_option_id: 'auction-1', pricing_model: "cpm", floor_price: 5.00, currency: "USD" } (buyer bids above floor)Each pricing option can set min_spend_per_package to enforce minimum budgets.
For all PricingOption variants and Product required fields, see docs/TYPE-SUMMARY.md.
For guaranteed buys, choose one:
create_media_buy returns completed with confirmed status. Simplest.submitted, buyer polls get_media_buys. Use registerAdcpTaskTool.input-required with a setup URL for IO signing.Non-guaranteed buys are always instant confirmation.
list_creative_formats + sync_creatives. Buyer uploads assets, seller validates.sync_catalogs. Common for retail media.get_adcp_capabilities — register first, empty {} schema
capabilitiesResponse({
adcp: { major_versions: [3] },
supported_protocols: ['media_buy'],
})
sync_accounts — SyncAccountsRequestSchema.shape
taskToolResponse({
accounts: [{
account_id: string, // required - your platform's ID
brand: { domain: string },// required - echo back from request
operator: string, // required - echo back from request
action: 'created' | 'updated', // required
status: 'active' | 'pending_approval', // required
}]
})
sync_governance — SyncGovernanceRequestSchema.shape
taskToolResponse({
accounts: [{
account: { brand: {...}, operator: string }, // required - echo back
status: 'synced', // required
governance_agents: [{ url: string, categories?: string[] }], // required
}]
})
get_products — GetProductsRequestSchema.shape
productsResponse({
products: [{
product_id: 'prod-1',
name: 'Homepage Display',
description: 'Premium display ads on homepage',
publisher_properties: [{ publisher_domain: 'example.com', selection_type: 'all' }],
format_ids: [{ agent_url: 'https://creative.example.com/mcp', id: 'display-300x250' }],
delivery_type: 'guaranteed',
pricing_options: [{
pricing_option_id: 'cpm-standard',
pricing_model: 'cpm',
fixed_price: 12.00,
currency: 'USD',
}],
reporting_capabilities: {
available_reporting_frequencies: ['daily'],
expected_delay_minutes: 240,
timezone: 'UTC',
supports_webhooks: false,
available_metrics: ['impressions', 'spend', 'clicks'],
date_range_support: 'date_range',
},
}],
sandbox: true, // for mock data
})
create_media_buy — CreateMediaBuyRequestSchema.shape
Validate the request before creating the buy. Return an error response (not adcpError) when business validation fails:
// Success — revision, confirmed_at, and valid_actions are auto-set:
mediaBuyResponse({
media_buy_id: string, // required
status: 'pending_creatives',// triggers valid_actions auto-population
packages: [{ // required
package_id: string,
product_id: string,
pricing_option_id: string,
budget: number,
}],
})
// Validation failure (reversed dates, budget too low, unknown product):
adcpError('INVALID_REQUEST', { message: 'start_time must be before end_time' })
get_media_buys — GetMediaBuysRequestSchema.shape
getMediaBuysResponse({
media_buys: [{
media_buy_id: string, // required
status: 'active' | 'pending_start' | ..., // required
currency: 'USD', // required
confirmed_at: string, // required for guaranteed approval — ISO timestamp
packages: [{
package_id: string, // required
}],
}]
})
list_creative_formats — ListCreativeFormatsRequestSchema.shape
listCreativeFormatsResponse({
formats: [{
format_id: { agent_url: string, id: string }, // required
name: string, // required
}]
})
sync_creatives — SyncCreativesRequestSchema.shape
syncCreativesResponse({
creatives: [{
creative_id: string, // required - echo from request
action: 'created' | 'updated', // required
}]
})
get_media_buy_delivery — GetMediaBuyDeliveryRequestSchema.shape
deliveryResponse({
reporting_period: { start: string, end: string }, // required - ISO timestamps
media_buy_deliveries: [{
media_buy_id: string, // required
status: 'active', // required
totals: { impressions: number, spend: number }, // required
by_package: [], // required (can be empty)
}]
})
Every AdCP request includes an optional context field. Buyers use it to carry correlation IDs, orchestration metadata, and workflow state across multi-agent calls. Your agent must echo the context object back unchanged in every response.
// In every tool handler:
const context = args.context; // may be undefined — that's fine
// In every response:
return taskToolResponse({
// ... your response fields ...
context, // echo it back unchanged
});
Do not modify, inspect, or omit the context — treat it as opaque. If the request has no context, omit it from the response.
Some schemas also define an ext field for vendor-namespaced extensions. If your request schema includes ext, accept it without error. Tools with explicit ext support: sync_governance, provide_performance_feedback, sync_event_sources.
Add registerTestController so the comply framework can deterministically test your state machines. Without it, compliance testing relies on observational storyboards that can't force state transitions.
import { registerTestController } from '@adcp/sdk';
import type { TestControllerStore } from '@adcp/sdk';
const store: TestControllerStore = {
async forceAccountStatus(accountId, status) {
const prev = accounts.get(accountId);
if (!prev) throw new TestControllerError('NOT_FOUND', `Account ${accountId} not found`);
accounts.set(accountId, status);
return { success: true, previous_state: prev, current_state: status };
},
async forceMediaBuyStatus(mediaBuyId, status) {
const prev = mediaBuys.get(mediaBuyId);
if (!prev) throw new TestControllerError('NOT_FOUND', `Media buy ${mediaBuyId} not found`);
const terminal = ['completed', 'rejected', 'canceled'];
if (terminal.includes(prev))
throw new TestControllerError('INVALID_TRANSITION', `Cannot transition from ${prev}`, prev);
mediaBuys.set(mediaBuyId, status);
return { success: true, previous_state: prev, current_state: status };
},
async forceCreativeStatus(creativeId, status, rejectionReason) {
const prev = creatives.get(creativeId);
if (!prev) throw new TestControllerError('NOT_FOUND', `Creative ${creativeId} not found`);
// archived blocks transitions to active states, but archived → rejected is valid (compliance override)
const activeStatuses = ['processing', 'pending_review', 'approved'];
if (prev === 'archived' && activeStatuses.includes(status))
throw new TestControllerError('INVALID_TRANSITION', `Cannot transition from archived to ${status}`, prev);
creatives.set(creativeId, status);
return { success: true, previous_state: prev, current_state: status };
},
async simulateDelivery(mediaBuyId, params) {
// params: { impressions?: number, clicks?: number, reported_spend?: { amount, currency }, conversions?: number }
return { success: true, simulated: { ...params }, cumulative: { ...params } };
},
async simulateBudgetSpend(params) {
return { success: true, simulated: { spend_percentage: params.spend_percentage } };
},
};
registerTestController(server, store);
When using this, declare compliance_testing in supported_protocols:
capabilitiesResponse({
adcp: { major_versions: [3] },
supported_protocols: ['media_buy', 'compliance_testing'],
})
Only implement the store methods for scenarios your agent supports. Unimplemented methods are excluded from list_scenarios automatically.
The storyboard tests state machine correctness:
NOT_FOUND when forcing transitions on unknown entitiesINVALID_TRANSITION when transitioning from terminal states (completed, rejected, canceled for media buys; archived blocks active states like processing/pending_review/approved, but archived → rejected is valid)Throw TestControllerError from store methods for typed errors. The SDK validates status enum values before calling your store.
Validate with: adcp storyboard run <agent> deterministic_testing --json
Don't close over module-scoped maps. If your session state is persisted (Postgres, Redis, JSONB) and rehydrated into a new object per request, a store whose methods close over a module-level WeakMap<SessionState, …> or module-scoped cache will silently drop entries between calls — the cached ref was GC'd when the session was serialized out and rebuilt.
Use the factory shape. scenarios declares the static capability set — the SDK answers list_scenarios from this field and never invokes createStore for capability probes, so it's safe to throw on missing session_id. createStore runs per request for every other scenario, returning a store bound to the live session.
import {
registerTestController,
CONTROLLER_SCENARIOS,
enforceMapCap,
TestControllerError,
} from '@adcp/sdk';
registerTestController(server, {
scenarios: [
CONTROLLER_SCENARIOS.FORCE_ACCOUNT_STATUS,
CONTROLLER_SCENARIOS.FORCE_MEDIA_BUY_STATUS,
CONTROLLER_SCENARIOS.FORCE_CREATIVE_STATUS,
CONTROLLER_SCENARIOS.SIMULATE_DELIVERY,
CONTROLLER_SCENARIOS.SIMULATE_BUDGET_SPEND,
],
async createStore(input) {
const sessionId = (input.context as { session_id?: string })?.session_id;
if (!sessionId) throw new TestControllerError('INVALID_PARAMS', 'context.session_id is required');
const session = await loadSession(sessionId);
return {
async forceAccountStatus(accountId, status) {
// enforceMapCap only rejects NET-NEW keys at the cap; updating an
// existing accountId always passes, so calling it before every set()
// is safe.
enforceMapCap(session.accountStatuses, accountId, 'account statuses');
const prev = session.accountStatuses.get(accountId) ?? 'active';
session.accountStatuses.set(accountId, status);
await saveSession(session);
return { success: true, previous_state: prev, current_state: status };
},
async forceMediaBuyStatus(mediaBuyId, status) {
const prev = session.mediaBuyStatuses.get(mediaBuyId);
if (!prev) throw new TestControllerError('NOT_FOUND', `Media buy ${mediaBuyId} not found`);
const terminal = ['completed', 'rejected', 'canceled'];
if (terminal.includes(prev)) {
throw new TestControllerError('INVALID_TRANSITION', `Cannot transition from ${prev}`, prev);
}
enforceMapCap(session.mediaBuyStatuses, mediaBuyId, 'media buy states');
session.mediaBuyStatuses.set(mediaBuyId, status);
await saveSession(session);
return { success: true, previous_state: prev, current_state: status };
},
// ...implement other scenarios from your `scenarios` list the same way
};
},
});
Wrap every Map.set on session-scoped state with enforceMapCap to reject unbounded growth with a typed INVALID_STATE error (vs. silent LRU eviction, which would make compliance tests nondeterministic). Existing-key overwrites always pass — only net-new keys are rejected at the cap. Default cap is SESSION_ENTRY_CAP (1000).
If you need AsyncLocalStorage, sandbox gating, or a custom task store around the controller tool, bypass registerTestController and call the exported building blocks directly. toMcpResponse and TOOL_INPUT_SHAPE are the exact pieces the default registration uses — reusing them keeps the envelope shape identical.
import { AsyncLocalStorage } from 'node:async_hooks';
import { handleTestControllerRequest, toMcpResponse, TOOL_INPUT_SHAPE } from '@adcp/sdk';
const sessionContext = new AsyncLocalStorage<{ sessionId: string }>();
const store = { async forceAccountStatus(id, status) { /* ... */ } };
server.tool('comply_test_controller', 'Sandbox only.', TOOL_INPUT_SHAPE, async input => {
if (!sandboxEnabled()) {
return toMcpResponse({ success: false, error: 'FORBIDDEN', error_detail: 'Sandbox disabled' });
}
const sessionId = (input.context as { session_id: string }).session_id;
return sessionContext.run({ sessionId }, async () => {
const response = await handleTestControllerRequest(store, input as Record<string, unknown>);
return toMcpResponse(response);
});
});
| SDK piece | Usage |
|---|---|
createAdcpServerFromPlatform(platform, opts) | Build a server from a typed DecisioningPlatform — compile-time specialism enforcement, ctx_metadata round-trip, idempotency-principal synthesis, status mappers, webhook auto-emit |
createAdcpServer(config) (legacy) | v5 handler-bag entry. Mid-migration / escape-hatch only; reach via @adcp/sdk/server/legacy/v5 |
serve(() => createAdcpServerFromPlatform(platform, opts)) | Start HTTP server on :3001/mcp |
ctx.store | State store in every handler — get, put, patch, delete, list |
InMemoryStateStore | Default state store (dev/testing) |
PostgresStateStore | Production state store (shared across instances) |
DEFAULT_REPORTING_CAPABILITIES | Use as reporting_capabilities: DEFAULT_REPORTING_CAPABILITIES on products |
checkGovernance(options) | Call governance agent before financial commits |
governanceDeniedError(result) | Convert governance denial to GOVERNANCE_DENIED error |
mediaBuyResponse(data) | Auto-applied for createMediaBuy (sets revision, confirmed_at, valid_actions) |
adcpError(code, { message }) | Structured error (e.g., BUDGET_TOO_LOW, PRODUCT_NOT_FOUND) |
registerTestController(server, store | { scenarios, createStore }) | Add comply_test_controller. Plain store or per-request factory. |
TestControllerError(code, message) | Typed error from store methods |
handleTestControllerRequest(store, input) | Low-level dispatch for custom MCP wrappers |
toMcpResponse(response) / TOOL_INPUT_SHAPE | MCP envelope + Zod input schema for custom wrappers |
enforceMapCap(map, key, label, cap?) | Reject net-new keys once a session Map hits SESSION_ENTRY_CAP (1000) |
expectControllerError(result, code) / expectControllerSuccess(result) | Unit-test assertions — narrow responses to error or success arms |
Response builders (productsResponse, mediaBuyResponse, deliveryResponse, etc.) are auto-applied by the framework — you return the data, the framework wraps it. You only need to call them directly for tools without a dedicated builder.
Import everything from @adcp/sdk/server. Types from @adcp/sdk/server with import type.
npm init -y
npm install @adcp/sdk
npm install -D typescript @types/node
Minimal tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"skipLibCheck": true,
"outDir": "dist"
}
}
skipLibCheck: true avoids false-positive errors from transitive .d.ts files (e.g., @opentelemetry/api).
Use createAdcpServerFromPlatform — it auto-wires schemas, response builders, and get_adcp_capabilities from a typed DecisioningPlatform class. Handlers receive (params, ctx) where ctx.store persists state, ctx.account is the resolved account, and ctx.ctxMetadata is the resource-keyed cache.
LEGACY (v5) — the worked example below is the v5 handler-bag shape (
createAdcpServer({ accounts, mediaBuy })). It still compiles via@adcp/sdk/server/legacy/v5. For new agents, refer toskills/build-seller-agent/SKILL.mdlines 44–85 (the canonical v6 example) orexamples/decisioning-platform-programmatic.tsfor the typedDecisioningPlatformclass shape. Lift the handler bodies (governance check, randomUUID-minted ids, ctx.store use) intoclass MySeller implements DecisioningPlatform<{}, MyMeta>withaccounts/salesfields, thencreateAdcpServerFromPlatform(new MySeller(), { name, version, idempotency }).
Imports: most things live at @adcp/sdk. The idempotency store helpers (createIdempotencyStore, memoryBackend, pgBackend) live at the narrower @adcp/sdk/server subpath. Both are re-exported from the root — either works — but splitting them makes intent obvious.
import { randomUUID } from 'node:crypto';
import {
createAdcpServer,
serve,
adcpError,
InMemoryStateStore,
checkGovernance,
governanceDeniedError,
} from '@adcp/sdk';
import { createIdempotencyStore, memoryBackend } from '@adcp/sdk/server';
import type { ServeContext } from '@adcp/sdk';
const stateStore = new InMemoryStateStore(); // shared across requests
// Idempotency — required for any v3-compliant seller that accepts mutating
// requests. `createIdempotencyStore` throws if `ttlSeconds` is outside the
// spec bounds (3600–604800).
const idempotency = createIdempotencyStore({
backend: memoryBackend(), // pgBackend(pool) for production
ttlSeconds: 86400, // 24 hours
});
function createAgent({ taskStore }: ServeContext) {
return createAdcpServer({
name: 'My Seller Agent',
version: '1.0.0',
taskStore,
stateStore,
idempotency,
// Principal scoping for idempotency. MUST never return undefined — or
// every mutating request rejects as SERVICE_UNAVAILABLE. A constant is
// fine for a demo; for multi-tenant production use ctx.account typed
// via the framework constructor's `<MyAccount>` generic.
resolveSessionKey: () => 'default-principal',
resolveAccount: async ref => {
if ('account_id' in ref) return stateStore.get('accounts', ref.account_id);
return null;
},
accounts: {
syncAccounts: async (params, ctx) => {
/* ... */
},
},
mediaBuy: {
getProducts: async (params, ctx) => {
return { products: PRODUCTS, sandbox: true };
// productsResponse() auto-applied by framework
},
createMediaBuy: async (params, ctx) => {
// Governance check for financial commitment
if (ctx.account?.governanceUrl) {
const gov = await checkGovernance({
agentUrl: ctx.account.governanceUrl,
planId: params.plan_id ?? 'default',
caller: 'https://my-agent.com/mcp',
tool: 'create_media_buy',
payload: params,
});
if (!gov.approved) return governanceDeniedError(gov);
}
// Use randomUUID (not Date.now) so ids are unguessable — a guessable
// media_buy_id lets another buyer probe or cancel. Same applies to
// any seller-issued id (package_id, creative_id, etc.).
const buy = {
media_buy_id: `mb_${randomUUID()}`,
status: 'pending_creatives' as const,
packages:
params.packages?.map(pkg => ({
package_id: `pkg_${randomUUID()}`,
product_id: pkg.product_id,
pricing_option_id: pkg.pricing_option_id,
budget: pkg.budget,
})) ?? [],
};
await ctx.store.put('media_buys', buy.media_buy_id, buy);
return buy; // mediaBuyResponse() auto-applied (sets revision, confirmed_at, valid_actions)
},
updateMediaBuy: async (params, ctx) => {
const existing = await ctx.store.get('media_buys', params.media_buy_id);
if (!existing) {
return adcpError('MEDIA_BUY_NOT_FOUND', {
message: `No media buy with id ${params.media_buy_id}`,
field: 'media_buy_id',
});
}
// Only merge the fields you want to persist — do NOT spread `params`
// wholesale. `params` carries envelope fields (idempotency_key,
// context) that have no business in your domain state. Spreading
// them pollutes `get_media_buys` responses and breaks dedup.
const updated = { ...existing, status: params.active === false ? 'paused' : 'active' };
await ctx.store.put('media_buys', params.media_buy_id, updated);
return {
media_buy_id: params.media_buy_id,
status: updated.status as 'paused' | 'active',
affected_packages: [],
};
},
getMediaBuys: async (params, ctx) => {
const result = await ctx.store.list('media_buys');
return { media_buys: result.items };
},
getMediaBuyDelivery: async (params, ctx) => {
/* ... */
},
listCreativeFormats: async (params, ctx) => {
/* ... */
},
syncCreatives: async (params, ctx) => {
return {
// Response shape is `creatives: [{ creative_id, action }]` per the
// sync_creatives response schema — NOT `synced_creatives`.
creatives:
params.creatives?.map(c => ({
creative_id: c.creative_id ?? `cr_${randomUUID()}`,
action: 'created' as const,
})) ?? [],
};
},
},
capabilities: {
features: { inlineCreativeManagement: false },
},
});
}
serve(createAgent);
Key points:
.ts file — one DecisioningPlatform class passed to createAdcpServerFromPlatformget_adcp_capabilities is auto-generated from your handlers — don't register it manually (idempotency capability is auto-declared too)ctx.store for state — persists across stateless HTTP requestssandbox: true on all mock/demo responsesadcpError() for business validation failuresas const on string literal arrays and union-typed fields in product definitions — TypeScript infers string[] from ['display', 'olv'] but the SDK requires specific union types like MediaChannel[]. Apply as const to channels, delivery_type, selection_type, and pricing_model values.AdCP v3 requires an idempotency_key on every mutating request. For sellers, that's create_media_buy, update_media_buy, sync_creatives, and any sync_* tools you implement. Idempotency is wired in the Implementation example above — this section explains what the framework does for you and the subtleties to know.
What the framework handles when you pass idempotency to createAdcpServerFromPlatform:
idempotency_key with INVALID_REQUEST. The spec pattern is ^[A-Za-z0-9_.:-]{16,255}$ — a test key like "key1" will be rejected for length, not idempotency logic.IDEMPOTENCY_CONFLICT on same-key-different-payload. The error body carries only code + message — no payload hash, no field pointer, no leaked cached content.IDEMPOTENCY_EXPIRED when a key is past the TTL (with ±60s clock-skew tolerance).replayed: true on result.structuredContent.replayed when returning a cached response; fresh executions omit the field.adcp.idempotency.replay_ttl_seconds on get_adcp_capabilities.check() so concurrent retries with a fresh key don't all race to execute side effects.Scoping: the principal comes from resolveSessionKey (or override with resolveIdempotencyPrincipal(ctx, params, toolName) for per-tool custom scopes). Two callers with the same principal share a cache namespace; different principals are isolated.
Two things to know:
ttlSeconds must be 3600 (1h) to 604800 (7d) — out of range throws at createIdempotencyStore construction. Don't pass minutes thinking they're seconds.idempotency, the framework logs an error at server-creation time (v3 non-compliance). Silence it by either wiring idempotency or setting capabilities.idempotency.replay_ttl_seconds in your config (declares non-compliance to buyers).The quick-start uses memoryBackend() for idempotency and InMemoryStateStore for state — both reset on process restart and don't scale across replicas. Production swaps three pieces:
LEGACY (v5) — example below uses
createAdcpServer. The Postgres wiring (pgBackend,PostgresStateStore,PostgresTaskStore) is identical for v6 — pass them tocreateAdcpServerFromPlatform(platform, { stateStore, taskStore, idempotency })instead. For new agents, see the v6 worked example inskills/build-decisioning-platform/SKILL.md§ Production wiring, or use thepoolshortcut:createAdcpServerFromPlatform(platform, { pool })auto-wires all three from a singlepg.Pool.
import { Pool } from 'pg';
import {
createIdempotencyStore,
pgBackend,
getIdempotencyMigration,
PostgresStateStore,
getAdcpStateMigration,
PostgresTaskStore,
MCP_TASKS_MIGRATION,
cleanupExpiredIdempotency,
} from '@adcp/sdk/server';
// Fail fast — pg silently defaults to localhost+OS-user if DATABASE_URL is
// missing, which works on a dev laptop and breaks cryptically in CI.
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL environment variable is required');
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Run once per deployment before starting the server (e.g., as a
// separate migrate step, or at boot with a feature flag).
await pool.query(getIdempotencyMigration());
await pool.query(getAdcpStateMigration());
await pool.query(MCP_TASKS_MIGRATION);
const idempotency = createIdempotencyStore({
backend: pgBackend(pool),
ttlSeconds: 86400,
});
const stateStore = new PostgresStateStore(pool);
const taskStore = new PostgresTaskStore(pool);
// Cleanup expired idempotency rows hourly so the cache table doesn't
// grow unboundedly. Schedule via cron in production.
setInterval(() => cleanupExpiredIdempotency(pool).catch(console.error), 3600 * 1000);
serve(() =>
createAdcpServer({
name: 'My Seller Agent',
version: '1.0.0',
taskStore,
stateStore,
idempotency,
// Real multi-tenant principal resolution — derived from an authenticated
// session (e.g., JWT claims middleware before serve()), not a constant.
resolveAccount: async ref => db.findAccount(ref),
resolveSessionKey: ctx => (ctx.account as { id?: string } | undefined)?.id ?? 'unknown-principal',
mediaBuy: {
/* handlers */
},
})
);
Two things the example doesn't wire (app-specific):
serve(). The library provides OAuth helpers; bearer is middleware territory (Express/Fastify).max, idleTimeoutMillis, connectionTimeoutMillis on new Pool({...}) per your deployment's concurrency characteristics. The pg driver defaults are fine for low traffic.After writing the agent, validate it. Fix failures. Repeat.
Full validation (if you can bind ports):
npx tsx agent.ts &
npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp media_buy_seller --json
Sandbox validation (if ports are blocked):
npx tsc --noEmit
When storyboard output shows failures, fix each one:
response_schema → response doesn't match Zod schemafield_present → required field missingKeep iterating until all steps pass.
| Storyboard | Use case |
|---|---|
media_buy_seller | Full lifecycle — every seller should pass this |
media_buy_non_guaranteed | Auction flow with bid adjustment |
media_buy_guaranteed_approval | IO approval workflow |
media_buy_proposal_mode | AI-generated proposals |
media_buy_catalog_creative | Catalog sync + conversions |
schema_validation | Schema compliance + date validation errors |
deterministic_testing | State machine correctness via comply_test_controller |
| Mistake | Fix |
|---|---|
Using createTaskCapableServer + server.tool() | Use createAdcpServerFromPlatform(platform, opts) — handles schemas, response builders, capabilities, ctx_metadata |
Calling createAdcpServer directly in new code | Reach for createAdcpServerFromPlatform first; createAdcpServer lives at @adcp/sdk/server/legacy/v5 for mid-migration / escape-hatch use only |
| Using module-level Maps for state | Use ctx.store — persists across HTTP requests, swappable for postgres |
| Return raw JSON without response builders | The framework auto-applies response builders — just return the data |
Missing brand/operator in sync_accounts response | Echo them back from the request — they're required |
| sync_governance returns wrong shape | Must include status: 'synced' and governance_agents array |
sandbox: false on mock data | Buyers may treat mock data as real |
| Returns raw JSON for validation failures | Use adcpError('INVALID_REQUEST', { message }) — storyboards validate the adcp_error structure |
Missing publisher_properties or format_ids on Product | Both are required — see product example in get_products section |
| format_ids in products don't match list_creative_formats | Buyers echo format_ids from products into sync_creatives — if your validation rejects your own format_ids, the buyer can't fulfill creative requirements |
Missing @types/node in devDependencies | process.env doesn't resolve without it — see Setup section |
Dropping context from responses | Echo args.context back unchanged in every response — buyers use it for correlation |
channels typed as string[] instead of MediaChannel[] | Use as const on channel arrays: channels: ['display', 'olv'] as const. TypeScript infers string[] from array literals, but the SDK requires the MediaChannel union type. |
docs/guides/BUILD-AN-AGENT.md — createAdcpServerFromPlatform patterns, async tools, state persistencedocs/llms.txt — full protocol referencedocs/TYPE-SUMMARY.md — curated type signaturesstoryboards/media_buy_seller.yaml — full buyer interaction sequenceexamples/error-compliant-server.ts — seller with error handlingsrc/lib/server/create-adcp-server.ts — framework source (for TypeScript autocomplete exploration)