mit einem Klick
build-decisioning-platform
// 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.
// 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.
| name | build-decisioning-platform |
| description | 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. |
Implement 6 functions. The framework does the rest.
A DecisioningPlatform for the sales-non-guaranteed (or sales-guaranteed) specialism. Buyers call your AdCP server to discover products, create media buys, push creatives, update buys, and pull delivery reports. You translate those calls to your platform (GAM, FreeWheel, Kevel, your own ad server, whatever).
For 95% of sales agents, these are the only @adcp/sdk/server imports you need:
import {
// Server entry + persistence
createAdcpServerFromPlatform,
getAllAdcpMigrations,
serve,
// Wire-shape helpers (eliminate 30+ lines of boilerplate per Product)
buildProduct,
buildPackage,
buildPricingOption,
DEFAULT_REPORTING_CAPABILITIES, // required field; override per-product
// Typed errors — pick from this catalog instead of `throw new AdcpError(...)`
PackageNotFoundError,
MediaBuyNotFoundError,
ProductNotFoundError,
BudgetTooLowError,
BackwardsTimeRangeError,
InvalidStateError,
RateLimitedError,
UnsupportedFeatureError,
// Types
type DecisioningPlatform,
type SalesPlatform,
} from '@adcp/sdk/server';
For other agent shapes:
SalesPlatform for CreativeBuilderPlatformSignalsPlatformBrandRightsPlatformcreateTenantRegistryThe full export list is in @adcp/sdk/server. Surfaces marked @deprecated will strikethrough in your IDE. Trust the strikethrough — pick from the typed-error catalog above instead.
advanced/HITL.mdadvanced/MULTI-TENANT.mdadvanced/OAUTH.mdadvanced/SANDBOX.mdcomply_test_controller) → advanced/COMPLIANCE.mdadvanced/GOVERNANCE.mdadvanced/BRAND-RIGHTS.mdadvanced/IDEMPOTENCY.mdpending_creatives → active) → advanced/STATE-MACHINE.md../../docs/guides/POSTGRES.mdadvanced/REFERENCE.mdimport {
createAdcpServerFromPlatform,
createCtxMetadataStore,
memoryCtxMetadataStore,
DEFAULT_REPORTING_CAPABILITIES,
PackageNotFoundError,
MediaBuyNotFoundError,
ProductNotFoundError,
BudgetTooLowError,
BackwardsTimeRangeError,
InvalidStateError,
type DecisioningPlatform,
type SalesPlatform,
} from '@adcp/sdk/server';
class MyPlatform implements DecisioningPlatform {
capabilities = {
adcp_version: '3.0.0',
specialisms: ['sales-non-guaranteed'] as const,
pricingModels: ['cpm'] as const,
channels: ['display', 'video'] as const, // strict literal-union — TS catches typos
formats: [{ format_id: 'display_300x250' }],
idempotency: { replay_ttl_seconds: 86400 },
};
accounts = {
resolution: 'derived' as const, // single tenant; framework returns the same Account every call
resolve: async () => ({ id: 'pub_main', operator: 'mypub', ctx_metadata: {} }),
upsert: async () => ({ ok: true, items: [] }),
list: async () => ({ items: [], nextCursor: null }),
};
sales: SalesPlatform = {
// 1. Catalog lookup. Brief in, products out.
getProducts: async (req, ctx) => {
const products = await this.platform.searchInventory(req.brief, req.promoted_offering);
return {
cache_scope: 'account',
products: products.map(p => ({
product_id: p.id,
name: p.name,
format_ids: p.formatIds.map(id => ({ id })),
delivery_type: 'non_guaranteed',
reporting_capabilities: DEFAULT_REPORTING_CAPABILITIES, // required — see ReportingCapabilities type for all fields
pricing_options: [
{ pricing_option_id: `${p.id}-cpm`, model: 'cpm', floor: { amount: p.floor, currency: 'USD' } },
],
ctx_metadata: { gam: { ad_unit_ids: p.adUnitIds } }, // stashed; framework round-trips
})),
};
},
// 2. Create a buy. Sync path; HITL is `ctx.handoffToTask` (see advanced/HITL.md).
// SDK auto-hydrates each pkg.product with the resolved Product (incl. ctx_metadata)
// from the prior getProducts call — no separate lookup needed.
//
// CONTRACT — `pkg.product` is `undefined` when SDK has no record of that product_id.
// That's NOT authoritative "doesn't exist" — the SDK store is a cache, and your
// publisher's DB might still have it. Decision tree when undefined:
// - Have your own product DB → look up there; throw `ProductNotFoundError(pkg.product_id)`
// only if YOUR DB also returns nothing.
// - Pure-SDK store (no own DB) → throw `ProductNotFoundError(pkg.product_id)` immediately.
// - Either way: never let `undefined` flow downstream silently.
createMediaBuy: async (req, ctx) => {
if (new Date(req.start_time) >= new Date(req.end_time)) throw new BackwardsTimeRangeError();
if (req.total_budget?.amount < 1000) throw new BudgetTooLowError({ floor: 1000, currency: 'USD' });
const lineItems = [];
for (const pkg of req.packages) {
if (!pkg.product) throw new ProductNotFoundError(pkg.product_id);
// pkg.product is the full Product from getProducts, with adapter-internal config attached:
const adUnits = pkg.product.ctx_metadata?.gam?.ad_unit_ids ?? [];
const formats = pkg.product.format_ids;
lineItems.push(await this.platform.createLineItem(pkg, { adUnits, formats }));
}
const order = await this.platform.createOrder(req, lineItems);
// Stash your platform's IDs so subsequent updateMediaBuy can hydrate them too.
return {
media_buy_id: order.id,
status: 'pending_creatives', // creative state machine — see advanced/STATE-MACHINE.md
ctx_metadata: { gam_order_id: order.gamOrderId }, // SDK persists; subsequent updateMediaBuy gets req.ctx_metadata.gam_order_id
packages: order.lineItems.map(li => ({
package_id: li.id,
status: 'pending_creatives',
buyer_ref: li.buyerRef,
ctx_metadata: { gam_line_item_id: li.gamLineItemId },
})),
};
},
// 3. Update a buy. SDK auto-hydrates the resolved MediaBuy (and its packages,
// each with ctx_metadata) at req.mediaBuy when present in the store from a
// prior createMediaBuy / getMediaBuys call. Falls back gracefully if absent
// (publisher uses their own DB).
// (6.2 will pre-read state + decompose into atomic verbs; track adcp-client#1071.)
updateMediaBuy: async (mediaBuyId, patch, ctx) => {
const orderMeta = await ctx.ctxMetadata?.mediaBuy(mediaBuyId);
if (!orderMeta) throw new MediaBuyNotFoundError(mediaBuyId);
for (const pkg of patch.packages ?? []) {
const pkgMeta = await ctx.ctxMetadata?.package(pkg.package_id);
if (!pkgMeta) throw new PackageNotFoundError(pkg.package_id);
await this.platform.updateLineItem(pkgMeta.gam_line_item_id, pkg);
}
const order = await this.platform.getOrder(orderMeta.gam_order_id);
return this.toMediaBuy(order);
},
// 4. Push creatives. Returns one row per creative with action + status.
syncCreatives: async (creatives, ctx) => {
const out = [];
for (const c of creatives) {
const native = await this.platform.upsertCreative(c);
await ctx.ctxMetadata?.set('creative', c.creative_id, { gam_creative_id: native.id });
out.push({ creative_id: c.creative_id, action: 'created', status: 'approved' });
}
return out;
},
// 5. List buys this account owns. REQUIRED — non-negotiable. Every seller
// needs to support reading back what they created. SDK auto-stores
// returned buys for hydration on subsequent updateMediaBuy calls.
//
// WRITE-ONLY ADOPTERS (proposal-mode push-channel sellers, retail-media
// flows that deliver via webhook): return `{ media_buys: [] }`. Never
// lie — empty array is truthful "no buys to enumerate via this surface."
// Buyers asking for a list get an empty answer. Don't omit the method
// or stub-throw; just return empty.
getMediaBuys: async (req, ctx) => {
const buys = await this.platform.listOrders({ accountId: ctx.account.id, status: req.status });
return {
media_buys: buys.map(buy => ({
media_buy_id: buy.id,
status: this.statusMappers.mediaBuy(buy.nativeStatus),
buyer_ref: buy.buyerRef,
total_budget: { amount: buy.budgetAmount, currency: buy.currency }, // REQUIRED on the wire shape
start_time: buy.startTime,
end_time: buy.endTime,
packages: buy.lineItems.map(li => ({
package_id: li.id,
status: this.statusMappers.mediaBuy(li.nativeStatus),
buyer_ref: li.buyerRef,
ctx_metadata: { gam_line_item_id: li.gamLineItemId }, // round-trip publisher state
})),
ctx_metadata: { gam_order_id: buy.gamOrderId },
})),
};
},
// 6. Delivery report.
getMediaBuyDelivery: async (filter, ctx) => ({ deliveries: await this.platform.fetchReports(filter) }),
};
constructor(private platform: MyAdServer) {}
}
That's the agent. Five functions. The framework wires the wire protocol around it (MCP tools, A2A skill manifest, idempotency, schema validation, HITL task envelopes, RFC 9421 webhook signing, multi-tenant routing).
import {
PackageNotFoundError, // wrong package_id on update
MediaBuyNotFoundError, // wrong media_buy_id
ProductNotFoundError, // wrong product_id on create
ProductUnavailableError, // product exists but sold out
CreativeNotFoundError, // wrong creative_id
CreativeRejectedError, // brand-safety failed, etc.
BudgetTooLowError, // under floor (correctable — buyer raises)
BudgetExhaustedError, // pacing burst hit cap
IdempotencyConflictError, // same key, different payload
InvalidRequestError, // generic field-level bad input
InvalidStateError, // illegal transition (paused → archived violations)
BackwardsTimeRangeError, // start_time >= end_time
AuthMissingError, // no Authorization header was presented
AuthInvalidError, // credentials were presented but rejected
PermissionDeniedError, // auth present, lacks scope
RateLimitedError, // throttled (clamps retry_after to [1, 3600])
UnsupportedFeatureError, // tool unimplemented
ComplianceUnsatisfiedError, // brand-safety attestation missing
GovernanceDeniedError, // spending authority revoked
PolicyViolationError, // categorical content rejection
} from '@adcp/sdk/server';
AuthRequiredError still exists for older code and emits legacy AUTH_REQUIRED;
new seller code should use AuthMissingError / AuthInvalidError so buyers can
distinguish missing credentials from rejected credentials.
Each class encodes the right code / recovery / field shape. Don't throw generic Error — the framework catches that and maps to SERVICE_UNAVAILABLE, which the buyer can't pattern-match.
ctx.ctxMetadataYour platform has IDs (GAM order_id, line_item_id) that AdCP doesn't model. Stash them once, read them on subsequent calls. The framework round-trips per (account.id, kind, id) and strips from buyer-facing wire payloads.
// Wire a store at server construction:
import { createCtxMetadataStore, memoryCtxMetadataStore, pgCtxMetadataStore, getCtxMetadataMigration } from '@adcp/sdk/server';
await pool.query(getCtxMetadataMigration()); // Postgres only
const ctxMetadata = createCtxMetadataStore({ backend: pgCtxMetadataStore(pool) });
// Stash in any handler return:
await ctx.ctxMetadata?.set('product', productId, { gam: { ad_unit_ids: [...] } });
// Read in a later handler:
const meta = await ctx.ctxMetadata?.product(productId);
Memory backend: fine for dev; use Postgres in cluster — silent loss after rolling restart produces "package not found" errors that look like publisher bugs and run for weeks.
Account scoping is automatic. ctx.ctxMetadata binds to ctx.account.id per request. No-account tools (provide_performance_feedback, list_creative_formats) get ctx.ctxMetadata = undefined — branch defensively.
import { Pool } from 'pg';
import { createAdcpServerFromPlatform, getAllAdcpMigrations, serve } from '@adcp/sdk/server';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await pool.query(getAllAdcpMigrations()); // one DDL call, all 3 tables
const platform = new MyPlatform(myAdServer);
const server = createAdcpServerFromPlatform(platform, {
name: 'My Sales Agent',
version: '1.0.0',
pool, // wires idempotency + ctxMetadata + taskRegistry
});
serve(() => server, { port: process.env.PORT });
That's the whole bootstrap. One pool, one migration, three persistence concerns wired by the framework.
For dev / single-process: omit pool entirely. Framework defaults to in-memory backends. Don't ship that to production — silent state loss after rolling restart produces "package not found" errors that look like publisher bugs and run for weeks.
Things you set up once at deploy time:
DATABASE_URL env var pointing at your Postgres instancegetAllAdcpMigrations() once per database (idempotent — safe to re-run)advanced/OAUTH.md if buyers authenticate via OIDCADCP_VERSION env (default 3.0.0) if pinning a specific spec versionadvanced/HITL.md — long-running tools (creative review, manual approval). Use ctx.handoffToTask(fn).advanced/MULTI-TENANT.md — TenantRegistry for one-process-many-publishers.advanced/OAUTH.md — auth providers (OIDC client_credentials, etc.).advanced/SANDBOX.md — test-mode routing via AccountReference.sandbox.advanced/COMPLIANCE.md — comply_test_controller for storyboard-driven QA.advanced/GOVERNANCE.md — campaign-governance specialism.advanced/BRAND-RIGHTS.md — brand-rights specialism.advanced/IDEMPOTENCY.md — replay TTL / principal resolver tuning.advanced/STATE-MACHINE.md — pending_creatives → pending_start → active transitions.advanced/REFERENCE.md — full reference (everything above + edge cases + design rationale).Use when building an AdCP creative agent — an ad server, creative management platform, or any system that accepts, stores, transforms, and serves ad creatives.
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 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.
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 an AdCP signal-marketplace OR signal-owned decisioning platform — a data provider serving audience signals to buyers. Use when the user wants the typed `DecisioningPlatform` shape; for fork-an-adapter starting points, see `build-signals-agent`.