| 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. |
Build a sales agent
Implement 6 functions. The framework does the rest.
What you're building
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).
Imports cheat sheet
For 95% of sales agents, these are the only @adcp/sdk/server imports you need:
import {
createAdcpServerFromPlatform,
getAllAdcpMigrations,
serve,
buildProduct,
buildPackage,
buildPricingOption,
DEFAULT_REPORTING_CAPABILITIES,
PackageNotFoundError,
MediaBuyNotFoundError,
ProductNotFoundError,
BudgetTooLowError,
BackwardsTimeRangeError,
InvalidStateError,
RateLimitedError,
UnsupportedFeatureError,
type DecisioningPlatform,
type SalesPlatform,
} from '@adcp/sdk/server';
For other agent shapes:
- Creative agent (template / generative): swap
SalesPlatform for CreativeBuilderPlatform
- Signals agent: swap for
SignalsPlatform
- Brand-rights agent: swap for
BrandRightsPlatform
- Multi-tenant host: add
createTenantRegistry
The 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.
When you need...
- HITL flows (operator approval, async creative review) →
advanced/HITL.md
- Multi-tenant hosting →
advanced/MULTI-TENANT.md
- OAuth / OIDC authentication →
advanced/OAUTH.md
- Sandbox / test-mode routing →
advanced/SANDBOX.md
- Compliance test scenarios (
comply_test_controller) → advanced/COMPLIANCE.md
- Campaign governance specialism →
advanced/GOVERNANCE.md
- Brand rights specialism →
advanced/BRAND-RIGHTS.md
- Replay TTL / idempotency principal tuning →
advanced/IDEMPOTENCY.md
- State machine transitions (
pending_creatives → active) → advanced/STATE-MACHINE.md
- Postgres operations / sizing / indices / cleanup →
../../docs/guides/POSTGRES.md
- Edge cases / full reference →
advanced/REFERENCE.md
The 6 functions
import {
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,
formats: [{ format_id: 'display_300x250' }],
idempotency: { replay_ttl_seconds: 86400 },
};
accounts = {
resolution: 'derived' as const,
resolve: async () => ({ id: 'pub_main', operator: 'mypub', ctx_metadata: {} }),
upsert: async () => ({ ok: true, items: [] }),
list: async () => ({ items: [], nextCursor: null }),
};
sales: SalesPlatform = {
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,
pricing_options: [
{ pricing_option_id: `${p.id}-cpm`, model: 'cpm', floor: { amount: p.floor, currency: 'USD' } },
],
ctx_metadata: { gam: { ad_unit_ids: p.adUnitIds } },
})),
};
},
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);
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);
return {
media_buy_id: order.id,
status: 'pending_creatives',
ctx_metadata: { gam_order_id: order.gamOrderId },
packages: order.lineItems.map(li => ({
package_id: li.id,
status: 'pending_creatives',
buyer_ref: li.buyerRef,
ctx_metadata: { gam_line_item_id: li.gamLineItemId },
})),
};
},
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);
},
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;
},
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 },
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 },
})),
ctx_metadata: { gam_order_id: buy.gamOrderId },
})),
};
},
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).
Errors you throw — pick from the import list
import {
PackageNotFoundError,
MediaBuyNotFoundError,
ProductNotFoundError,
ProductUnavailableError,
CreativeNotFoundError,
CreativeRejectedError,
BudgetTooLowError,
BudgetExhaustedError,
IdempotencyConflictError,
InvalidRequestError,
InvalidStateError,
BackwardsTimeRangeError,
AuthMissingError,
AuthInvalidError,
PermissionDeniedError,
RateLimitedError,
UnsupportedFeatureError,
ComplianceUnsatisfiedError,
GovernanceDeniedError,
PolicyViolationError,
} 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.
Persisting platform state — ctx.ctxMetadata
Your 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.
import { createCtxMetadataStore, memoryCtxMetadataStore, pgCtxMetadataStore, getCtxMetadataMigration } from '@adcp/sdk/server';
await pool.query(getCtxMetadataMigration());
const ctxMetadata = createCtxMetadataStore({ backend: pgCtxMetadataStore(pool) });
await ctx.ctxMetadata?.set('product', productId, { gam: { ad_unit_ids: [...] } });
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.
Run it
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());
const platform = new MyPlatform(myAdServer);
const server = createAdcpServerFromPlatform(platform, {
name: 'My Sales Agent',
version: '1.0.0',
pool,
});
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.
Operator checklist
Things you set up once at deploy time:
See also
advanced/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).