Buying from a
live seller agent.
sell.nofluffadvisory.com is a publisher-direct AdCP sales agent with real display inventory on the essays here. This is the whole loop: discovery, audience targeting, a media buy, creatives, approval, serving, and the delivery report that closes it.
Every command on this page was run against production before it went up.
01 What you’re talking to
One Cloudflare Worker that does four jobs: it publishes a catalog, accepts media buys over MCP, decides which creative fills a slot on a page, and reports what actually rendered. There is no ad server behind it and no exchange in front of it. A buying agent talks straight to the publisher.
The transport is MCP over JSON-RPC 2.0 at https://sell.nofluffadvisory.com/mcp.
Every request is a POST. Discovery tools are public; anything that writes needs a
bearer token. There are 15 tools, listed in full in
section 12.
- 01Discover
- 02Target
- 03Buy
- 04Creatives
- 05Human gate
- 06Serve
- 07Report
02 Three things that will trip you up
Read these before you write any code. Each one costs an hour if you find it the hard way.
1. A .example domain sends you to sandbox
RFC 2606 reserved TLDs — .example, .test,
.invalid, .localhost — can never belong to a real
advertiser, so a buy branded with one is classified as conformance traffic
automatically. It is persisted and echoed back to you exactly like a real buy, so
the response looks identical. But it never reaches the human approval queue and it
never serves.
That is deliberate: it keeps fixture traffic out of the queue. The real path
needs a real domain in brand.domain and a key we issued you (see 2).
If you want sandbox on purpose, either use a reserved TLD or pass
account.sandbox: true.
2. Self-serve keys write sandbox only
Discovery needs no credentials at all — auth-gated discovery is where buying
agents go to die. Anything that writes needs a bearer token. Any token matching
demo-* is accepted with no sign-up: pick something identifiable
like demo-scope3 (it becomes your account key), reference your
account as { brand, operator, sandbox: true } or brand the
buy with a reserved TLD, and you can exercise every write in sandbox.
A demo- key never creates or changes anything live. A buy,
creative or usage report that would be live comes back
PERMISSION_DENIED, and nothing is written. So does an account sync
that says sandbox: false; one with no flag gets you a sandbox
account. Live buying (a real
brand, the human gate, real serving) needs a key we issue. Ask for one on the
contact page.
3. You get pending, never a confirmation
Every buy clears a machine policy gate, then stops and waits for a human at a
$0 floor. There is no auto-accept threshold to clear. Your agent receives
media_buy_status: pending_creatives and
human_review: pending, and that is the terminal state until a
person clicks approve.
If your agent expects a synchronous confirmation, this is where it will break.
Pass a webhook_url and you will get a status callback
instead of polling. Callbacks are unsigned today (RFC 9421 signing is deferred), so
confirm state with get_media_buys before acting on one.
03 Discovery
Three public calls. No auth. Start here.
What can this agent do?
Returns supported AdCP versions, the protocol block, governance posture (mode, review thresholds), creative policy, and the tool list. This is the call that tells you the human gate exists before you design around its absence.
curl -s -X POST https://sell.nofluffadvisory.com/mcp \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_adcp_capabilities",
"arguments": {}
}
}' $body = @'
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_adcp_capabilities",
"arguments": {}
}
}
'@
$r = Invoke-RestMethod -Uri 'https://sell.nofluffadvisory.com/mcp' -Method Post `
-ContentType 'application/json' `
-Body $body
$r.result.content[0].text Response
{
"adcp_version": "3.1",
"supported_versions": ["3.1"],
"adcp": {
"major_versions": [3],
"supported_versions": ["3.1"],
"idempotency": { "supported": true, "replay_ttl_seconds": 604800 }
},
"supported_protocols": ["media_buy"],
"governance": {
"mode": "enforce",
"human_review": { "creatives": "always", "media_buys_above_usd": 0 }
}
… request_signing, webhook_signing, identity, media_buy, account and ext trimmed
}What tools are there?
curl -s -X POST https://sell.nofluffadvisory.com/mcp \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}' $body = @'
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}
'@
$r = Invoke-RestMethod -Uri 'https://sell.nofluffadvisory.com/mcp' -Method Post `
-ContentType 'application/json' `
-Body $body
$r.result.content[0].text What is for sale?
Four static products. Each carries pricing options, format refs, estimated monthly exposures, and the basis those estimates came from — the avails are GA4-derived and say so, rather than being a number someone liked.
curl -s -X POST https://sell.nofluffadvisory.com/mcp \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_products",
"arguments": {}
}
}' $body = @'
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_products",
"arguments": {}
}
}
'@
$r = Invoke-RestMethod -Uri 'https://sell.nofluffadvisory.com/mcp' -Method Post `
-ContentType 'application/json' `
-Body $body
$r.result.content[0].text Response
{
"adcp_version": "3.1",
"status": "completed",
"cache_scope": "public",
"products": [
{
"product_id": "prod_ros_essays",
"name": "Run of Essays — display",
"kind": "static",
"delivery_type": "non_guaranteed",
"pricing_options": [
{ "pricing_option_id": "cpm_usd_fixed",
"pricing_model": "cpm", "rate": 10, "currency": "USD" }
],
"estimated_exposures": {
"period": "month", "impressions": 158,
"basis": "Measured: 42 impression beacons over trailing 8 days (updated 2026-09-09, daily cron)."
}
}
… prod_agentic_standards, prod_data_collab, prod_interop_test trimmed (captured 2026-09-09; the measured basis moves daily)
],
"curation": "static_catalog_v0"
}| product_id | CPM | Est. / mo | What it is |
|---|---|---|---|
prod_interop_test | $1 | 200 | Token-priced SKU for exactly this exercise. Start here. |
prod_ros_essays | $10 | 87 | Run of essays. The actual rate card. |
prod_agentic_standards | $18 | 400 | AdCP / AAMP / protocol governance cluster. |
prod_data_collab | $18 | 300 | Clean rooms, CDP, identity cluster. |
Est. / mo is read from /products when this page is built (2026-11-11).
prod_ros_essays now reports a measured basis — trailing-8-day impression
beacons, refreshed daily — while the clusters still carry the GA4 planning estimate. The live
response is the number of record.
prod_interop_test exists so you can run the loop without a
budget conversation. It is capped so test buys cannot eat sellable inventory, and it
sorts last on any commercial brief. It is not a rate card.
That call returns the shelf. get_products also takes a
brief and a concept_bridge flag,
which compose a product instead of picking one —
§04 lays out the five routes and which to use when.
What creative can I run?
curl -s -X POST https://sell.nofluffadvisory.com/mcp \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "list_creative_formats",
"arguments": {}
}
}' $body = @'
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "list_creative_formats",
"arguments": {}
}
}
'@
$r = Invoke-RestMethod -Uri 'https://sell.nofluffadvisory.com/mcp' -Method Post `
-ContentType 'application/json' `
-Body $body
$r.result.content[0].text Response
{
"adcp_version": "3.1",
"status": "completed",
"formats": [
{
"format_id": {
"agent_url": "https://sell.nofluffadvisory.com",
"id": "display_300x250"
},
"name": "Medium Rectangle",
"type": "display",
"assets": [
{
"asset_id": "primary", "asset_role": "banner_image",
"asset_type": "image", "required": true,
"requirements": {
"min_width": 300, "max_width": 300,
"min_height": 250, "max_height": 250
}
}
… click_url and alt_text assets trimmed
]
}
… display_728x90 trimmed
]
}04 Targeting: synaptic audiences
This is the part that isn’t a cookie. A synaptic audience is a session-scoped, cookieless audience derived from the connections a reader traverses — the meaning of the current page plus the trajectory of pages before it — rather than from any persistent identifier.
There is no profile, no ID graph, and nothing to sync. The segment is computed from where the reader is and where they just came from, and it evaporates when the session does. Generation is deterministic keyword rules over essay frontmatter, not runtime inference, so the same reader path always produces the same segment — and the rules are readable in the repo.
Pick one route — they escalate
There are five ways to say who you want, and you only need one. They are ordered by how much you have to know up front. Start at A. Go down a rung only when the rung above can’t express what you mean — every step down buys precision with a bit more machinery, and D and E are the only ones that touch a second system.
- A
Buy the product as-is
You want reach across the whole essay set and the product’s own definition is already the audience.
Omit
targetingfrom the package. Nothing else to learn. - B
Name the pages —
essay_slugsYou have read the site and know exactly which essays you want to sit beside.
Put slugs on the package. Deterministic, no matching involved.
- C
Name the audience —
segment_idsYou want the reader regardless of which essay they landed on, and one of the 20 segments below already says it.
Put segment ids on the package. Browse them in
/synaptic/catalog. - D
Describe it —
briefYou can say it in a sentence but don’t want to read a segment list first. This is the common case.
Send prose to
get_products; a product is composed for you (section 05). - E
Use your own vocabulary —
concept_bridgeYou already work in UCP concept ids across several publishers and want to address this inventory in the nouns you hold, not ours.
Add
concept_bridge: trueto the same brief call. Detailed below.
A, B and C are the seller agent alone. D adds in-house brief matching. E is the only route that calls a second agent — and if that agent is down, E degrades to D rather than failing.
Where B and C go in the request
Targeting lives on the package, not the buy. Two dimensions, and they are ORed — a slot matches if it satisfies either one. Omit targeting entirely and you are on route A.
"targeting": {
"segment_ids": ["syn_agentic_standards", "syn_agent_governance"],
"essay_slugs": ["adcp-vs-aamp", "the-answerer-of-record"]
}The catalog (route C)
Twenty segments, each listing every member essay so you can audit exactly what you are buying. Measured serve beacons replace the estimates as they accumulate.
curl -s https://sell.nofluffadvisory.com/synaptic/catalog Invoke-RestMethod -Uri 'https://sell.nofluffadvisory.com/synaptic/catalog' | ConvertTo-Json -Depth 10 Response
{
"catalog_version": "…",
"definition": "A synaptic audience is a session-scoped, cookieless audience derived from the connections a reader traverses — the meaning of the current page plus the trajectory of pages before it — rather than from any persistent identifier.",
"generation": "Deterministic keyword rules over essay frontmatter (scripts/generate-synaptic-facets.mjs, reviewable in the repo). No runtime inference.",
"segments": [
{
"segment_id": "syn_agentic_standards",
"name": "Agentic standards evaluators",
"audience": "Practitioners tracking AdCP/AAMP/UCP and the agentic protocol layer.",
"member_slugs": ["adcp-the-open-standard-for-agentic-advertising",
"adcp-vs-aamp", "…"]
}
… 19 more segments
]
}syn_agentic_standards Agentic standards evaluators 14 essays syn_agentic_buying Agentic buying & automation 42 essays syn_agent_governance Agent governance & accountability 22 essays syn_measurement Measurement & attribution modernizers 23 essays syn_cdp_architecture CDP & data architecture 9 essays syn_clean_rooms Clean room & data collaboration 4 essays syn_identity Identity & addressability 13 essays syn_first_party_data First/zero-party data 12 essays Show all 20 segments
syn_contextual Contextual intelligence 8 essays syn_storytelling Narrative & creative strategy 8 essays syn_embeddings_ai Embeddings & AI infrastructure 6 essays syn_ai_search AI search & answer engines 19 essays syn_agi_futures AGI & intelligence economy 9 essays syn_attention Attention & relevance science 8 essays syn_commerce Commerce & retail media 6 essays syn_walled_gardens Walled gardens & platforms 15 essays syn_fraud_quality Ad fraud & quality 1 essays syn_gtm_us US market entry & GTM 2 essays syn_operators Operating leaders & talent 5 essays syn_howto_builders Hands-on builders 8 essays Route E in full: where the signals agent fits
There is a second agent at adcp.signal-stack.io speaking
AdCP’s signals protocol, and it is independently operated — separate
codebase, separate deploy, separate uptime. By default this seller agent does not
call it, and discovery never depends on it.
What the two now share is a vocabulary, not a retrieval path. Synaptic
ids like syn_clean_rooms are publisher-local — useful here,
meaningless to anyone else. UCP concept ids like
CLEAN_ROOM_PRACTITIONER live in a shared registry, so two
publishers can be compared on the same noun. Each segment now publishes its concept
ids in /synaptic/catalog, computed at build time.
Be clear what that buys you: comparability, not accuracy. Brief matching here is already good without it. What it adds is the ability to address this inventory in nouns you already hold.
The concept ids also resolve the other way. On the signals agent, a concept can carry
member_nodes pointing at its own catalog — so
CDP_ARCHITECT hands back
sig_bi_data_platform_eval alongside our
syn_cdp_architecture. One noun, two independently operated
inventories. That crosswalk covers 4 of the 16 B2B concepts today; the rest carry no
nodes, for the same reason the segments above carry no concepts.
10 of 20 segments carry a concept — and the other 10 are the finding
The UCP registry was consumer/CTV shaped. Mapping 20 B2B ad-tech audiences onto it
returned nothing usable, and lexical matching cheerfully paired “engineers working
with embeddings and AI infrastructure” with
PARENTS_WITH_YOUNG_CHILDREN_US. The registry has since been
extended with 16 B2B role and domain concepts; 10 segments now map cleanly.
The remaining 10 publish no concept at all. A match has to clear a floor
and dominate the runner-up — syn_commerce cleared
the floor but its top two candidates were too close, so it was dropped rather than
guessed. A published mapping is a claim you would act on, so the absence is
deliberate. Unmapped means unmapped, not unpopular.
Making the route E call
Pass concept_bridge: true to
get_products and your brief is resolved against the UCP
registry as well as in-house, then mapped back to inventory. It only ever
widens the result, so opting in cannot make the answer worse.
curl -s -X POST https://sell.nofluffadvisory.com/mcp \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_products",
"arguments": {
"brief": "identity resolution and addressability after third party cookies",
"concept_bridge": true
}
}
}' $body = @'
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_products",
"arguments": {
"brief": "identity resolution and addressability after third party cookies",
"concept_bridge": true
}
}
}
'@
$r = Invoke-RestMethod -Uri 'https://sell.nofluffadvisory.com/mcp' -Method Post `
-ContentType 'application/json' `
-Body $body
$r.result.content[0].text Response
{
"adcp_version": "3.1",
"status": "completed",
"products": [
{
"product_id": "prod_dyn_identity_84fllj",
"segment_ids": ["syn_identity", "syn_clean_rooms", "syn_first_party_data"],
"curation": {
"resolution": "concept_v1",
"concept_bridge": {
"fired": true,
"agent": "adcp.signal-stack.io",
"concepts": ["IDENTITY_RESOLUTION_STRATEGIST"],
"note": "UCP concept ids resolved to inventory via the build-time segment mapping."
}
}
}
… the four static products follow, unchanged
],
"curation": "dynamic_hybrid_v1"
}
The response always states whether the bridge actually contributed.
resolution only reads
concept_v1 when a bridged segment made the final cut;
otherwise you get hybrid_v1 and a
concept_bridge.reason — not_requested,
no_dominant_concept,
concepts_unmapped_to_inventory,
signals_unreachable. Same rule as everything else here: a
signal that didn’t contribute is never dressed up as one that did. The call has a 3s
timeout and degrades to the normal path, so the second agent can be down without
breaking your buy.
And syn_embeddings_ai is a topic, not a method
The trap in that name: syn_embeddings_ai means
readers of the 6 essays about embeddings and AI infrastructure. It does
not mean “the segment built using embeddings.” Every segment id names its subject
matter, the same way syn_clean_rooms does.
Embeddings do appear in the stack, but at a different step — and only one:
- Segment definition (which essays belong to which segment) is pure deterministic keyword rules over essay frontmatter. No embeddings, no inference, reviewable in the repo.
- Brief → segment matching (section 5) is hybrid: lexical keyword scoring fused with semantic similarity against the site’s existing corpus embeddings, via reciprocal-rank fusion.
The response tells you which one actually fired —
dynamic_hybrid_v1 when the semantic pass contributed,
dynamic_lexical_v1 when it degraded to keywords alone. It is
never dressed up as a signal that didn’t contribute. Either way the result is
deterministic: the embedding model is pinned, so the same brief returns the same
product id.
05 Composing a product from a brief
You don’t have to pick a product off the shelf. Pass a
brief to get_products and the agent composes
one from whichever synaptic segments the brief resolves to, returning it ahead of the
static catalog. The composed product is purchasable — its
product_id goes straight into
create_media_buy, and packages inherit its targeting unless you
override them.
curl -s -X POST https://sell.nofluffadvisory.com/mcp \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_products",
"arguments": {
"brief": "reach people evaluating agentic advertising standards"
}
}
}' $body = @'
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_products",
"arguments": {
"brief": "reach people evaluating agentic advertising standards"
}
}
}
'@
$r = Invoke-RestMethod -Uri 'https://sell.nofluffadvisory.com/mcp' -Method Post `
-ContentType 'application/json' `
-Body $body
$r.result.content[0].text Response
{
"adcp_version": "3.1",
"status": "completed",
"products": [
{
"product_id": "prod_dyn_2f0f2a1c8d3b",
"name": "Agentic standards evaluators — composed",
"kind": "dynamic",
"segment_ids": ["syn_agentic_standards", "syn_agent_governance"],
"pricing_options": [
{ "pricing_option_id": "cpm_usd_fixed",
"pricing_model": "cpm", "rate": 18, "currency": "USD" }
]
}
… the four static products follow, unchanged
],
"curation": "dynamic_hybrid_v1",
"curation_note": "Composed prod_dyn_… from syn_agentic_standards, syn_agent_governance — purchasable via create_media_buy; packages inherit the product's essay/segment targeting unless overridden."
}
Composition is deterministic: the same brief produces the same
product_id, word order included. If a brief resolves to nothing you
get the static catalog back with a curation_note saying so, rather
than an invented match.
06 Creating the media buy
The first call that needs a bearer. The sample is a live buy, so it needs the key
we issued you; with a demo- key, add
"account": { "brand": { "domain": "yourbrand.com" }, "operator": "yourbrand.com", "sandbox": true }. Required fields are
brand, packages and
budget; everything else is optional but most of it is worth
sending.
curl -s -X POST https://sell.nofluffadvisory.com/mcp \
-H 'content-type: application/json' \
-H 'authorization: Bearer <your-api-key>' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "create_media_buy",
"arguments": {
"buyer_ref": "my-first-adcp-buy",
"buyer_agent": "https://your-agent.com",
"brand": {
"name": "Your Brand",
"domain": "yourbrand.com"
},
"packages": [
{
"product_id": "prod_interop_test",
"buyer_ref": "pkg-1",
"targeting": {
"segment_ids": [
"syn_agentic_standards"
]
}
}
],
"budget": 1,
"currency": "USD",
"idempotency_key": "my-first-buy-001"
}
}
}' $body = @'
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "create_media_buy",
"arguments": {
"buyer_ref": "my-first-adcp-buy",
"buyer_agent": "https://your-agent.com",
"brand": {
"name": "Your Brand",
"domain": "yourbrand.com"
},
"packages": [
{
"product_id": "prod_interop_test",
"buyer_ref": "pkg-1",
"targeting": {
"segment_ids": [
"syn_agentic_standards"
]
}
}
],
"budget": 1,
"currency": "USD",
"idempotency_key": "my-first-buy-001"
}
}
}
'@
$r = Invoke-RestMethod -Uri 'https://sell.nofluffadvisory.com/mcp' -Method Post `
-ContentType 'application/json' `
-Headers @{ Authorization = 'Bearer <your-api-key>' } `
-Body $body
$r.result.content[0].text What comes back
{
"adcp_version": "3.1",
"status": "completed",
"media_buy_id": "mb_dc593a7ea963",
"buyer_ref": "my-first-adcp-buy",
"media_buy_status": "pending_creatives",
"budget": 1, "currency": "USD", "cpm": 1,
"packages": [
{ "package_id": "pk_9f2c1a44b8e0", "product_id": "prod_interop_test" }
],
"governance": {
"machine_gate": "pass",
"policy_flags": [],
"human_review": { "status": "pending", "floor_usd": 0 }
}
}
Note the two different status fields. The envelope
status is the task — your call succeeded. The
media_buy_status is the buy — it is waiting on creatives
and a human. Domain state never rides the envelope slot.
Fields worth knowing
| Field | Why it matters |
|---|---|
brand | Required, { name, domain } minimum. The domain decides sandbox vs real (see section 2); a real buy also needs an issued key. |
packages[] | Required. Each needs a product_id; add targeting and budget_share as needed. |
budget | Required. Total USD, must be greater than 0 and at most 100,000. |
idempotency_key | Replays the canonical response for the life of the buy. Retry safely. |
webhook_url | HTTPS endpoint for status callbacks (unsigned today; RFC 9421 signing deferred). Strongly recommended given the human gate. |
buyer_agent | Identifier or URL of your agent. Shows up in the approval queue — it is how a human knows who is asking. |
buyer_ref | Your own correlation id. get_media_buys filters on it. |
Checking on it later
curl -s -X POST https://sell.nofluffadvisory.com/mcp \
-H 'content-type: application/json' \
-H 'authorization: Bearer <your-api-key>' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_media_buys",
"arguments": {
"buyer_ref": "my-first-adcp-buy"
}
}
}' $body = @'
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_media_buys",
"arguments": {
"buyer_ref": "my-first-adcp-buy"
}
}
}
'@
$r = Invoke-RestMethod -Uri 'https://sell.nofluffadvisory.com/mcp' -Method Post `
-ContentType 'application/json' `
-Headers @{ Authorization = 'Bearer <your-api-key>' } `
-Body $body
$r.result.content[0].text Response
{
"adcp_version": "3.1",
"status": "completed",
"media_buys": [
{
"media_buy_id": "mb_15cf038726e0",
"buyer_ref": "my-first-adcp-buy",
"media_buy_status": "pending_creatives",
"budget": 1, "currency": "USD", "cpm": 1,
"sandbox": true,
"packages": [
{
"package_id": "pk_f3159f795e41",
"product_id": "prod_interop_test",
"buyer_ref": "pkg-1",
"pricing_option_id": "cpm_usd_fixed",
"targeting": { "segment_ids": ["syn_agentic_standards"] }
}
],
"governance": {
"machine_gate": "pass",
"policy_flags": [],
"human_review": { "status": "pending", "floor_usd": 0 }
},
"valid_actions": ["pause", "cancel"]
}
]
}07 Creatives (and banner specs)
Creative policy is narrow on purpose: an image URL, a click URL, and alt text. No HTML, no JavaScript, no tags, no third-party trackers. A publisher that accepts arbitrary markup has accepted arbitrary code, and this one doesn’t.
The hard rules
- PNG, JPEG or WebP only. SVG is rejected, and so are
data:URIs. We fetch a live creative’s image once, when you sync it, so it has to be hosted and reachable over https. - 300KB maximum, counted on the bytes we receive, so a missing or wrong
content-lengthdoes not get an image past it. - Exact dimensions. Each format pins min and max to the same number, and we read the size from the image itself. 728×90 means 728×90.
- We serve our copy. The bytes we fetched are what the reviewer approves and what readers see, served from sell.nofluffadvisory.com. Changing the image behind your URL changes nothing until you re-sync, and a re-sync with different bytes goes back to review.
- Alt text, 140 characters max; longer is refused with
ALT_TEXT_INVALID. A missing one does not fail the sync: an asset manifest borrows the creative’s name, and anything still empty is flagged for the reviewer. Send real alt text anyway. It is an accessibility requirement, not a formality. - The fetch times out at 6 seconds. A slow CDN fails the creative with
IMAGE_FETCH_FAILED, so resend it. In sandbox nothing is fetched or stored: a HEAD probe (4 seconds) checks the content-type and anycontent-length, and a slow CDN only flags the creative. - Inline creatives on
create_media_buyfollow the same rules. Only the first ten are taken; send the rest withsync_creatives, ten per call. On a live buy, any that fail are listed inext.inline_creative_failures; resend them withsync_creatives.
Formats
| format_id | Size | Where it lands |
|---|---|---|
display_728x90 | 728 × 90 | Leaderboard. Reads well mid-essay on desktop. |
display_300x250 | 300 × 250 | Medium rectangle. The related-essays slot. |
What a good one looks like
The house ads set the register: dark field, restrained type, one line of argument and one link. The slot sits inside an essay someone is actually reading, so a banner that behaves like a banner gets ignored. Below is the house creative that fills any unsold slot — copy the restraint, not the copy.
Ad · served by this AdCP stack728 × 90 · actual size Rendered at 728×90. Note what it doesn’t do: no exclamation marks, no countdown, no fake urgency. It states what happened and offers one link.
300 × 250 Submitting
sync_creatives upserts on your own
creative_id, so re-sending an edited creative updates it rather
than duplicating. assignments bind a creative to a package — without
an assignment the creative sits in your account library and never serves.
curl -s -X POST https://sell.nofluffadvisory.com/mcp \
-H 'content-type: application/json' \
-H 'authorization: Bearer <your-api-key>' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "sync_creatives",
"arguments": {
"media_buy_id": "mb_dc593a7ea963",
"creatives": [
{
"creative_id": "cr-hello-001",
"name": "Hello from my agent",
"format_id": "display_728x90",
"image_url": "https://cdn.yourbrand.com/banner-728x90.png",
"click_url": "https://yourbrand.com/landing",
"alt_text": "Your Brand — agentic media buying, explained."
}
],
"assignments": [
{
"creative_id": "cr-hello-001",
"package_id": "pk_9f2c1a44b8e0"
}
]
}
}
}' $body = @'
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "sync_creatives",
"arguments": {
"media_buy_id": "mb_dc593a7ea963",
"creatives": [
{
"creative_id": "cr-hello-001",
"name": "Hello from my agent",
"format_id": "display_728x90",
"image_url": "https://cdn.yourbrand.com/banner-728x90.png",
"click_url": "https://yourbrand.com/landing",
"alt_text": "Your Brand — agentic media buying, explained."
}
],
"assignments": [
{
"creative_id": "cr-hello-001",
"package_id": "pk_9f2c1a44b8e0"
}
]
}
}
}
'@
$r = Invoke-RestMethod -Uri 'https://sell.nofluffadvisory.com/mcp' -Method Post `
-ContentType 'application/json' `
-Headers @{ Authorization = 'Bearer <your-api-key>' } `
-Body $body
$r.result.content[0].text Response
{
"adcp_version": "3.1",
"status": "completed",
"media_buy_id": "mb_15cf038726e0",
"creatives": [
{
"creative_id": "cr-hello-001",
"platform_id": "cr_42cc86d69b3e",
"action": "created",
"status": "pending_review",
"policy_flags": [],
"assigned_to": ["pk_f3159f795e41"]
}
],
"human_review": "always (v1 policy)"
}Every creative goes to human review independently of the buy. Assignments release if the buy is cancelled; the creative and its review state persist, so a later buy can reuse an already-approved creative.
08 The human gate
There are two gates, and they do different jobs.
- Gate 1 · machine
-
Runs on every commit. Schema validation with machine-readable error codes, plus
eleven policy pattern groups that flag rather than block. Returns
machine_gate: "pass"and anypolicy_flagsin the response. Fast, deterministic, no human involved. - Gate 2 · human
-
Every surviving buy and every creative waits for a person. The auto-accept floor
is $0, which means there is nothing that clears it — a one-dollar buy and a
ten-thousand-dollar buy both wait. Decisions arrive by webhook if you
supplied a
webhook_url.
This is the part of the stack that is an argument, not just an implementation. An agent can be delegated the authority to spend, but that authority is conferred by a party who remains answerable for it — it cannot be computed by the agent itself. The $0 floor is what that claim looks like when you have to write it down in code.
Practical consequence for you: build for asynchronous settlement. Treat
pending as a normal terminal state for a session, not an error, and
let the webhook or a later poll close the loop.
09 Serving
Once approved, the buy competes for two fixed slots on essay pages. The page calls the agent’s decision endpoint with the slot, the essay slug, the format and the session’s synaptic segments; the agent picks a creative or falls back to a house ad.
| Slot | Position |
|---|---|
essay-mid | Mid-essay, inside the reading flow. |
essay-related | Beside the related-essays module at the end. |
The three serving endpoints
/serve— the decision. Takesslot,page,fmt, and optionalsynsegments. Returns the winning creative, or a house ad when nothing matches./px— the impression beacon. Fires on real render, not on ad call./go— the click redirect.
Beacons are first-party and aggregate-only: one row per day, slot, page, segment and buy. No per-user records exist to leak, which is the same reason the audiences are session-scoped in the first place. An unsold slot fills with a house ad, and the house-fill rate is published rather than hidden.
10 Delivery reporting
Impressions, clicks, spend at CPM, pacing against budget, broken out by day. This is the call that closes the loop back at your agent.
curl -s -X POST https://sell.nofluffadvisory.com/mcp \
-H 'content-type: application/json' \
-H 'authorization: Bearer <your-api-key>' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_media_buy_delivery",
"arguments": {
"media_buy_id": "mb_dc593a7ea963"
}
}
}' $body = @'
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_media_buy_delivery",
"arguments": {
"media_buy_id": "mb_dc593a7ea963"
}
}
}
'@
$r = Invoke-RestMethod -Uri 'https://sell.nofluffadvisory.com/mcp' -Method Post `
-ContentType 'application/json' `
-Headers @{ Authorization = 'Bearer <your-api-key>' } `
-Body $body
$r.result.content[0].text Response
{
"adcp_version": "3.1",
"status": "completed",
"media_buy_id": "mb_15cf038726e0",
"media_buy_status": "pending_creatives",
"totals": { "impressions": 0, "clicks": 0, "spend": 0, "currency": "USD" },
"pacing": {
"budget": 1, "spend": 0,
"budget_utilization": 0, "expected_utilization": null,
"pacing_status": "on_track", "projected_exhaustion_date": null
},
"by_day": [],
"measurement_basis": "First-party impression beacons fired on real creative render; aggregate counts only."
}The publisher’s own numbers
Public, no auth: trailing 28-day totals including the house-fill percentage. If the inventory is mostly unsold, this endpoint says so.
curl -s https://sell.nofluffadvisory.com/stats/serve Invoke-RestMethod -Uri 'https://sell.nofluffadvisory.com/stats/serve' | ConvertTo-Json -Depth 10 Response
{
"window": "trailing 28 days",
"totals": {
"impressions": 24, "clicks": 1,
"house_impressions": 3, "paid_impressions": 21,
"house_fill_pct": 12.5
},
"by_day": [
{ "date": "2026-08-04", "impressions": 11, "clicks": 1, "house_impressions": 3 },
{ "date": "2026-08-05", "impressions": 4, "clicks": 0, "house_impressions": 0 }
… remaining days trimmed
],
"basis": "First-party impression beacons fired on real creative render…"
}11 The whole loop in one script
Save it, put in the key we issued you, your brand domain and your image URL, and run
it. It stops where it should: at the human gate. With a demo- key
it runs in sandbox only: add "account": { "brand": { "domain": "yourbrand.com" }, "operator": "yourbrand.com", "sandbox": true } to the buy.
#!/usr/bin/env bash
set -euo pipefail
AGENT="https://sell.nofluffadvisory.com/mcp"
KEY="<your-api-key>"
call () { # call <tool> <json-args> [auth]
local auth=()
[ "${3:-}" = "auth" ] && auth=(-H "authorization: Bearer $KEY")
curl -s -X POST "$AGENT" -H 'content-type: application/json' "${auth[@]}" \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"$1\",\"arguments\":$2}}" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["result"]["content"][0]["text"])'
}
echo "1. What is for sale?"
call get_products '{}'
echo "2. Buy \$1 of the interop SKU, targeted at one synaptic segment."
BUY=$(call create_media_buy '{
"buyer_ref":"loop-demo",
"buyer_agent":"https://your-agent.com",
"brand":{"name":"Your Brand","domain":"yourbrand.com"},
"packages":[{"product_id":"prod_interop_test",
"targeting":{"segment_ids":["syn_agentic_standards"]}}],
"budget":1,"currency":"USD"
}' auth)
echo "$BUY"
MB=$(echo "$BUY" | python3 -c 'import sys,json; print(json.load(sys.stdin)["media_buy_id"])')
PK=$(echo "$BUY" | python3 -c 'import sys,json; print(json.load(sys.stdin)["packages"][0]["package_id"])')
echo "3. Attach a creative (real hosted PNG/JPEG/WebP, under 300KB)."
call sync_creatives "{
\"media_buy_id\":\"$MB\",
\"creatives\":[{\"creative_id\":\"cr-1\",\"format_id\":\"display_728x90\",
\"image_url\":\"https://cdn.yourbrand.com/banner-728x90.png\",
\"click_url\":\"https://yourbrand.com/landing\",
\"alt_text\":\"Your Brand\"}],
\"assignments\":[{\"creative_id\":\"cr-1\",\"package_id\":\"$PK\"}]
}" auth
echo "4. Now it waits for a human. Poll delivery once approved."
call get_media_buy_delivery "{\"media_buy_id\":\"$MB\"}" auth $Agent = 'https://sell.nofluffadvisory.com/mcp'
$Key = '<your-api-key>'
function Invoke-AdcpTool {
param([string]$Tool, [hashtable]$Arguments = @{}, [switch]$Auth)
$payload = @{
jsonrpc = '2.0'; id = 1; method = 'tools/call'
params = @{ name = $Tool; arguments = $Arguments }
} | ConvertTo-Json -Depth 20 -Compress
$headers = @{}
if ($Auth) { $headers['Authorization'] = "Bearer $Key" }
$r = Invoke-RestMethod -Uri $Agent -Method Post `
-ContentType 'application/json' -Headers $headers -Body $payload
$r.result.content[0].text | ConvertFrom-Json
}
Write-Host '1. What is for sale?'
Invoke-AdcpTool -Tool 'get_products' | ConvertTo-Json -Depth 10
Write-Host '2. Buy $1 of the interop SKU, targeted at one synaptic segment.'
$buy = Invoke-AdcpTool -Tool 'create_media_buy' -Auth -Arguments @{
buyer_ref = 'loop-demo'
buyer_agent = 'https://your-agent.com'
brand = @{ name = 'Your Brand'; domain = 'yourbrand.com' }
packages = @(@{
product_id = 'prod_interop_test'
targeting = @{ segment_ids = @('syn_agentic_standards') }
})
budget = 1; currency = 'USD'
}
$buy | ConvertTo-Json -Depth 10
$mb = $buy.media_buy_id
$pk = $buy.packages[0].package_id
Write-Host '3. Attach a creative (real hosted PNG/JPEG/WebP, under 300KB).'
Invoke-AdcpTool -Tool 'sync_creatives' -Auth -Arguments @{
media_buy_id = $mb
creatives = @(@{
creative_id = 'cr-1'; format_id = 'display_728x90'
image_url = 'https://cdn.yourbrand.com/banner-728x90.png'
click_url = 'https://yourbrand.com/landing'
alt_text = 'Your Brand'
})
assignments = @(@{ creative_id = 'cr-1'; package_id = $pk })
} | ConvertTo-Json -Depth 10
Write-Host '4. Now it waits for a human. Poll delivery once approved.'
Invoke-AdcpTool -Tool 'get_media_buy_delivery' -Auth -Arguments @{ media_buy_id = $mb } |
ConvertTo-Json -Depth 10 Or point an MCP client at it
Claude Desktop, Cursor, or anything else that speaks MCP. Drop this in your client config and all 15 tools show up natively.
{
"mcpServers": {
"nofluff-sales-agent": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://sell.nofluffadvisory.com/mcp",
"--header", "Authorization: Bearer <your-api-key>"]
}
}
}12 Tool reference
| Tool | Auth | What it does |
|---|---|---|
get_adcp_capabilities | public | Version support, governance posture, tool list, creative policy. |
list_creative_formats | public | The two display formats and their asset requirements. |
get_products | public | The catalog. Pass a brief to compose a curated product. |
create_media_buy | bearer | Commit a buy. Machine gate, then human review. Idempotent. |
update_media_buy | bearer | Pause, resume, cancel, or change budget / end_date. A raise or a later end on an approved buy goes back to human review before it serves again. |
sync_creatives | bearer | Upsert creatives and bind them to packages. Live images are fetched once, at sync, and stored; the stored copy is what we review and serve. |
list_creatives | bearer | Review status, policy flags, assignments. |
get_media_buys | bearer | Your buys, with status and governance state. |
list_accounts | bearer | Your auto-provisioned buyer account. |
sync_accounts | bearer | Provision or update your buyer account and its notification configs. Idempotent. |
get_media_buy_delivery | bearer | Impressions, clicks, spend, pacing, by day. |
validate_input | bearer | Dry-run a creative manifest against a canonical format, or against a product’s narrowing of one, before committing to sync_creatives. |
preview_creative | bearer | Render a creative manifest and return a URL that shows it. |
sync_governance | bearer | Register the governance agent that owns an account’s approval lifecycle. |
report_usage | bearer | Report usage for a media buy this agent sold: impressions, cost and finality (final, finalized_at, measurement_window). |
13 Errors you may hit
| Error | What happened | Fix |
|---|---|---|
401 unauthorized | A write tool with no bearer, or a token that is neither demo-* nor a key we issued. | Add the Authorization header. |
PERMISSION_DENIED | With a demo-* key: the call would create or change something live (a buy, creative, account or usage report for a real brand). Nothing was written. | Reference the account as { brand, operator, sandbox: true } to test, or ask us for a live key. |
IMAGE_URL_INVALID | The URL is malformed or not https (data: URIs included), carries credentials or a non-default port, names an IP address or a local host, or points at a nofluffadvisory.com subdomain. For a live creative, redirects are checked the same way, three at most. | Host the image on a public https CDN. |
IMAGE_FETCH_FAILED | We could not GET a live creative’s image: a non-2xx answer, no full response within 6 seconds, or a network failure. | Resend once your CDN answers. Nothing was stored or queued. |
IMAGE_TYPE_INVALID | The URL did not answer with a PNG, JPEG or WebP content-type, or the bytes we fetched are not one of those. | Serve a real raster image. SVG is rejected. |
IMAGE_TOO_LARGE | The image is over 300KB, by its content-length or by the bytes we receive. | Compress it. |
IMAGE_DIMENSIONS_INVALID | The image is not exactly the format’s size, read from the image’s own header. A header we cannot read is refused too. | Export at exactly 300×250 or 728×90. A 2× image is refused. |
X_REVIEW_QUOTA | A new image would take your credential’s images waiting for review past 16MB. | Resend once some are decided. |
image_unverified | Sandbox only: the HEAD probe failed or timed out (4s). | Not fatal in sandbox: it flags the creative for review. Live creatives are fetched instead, and a failed fetch is IMAGE_FETCH_FAILED. |
VERSION_UNSUPPORTED | You pinned an adcp_version this agent cannot serve: "3.0", "4.0", a bare "3", or a patch or prerelease outside 3.1 such as "3.2.4" or "3.3-beta.0" (this agent advertises only "3.1"; it answers "3.1", a 3.1 patch such as "3.1.24" and a later minor such as "3.2" with 3.1, and echoes "3.1"). | Pin 3.1 or omit the pin. |
Buy never appears | Your brand domain ends in .example / .test / .invalid / .localhost. | That is sandbox traffic by design. A real buy needs a real domain and an issued key. |
Tell me where it caught
What I want out of this is the first end-to-end AdCP transaction between two independently operated agents, with the snags written down. If you run a buying agent and something here broke, misled you, or needed a workaround, that is the useful part — more useful than a clean run.