# Brief Gateway — full text corpus > Turn a campaign brief into a structured object a seller agent can actually consume. Generated 2026-08-13. 8 documents. --- # Brief Completeness Rubric: What AdCP and AAMP Can Actually Read Source: https://briefgateway.com/guides/brief-completeness-rubric/ A brief completeness rubric scores a campaign brief on two axes: transactable, whether it carries every field a protocol refuses to book without, and expressive, how many of the receiving side's structured fields it fills rather than leaving to prose. Score a brief on two axes before you send it anywhere. Transactable tells you whether the receiving side can book from it. Expressive tells you how much of it arrives as structured fields instead of prose. Score each axis separately: a brief can be transactable and still fill almost none of the structured fields. Transactable is a pass or a fail, and there are two of them because there are two receivers. Score against the receiver you have. - Transactable (AAMP). The eight fields IAB Tech Lab's reference buyer agent refuses to construct a campaign without. AAMP is a reference pair of buyer and seller agents rather than a released standard, so its constraints are one vendor's defaults. - Transactable (AdCP). The three fields on createmediabuy that a brief author can supply: brand, starttime, endtime. That call requires five, but idempotencykey and account come from your session and your client, not from anything a planner writes. At discovery, AdCP requires buyingmode and nothing else. A buyer transacting only over AdCP never runs AAMP's Python validator, so a failure against it is not a failure against the wire. Expressive is a count out of thirty. AdCP 3.1.13's product-filters.json declares 30 filter fields. Denominator: all 30 properties, deprecated and ext included. The generator scores against the same 30. Label every value: stated, derived, assumed, missing Each of the twenty brief items in the crosswalk gets one of four labels, and the label is part of the artefact. - Stated. The buyer wrote it. Quote the span. "Budget: $500,000 net" produces totalbudget: 500000 with the sentence attached. - Derived. Unambiguously implied by something stated, with the derivation shown. "$500,000 net" plus a US market gives currency: USD. "November 1 - December 31, 2026" gives both flight dates. Write the derivation on one line. If it needs more than one line, label the value assumed. - Assumed. The tool picked a value, so it starts off and it renders differently from everything else. - Missing. Name the field, then name what happens without it. The labels are for your side, not the seller's. Neither protocol carries them: on AAMP the brief never leaves the buyer agent, and get-products-request.json has no provenance channel anywhere in it. The only thing that travels is what you type into the brief string, so "channel split: not specified" is a sentence a seller might read and might not. The distinction exists in exactly one place, the accept step in your own agent: an assumed value stays out of the payload until someone accepts it explicitly. Five checks against your last brief 1. Mark each of the twenty items in the crosswalk stated, derived, assumed or missing, strictly on derived. This is the input to both scores. 2. Count the eight AAMP-enforced fields, plus a budgetpct on every channel, plus brand, starttime and endtime for createmediabuy. All present is transactable. One gap is a brief that can't become a campaign record, and there's no partial credit. 3. Count how many of AdCP's 30 filters you can fill from stated and derived values. That is your expressive score. 4. Find every number in the brief that no planner ever said out loud. Channel splits and attribution windows are where they hide. Report that count next to both scores: an invented split raises transactable, an invented CTR floor raises expressive, and nobody signed either one. 5. Check your KPI against the enums. A planner on r/programmatic described a full-funnel brief whose KPIs were Cost-per-Engaged-Visit and Engaged Visit Rate; AdCP's 36-value metric list has engagements and engagementrate, and AAMP's seven-value list has neither. If your KPI is not in the enum, decide now what it degrades to, because otherwise the seller decides. A goal with no objective field may still have a metric field: incrementalsaleslift is one of the 36 in enums/available-metric.json. Run the five checks on this brief: Q4 holiday campaign for a CPG snack brand. Channels: CTV plus digital display and online video. Budget: $500,000 net. Market: United States. Target demo: adults 25-54. Flight: November 1 - December 31, 2026. Goal: drive incremental household penetration and retail sales lift. Requirements: premium CTV inventory, brand safety controls, frequency cap 3 per week. Transactable: no, against either receiver. The advertiser is "a CPG snack brand", so advertiserid is absent on the AAMP side and brand.domain is absent on the AdCP side. There is no campaign name. Three channels are named with no percentage on any of them, so ChannelAllocation can't construct and the sum-to-100 check never gets to run. Expressive: six of thirty. channels, budgetrange, pricingcurrencies, countries, startdate, enddate. Assumed: two, both AAMP defaults. EVEN pacing, and human approval on plan review and booking. Nothing in the brief itself is invented. Ten of the twenty rows come back missing: advertiser identity, agency, campaign name, channel budget split, audience segments, KPIs, brand safety, formats, seller preferences and deal type. The stated goal, incremental household penetration and retail sales lift, matches none of AAMP's four objectives, and AdCP has no objective field at discovery to fail against. Fill those gaps rather than marking them and the arithmetic still passes. A fabricated 60/25/15 split across CTV, online video and display clears AAMP's validator: it sums to 100 inside the 0.01 tolerance, no channel type repeats, budgetamount computes off each percentage, and the buyer agent writes a campaign record in DRAFT. The validator checks arithmetic only. A fabricated split that sums to 100 passes. A missing split does not. Where every part of a brief lands Twenty things a real brief contains, and where each one goes, plus a last row for everything with no home on either side. AdCP splits them across two calls and three places, getproducts at discovery, then createmediabuy and its nested packages[] at execution. AAMP holds them in one Python class inside the buyer agent, CampaignBrief, which never crosses the wire to a seller. A blank in the AdCP or AAMP column means the concept has no home on that side. | Brief item | AdCP | AAMP CampaignBrief | If you leave it out | | -------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | | Advertiser identity | brand.domain, required on createmediabuy | advertiserid, required | AdCP cannot resolve /.well-known/brand.json; AAMP raises before a campaign record exists | | Agency | | agencyid, optional | The buying agency is invisible to the seller on both sides | | Campaign name | no field; a buyernote was proposed and closed | campaignname, required | Nothing to reconcile against later | | Objective | packages[].optimizationgoals, at buy time, different vocabulary | objective, required, one of AWARENESS / CONSIDERATION / CONVERSION / REACH | The seller ranks on relevance alone | | Total budget | filters.budgetrange at discovery, packages[].budget at buy | totalbudget, must be 0 | AdCP returns products you can't afford | | Currency | filters.budgetrange.currency, required whenever you send a range; filters.pricingcurrencies | currency, ISO 4217, required | AdCP rejects the filter object | | Flight dates | filters.startdate / enddate, then starttime / endtime required on createmediabuy | flightstart / flightend, end must be after start | Availability is unchecked; sellers forecast against nothing | | Channels | filters.channels, 20 enum values | channels[].channel, 6 enum values | AAMP raises; AdCP returns the full catalogue | | Channel budget split | no request field; the seller answers with proposals[].allocations, across products, percentages must sum to 100 | channels[].budgetpct, across channels, must sum to 100 | AAMP validates a split you didn't choose; AdCP has no request field, so the seller proposes one | | Geography | filters.countries / regions / metros / postalareas / geoproximity, then targetingoverlay.geo | targetgeo[], geotype of COUNTRY / STATE / DMA / METRO / ZIP | National by default in AAMP; unfiltered in AdCP | | Target demographic | no filter field; prose or a signal reference | inside targetaudience, an AudiencePlan | The seller guesses, or ignores it | | Audience segments | filters.signaltargeting, targetingoverlay.audienceinclude / audienceexclude | targetaudience | AdCP returns a product set no signal narrowed; AAMP's audience plan is null and the pipeline reads that as no targeting | | KPIs | filters.requiredmetrics, 36 enum values; filters.requiredperformancestandards with metric, threshold and vendor | kpis[], 7 metric values, each with a targetvalue | No reporting contract is carried into the buy | | Frequency cap | not a filter; packages[].targetingoverlay.frequencycap, buy time only | frequencycap, max impressions per period in hours | Uncapped | | Pacing | packages[].pacing: even, asap, frontloaded | pacingmodel: EVEN, FRONTLOADED, BACKLOADED, CUSTOM | AAMP defaults to EVEN. BACKLOADED has no AdCP equivalent | | Brand safety | filters.requiredperformancestandards with metric: brandsafety and a named vendor; requiredpolicies | brandsafety.excludedcategories (IAB Content Taxonomy) and excludedkeywords | Seller policy applies, not yours | | Formats | filters.formatids, standardformatsonly, packages[].formatidstoprovide | channels[].formatprefs, free-text strings | Format mismatch surfaces at creative assignment | | Seller preferences | propertylist, requiredpolicies | preferredsellers[], excludedsellers[] | An excluded seller can win the buy; the blocklist never leaves your side | | Deal type | filters.deliverytype (guaranteed / nonguaranteed), filters.isfixedprice | dealpreferences.preferreddealtypes (PG / PD / PA), maxcpm, minimpressions | AAMP's QuoteRequest requires dealtype, so something must supply it | | Approval gates | checkgovernance, planid when the account has a governance agent | approvalconfig, four booleans | AAMP defaults to human approval on plan review and booking, none on creative or pacing | | Everything else | the brief string, unbounded | description and notes, free text | Nothing. The content was free text on both sides | The budget split is the row where the two stacks disagree about whose job it is. AAMP puts it in the buyer's brief and validates it there. AdCP puts it in the seller's answer: issue #843 reported a Swiss buyer's brief reading "50% desktop / 20% mobile / 30% in-app, language splits: 40% German / 60% French" with no way for the publisher to respond with a line-item plan, and 3.1 answers it with proposals[], whose allocations are across products and must also sum to 100. So write the split into your brief prose, then read it back off the proposal. Ask for briefalignment too, a 2,000-character field where the seller explains what it made of what you sent, and the only structured place a seller tells you how it read your brief. The target demographic has no dedicated field on either side. There is no age or gender filter in product-filters.json, and targeting.agerestriction is for legal compliance on alcohol and gambling, not for planning. "Adults 25-54" is prose in AdCP, and in AAMP it goes inside an open audience-plan object that the buyer agent passes through without a demographic schema of its own. Brief string or filters has the full list of requirements with no typed home. What AAMP won't book without models/campaignbrief.py enforces eight fields, and three cross-field checks on top of them. | Field | What the validator requires | | --------------- | -------------------------------------------------------------------------------------------------------------------- | | advertiserid | present | | campaignname | present | | objective | present; AWARENESS, CONSIDERATION, CONVERSION or REACH | | totalbudget | present, greater than 0 | | currency | present, ISO 4217 | | flightstart | present | | flightend | present, strictly after flightstart | | channels[] | non-empty; every entry carries its own budgetpct; the percentages sum to 100 within 0.01; no channel type repeats | Plan against those eight. The module docstring lists nine, adding targetaudience[], and the field declaration below it defaults to None, so a consumer that trusts the docstring will expect an audience the code never demands. Two further validators fire only when targetaudience is present: validatecontenttaxonomyversion raises ContentTaxonomyMigrationRequired on any contextual audience reference from before Content Taxonomy 3.0, and validatenoglobalagentic raises GlobalAgenticUnsupported on a GLOBAL-scoped agentic reference. With no audience neither runs, and the null plan is read downstream as no targeting. Everything past those eight is optional and defaulted. Omit pacingmodel and AAMP applies EVEN. Nothing re-checks the eight fields after that first validation. The booking flow holds the brief as campaignbrief: dict[str, Any] on BookingState, an untyped dictionary in a second module carrying its own incompatible CampaignBrief. What getproducts requires Send a getproducts request and the only thing that has to be in it is buyingmode. That is the whole required array in get-products-request.json. The brief property is {"type": "string"} with no minimum length and no structure; its description says it is required when buyingmode is brief and must not be provided otherwise, and the schema enforces neither of those sentences. additionalProperties is true on both the request and the filter object, so misspell a filter name and your request still validates, with no seller obliged to notice. The brief-versus-filters placement rules cover the rest, including filter-not-fail: a threshold that lands in requiredmetrics or requiredperformancestandards deletes products instead of flagging them, with no explanation of which constraint emptied the array. No conformance test covers whether a seller read your brief. Issue #2902 asks AdCP's compliance storyboards to start testing that filters actually filter, because today "an endpoint can accept startdate/enddate, return a correctly-shaped response, pass all compliance checks — and silently return the same data regardless of what dates were passed". So read the returned products back against the constraints you sent, and keep the mismatches for your next call with that seller. Probe more than one seller before you conclude anything: three registered sales agents return products to an anonymous getproducts call, Cora AI, Equativ and No Fluff Advisory, and the registry, measured has the endpoint-by-endpoint result. Issue #5278 lays out the choice when no structured field exists: know the seller's format IDs in advance, which breaks cross-seller discovery; accept whatever the seller maps you to; or use the brief string, which its author calls "non-deterministic across sellers". Issue #5754 found a brief line reading Budget Type: Affiliate / Commission: 4% with no AdCP pricing option to map to, handled out of band instead. On the AAMP side the brief does not travel at all. ProductAvailsSearch requires productids, accountid, advertiserbrandid and both dates; QuoteRequest requires idempotencykey, productid and dealtype. Both start from products the buyer already chose. The seller agent's only reference to a brief anywhere in its source is a comment in models/ucp.py marking three fields on the seller's audience plan as "requirements from campaign brief": targetdemographics, an untyped dict[str, Any], and targetinterests and targetbehaviors, both bare list[str]. That comment is the whole of the seller's brief interface, a note above the fields where brief-derived targeting is expected to arrive, with no schema saying what it looks like. Where to go next - Paste a brief into the generator and read the two scores off the same twenty rows, with every value labelled. - Brief string or filters covers which requirements belong in filters and which belong in the prose. - The two brief formats, field by field is where the second, incompatible CampaignBrief in the same repository is laid out. --- # Preflight Checklist: What to Agree Before You Turn On a Sales Agent Source: https://briefgateway.com/guides/brief-preflight-checklist/ Run this preflight before a sales agent quotes for you: settle floors, minimum deal size, discount authority, sign-off thresholds, and which agents may sell your inventory. Every answer must be a number or a name with an owner. Settle these eleven questions before a sales agent quotes on your behalf, and write the answers down. Every row here is something a buyer's getproducts call will ask your agent about, so this is the sell-side mirror of composing a brief. Copy the table below into a sheet and add two columns: the name of the person who owns each answer, and the date it gets looked at again. Every answer has to be a number or a name, because an agent cannot act on "we don't discount much". Who has to be in the room Three roles have to be present: - Whoever sets rates. - Whoever runs the ad server. - Legal. Legal signs the lawful basis on each published signal and nobody else can. Name a tie-breaker per row before you start, usually whoever carries the revenue number. When sales and yield hold different floors and neither moves, take the lower number, mark it provisional, put a date 30 days out against it, and move on. TeqBlaze, announcing free AdCP sales agent testing on r/programmatic: "So far the automation part works, but the challenge is getting teams to agree on the rules before the system runs. If goals or limits are vague, the agent simply scales that ambiguity faster." The eleven decisions | Decide | A settled answer looks like | Where it lands | | ------------------------------------------ | ---------------------------------------------------------------- | ------------------------------------------------------------------- | | The floor, per product, per currency | "18.00 USD on CTV guaranteed, 6.50 USD on display RoS" | floorprice or fixedprice on each pricing option | | Minimum deal size | "No package below 25,000 USD" | minspendperpackage, also on each pricing option | | Discount authority | "Down to 12% off rate card without a human; below that it stops" | Vendor config; AdCP carries the result in pricebreakdown | | Advertiser and category exclusions | A named list | Your ad server, not the protocol | | What exclusivity is worth | "Category lock adds 35%, sole sponsorship doubles it" | exclusivity: none, category or exclusive | | Which products exist | Named packages with inventory attached | product.json entries: id, name, description, properties, pricing | | Whether a buyer can take a package apart | "CTV bundle is all-or-nothing; display is subsettable" | propertytargetingallowed, collectiontargetingallowed | | Which first-party signals are published | A list, each with a lawful basis and a country list | signals[] in /.well-known/adagents.json | | What you commit to on delivery and billing | "70% viewable to MRC, IVT under 2%, DV counts" | performancestandards, measurementterms, cancellationpolicy | | Who signs off, above what number | A value threshold with a named approver against it | Approval gates in your seller agent | | Which agents may sell your inventory | A named list | authorizedagents in /.well-known/adagents.json | AdCP's adagents.json comes in two forms: a pointer file carrying authoritativelocation, or the inline form, which requires exactly one property, authorizedagents. That last row is the only thing the file format insists on. What applies if you skip the meeting Until you replace them, these defaults are your commercial policy. Four are AdCP schema defaults. Six come from the IAB reference seller agent's settings.py, and a commercial vendor may ship entirely different numbers. | The decision you did not make | What applies instead | Where it comes from | | ------------------------------------ | ------------------------------------------------------- | ----------------------------------------------------------------------------- | | Exclusivity | Every product is non-exclusive | AdCP product schema: exclusivity "defaults to 'none' when absent" | | Buyers subsetting a package | Not allowed; all-or-nothing | AdCP product schema, propertytargetingallowed, default false | | Buyers selecting signals | Not allowed; signals are bundled into the product terms | AdCP product schema, signaltargetingallowed, default false | | Delivery commitments | None; no structured performance standards apply | AdCP product schema, performancestandards absent | | Floor CPM | 5.00 in the default currency | IAB seller agent settings.py | | Minimum deal value | 1,000 in the default currency | IAB seller agent settings.py | | Preferred-deal discount ceiling | 15% | IAB seller agent settings.py | | Human approval on a proposal | Off | IAB seller agent settings.py, approvalgateenabled: bool = False | | Approval above a deal value | No threshold | IAB seller agent settings.py, approvalrequiredabovevalue: float = 0.0 | | A buyer agent listed in the registry | Approved automatically | IAB seller agent settings.py, autoapproveregisteredagents: bool = True | Floors, minimums and discount authority Assume every deal clears at the floor. Set it at your walk-away price. Set it per product and per currency. Two currencies on the same product means two pricing options, each carrying its own floor and its own minimum, so decide both twice rather than converting one number at the last minute. Minimum deal size stops the agent booking a 400 USD guaranteed order that costs more to traffic than it earns. Discount authority is how far below rate card the agent may go on its own. Say which one it does at the edge of that range: "it stops and asks" holds the deal open until someone reads the queue, and "it declines" ends it. Exclusivity and package structure A product in AdCP is a commercial package: an id, a name, a description written for a machine to match a brief against, the properties it covers, and its pricing options. Which packages exist, and what each one includes, is a decision the agent inherits from you. Two decisions per package. Can a buyer buy part of it, or is the bundle the product? And what does exclusivity cost, at each of the two levels above none? Price both levels before a buyer asks for one, because category lockout commits future revenue. An ad ops practitioner on r/adops, on a fully agent-negotiated sell-side deal: "The 2-minute deal automated trafficking and IO paperwork. That was never the job with a lot of value. Someone still needs to decide what packages exist, what the exclusivity is worth, and which first-party data is exposed to agents. Agents execute deals. They don't decide which deals deserve to exist." Advertiser and category exclusions AdCP has no advertiser blocklist. A product's creativepolicy requires exactly three things, a co-branding requirement, a landing page requirement, and whether templates are available, and none of the optional fields it also carries is a list of advertisers either. enforcedpolicies holds registry policy IDs the seller enforces, which is a different mechanism with a different scope. So your list stays in the ad server or the order management system, and somebody has to own keeping it aligned with what the agent is out there offering. Name that person in the meeting. Published signals Publishing a signal means declaring, on the record, where the data came from, the lawful basis you process it under, and which countries it may be used in. The schema is explicit that a seller must not expose a signal for media buys in countries outside the list declared on it. (datasources, consentbasis, restrictedattributes and countries are four of the 31 fields a signal definition carries.) Decide two things: which signals are published at all, and who signs the lawful basis on each. Both answers belong to legal rather than to yield. Delivery, measurement and cancellation Viewability, IVT and completion thresholds sit in performancestandards. Who counts the billing metric, and what remedy applies when a threshold is breached, sit in measurementterms. Settle whose number wins in advance. Two verification vendors measuring the same campaign will not report the same figure, and measurementterms is where you say which one bills. AdCP's cancellationpolicy requires exactly two things, a notice period and a cancellation fee, and a buyer accepts both simply by booking against the product. Whoever signs off on revenue recognition owns that row. Approval gates and timeouts Write down the value above which a proposal needs a person, and name that person. Name a deputy for each approver. Your counterparty has gates of its own. In the IAB buyer agent, ApprovalConfig gates plan review and booking by default and leaves creative and pacing adjustments ungated, which tells you how much of the other side of your deal has nobody watching it. An approval request in the seller agent expires 24 hours after it is raised, and once expired it can't be decided at all: the code marks it TIMEDOUT and rejects the decision. Nothing emits an expiry event. The status flips the next time someone tries to decide the request or lists the pending queue, so an expired approval is found by polling. Name who reads the pending queue outside working hours. Check that it landed Two checks, neither of which needs more than an engineer and a terminal: - Fetch your own /.well-known/adagents.json and read it. The authorised agent list and the published signals should match the sheet, name for name, with no extra rows. - Run one getproducts call against your own agent and read the response. Floor, minimum spend, exclusivity and the two targeting flags should come back as decided. Run both again after any vendor change. Where to go next - See what a getproducts request contains — the buy-side mirror of this exercise, and the object a buyer's agent sends you. - What belongs in a brief and what belongs in filters — how buyers are told to split the same decisions. - Publisher discovery files — the adagents.json fields the last row of the table lands in. --- # AdCP Brief Generator: Paste Prose, Get a get_products Request Source: https://briefgateway.com/ Paste a campaign brief and Brief Gateway returns the AdCP get_products request and the AAMP CampaignBrief it produces, labelling each of 64 campaign concepts stated, derived, assumed or missing. Nothing leaves the browser. Paste your brief. You get back the AdCP getproducts request and the AAMP CampaignBrief it produces, with the words that produced each value quoted beside it, which is the parse a seller agent performs against a published schema. Score a brief. The box arrives holding a worked example, so there is nothing to type. Nothing leaves the browser. There is no network call and no account. The same brief and the same reference date always produce byte-identical JSON. Relative expressions like "next quarter" resolve against the browser's today, with no override, so absolute dates are the reproducible option; the result prints the date it used. Use it 1. Open the generator, paste your brief over the worked example in the box, and press Score this brief or Ctrl/Cmd + Enter from inside the box. Line breaks count as sentence breaks, so a bulleted brief reads the same as a paragraph. 2. Read the two scores: transactable (can a counterparty book this?) and expressive (how many AdCP filters hold a value?). Then read the field table. All 64 concepts get a row, including the ones your brief says nothing about; a toggle hides the empty rows. 3. Work the assumptions list. Each candidate has its own checkbox and prints the rule behind it. The worked example below produces one candidate, currency. 4. Copy either payload with the button above it. The worked example, scored The generator is loaded with this paragraph: The AdCP getproducts request that comes out: And the AAMP CampaignBrief, with description holding the same paragraph: That request is the body of a getproducts call to a sales agent. The getproducts operation has the request and the response it comes back with. Nothing here sends it for you. That brief scores 5 of 10 transactable and 4 of 30 expressive. Separately, of the 64 concepts in the field table, 4 are stated, 5 derived and 55 missing. The five transactable checks it fails are advertiser, campaign name, objective, currency and the per-channel budget split, each printed with the reason. The four filters it populates are startdate, enddate, countries and channels. A populated filter is a constraint and every one of them deletes products, so the expressive score is coverage: brief vs filters has the routing rule. Online video reaches AdCP as channels: ["olv"] and reaches AAMP as nothing. AAMP has six channel values, CTV, DISPLAY, AUDIO, NATIVE, DOOH and LINEARTV, and 13 AdCP channels have no AAMP equivalent. "adults 25-54" is stated and lands in neither payload, because filters.signaltargeting takes a SignalRef resolved against a signals agent rather than a demographic, and the phrase resolves to no IAB Audience Taxonomy 1.1 identifier for the AAMP side. AAMP takes maximpressions and periodhours. getproducts has no frequency field at all, so "3 per week" travels to a seller only as prose inside brief. AdCP does define a frequency cap scope, but it applies to a media buy's packages, so it cannot influence which products come back. What you get back | You get | What it is | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | Every field, labelled | 64 concepts, the union of what the two protocols can express about a campaign, each one stated, derived, assumed or missing. | | A ranked list of what a seller will guess or drop | Every gap in the brief, ranked blocks, then drops, then degrades. | | What each payload left out | The values that reached one protocol and not the other, with one sentence on what that costs. | Four rows of the field table, from the worked example: | Concept | Label | What the row carries | | --------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | budgettotal | stated | 500000, quoted from "$500,000". | | countries | derived | ["US"], with the rule: "'United States' - countries: ['US'] (ISO 3166-1 alpha-2 name match)." | | currency | assumed | "USD", offered with the reason and out of both payloads until you tick it. | | advertiserid | missing | Nothing to read and nothing to derive, plus the consequence: the AAMP brief will not construct, so nothing downstream of the buyer agent runs. | The ranking has three levels: | Rank | What it means | | ---------- | --------------------------------------------------------------------------------- | | blocks | Nothing constructs without it. | | drops | You wrote it, and neither protocol has anywhere to put it. | | degrades | The field exists, your brief is silent, and the seller fills it however it likes. | The worked example produces 56 ranked gaps: 5 blocks, 1 drops, 50 degrades. The first three, and the one drops: | Rank | Field | Level | Consequence printed beside it | | ---- | ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------- | | 1 | advertiserid | blocks | The AAMP brief will not construct, so nothing downstream of the buyer agent runs. | | 2 | campaignname | blocks | On the AdCP side the campaign has no human-readable handle in any artefact the seller sees. | | 3 | objective | blocks | No downstream allocation logic knows whether to optimise for reach or for conversion. | | 6 | audienceprimary | drops | The buy runs untargeted: AdCP returns run-of-network products, and AAMP treats a null plan as "no audience targeting". | The 64 concepts are the union, so the count includes concepts only one protocol can express: frequency cap, pacing and approval gates have no AdCP discovery-time home. The completeness rubric has the concept-by-concept map. The AdCP side is built against release 3.1.13. The AAMP side targets the CampaignBrief in the IAB Tech Lab buyer agent's src/adbuyer/models/campaignbrief.py, the typed and validated one; that repository defines a second, incompatible CampaignBrief in models/flowstate.py with no adapter between them, so the output names the model it emitted. The four labels | Label | What it means | Reaches the payloads | | --------- | ------------------------------------------------------------------------- | ------------------------- | | stated | You wrote the value. The words that produced it are quoted beside it. | Yes, where a field exists | | derived | Your words imply the value. The rule that fired is printed beside it. | Yes | | assumed | The engine picked a default and names where the default came from. | Only after you accept it | | missing | Nothing to read and nothing to derive, plus what the seller does instead. | No | A stated field can still miss both payloads, because neither protocol has a field to carry it. The two scores A score tells you which of your sentences cannot reach a protocol field. Transactable, out of ten. Nine of the ten checks are the values a counterparty has to be given before a campaign can be booked, whichever protocol carries it, and AAMP's CampaignBrief is where they are written down: it will not construct without them. Eight are required fields on the model itself: advertiser, campaign name, objective, total budget, currency, flight start, flight end, and at least one channel. The ninth is budgetpct, which is required on every ChannelAllocation and validated to sum to 100. If you are buying AdCP-only, read the tenth check on its own and treat the other nine as the decisions still open. The tenth is an AdCP request check: buyingmode present and inside its enum, brief present in brief mode and absent in wholesale and refine, ifpricingversion only alongside ifwholesalefeedversion, the budgetrange object contract, the minItems: 1 arrays, the ISO 4217, ISO 3166-1, ISO 3166-2 and date patterns, and the required keys of performance-standard. That tenth check is a subset of get-products-request.json, not the schema itself. Full JSON Schema validation does not run in your browser, so a payload that clears it here can still fail a seller's validator on a rule outside the subset. All ten count what reached the payloads, not what your sentences promised, because a value can be read correctly, labelled stated, and still have no field to travel in. Expressive, out of thirty. core/product-filters.json carries 30 properties at AdCP 3.1.13: 29 filters, one of them (requiredaxeintegrations) deprecated in favour of trustedmatch, plus ext for vendor keys. The score is how many hold a value. The rest arrive at the seller empty and filter nothing. Two of the 30 are not fields you would ordinarily fill, the deprecated one and ext, so treat 28 as the working ceiling. Every filter you populate is a hard constraint that deletes products before the seller ranks anything. It will not invent a value The $500,000 in the worked brief does not reach the AdCP request. filters.budgetrange requires an ISO 4217 currency, and $is the sign for the US, Canadian, Australian, New Zealand, Singapore, Hong Kong and Mexican dollars among others, socurrencycomes backmissingand the wholebudgetrangeobject is omitted. USD sits in a separate list with the reason attached. Tick it and both payloads are emitted again,budgetrangecarrying{"currency": "USD", "max": 500000}and labelledassumed everywhere it appears, and transactable moves to 6 of 10. AAMP makes the gap unavoidable: budgetpct is required on every channel allocation and the values must sum to 100, so a brief that never stated a split cannot transact until a person decides what it is. Write "60/25/15" with three channels and the engine pairs them positionally, says it has done so, and offers the result; the split stays assumed until you tick it. Writecream took the same paragraph and returned a 60/25/15 channel split, a 14-day view-through window for display and 28 days for CTV, a click-through floor and a completion-rate target, none of it in the input. The completeness rubric has what the other generators returned. A month named in an exclusion, as in "October is excluded", is still read as an October flight window. It is labelled derived with the rule printed beside the dates, so you see it before you send. You cannot verify what the seller did Nothing in an AdCP response reports what a seller did with your prose. get-products-request.json sets additionalProperties: true, so a misspelled fliters key is accepted as an extension and ignored. A brief sent in wholesale mode passes validation while breaking the rule its own field description states, because that rule was never written as a conditional. The one field that reports narrowing, filterdiagnostics.excludedby, is optional, counts-only, and keyed by the names of your filters. Three sales agents in the public AdCP registry answer an anonymous getproducts, and the registry sweep has what they returned, including a four-product catalogue that answered a US-only brief with Korean CTV. Whether a better brief wins better inventory is untested. Where to go next - What a protocol-conformant brief actually is: the brief is one string typed {"type": "string"} and gated by buyingmode. Three payloads there pass a schema validator without being conformant, and the official SDK is looser than the schema it was generated from. - The brief completeness rubric: the field map the scores implement, concept by concept, including the cells left blank because no mapping exists. - The pre-flight checklist: the decisions to settle with the people who have to agree, before any of this is a JSON problem. --- # AdCP Brief vs AAMP CampaignBrief: Two Objects, One Name Source: https://briefgateway.com/compare/adcp-brief-vs-aamp-campaign-brief/ AdCP's brief is a natural-language string on the get_products request. AAMP's CampaignBrief is a Pydantic class with five declarations across two repositories, four of them in IAB Tech Lab's buyer-agent, and it is absent from the AAMP wire contract. Only one of these two briefs is on a wire AdCP's brief is a string on a released schema. AAMP's CampaignBrief is a Python class inside a reference implementation, with five declarations across two repositories, four of them in the buyer agent, field names that disagree, and no presence in the wire contract those same repositories publish. AdCP's brief is the smaller object and the only one with a released schema behind it. Put your own campaign object on the AdCP side of that line and emit whatever AAMP shape you need out of it. AdCP is the Ad Context Protocol, and it ships as versioned JSON Schema releases; 3.1.13 is the current stable one. AAMP is IAB Tech Lab's Agentic Advertising Management Protocols: eight repositories, versioned independently, no protocol-wide number anywhere, and a shared wire contract that is unreleased. One property, on one operation. getproducts carries brief as a string, and the schema tells you exactly how much structure to expect: "Natural language description of campaign requirements. Required when buyingmode is 'brief'. Must not be provided when buyingmode is 'wholesale' or 'refine'." A seller agent reads the prose and decides what it means. Both halves of that coupling live in the description text and nothing validates either one, so a brief sent in wholesale mode passes any validator you point at it and arrives at a seller that was told not to expect it. Check the pairing in your own code, because nothing downstream does. CampaignBrief is a Pydantic class with eight enforced required fields in IAB Tech Lab's buyer agent, an objective enum, cross-field validators, a typed brand-safety block and four human approval gates. As a model of what a planner hands over it is the better object, and AdCP has nothing close to it. No brief schema exists in the normative spec. iab-agentic-primitives names the artifact that ought to sit above the code, in its own README: "The spec is the normative artifact; this package is its reference implementation." Its spec/ directory holds 48 JSON Schemas and an OpenAPI document, and none of them is a brief. The buyer agent's only upstream pointer is to a "Campaign Automation Strategic Plan, Sections 6.1 and 7.1" that appears in none of the eight public repositories. So whichever CampaignBrief you build against is an implementation detail with no versioning promise attached, and this one has no version to bump. The integration cost is inside one Python package: five incompatible declarations of one class name. Which one to build against: AdCP, with AAMP's model vendored internally AdCP, and take the non-determinism with it knowing that you are taking it. The same brief string sent to two seller agents comes back as two different product sets, and AdCP promises nothing about that: 3.1.13 released the field, not the interpretation. What makes the trade worth it is where the determinism sits. The parts you can hold a counterparty to travel in filters as typed properties: a 20-value channel enum, a 36-value metric enum, a budget range with a required currency. Diff those against what came back. The prose carries the residue, the part no enum was ever going to hold. AAMP moves that problem instead of solving it, because the matching runs inside your own process where no counterparty can see it, and the only brief-derived field on its wire is an open object. The worked CTV payload is a real request you can send today. Two conditions still make AAMP the right call: a counterparty you need has picked it, or what you want is a planning object and not a wire contract. Since CampaignBrief never touches the wire, vendoring it as your internal planning model costs you nothing on the AdCP side. If you do vendor it, pin the commit, write down which of the five definitions you mean, and own the drift, because the wire contract is unreleased and nobody is versioning the model for you. AAMP Explorer tracks the buyer agent and the wire contract commit by commit if you want to watch it move. Supporting both is two integrations. They share no type and no endpoint, and neither corpus references the other. The generator will emit both objects from a paragraph of prose, with every field marked as stated in your text, derived from it, or assumed, and the front page writes out the seven rules the official Python SDK leaves you to check by hand. AAMP deleted server-side product search on purpose AdCP sends an 18-property request. AAMP sends limit and offset. ProductListRequest in iab-agentic-primitives has exactly two properties, and its description states the decision instead of hiding it: "Deliberately carries no filter fields: filtering is client-side over the returned Product records" The OpenAPI document repeats it in a header comment: "There is deliberately NO POST /products/search (plan §7 amendment 3): the buyer filters client-side over the ProductListResponse fields." POST /products/avails is not a way around this. It requires productids, accountid, advertiserbrandid, startdate and enddate. You name the products first. QuoteRequest requires idempotencykey, productid and dealtype before it will look at flightstart, impressions or targetcpm. And the single piece of brief-derived structure that does cross the AAMP wire is audienceplan on QuoteRequest, typed as anyOf[{object, additionalProperties: true}, null] with the description "Audience plan slot (open object; the typed model lands with the audience-plan bead)", and it attaches after product selection. The seller side confirms it. No file in the AAMP seller agent names CampaignBrief. The phrase "campaign brief" survives there only as a comment inside AudiencePlan, above its targeting fields: "Requirements from campaign brief." | | AdCP | AAMP | | ------------------------------ | ---------------------------------------------------- | --------------------------------------------------- | | Who filters inventory | The seller, from a prose brief and 30 typed filters | The buyer, client-side, over a paginated catalog | | Server-side product search | getproducts, the protocol's first operation | Deliberately absent | | Discovery request shape | 18 top-level properties | limit, offset | | Where campaign intent enters | Discovery | Quote, per product, after selection | | Brief-derived data on the wire | The brief string, filters, requiredpolicies[] | An untyped audienceplan object on QuoteRequest | AdCP runs curation on the seller. AAMP leaves it to the client. limit defaults to 50 and caps at 500, so filtering client-side means paging the whole catalog, holding it somewhere, deciding how stale you will let it get, and writing the matching logic that AdCP's seller runs on its own inventory. Nothing in the eight repositories says how large that catalog gets or how often it changes. On AdCP's side the same choice sets what belongs in the brief string versus the typed filters. On the sell side the question inverts. AdCP puts the brief on your doorstep: getproducts arrives carrying prose, 30 typed filters and requiredpolicies[], and turning all of that into a product list against your own inventory is your implementation, the harder build and also the surface you compete on. AAMP asks for nothing of the kind. The seller agent's GET /products takes limit and offset, the string "brief" doesn't appear anywhere in its 74-path OpenAPI document, and quoting happens per product after the buyer has already chosen. Serving a paginated catalog is a smaller job than reading intent. Where each object lives | | AdCP brief | AAMP CampaignBrief | | ------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | What it is | A string property | A Pydantic BaseModel class | | Where it is defined | dist/schemas/3.1.13/media-buy/get-products-request.json | buyer-agent/src/adbuyer/models/campaignbrief.py | | Artifact type | Published JSON Schema, released version | Python source in a reference implementation | | On the wire? | Yes. It is a request field. | No. No CampaignBrief schema exists in the AAMP wire contract. | | Required fields | None inside it. The parent request requires only buyingmode. | Eight, enforced: advertiserid, campaignname, objective, totalbudget, currency, flightstart, flightend, channels[]. A ninth value, budgetpct, is required on every ChannelAllocation by a validator rather than by the brief. | | Objective | Free text inside the string. The 5-value enum lives on a different object, core/creative-brief.json. | CampaignObjective enum: AWARENESS, CONSIDERATION, CONVERSION, REACH | | Budget | Sibling field filters.budgetrange {min, max, currency}, currency required inside it | totalbudget: float plus currency as ISO 4217, validated by pattern | | Channels | Sibling field filters.channels, a 20-value enum | list[ChannelAllocation], each {channel, budgetpct, formatprefs[]}, budgetpct values must sum to 100 | | Channel vocabulary | 20 values including retailmedia, dooh, sponsoredintelligence | 6 values: CTV, DISPLAY, AUDIO, NATIVE, DOOH, LINEARTV | | Flight | Sibling fields filters.startdate / enddate | flightstart / flightend as date, with a validator that flightend flightstart | | KPIs | Sibling field filters.requiredmetrics, a 36-value capability filter | list[KPI] of {metric, targetvalue}, metric from a 7-value enum | | Brand safety | requiredpolicies[] on the request: registry policy IDs, resolving to prose a governance agent judges | BrandSafety object: excludedcategories[] as IAB Content Taxonomy 3.0 IDs, excludedkeywords[] as strings | | Frequency cap | Buy time only, packages[].targetingoverlay.frequencycap | In the brief: FrequencyCap {maximpressions, periodhours} | | Pacing | Buy time only, packages[].pacing | In the brief: PacingModel enum | | Human approval | Not modelled. Seller-side HITL only. | ApprovalConfig with four gates: planreview, booking, creative, pacingadjustment | | Version | AdCP 3.1.13, published stable | No AAMP-wide version. buyer-agent is at v2.3.0-24-g0c3a154. | The brand-safety row holds two different mechanisms. AAMP's BrandSafety is category IDs and keyword strings: machine-checkable, and only as complete as the list. AdCP hands over registry policy IDs, and a governance agent reads the policy text and judges, which is the only one of the two routes that can carry a rule like "nothing adjacent to conflict reporting." Buyer-side approval is the row with an operational cost behind it. AdCP doesn't model it, so plan sign-off, booking sign-off and creative sign-off are workflow you build, run and audit outside the protocol. AAMP names four gates in ApprovalConfig and defaults two of them on, planreview and booking, on the model that only the demo script and the test suite ever construct. The approval pipeline imports the ApprovalConfig type and then reads its configuration off a stored campaign record, not off a typed brief. The one pipeline module that does annotate brief: CampaignBrief is the audience planner, and the only function reaching it says in its own docstring that it is the path "used by tests and demos that don't go through the canonical flow (DealBookingFlow)". Neither protocol exposes your sign-off chain to the counterparty. Budget for it as headcount. Both protocols name frequency cap and pacing. AAMP takes them in the brief; AdCP only at buy time, on packages. If your planning system decides pacing, you carry that decision yourself from brief to buy, because AdCP's discovery call has nowhere to put it. Five objects, one name | Definition | Where | Kind | What anchors it | Does the shipped campaignbrief.json validate? | | ----------------- | --------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------ | | Pipeline model | models/campaignbrief.py | Pydantic BaseModel, 8 required | campaignname, totalbudget, flightstart, flightend, channels[ChannelAllocation] | No. | | Flow-state model | models/flowstate.py | Pydantic BaseModel, camelCase aliases | name, budget, startdate, enddate, channels[str] | Yes | | HTTP API model | interfaces/api/main.py | Pydantic BaseModel, the published one | name, objectives[], budget, startdate, enddate, targetaudience{} | Yes | | Demo dataclass | examples/buyerdemo.py | dataclass, docstring "Parsed campaign brief from PDF" | its own key set | Not JSON-fed | | Harness dataclass | iab-agentic-primitives/.../harness/roles.py | frozen dataclass, 15 fields | campaignname, dealtype, impressions, maxcpm, budget as Money | Different repo, not JSON-fed | The five share no field name. The sharpest collision is the pair sitting in one package: models/campaignbrief.py and models/flowstate.py both declare a class called CampaignBrief, and they agree on almost nothing — campaignname against name, totalbudget against budget, flightstart / flightend against startdate / enddate, objective as an enum against objectives as a list of bare strings, channels as typed allocations against channels as a list of strings. No adapter converts between them, and models/init.py re-exports only the first, so which class you get depends on which import path you typed. Only the harness dataclass documents its own scope, in its docstring: "Not a wire primitive: it is the buyer agent's internal objective." Both campaignbrief.json files the repository ships fail the model that defines them, and they fail it identically: 12 validation errors each. Seven required fields absent, all three channels entries as bare strings where the model wants ChannelAllocation objects, targetaudience missing its primary key, kpis a dict where the model wants a list. The shipped channels: ["branding", "ctv", "performance"] goes up against a six-value enum containing neither branding nor performance. A third file, data/campaignbriefs.json, carries three more demo briefs on a fourth key set, with flightdates as the string "2026-09-01 to 2026-12-31", and those validate against none of the buyer agent's three JSON-fed models. None of it stops anything, because the model that validates and the model that runs are different objects. ad-buyer init writes that failing template, hard-coded in the CLI, then prints: "Edit this file with your campaign details, then run: ad-buyer book campaignbrief.json" And book never calls the parser. It loads the JSON, reads name, budget and startdate straight off the dict, and hands the raw dict to DealBookingFlow, which keeps treating it as a dict all the way down. The documentation describes a third shape again: the CampaignBrief field table under docs/ is the API model, and the string campaignname never appears as a brief field anywhere in the documentation. The module docstring of campaignbrief.py adds a discrepancy of its own, listing a ninth required field, targetaudience, which the class defines with a default of None. No code path in the repository connects the two. The strictest of the five, the one carrying the enums and the approval gates, is documented nowhere and reached from a single test-and-demo path. The documented shape and the annotated shape are different classes, and neither route leads to the strict one. There is exactly one published CampaignBrief schema and it is the loosest one buyer-agent/docs/api/openapi.json carries components.schemas.CampaignBrief, info.version 1.0.0, contact "IAB Tech Lab", description "Campaign brief for booking." The committed document is served live from IAB Tech Lab's GitHub Pages site. It documents the buyer agent's own inbound HTTP API, not anything that crosses the buyer-to-seller wire, and it is not the Pydantic class in campaignbrief.py. If you searched for a campaign brief JSON Schema and found nothing, this is what you were looking for. Six required fields: name, objectives, budget, startdate, enddate, targetaudience. Two optional: kpis, channels. It carries no objective enum at all — objectives is an array of bare strings with minItems: 1 — no channel allocation, no sum-to-100 rule, no approval gates, no brand safety. targetaudience is {"type": "object", "additionalProperties": true}, an open bag, and it is required, where the pipeline model makes it optional and typed. So the only fetchable CampaignBrief schema is the loosest of the five, and it describes what you POST to a buyer agent you are running yourself. Nothing travelling the other way carries a brief at all. AdCP has a structured brief too, and it's for creative dist/schemas/3.1.13/core/creative-brief.json ships inside the release and you can validate a payload against it today. It cannot reach discovery. Title "Creative Brief", eight properties, only name required, objective a five-value enum, tone and audience and territory as prose, messaging holding headline, tagline, cta and an ordered keymessages[], plus a compliance block of requireddisclosures[] and prohibitedclaims[] that AAMP's brand-safety object has no equivalent for. Its only route to the wire is core/assets/brief-asset.json, {"assettype": {"const": "brief"}} plus an allOf on the creative brief, wrapped in a creative manifest. The manifest is referenced by buildcreative, previewcreative, getcreativefeatures and validateinput, and by neither get-products-request nor create-media-buy-request. Discovery still gets a string. The release carries one more brief string, on listtransformers, ranking creative transformers by intent: prose again, creative side again. AdCP and AAMP share three objective values and diverge on three. | AdCP core/creative-brief.json | AAMP CampaignObjective | | ------------------------------- | ------------------------ | | awareness | AWARENESS | | consideration | CONSIDERATION | | conversion | CONVERSION | | retention | no counterpart | | engagement | no counterpart | | no counterpart | REACH | AAMP kept REACH; AdCP kept retention and engagement. The creative-brief crosswalk maps the agency-brief rows onto AdCP media-buy fields, and that mapping is where the loss happens. --- # Brief String or Filters: What Goes Where in AdCP get_products Source: https://briefgateway.com/spec/brief-vs-filters/ In an AdCP get_products call, filters are hard constraints that silently delete every product missing them, and the brief string is intent a seller can only rank against and explain. Hard requirements go in filters, negotiable preferences in the brief. Route every line of a campaign request to one of two places: the typed filters object, or the brief string. A preference encoded as a filter deletes inventory you would have taken. A hard constraint left in prose is one the seller is free to ignore. A filter deletes products. The brief string cannot. Ask of each requirement: should a product that misses this disappear, or show up and let you decide? The generator applies the split for you and labels each extracted value stated, derived, assumed or missing. Use the tables below to check a call you have already written. buyingmode decides whether you get to ask the question at all. It is the one required property on the request, and it takes three values: - brief. The brief string belongs here and nowhere else. The seller reads it, ranks against it, and may answer with proposals. - wholesale. The description says the brief must not be provided. The seller returns no proposals and the answer is synchronous. - refine. The description says the brief must not be provided here either, which neither brief document mentions. Rules to encode in your buyer agent The specification does not settle the split, so encode these. 1. The typed field wins. Dates, budget, currency, countries, channels, formats, required metrics and exclusivity go in the typed field. 2. Restate a value in the brief only for a reason the value itself does not carry. "September 14 to November 8" belongs in filters. "The flight has to land before the route launches" belongs in the brief. In wholesale mode there is no brief, so the reason cannot travel. 3. Put a hard requirement in a filter and a preference in the brief. AdCP's own supplement states it: "If the buyer says 'ideally CTV, but display is okay,' keep that preference in the brief. If they say 'CTV only,' use filters.channels." Encode a soft preference as a filter and you never see the products you would have taken. 4. A requirement with no typed field goes in the brief and stays unauditable: brand safety adjacency, competitive separation, tone, creative rationale. In wholesale mode it cannot be sent at all. 5. Verify a filter by diffing. Send the request twice, once with your tightest filters and once without, and compare the product counts. A diff proves the filter was applied. A zero diff proves nothing, because the filter may have matched everything or the seller may have ignored it; to tell those apart, send a deliberately impossible value and confirm the set empties. It doubles your discovery calls, so run it on your first request against a new seller and on any filter whose absence would change the plan, once per seller rather than once per campaign. 6. Send each requirement once. The reference page's own "Budget and Date Filtering" example sends brief: 'Q2 campaign for athletic footwear in North America' alongside startdate, enddate, budgetrange, countries: ['US','CA'], channels and deliverytype, so Q2, North America and the budget all travel twice. Filters run before ranking, so ['US','CA'] removes Mexico before the brief is read at all. The seller then ranks what is left against a brief describing a market it no longer has, and nothing in the response says which encoding shaped the set. Wholesale mode narrows this to one rule. Every constraint is a typed filter or it does not exist, so every requirement with no typed home is unsendable on a wholesale request. Route the brief, line by line core/product-filters.json at AdCP 3.1.13 has 30 properties. Twenty-seven of them are hard constraints on the product set. Three are not: ext carries vendor-namespaced keys, requiredgeotargeting asks for a capability level rather than a target, and requiredaxeintegrations is deprecated. This is the whole list, with the brief line that belongs in each. | The line in your brief | Filter property | Shape | | ------------------------------------------------------ | ---------------------------------------- | --------------------------------------------------------- | | "Live 14 September" | filters.startdate | ISO 8601 date | | "Off air 8 November" | filters.enddate | ISO 8601 date | | "$500,000, and nothing under $50,000" | filters.budgetrange | object; currency required, plus min or max | | "We transact in USD and CAD" | filters.pricingcurrencies | ISO 4217 array | | "Fixed rate card only" | filters.isfixedprice | boolean | | "Guaranteed only" | filters.deliverytype | guaranteed or nonguaranteed | | "Category lock, or sole sponsorship" | filters.exclusivity | none, category, exclusive | | "US and Canada" | filters.countries | ISO 3166-1 alpha-2 | | "New York State and Scotland" | filters.regions | ISO 3166-2 | | "DMA 501 and 803" | filters.metros | metro codes, for locally bound inventory | | "These 40 postcodes" | filters.postalareas | country plus postal system | | "Near these store locations, by drive time" | filters.geoproximity | travel time or radius per entry | | "The seller has to be able to target metros at all" | filters.requiredgeotargeting | capability level, not a target | | "CTV and online video only" | filters.channels | 20-value enum | | "These format IDs" | filters.formatids | format-id array | | "IAB standard formats only" | filters.standardformatsonly | boolean | | "In-stream, not accompanying content" | filters.videoplacementtypes | OpenRTB 2.6 video.plcmt names | | "Broadcast and podcast audio" | filters.audiodistributiontypes | OpenRTB 2.6 audio.feed names | | "Sponsored search and sponsored display" | filters.sponsoredplacementtypes | retail media enum | | "Feed and stories, not search" | filters.socialplacementsurfaces | social enum | | "Bid on these search terms" | filters.keywords | keyword array | | "These audience signals, and suppress that one" | filters.signaltargeting | signal refs; suppression uses targetingmode: "exclude" | | "Must report completed views and viewable impressions" | filters.requiredmetrics | 36-value metric enum | | "70% viewability to MRC, IVT under 2%, DV counts" | filters.requiredperformancestandards | metric, threshold, vendor | | "DoubleVerify's own attention metric" | filters.requiredvendormetrics | vendor plus metric id | | "At least 2m impressions for the measurement to hold" | filters.minexposures | integer | | "Seller must support inline creative management" | filters.requiredfeatures | four capability booleans | | "Clean-room match on hashed email" | filters.trustedmatch | Trusted Match Protocol object | | "Executable through this agentic exchange" | filters.requiredaxeintegrations | deprecated; use trustedmatch | | "This seller's own private criterion" | filters.ext | vendor-namespaced keys | Four more constraints sit on the request rather than inside filters, and behave the same way: | The line in your brief | Where it goes | | ------------------------------------------------- | ---------------------------------------------------------------------- | | "Only these approved properties" | propertylist, a reference another agent holds | | "Only sellers enforcing these registry policies" | requiredpolicies | | "The advertiser is Acme, at acme.com" | brand.domain, brand.industries | | "Guaranteed if you have it, but show me the rest" | preferreddeliverytypes, which steers curation instead of excluding | What a filter does that the brief string cannot requiredmetrics tells sellers to "silently exclude products that cannot meet this list (filter-not-fail; do not return an error)", and the other required filters carry the same clause. Send a filter and the products that miss it are gone before the response is built. The brief string buys you ranking and one sentence back. A seller that reads it can order the results and return briefrelevance per product, defined as "Why this product matches the brief (when brief provided)". briefrelevance is the only field in which a seller explains a match. The response MAY carry one optional window into what was cut, filterdiagnostics: - excludedby, an object keyed by the filter property name as you sent it, each value carrying a count plus optional filter-specific detail. - totalcandidates, the products the seller considered before applying filters. - semantics, one of only, any or approximate. Read it before doing arithmetic on the counts: only attributes each exclusion to a single filter, any counts a product against every filter that excluded it, approximate means the seller could not attribute cleanly. excludedby and totalcandidates are independently optional, and only filters that "meaningfully narrowed the set need appear", so a filter missing from the block either matched everything or never ran. getproducts.mdx is careful about its purpose: "This is observability — not error reporting; sellers still silently exclude unmatched products per the filter-not-fail convention." No filter can be declared in advance or confirmed afterwards. getadcpcapabilities declares four optional media-buy features — inlinecreativemanagement, propertylistfiltering, catalogmanagement and committedmetricssupported — and the only one that narrows a product set covers propertylist, a request-level parameter. product-filters.json is additionalProperties: true, so a filter name a seller has never heard of is accepted rather than refused. Assume a seller over-returns on any filter it has not implemented, and verify it with the diff in rule 5. The creative protocol is the only place AdCP settles the question, in listcreatives.mdx: "Agents that do not implement assettypes MUST ignore that field and apply all other active filters." Those agents "deliberately over-return". Nothing on the media-buy side says what a seller must do with a filter it has not implemented, so post-filter the response yourself and treat that filter as advisory for that seller. Requirements with no typed home These go in the brief string because no field holds them. Nothing in the response records how the seller handled them. | The line in your brief | Nearest structured home | | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | "Adults 25-54" | none. targeting.agerestriction is legal compliance for alcohol and gambling, not planning | | "Awareness campaign" | none at discovery. packages[].optimizationgoals exists at buy time, in a different vocabulary | | "60% CTV, 25% OLV, 15% display" | no request field. The seller answers with proposals[].allocations, which must sum to 100 | | "$50,000, with flexibility to add 20% for high performers" | budgetrange holds the number, nothing holds the contingency | | "2% CTR and $50 CPA" | none. requiredmetrics asks who can report a metric; no discovery field carries a target value | | "Avoid news, political content and competitive automotive brands" | none. requiredpolicies takes registry policy IDs that must already exist; propertylist points at a list another agent holds | | "Keep our ads three pages away from a competitor's" | none | | "Cap at three per week" | packages[].targetingoverlay.frequencycap, at buy time only | | "Front-load the first fortnight" | packages[].pacing: even, asap, frontloaded | | "Q4 Holiday Push, for Butler/Till" | none. getproducts has no campaign name and no agency field | | "The flight has to land before the route launches" | the brief string, and it belongs there: no seller infers urgency from two dates | Where AdCP's two brief documents disagree AdCP has two brief documents. They send five of these seven requirements to opposite places; brand safety has no typed field at all, and audience description is the one row they agree on. | Requirement | brief-expectations.mdx says | buyer-briefs-and-get-products.mdx says | Typed field in AdCP 3.1.13 | | -------------------- | ----------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------- | | Flight dates | Put it in the brief ("Core Brief Components") | Not a brief item; constraints go in typed fields | filters.startdate, filters.enddate | | Budget | Put it in the brief ("Optional Brief Components") | Explicit "good filter candidate" | filters.budgetrange (with required currency) | | Geography | Put it in the brief, down to metro level | Filter candidate ("required geo targeting support") | filters.countries, regions, metros, postalareas, geoproximity | | Creative formats | Put it in the brief ("we have 30s and 15s") | Filter candidate ("required channels or formats") | filters.formatids, filters.standardformatsonly, filters.channels | | Success metrics | Put it in the brief ("2% CTR and $50 CPA") | Filter candidate ("required measurement or reporting capabilities") | filters.requiredmetrics, filters.requiredperformancestandards | | Brand safety | Put it in the brief ("avoid news, political content") | Not addressed | No filter. Brief string, or requiredpolicies, or propertylist | | Audience description | Put it in the brief, in demographic detail | Put it in the brief, "in human terms" | Both agree. filters.signaltargeting exists but is a different thing. | The two documents address different readers. buyer-briefs-and-get-products.mdx is written for the buyer composing a request, and it is the one to follow: "The goal is not to make the brief verbose. The goal is to put intent in the brief and hard constraints in typed fields so the seller can curate inventory without guessing which parts are negotiable." brief-expectations.mdx is written for the publisher parsing a request, and tells that reader to pull "temporal expressions", "budget indicators" and "geographic references" out of the brief text, with worked examples putting flight dates, budget, metro-level geography and creative constraints in prose. Follow that one as a buyer and $25K and March 1-31 go to somebody else's parser. Take one of its own examples through both encodings. "$50,000 total budget with flexibility to increase by 20% for high-performing inventory" becomes budgetrange: {"currency":"USD","min":50000,"max":60000}, and the prose never named a currency, which budgetrange requires, so a seller parsing it picks one for you. The contingency is the part the typed form cannot hold, and it stays in the brief. Both documents are also behind the schema in two places. brief-expectations.mdx opens its Required Components section with "Every getproducts and createmediabuy request MUST include:" and then lists Brand and the Brief Field, while create-media-buy-request.json has no brief property among its 20. And both warn you off sending a brief in wholesale mode and stop there, while the schema also forbids it in refine. The crosswalk walks a whole creative brief through this fork, field by field. The schema will not catch a misrouted brief The brief property in get-products-request.json is described as "Required when buyingmode is 'brief'. Must not be provided when buyingmode is 'wholesale' or 'refine'." No if/then binds it. No not, no oneOf, no dependencies entry. The file's three Draft-07 conditionals are all spent on cache-version plumbing: a top-level if/then requiring buyingmode to be "wholesale" whenever ifwholesalefeedversion or ifpricingversion is present, and a dependencies block making brand mandatory when catalog appears and ifwholesalefeedversion mandatory when ifpricingversion appears. Against the released 3.1.13 schemas: | Payload | Validator result | | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | {"buyingmode":"wholesale","brief":"reach runners"} | valid — the docs call this an error | | {"buyingmode":"refine","brief":"reach runners"} | valid — the description says must not be provided | | {"buyingmode":"brief"} with no brief | valid — the docs say brief is required here | | {"buyingmode":"brief","breif":"x","fliters":{"contries":["US"]}} | valid — additionalProperties: true | | no adcpversion at all | valid | | catalog present, no brand | invalid — "'brand' is a dependency of 'catalog'" | | ifpricingversion without ifwholesalefeedversion | invalid — "'ifwholesalefeedversion' is a dependency of 'ifpricingversion'" | | ifwholesalefeedversion in brief mode | invalid — "'wholesale' was expected" | | filters.budgetrange = {"min":1000} | invalid — "'currency' is a required property" | | filters.budgetrange = {"currency":"USD"} | invalid — fails the min-or-max anyOf | Nothing in the file binds brief to buyingmode. The rule the brief docs repeat in four places is the one rule the schema does not carry. Both request schemas set additionalProperties: true, so a misspelled fliters block is ignored and you get back a full, unfiltered result set with nothing saying you asked for something else. Validate your own requests in a test against a local copy of get-products-request.json and product-filters.json with additionalProperties forced to false, because nothing downstream will. create-media-buy-request.json has the same permissiveness. It has no brief property among its 20 and sets additionalProperties: true, so a brief attached to the buy is accepted, dropped, and never mentioned again. Elsewhere AdCP settles conflicts like this in the prose. When budgetrange.currency and pricingcurrencies conflict, getproducts.mdx says sellers "SHOULD return zero matching products rather than reject the request solely because of the conflict", and the creative protocol resolves the prose-versus-structured question outright in buildcreative.mdx: "When both provide conflicting direction, message takes precedence as the most specific instruction." The media-buy side has no equivalent sentence. The doc table and the released filter list disagree The Filters Object table in getproducts.mdx has 27 rows against the 30 properties in the release. Three filters in the doc table exist in no release. Six released filters are missing from it. | Filter | In the 27-row doc table | In released 3.1.13 | | -------------------------------- | ----------------------- | ------------------ | | formatkinds | yes | no | | formatoptionrefs | yes | no | | audienceevidencerequirements | yes | no | | exclusivity | no | yes | | trustedmatch | no | yes | | requiredfeatures | no | yes | | requiredaxeintegrations | no | yes | | requiredgeotargeting | no | yes | | ext | no | yes | Two of the six missing filters are worth checking by hand. exclusivity resolves to enums/exclusivity.json, three values: none, category ("only one advertiser per industry category") and exclusive ("Sole sponsorship"). Category exclusivity is a routine ask on a guaranteed buy. A buyer working from the reference page writes it into the brief as prose, where a seller may ignore it, while a working hard filter sits in the release. requiredfeatures $refs the same media-buy-features.json that getadcpcapabilities declares, and that file promises "If a seller declares a feature as true, they MUST honor requests using that feature". It is the one place a filter turns a declared capability into an enforceable requirement, and the reference page never names it. formatids has the mismatch in the other direction. The doc table labels it "Deprecated 3.x named-format compatibility filter". In released 3.1.13 it carries no deprecated flag and its description is four words: "Filter by specific format IDs". AdCP's documentation marks it "Deprecated in AdCP 3.2; removed in AdCP 4.0." Where to go next - The CTV guaranteed launch example runs the whole rule set over a real payload, exclusivity filter and all. - The brief format comparison covers AAMP, whose CampaignBrief is a typed object with no prose field at all, so a requirement with no typed home has nowhere to go. - The getproducts operation reference lists the request and response fields on their own. --- # Creative Brief to AdCP: A Field-by-Field Crosswalk Source: https://briefgateway.com/crosswalk/creative-brief-to-adcp/ A creative brief doesn't convert into one AdCP object; it scatters across five: typed filters on get_products, the free-text brief string, the advertiser's hosted brand.json, package fields on create_media_buy, and the message field on build_creative. A creative brief splits five ways A creative brief does not convert into one AdCP object. It splits across five destinations: typed filters on getproducts, the brief string, a hosted brand.json, the package fields on createmediabuy, and message on buildcreative at production time. Take a filled-in brief, the ordinary kind, with objective, audience, single-minded proposition, tone, mandatories, deliverables, budget, flight, KPIs and an approver list, and those rows land in five separate places. Four are fields on three different requests. The fifth is a file you host on your own domain. The proposition, the reasons to believe and the rationale under the tone have no enforceable counterpart anywhere in AdCP. They go in the prose field. | Destination | What it is | Who enforces it | How long it lives | | --------------------------------------- | --------------------------------------- | ------------------------ | ----------------- | | filters. on getproducts | 30 typed properties | the seller, by exclusion | one request | | brief string on getproducts | one prose field | nobody | one request | | /.well-known/brand.json | a hosted discovery file | nobody, but it's typed | the brand | | createmediabuy and its packages[] | the commitment, plus the real targeting | schema required | the campaign | | message on buildcreative | one prose field, read at production | nobody | one creative | getproducts has exactly one required property, buyingmode, whose enum is brief, wholesale or refine. createmediabuy requires five: idempotencykey, account, brand, starttime, endtime. It has no brief property at all, and it does carry additionalProperties: true, so you can attach one, a validator will pass it, and it will be dropped without a word. Content adjacency is the row with no typed home at discovery. "Nothing near news" reaches a seller as prose unless you build it a home first, and there are two routes. createcontentstandards produces a standards object of policies[] addressable by policyid, which the top-level requiredpolicies array then references as "Registry policy IDs that the buyer requires to be enforced for products in this response". Or createpropertylist produces a list that the top-level propertylist references, and that route only works where the seller has declared features.propertylistfiltering: true in getadcpcapabilities. That is four content-standards operations or five property-list operations before the words are enforceable, so start the build before the campaign rather than during it. Publish brand.json first. It is the discovery file the advertiser hosts at /.well-known/brand.json, and the persistent half of the brief only has to be published once. Route everything else on the day. The destinations have four different owners. The advertiser publishes brand.json and keeps it current. Your buyer agent assembles the filters and the prose, holds the sign-off chain, and registers any content standards it wants enforced. The seller enforces the filters mechanically and runs a language model over the brief string. Nobody on either side owns the proposition. The split feels lossy because a creative brief mixes two lifetimes in one document. Budget, flight, market and KPI are campaign-scoped and belong on the wire. Tone, colours, disclaimers and do-nots are brand-scoped, they change once a year at most, and AdCP declines to accept them per request. Which row belongs in filters and which stays in the prose is its own decision with its own rule: brief vs filters. Format vocabulary is in the spec reference. Every brief row, and where it goes | Creative brief row | AdCP field | Where | Notes | | ------------------------------------------- | --------------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Advertiser / client | brand (BrandRef) | both | Only domain is required. Optional brandid, plus an industries inline override. | | Agency / billing entity | account (AccountRef) | both | Switches returned pricing from list to your rate card. | | Budget | filters.budgetrange {min, max, currency} | getproducts | currency is required inside the object. The committed number is totalbudget on createmediabuy. | | Flight dates | filters.startdate, filters.enddate | getproducts | Availability check only. The committed flight is starttime / endtime, both required at buy time. | | Markets | filters.countries, regions, metros, postalareas, geoproximity | getproducts | metros takes a {system, code} pair. nielsendma is one of five systems, alongside two UK ITL levels, Eurostat NUTS2 and custom. | | Channels | filters.channels | getproducts | A 20-value enum, from display and ctv through dooh, retailmedia and sponsoredintelligence. | | Formats | filters.formatids, filters.standardformatsonly | getproducts | Plus four placement vocabularies: videoplacementtypes, audiodistributiontypes, sponsoredplacementtypes, socialplacementsurfaces. | | Guaranteed vs biddable | filters.deliverytype | getproducts | Excludes. preferreddeliverytypes is a different thing, and the two should not travel together; see the call-sequencing section. | | Fixed vs auction pricing | filters.isfixedprice | getproducts | Boolean. True returns products with at least one fixed-price option. | | KPIs | filters.requiredmetrics | getproducts | A 36-value vocabulary. A capability filter: it matches products whose reportingcapabilities.availablemetrics is a superset of yours. It does not make anyone promise a number. | | Verification thresholds | filters.requiredperformancestandards | getproducts | {metric, threshold, vendor}, all three required. Viewability also needs standard: "mrc" or "groupm". ivt is a ceiling; viewability, completion rate, brand safety and attention score are floors. | | Competitive separation | filters.exclusivity | getproducts | none, category or exclusive. Real, released, and absent from the reference docs. | | Search / retail keywords | filters.keywords | getproducts | {keyword, matchtype} pairs, for search and retail media supply. | | Currency | filters.pricingcurrencies | getproducts | ISO 4217. Products match only if they price in one of yours. | | Minimum scale for measurement | filters.minexposures | getproducts | Integer floor for measurement validity. Minimum 1. | | Audience description | nothing here | — | filters.signaltargeting looks like the home for it and is not. It filters to products where named signals are buyer-selectable and jointly composable. It is not a demographic description, and passing one will not match anything. | | Industry classification | advertiserindustry | createmediabuy | An 81-value enum, one per buy. At discovery the nearest equivalent is BrandRef.industries, an inline override for callers who "cannot modify the brand's canonical brand.json". | | PO number, IO acceptance, invoice recipient | ponumber, ioacceptance, invoicerecipient, agencyestimatenumber | createmediabuy | The commercial paperwork lives entirely at commit time. | Field-level reference for the whole request is on getproducts. The conversion, end to end A plain brief paragraph: Northwind Air, transatlantic route launch, national CTV, September 14 to November 8, roughly a million dollars, adults 25-54 who travel internationally, premium full-episode streaming, nothing near news, no other airline in the pod, 30s hero and 15s cutdown in English and French-Canadian, measured on completed views and verified viewability with a brand-lift readout. Channel, placement, market, flight, currency and budget go on the first call, because a product that fails one of those is no use on this campaign, and brand, preferreddeliverytypes and the brief string ride as siblings of filters rather than members of it. The brief string and buyingmode: "brief" have to travel together: the field is required in this mode and forbidden in the other two, and that rule lives in the description text where no validator will ever apply it. budgetrange is the one filter here with rules inside it, currency required plus an anyOf demanding at least one of min and max, so a range with neither bound is rejected. And adcpversion is "3.1" and not "3.1.13" because the envelope pattern takes two numeric segments and a full release number fails it, which makes the version you read the schemas from the one string that won't validate; the spec reference has the pattern. Read the response, then narrow. The filters describing what a seller can measure and promise go in a second call, and deliverytype joins them because this client will not take biddable. Same envelope, same brief, this filters block replacing the first one, and preferreddeliverytypes dropped from the request now that the filter excludes: vendor is a BrandRef like any other, which is how a measurement company ends up needing a brand.json of its own. threshold is a rate with maximum: 1, so 70 percent is 0.7 and typing 70 fails validation. brandlift stays out of requiredmetrics on purpose. It is one of the 36 values in the metric vocabulary, so it would validate, and it would silently exclude every CTV product that doesn't declare brand-lift reporting. Keep the lift readout in the prose and negotiate it at package level; the worked CTV example sets out the arithmetic, including the minexposures floor that makes a lift study readable. brand.json on northwindair.example takes nothing at all from this paragraph. The tone, the logo lockup and the fare disclaimers were published once, before this campaign existed, and they arrive through brand: {domain: "northwindair.example"}. createmediabuy and its packages get the committed budget and flight, the PO number, any frequency cap on the 30s hero, and the cutdowns themselves once they exist, as creativeassignments on the package. The full payload, with all 13 filters and the constraint mistakes the schema accepts, is in the worked CTV example. To route your own brief, paste it into the generator: it applies the rules on this page mechanically, marks each field as stated, derived or assumed, and leaves the rows with no destination blank instead of filling them in with something plausible. Send half your filters on the first call, not all of them Every filter you set can only shrink the response. core/product-filters.json publishes 30 properties in the 3.1.13 release. Set one wrong and the response comes back short and clean, missing the thing you'd have bought. That is the specified behaviour, not a bug: the docs entry for requiredmetrics says sellers "MUST silently exclude products that cannot meet the list — filter-not-fail; do not return an error." You cannot ask a seller which filters it honours. getadcpcapabilities carries 17 blocks under mediabuy, and the one filters.requiredfeatures reads holds four booleans: inlinecreativemanagement, propertylistfiltering, catalogmanagement, committedmetricssupported. Nothing in the response enumerates support for the 30 filters themselves. A response may carry a filterdiagnostics block attributing exclusions to named filters, and "may" is the docs' word for it: observability, not error reporting. There is no compatibility matrix to look up. The one carve-out is audienceevidence, where sellers "MUST NOT silently ignore unsupported hard requirement or presence modes". The control you can get costs one extra round trip. Send the unbuyable-if-wrong filters first: currency, flight dates, countries, channels. A product that fails those is genuinely no use on this campaign. Hold back requiredperformancestandards, requiredmetrics and exclusivity, read what came back, then narrow. Those three describe what the seller can measure and promise, not anything about your campaign, and they are exactly the three a planner will set straight off the brief. The count of 30 is the released tree. AdCP's unreleased branch carries four filters that are not in 3.1.13 (audienceevidencerequirements, formatkinds, formatoptionrefs, pricingstructures), so a filter someone demos to you may not exist in the version you are sending. And don't send filters.deliverytype and preferreddeliverytypes together: one excludes and one ranks, they sit at different levels of the request, and no precedence rule is published for the case where they disagree. Set the filter only when the other kind is genuinely unbuyable, a guaranteed-only client or a biddable-only pipe. Otherwise state the preference and let the response tell you what exists. The worked CTV example sends both deliberately and shows what that buys and what it costs. Tone and mandatories live in a hosted file Tone, colour, logo lockups and legal lines go into a file the advertiser publishes. dist/schemas/3.1.13/brand.json opens with: "Brand identity and discovery file. Hosted at /.well-known/brand.json on house domains." It's a released AdCP schema, and it types tone as either a plain string or an object of {voice, attributes[], dos[], donts[]}. The array for guardrails is literally named donts. An agency brief's mandatories section has a field waiting for it, spelled the way the planner would spell it. | Agency brief section | brand.json field | | ------------------------------------------- | ----------------------------------------------------- | | Tone of voice | tone.voice, tone.attributes[] | | Do's and do-nots | tone.dos[], tone.donts[] | | Mandatories: logo, colour, type | logos[], colors, fonts, visualguidelines | | Legal lines | disclaimers[], items of {text, context, required} | | Endline / strapline | tagline | | Who we are talking to, the standing version | targetaudience | | Products in scope | productcatalog | Every AdCP request reaches all of that through exactly one field. core/brand-ref.json requires domain and nothing else, described as "Domain where /.well-known/brand.json is hosted, or the brand's operating domain". More on the pattern under discovery files. Whether a seller agent in production reads your tone.donts and acts on it is a different question. Publish the file anyway: it is one static document on a domain you already own, and it costs one deploy. To find out whether a counterparty resolved it, watch your own access log for a fetch of /.well-known/brand.json around the time you sent the request. A vendor's compliance claim is not evidence that anything on the other end parsed the field. BrandRef.brandkitoverride exists because the file is often wrong. Its description says to use it when brand.json is "missing, stale, or inappropriate for this specific call", with a campaign-scoped tagline and a freshly-rebranded colour palette as the worked examples. The merge is field-level: override colors.primary and you still inherit colors.secondary. Freshness elsewhere is advisory: 24-hour cache TTLs recommended for canonical and redirect files, an hour for failed lookups, with one MUST, that redirecteffectiveat invalidates anything cached before it. The resolution algorithm is three steps and a three-hop cap, and it has no failure branch: it says what to do when the fetch works and stays silent on what to do when it 404s. brand is optional on getproducts and required on createmediabuy, so the brand layer only becomes mandatory when you commit. A discovery call can go out with no brand identity at all and the seller will still curate. What verifies brand.domain Nothing at discovery. A house publishes authorizedoperators[], "Entities authorized to represent brands from this house. Third parties (sellers, platforms) can verify an operator's authorization by checking this list", scoped by brand, country, activity and a validity window. Checking it is a SHOULD, and the docs hang it on syncaccounts, account provisioning, well away from getproducts. Removing an operator doesn't kill live accounts either: "Revocation is eventual, not immediate." AdCP's own trust page names the residue, "no buyer-side authorization primitive symmetric to adagents.json", and spells out the consequence: "A compromised agent at an authorized operator's domain can transact unilaterally on every brand that lists that operator." That is a vendor-risk line, not a schema quibble. If you are the operator on that list, your agent's compromise is your client's brand transacting without them; if you are the advertiser, your exposure is the length of your authorizedoperators[] array and the memory of whoever last pruned it. buildcreative carries a second brief field This is the fifth destination. A creative agent reads it at production time, long after discovery and commit, so a brief row can land here on top of one of the other four. The message property on media-buy/build-creative-request.json reads: "Natural language instructions for the transformation or generation. For pure generation, this is the creative brief." So AdCP has two fields that both mean brief, sitting on different operations and read by different agents. buildcreative's only required property is idempotencykey, and its brand reference resolves "to full brand identity (colors, logos, tone) at execution time", which is the same brand.json arriving by the same route. Script, duration and cutdown structure land here, as creative/video-brief.json segments of {order, durationms, prompt} with optional voiceover and caption. The deliverables list itself, the 30s hero and the 15s cutdown and the two language versions, has no discovery-time home at all. It attaches at commit, on packages[].creatives for assets you upload inline or packages[].creativeassignments for library creatives that already exist. The storyboard row is refused outright, in creative/video-brief.json's own description: "This is a generation brief, not a creative-direction storyboard: there is no camera direction, shot type, mood, or reference-asset attachment." Direction does travel, as an attachment: buyers who want camera, lighting or reference-image conditioning send a reference-asset.json with purpose: "storyboard". Which rows are buy-time only: frequency cap, pacing, targeting, optimisation goals Four of the most familiar brief rows have no discovery-time home. All four attach to the package at buy time. - Frequency cap is absent from product-filters.json entirely. It lives at packages[].targetingoverlay.frequencycap, one of 28 properties on core/targeting.json. The capabilities response carries a frequencycapping object whose presence means the seller "honors targeting.frequencycap on packages and MUST reject caps it cannot enforce rather than silently dropping them". - Pacing is packages[].pacing, three values: even, asap, frontloaded. - The real targeting surface is core/targeting.json: geo includes and excludes at four levels, dayparts, device platform and type, language, keyword and negative keyword targets, store catchments, audience includes and excludes, signal targeting groups. All of it attaches to a package. - Your optimisation target is packages[].optimizationgoals[], a discriminated union with three kinds. metric draws on an 11-value seller-native list from clicks and completedviews to attentionseconds and reach. event covers advertiser-tracked conversions, vendormetric covers the rest. Goals carry a priority, where 1 is primary and duplicate values are explicitly undefined behaviour. Full request shape at createmediabuy, and the object itself at media buy. Eight rows survive only as prose Write these into the brief string and expect nothing back that confirms they were read. None has a typed field anywhere in AdCP 3.1.13. brief is a bare string, "Natural language description of campaign requirements", so a curation model on the seller's side decides what each one meant and nothing validates the answer. | Creative brief row | Where it lands | What you give up | | -------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Business background, the problem | brief string | Nothing structured. This is what prose is for. | | Campaign objective | brief string | There is no objective enum on the media-buy surface. Awareness, consideration and conversion are words, not values. The five-value enum lives on core/creative-brief.json, which never reaches discovery. | | Target audience description | brief string | filters.signaltargeting is a different mechanism; the crosswalk row above has the detail. | | Single-minded proposition | brief string | No enum, no validation, and no confirmation the seller read it. | | Tone of voice | brief string | Nothing carries it at request time. The standing version has a home in brand.json. | | Reasons to believe | brief string | No enum, no validation, and no confirmation the seller read it. | | Content adjacency to avoid | brief string | This is the expensive one. It has two structured routes and both are builds. | | Sole-sponsorship rationale | brief string | filters.exclusivity carries the ask. Nothing carries the reason. | IAB Tech Lab's buyer agent types several of them where AdCP does not: objective as a four-value enum, brandsafety with excludedcategories[] and excludedkeywords[], a frequencycap, a pacingmodel enum, kpis[] over a seven-value metric list, and an approvalconfig with four gates. None of it is on a wire — no CampaignBrief schema exists anywhere in the AAMP wire contract, whose 22 object schemas run Account through SupplyChain with no brief among them — and the same repository declares two CampaignBrief classes with incompatible field names, so you would also have to pick one. It is a planning model, not a wire contract. The full execution trace is in AdCP brief vs AAMP CampaignBrief. brief-expectations.mdx puts "Include brand safety requirements" in its DO list for brief authors, with the worked example "Avoid news, political content, and competitive automotive brands". No discovery field carries it; the two routes that do are the content-standards and property-list builds. Buyer-side approval is the other hole, and it is a headcount problem. AdCP models human review only on the seller's side: a brief or refine call may return Submitted "when curation requires upstream-system queries or HITL review the seller cannot complete inside timebudget", and refine with action: "finalize" "may trigger seller-side approval (HITL)". Your own sign-off chain, the one with the client lead and the legal reviewer on it, is your buyer agent's problem entirely, and nobody on the other side of the wire can see whether it ran. --- # A Guaranteed CTV Launch Brief, Filled In Source: https://briefgateway.com/examples/ctv-guaranteed-launch/ A guaranteed CTV launch brief in AdCP is one get_products request in brief mode: 13 typed filter keys covering delivery type, channel, flight, budget, currency, metrics and performance thresholds, plus the campaign described in prose in the brief string. A guaranteed CTV launch, written out as an AdCP getproducts request: thirteen populated filter keys and a prose brief, valid against the published 3.1.13 schema. Copy the payload, swap the brand, the flight, the budget range and the metric list, and the shape holds for any guaranteed video buy. The scenario Match these rows against your own campaign. | Input | This campaign | | --------------- | ---------------------------------------------------------------------------------------- | | Advertiser | Northwind Air, opening a nonstop transatlantic route out of the US east coast | | Markets | US and Canada, budget booked in US dollars | | Flight | Mid-September to the week before the mid-November first flight, ahead of booking windows | | Budget | Around a million dollars | | Audience | Adults 25 to 54 who travel internationally at least once a year | | Deal type | Guaranteed | | Creative | 30-second hero spot and a 15-second cutdown, English and French-Canadian | | Success metrics | Completed views and verified viewability, plus a brand-lift readout at end of flight | Two lines are standing rules for this advertiser rather than preferences: no news and no hard-news adjacency at all, and no competing airline or online travel agency in the same pod. The payload Northwind Air doesn't exist and northwindair.example is a reserved name. The field names, the enum values and the shape rules are all from the published 3.1.13 schemas, and the payload validates against media-buy/get-products-request.json in that release. The generator will emit the skeleton if you'd rather start from your own prose than from someone else's JSON. The measurement block follows docs/media-buy/advanced-topics/accountability.mdx, which sends deliverytype: "guaranteed" with viewability at 0.70 MRC DoubleVerify and an ivt ceiling of 0.05. The block above is that snippet with a tighter ivt ceiling. This payload is brief mode throughout, where you describe the campaign and the publisher recommends products against it. brief is required in that mode and forbidden in wholesale and refine — the spec page has the full mode table, and the rule that adcpversion goes on the wire as "3.1" rather than "3.1.13". One key in that payload does not mean what it looks like. isfixedprice: true does not mean "only fixed-price products". It returns products offering at least one fixed-price option, and a product carrying both fixed and auction options matches either value of the filter. What you get for sending it is on the response side: sellers "MUST return only the pricingoptions entries matching the requested pricing type so buyers can deterministically select from the returned options". What each filter is doing | Filter | Value here | Why it is in this payload | | -------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------- | | deliverytype | "guaranteed" | Excludes every non-guaranteed product outright. | | channels | ["ctv"] | CTV only. | | videoplacementtypes | ["instream"] | Instream video, not outstream units. | | exclusivity | "category" | The pod requirement: "only one advertiser per industry category". | | countries | ["US", "CA"] | The two markets the route sells into. | | startdate, enddate | 2026-09-14, 2026-11-08 | The flight. A product the seller can't serve in those weeks is wasted response payload. | | budgetrange | 900000-1200000 USD | The order of magnitude, so the seller doesn't return price points nobody can transact. | | pricingcurrencies | ["USD"] | The buy is transacted in dollars in both markets. | | isfixedprice | true | Guaranteed at a fixed rate, not an auction floor. | | requiredmetrics | impressions, completed views, completion rate, viewability | Capability filter, and the binding reporting contract on the resulting media buy. | | requiredperformancestandards | viewability 0.70 MRC DoubleVerify, ivt 0.01 | Threshold filter. The floors and ceilings the invoice turns on. | | minexposures | 25000000 | Measurement-validity floor for the brand-lift study. | The crosswalk walks a full agency brief row by row. Reach for exclusivity: "category" by name, because you won't find it by reading the docs. The enum in enums/exclusivity.json is none, category, exclusive. The getproducts reference page upstream never mentions exclusivity; the field is discoverable only by reading the properties of core/product-filters.json directly. Measurement splits across two filters that do different jobs, which is why viewability appears in both. requiredmetrics is the capability filter: sellers "MUST silently exclude products that cannot meet this list (filter-not-fail; do not return an error)", and "the product's declared availablemetrics becomes the binding reporting contract carried into the resulting media buy". requiredperformancestandards is the threshold filter, {metric, threshold, vendor} with standard for viewability, and direction matters: "viewability, completionrate, brandsafety, and attentionscore are floors (must exceed); ivt is a ceiling (must not exceed)." Send a metric in the first to have it reported at all, and in the second to set the floor. Keep brandlift out of requiredmetrics, and keep it in the prose. It is a reportable metric, listed in available-metric.json, so it would validate there; it is not in performance-standard-metric.json, so it could never carry a threshold anyway. Requiring it would silently delete every CTV product that doesn't declare brand-lift reporting, which is most of them, to secure a readout that's negotiable at package level. The crosswalk routes it the same way. minexposures: 25000000 is what carries the brand-lift ask instead. Its description is the only one in product-filters.json about measurement rather than money: "Minimum exposures/impressions needed for measurement validity." Twenty-five million is what this budget buys at a $40 CTV CPM. Nothing in the schema says whether that floor is read per product or across the whole response, and the difference decides what comes back: read per product, a whole-campaign number matches only products that could carry the flight on their own. Size it to what one product plausibly delivers if you want a wider set returned. filters.deliverytype excludes, preferreddeliverytypes ranks: "Unlike filters.deliverytype which excludes non-matching products, this signals preference for curation — the publisher may still include other delivery types when they match the brief well." Sending guaranteed twice is safe here only because both name the same value: the filter decides, and the preference array documents intent for whoever reads the request later. No precedence rule is published anywhere, and a request that filters for guaranteed while preferring nonguaranteed validates cleanly. Content exclusions have no filter product-filters.json has no content-exclusion filter. No blocklist, no category exclusion, nothing that expresses "no news". The two structured surfaces that come close sit at the top level of the request, outside filters: requiredpolicies, an array of registry policy IDs that must already exist, and propertylist, a PropertyListRef of {agenturl, listid, authtoken?} pointing at a list you build first through the property domain (createpropertylist and its four siblings are labelled "Property domain" in the task-type enum). Neither is set up for this advertiser, and the upstream getproducts reference page never mentions requiredpolicies either. Either budget engineering time for the "no news" line months before the campaign, or accept that a curation model decides what it means. Stand the property list up before the first getproducts call, and treat it as a build rather than a checkbox. PropertyListRef points at an agenturl, so somebody has to host an agent answering createpropertylist, updatepropertylist, getpropertylist, listpropertylists and deletepropertylist, and keep the list current as publishers change. That is buyer-side engineering, or a vendor you contract to hold it. The payload targets nobody. Every property of core/targeting.json is buy-time, attached to packages at createmediabuy through targetingoverlay: dayparts, device type, language, frequency cap, geo includes and excludes at four levels, signal targeting groups. None of them belong in a discovery request, and nothing here commits. The commitment is createmediabuy, which requires idempotencykey, account, brand, starttime and endtime, and which has no brief property at all. Nothing records an internal approval. createmediabuy carries ioacceptance with {ioid, acceptedat, signatory}, and signatory is "Who accepted the IO — agent identifier or human name", so a human sign-off is representable, but what it records is acceptance of the seller's insertion order, and only "when the proposal's insertionorder has requiressignature: true". A media director signing off the plan before anyone books it has no field anywhere in the protocol. Enforce that gate in your own system before you issue createmediabuy. What validates but should not Don't ship on a green validator. Every mutation below is a change to the payload above that the released schema accepts. | Mutation | Validator | What it costs on this campaign | | --------------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | "filters" misspelled "filter" | valid | All 13 constraints vanish at once. Guaranteed-only, category exclusivity, the flight, the budget floor, the viewability standard, gone. The seller returns non-guaranteed FAST inventory and the response looks legitimate. | | Viewability standard omitted | valid | 0.70 viewable now means MRC or GroupM, seller's choice, on the term the invoice turns on. | | deliverytype: "guaranteed" plus preferreddeliverytypes: ["nonguaranteed"] | valid | No diagnostic, and no published precedence rule to say which one the seller honours. | | startdate after enddate | valid | An impossible flight. Draft-07 can't compare two sibling values, so this one is on the schema language. | | Misspelled key inside filters (exclusivty) | valid | That one constraint is dropped silently. product-filters.json is also additionalProperties: true. | | adcpversion omitted | valid | Falls back to the deprecated adcpmajorversion or a server default. | | brief sent in wholesale mode | valid | Forbidden in the field descriptions and enforced by nothing. The spec page covers the rule. | | brief sent in refine mode | valid | Same rule, same silence. | What the released schema does catch threshold: 70 instead of 0.70 fails on "must be <= 1"; the field is a rate, not a percentage. exclusivity: "sole", channels: ["CTV"] in caps, countries: ["us"] in lowercase and videoplacementtypes: ["pre-roll"] all fail their enums or patterns. minexposures: -5 fails its minimum: 1. AccountRef is a oneOf with additionalProperties: false on both branches, so an account carrying accountid alongside brand and operator is rejected. budgetrange has structure of its own, and it is one of the few conditionals AdCP encodes inside filters at all: currency is required, and an anyOf demands at least one of min and max, so {"currency": "USD"} alone is rejected. timebudget is a Duration, not a number of seconds. {interval, unit}, both required, additionalProperties: false, and unit from seconds, minutes, hours, days, campaign. It fails twice, once for a missing unit and once for a bare 120. The interval is a trade: 120 seconds leaves curation room, and a shorter one makes the async Submitted envelope likelier. Run the whole set yourself. validate.mjs takes a clone of the AdCP repository and prints every mutation with its result: Read the viewability row against the measurement docs. docs/measurement/taxonomy.mdx says viewability is the canonical Tier 1 metric and that MRC and GroupM "define materially different thresholds and require schema-enforced disambiguation via qualifier.viewabilitystandard". At 3.1.13, nothing enforces it. core/performance-standard.json requires metric, threshold and vendor; standard sits outside that list with a description that says "Required when metric is 'viewability'" and no if/then to make it so. core/committed-metric.json leaves qualifier out of both of its required arrays. Send the standard yourself on every viewability threshold. Tighten the schema locally Use your own copy of it, tightened. Wrap the released request schema in an allOf that closes additionalProperties at the top level and inside filters, and add the if/then that performance-standard.json doesn't carry. Run every outbound request through that before it goes on the wire. Three of the eight misses fail immediately: the "filter" typo, the exclusivty typo, and the omitted viewability standard. Because the overlay only ever adds constraints, anything that clears it still validates against the published schema, so you're not inventing a private dialect. Two misses need imperative code. Assert startdate <= enddate, and assert filters.deliverytype is in preferreddeliverytypes when you send both. Three lines each, and they cover the contradictions Draft-07 structurally can't see. Downstream, the response won't do it for you. get-products-response.json carries no echo of the filters you sent. The nearest thing is filterdiagnostics, and it's deliberately weak evidence: sellers "MAY populate this when meaningful narrowing occurred; buyers MAY use it for triage UX without depending on its presence", it is "Counts only — products are not enumerated by name", and its semantics enum is only, any or approximate, so the same integer means three different things depending on a sibling field ("Buyers SHOULD inspect semantics before doing arithmetic on counts"). propertylistapplied and catalogapplied are booleans covering those two surfaces and nothing else. Re-check each returned product against your own constraint list before you commit. core/product.json requires deliverytype, and carries exclusivity, performancestandards and enforcedpolicies, the same vocabulary the filters use. Read the defaults carefully: exclusivity "Defaults to 'none' when absent", and for performancestandards, "When absent, no structured performance standards apply", so an absent field is not a guarantee. Refine, then commit, in separate calls The seller answers with products, and probably with proposals. If the mix is wrong, follow up with a refine call: The seller answers each entry through refinementapplied, "matched by position", so the order you send them in decides which answer maps to which ask. Once any entry carries action: "finalize", "ALL entries in the array MUST be proposal-scoped with action: 'finalize' — mixing finalize entries with include/omit entries or with request- / product-scoped entries MUST be rejected by the seller with INVALIDREQUEST. Finalize is a commit, not a refinement." Plan for the Submitted envelope, because it is a second code path. In brief and refine modes the seller MAY answer with one instead of products, when curation needs an upstream query or human review it can't finish inside timebudget. The envelope carries status: "submitted" and a taskid, and the products land on the completion artifact rather than on the envelope, so the buyer either polls gettaskstatus with that handle or registers a pushnotificationconfig up front and takes the terminal callback. wholesale never uses this arm; it signals shortfall through incomplete[] instead. Next: brief vs filters argues each line of a brief onto one side of the request. --- # Send a Campaign Brief on get_products Source: https://briefgateway.com/spec/ Send a campaign brief in AdCP 3.1.13 as one free-text string, the brief field of a get_products request: no sub-fields, no maximum length, required when buying_mode is brief and forbidden in wholesale and refine. create_media_buy has no brief field. Send the campaign brief as the brief string on a getproducts request, and set buyingmode before you decide whether you are allowed to send one at all. Nothing else in AdCP 3.1.13 carries a campaign brief, and no validator will hold you to either rule. A conformant request, filled in Copy this and swap the brand and the filters. brief is typed "type": "string". No sub-fields, no maxLength. That is the whole definition of the field, and the string does not persist: it is scoped to the one request that carries it, and no later operation can read it back. Send adcpversion as "3.1", not "3.1.13". The envelope pattern allows two numeric segments plus an optional prerelease tag, so a full semver fails it, and patches aren't negotiated on the wire at all; they surface as buildversion on capabilities. These rules are mechanical, so the generator applies them for you: paste prose, get this request back with each field marked as taken from your text, derived from it, or assumed. It leaves a field blank when your brief didn't say so. Check any generated number against your own brief before it reaches a seller, because a number in this payload is a number you can be held to. There's a harder payload in the worked CTV example, with exclusivity and viewability floors on a real flight. Before the request leaves Five checks to put in the client: 1. brief present when buyingmode is brief, absent when it is wholesale or refine. 2. adcpversion equal to "3.1". 3. Spelling of the top-level keys. breif and fliters are accepted by the schema and dropped by any seller that doesn't recognise them. 4. A character cap on brief that you choose. No maxLength exists on the field in any 3.1.13 schema, and getproducts.mdx ships an INVALIDREQUEST row for "Brief too long or malformed filters", so the limit is seller-side and you cannot read it before you hit it. 5. The release directory your generated types came from, pinned to dist/schemas/3.1.13/. static/schemas/source/ is a different tree with different property counts. Put the brief on getproducts and nowhere else create-media-buy-request.json has no brief property. The migration guide tells you to send one anyway: it validates, and nothing reads it. The file, media-buy/create-media-buy-request.json, has twenty properties. Five are required: idempotencykey, account, brand, starttime, endtime. Neither brief nor filters is among the twenty. Because that schema sets additionalProperties: true, a payload carrying brief validates cleanly, with no error and no warning. By the time you call createmediabuy the products are already chosen, so a description of what you're trying to achieve has nothing left to steer. This is the call that commits: idempotencykey is required, its pattern is ^[A-Za-z0-9.:-]{16,255}$, and the description tells sellers to return the existing media buy rather than create a duplicate. The release marks 31 request schemas x-mutates-state: true and createmediabuy is one of them; getproducts isn't. docs/reference/migration/catalogs.mdx tells you to put a brief on createmediabuy: The promotedoffering string field is removed from media buy objects. What's being promoted is now expressed through the brief on getproducts/createmediabuy and through catalog references on creatives. The same guide names "The brief field on getproducts and createmediabuy" as a replacement source of context, and a numbered migration step reads "Remove the promotedoffering string from createmediabuy requests. Use the brief field to describe what's being promoted." docs/reference/whats-new-in-v3.mdx carries it into its migration tables as "Removed — use brand + brief". promotedoffering really is gone; it appears in no request schema in 3.1.13. Three things tell the seller what is being promoted at the moment of commitment, and none of them is prose: - brand, which is required and is a domain. - The packages or proposalid you carry over from the getproducts response the seller curated itself. - Catalog references on the creatives. A buyer that skips discovery and posts a media buy against a week-old proposalid is handing over a domain and some IDs, and the promoted-offering context now lives in the seller's memory of the conversation rather than in the request. If your workflow allows that gap, carry the context yourself and re-send it as a refine or a fresh getproducts before you commit. Every field on a getproducts request Read the last column before you rely on a field. Everything not held to more than its type is type-checked and otherwise yours. | Field | Type | Required when | Schema enforces it? | | --------------------------- | ----------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | adcpversion | string (envelope) | Never | Yes, pattern ^\d+\.\d+(-[a-zA-Z0-9.-]+)?$ | | adcpmajorversion | integer 1-99 (envelope) | Never | Type only; deprecated, removed in 4.0 | | buyingmode | enum | Always | Yes, the single entry in required | | brief | string | buyingmode: "brief" | No, prose only | | refine | array | buyingmode: "refine" | Partly. minItems: 1, and the mode link and the finalize-exclusivity rule are prose only | | brand | BrandRef | Never on this call | Yes, domain required and additionalProperties: false | | catalog | CatalogRef | Never | Yes, dependencies requires brand alongside it | | account | AccountRef | Never | Type only | | preferreddeliverytypes | array | Never | Type only | | filters | ProductFilters, 30 properties | Never | Partly. additionalProperties: true at the object level, so a misspelled filter key is waved through, but the enums, the countries pattern, minexposures minimum: 1, threshold maximum: 1 and budgetrange's required currency plus its min-or-max anyOf all bite. Worked example | | propertylist | ref | Never | Type only | | fields | array | Never | Type only | | timebudget | Duration | Never | Yes, {interval, unit} with both required, additionalProperties: false, unit from a five-value enum. A bare integer is rejected | | pushnotificationconfig | ref | Never | Type only | | pagination | ref | Never | Type only | | ifwholesalefeedversion | string | Never | Yes, forces buyingmode: "wholesale" | | ifpricingversion | string | Never | Yes, requires ifwholesalefeedversion and forces wholesale | | context | ref | Never | Type only | | requiredpolicies | array | Never | Type only | | ext | ref | Never | Type only | adcpversion and adcpmajorversion are composed in from core/version-envelope.json; the rest are the request's own properties. The filters object carries thirty typed properties. brief is one untyped string, and the only field on the request a seller-side model interprets. The required array has one entry, and it isn't brief: buyingmode decides whether the brief is required or forbidden Set the mode first, then read this row before you attach a string. | buyingmode | brief | refine | What the seller does | | ------------- | --------- | --------- | ----------------------------------------------------------------------------------------------------- | | brief | Required | Forbidden | Curates products and proposals from your description. May answer asynchronously. | | wholesale | Forbidden | Forbidden | Returns a raw product feed for you to target yourself. Nothing is curated and no proposals come back. | | refine | Forbidden | Required | Applies your change requests to a previous response, answered by array position. | The rule itself is prose, in brief-expectations.mdx: "buyingmode: "wholesale" and brief are mutually exclusive — providing both is an error. If buyingmode: "brief" is set explicitly, brief is required." The buyingmode enum description carries a timing rule too. - A wholesale request MUST NOT be routed through the async Submitted arm. Partial completion comes back in the response's incomplete[] array instead. - brief and refine mode MAY return a Submitted envelope when curation needs an upstream query or human review. Wholesale is the only mode that cannot answer asynchronously. That is not a latency bound: nothing in the schema caps how long a synchronous answer takes. You do your own targeting in exchange. brief-expectations.mdx is also the normative document on what goes inside the string. It asks for objectives, success metrics, flight dates, audience, budget, geography, creative constraints and brand safety requirements, grades briefs into quality levels, and tells sellers how to come back for clarification. That is a useful brief. A conformant brief is any non-empty string sent in the right mode, and no error code distinguishes the two. The document never mentions refine at all, so the third mode's prohibition on brief lives only inside a JSON description string. See brief vs filters for which side of the request each line of a requirement goes on. What nothing validates for you Run a draft-07 validator over an outbound request and four of the rules below still go unchecked. Rely on the official Python SDK alone and seven do. The last row is a deprecation notice rather than a rule to enforce. | Rule | Where it is written | Schema | SDK model | | ----------------------------------------------------------------------- | ---------------------------------------------------------- | ------ | --------- | | brief required in brief mode, forbidden in wholesale and refine | property descriptions, brief-expectations.mdx | no | no | | refine[] present at all in refine mode | prose, and assertbuyingmodeconsistent | no | no | | refine[] non-empty when sent | minItems: 1 | yes | yes | | catalog requires brand | dependencies | yes | no | | ifpricingversion requires ifwholesalefeedversion | dependencies | yes | no | | Version-feed tokens imply buyingmode: "wholesale" | allOf[1] if/then | yes | no | | adcpversion at release precision | pattern | yes | yes | | requiredaxeintegrations, a filters property, is deprecated | its description string, "Use trustedmatch filter instead" | no | no | get-products-request.json does carry conditionals. One if/then inside allOf[1] binds ifwholesalefeedversion and ifpricingversion to buyingmode: "wholesale", and two dependencies entries make catalog require brand and ifpricingversion require ifwholesalefeedversion. None of them constrains brief. Don't treat a schema pass as a correctness check on this request. Three payloads that AdCP says in writing are illegal pass a draft-07 validator against the published 3.1.13 schemas: {"buyingmode": "wholesale", "brief": "..."}, which the property description says must not be provided; {"buyingmode": "brief"} with no brief, which the same description makes required; and {"buyingmode": "brief", "breif": "...", "fliters": {...}}, two misspelled top-level keys. The full validator matrix has the payloads that do fail. core/brand-ref.json sets additionalProperties: false, so {"brand": {"domian": "example.com"}} is rejected while the identical typo one level up passes. Each object sets its own permissiveness, so check the object you are sending rather than assuming a validator covers the request. create-media-buy-request.json has the same gap against its own prose: the description says "One of packages or proposalid must be provided", and a request with neither passes. getsignals runs the same design under different names: a discoverymode enum gating a signalspec string, described in the schema as symmetric with getproducts buyingmode, with nothing at all in its required array. Python SDK The official client is adcp on PyPI at 7.0.0rc1, typed Pydantic models generated from these schemas by the organisation that publishes them. SDK versioning is independent of the protocol version, and rc1 is a prerelease. Don't lean on the generated types: they catch less than a raw schema pass. GetProductsRequest sets modelconfig = ConfigDict(extra='allow'), inherited from the schemas' own additionalProperties: true, and brief is not a required field on the model. All three broken payloads above construct without complaint, and breif and fliters survive into modeldump() as real keys. Consumers override that policy by hand on every subclass to get extra='forbid' back (adcp-client-python#153). | Rule the schema declares | Model keeps it? | | ------------------------------------------------------------------------- | --------------- | | pattern on adcpversion | yes | | minItems: 1 on refine | yes | | dependencies: catalog requires brand | no | | dependencies: ifpricingversion requires ifwholesalefeedversion | no | | if/then: a version token forces buyingmode: "wholesale" | no | Every conditional the schema declares is dropped, so the SDK and a plain schema pass reject exactly one identical input, adcpversion: "3.1.13". The buyingmode rule does exist in the SDK, in hand-written Python. assertbuyingmodeconsistent, in src/adcp/decisioning/refine.py, raises AdcpError("INVALIDREQUEST") on exactly the cases the schema misses. Its only call site is the seller-side decisioning shim, so the check runs when a request arrives and not when a buyer builds one. The docstring's parenthetical for buyingmode='brief' says the check is "handled by Pydantic validation upstream". No modelvalidator in the package touches buyingmode. Permissive-on-send is deliberate across AdCP's clients. Its own documentation describes the Go SDK's adcp.AddTool as generating a schema "while allowing additional protocol fields", explicitly against mcp.AddTool, "which rejects extra fields". What the brief string won't carry Tone, territory and messaging If your existing template asks for tone or territory, don't look for those on getproducts. AdCP does have a structured object for them: core/creative-brief.json, eight properties including tone, territory and a messaging block. It rides inside creative manifests as an asset and never touches the media-buy surface. The crosswalk takes a human brief field by field and says where each one lands. Two operations use the name brief The creative surface declares a top-level brief too. creative/list-transformers-request.json takes a "Natural-language brief used to rank and filter transformers... e.g. 'warm female Spanish-language voiceover'", and its own description says it curates "the way getproducts curates inventory". | Operation | Free-text field | What the text selects | | ------------------- | --------------- | ------------------------------- | | getproducts | brief | Inventory, in brief mode | | getsignals | signalspec | Audience and contextual signals | | listtransformers | brief | Creative transformers | Generate client types from these schemas and two unrelated things arrive under one name. Rename one of them in your own code before they collide. AAMP's campaign brief has no overlap AAMP's buyer agent models a campaign brief as eight required fields plus a budgetpct on every channel allocation, with no ratified JSON Schema behind any of them. That difference gets its own comparison. ---