Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/billing-did-you-mean-lookup-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
---

Surface `did_you_mean` in Addie billing tool responses when the LLM passes a non-canonical `lookup_key` alias (e.g. `explorer_annual` instead of `aao_membership_explorer_50`). Tighten tool descriptions on `create_payment_link`, `send_invoice`, and `confirm_send_invoice` to explicitly forbid constructing the key from tier name and billing interval. Closes #2550.

Server-side Addie change only — no protocol schema changes.
6 changes: 6 additions & 0 deletions .changeset/docs-media-buy-lifecycle-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
---

Add new canonical Media Buy Lifecycle Flow page (`docs/media-buy/media-buys/lifecycle.mdx`) covering the sequenced task flow, full state machine diagram, guaranteed/PG deal IO acceptance path, and creative sync timing. Adds nav entries in both navigation tabs, a link in the quickstart What's next section, and a link from `sales-agent-creative-capabilities.mdx`. Closes #2570.

Docs-only change — no protocol schema changes.
2 changes: 2 additions & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@
"docs/media-buy/product-discovery/collections-and-installments",
"docs/media-buy/product-discovery/refinement",
"docs/media-buy/media-buys/index",
"docs/media-buy/media-buys/lifecycle",
"docs/media-buy/media-buys/optimization-reporting",
"docs/media-buy/media-buys/policy-compliance",
"docs/media-buy/creatives/index",
Expand Down Expand Up @@ -696,6 +697,7 @@
"docs/media-buy/product-discovery/collections-and-installments",
"docs/media-buy/product-discovery/refinement",
"docs/media-buy/media-buys/index",
"docs/media-buy/media-buys/lifecycle",
"docs/media-buy/media-buys/optimization-reporting",
"docs/media-buy/media-buys/policy-compliance",
"docs/media-buy/creatives/index",
Expand Down
1 change: 1 addition & 0 deletions docs/creative/sales-agent-creative-capabilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,4 @@ if (managesCreatives) {
- [Generative Creative](/docs/creative/generative-creative) — Using `build_creative` for AI-powered generation
- [get_creative_delivery](/docs/creative/task-reference/get_creative_delivery) — Variant-level delivery reporting
- [get_media_buy_delivery](/docs/media-buy/task-reference/get_media_buy_delivery) — Media buy delivery reporting
- [Media Buy Lifecycle](/docs/media-buy/media-buys/lifecycle) — State machine, sequenced flow, guaranteed deal IO path, creative sync timing
170 changes: 170 additions & 0 deletions docs/media-buy/media-buys/lifecycle.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
---
title: Media Buy Lifecycle Flow
description: "Step-by-step sequence from product discovery through delivery, including the guaranteed-deal IO acceptance path and creative sync timing."
"og:title": "AdCP — Media Buy Lifecycle Flow"
---

This page is the canonical sequence reference for media buy lifecycle. For conceptual background on the full lifecycle — campaign structure, package model, property targeting, and async operations — see [Media Buy Lifecycle](/docs/media-buy/media-buys/).

## Standard flow

Every media buy follows four steps:

```mermaid
flowchart TD
A[get_products] --> B[create_media_buy]
B --> C{initial state}
C -->|creatives missing| D[pending_creatives]
C -->|creatives present,\nflight not yet started| E[pending_start]
C -->|creatives present,\nflight started| F[active]
D --> G[sync_creatives]
G --> E
E --> F
F --> H{delivery}
H -->|budget exhausted /\ngoal met / flight ended| I[completed]
H -->|buyer or seller action| J[paused]
J --> F
```

1. **`get_products`** — discover available inventory matching your brief.
2. **`create_media_buy`** — submit packages; the seller validates and confirms.
3. **`sync_creatives`** — assign creative assets to packages that need them.
4. **Delivery** — the buy enters `active`, accrues impressions, and eventually reaches a terminal state.

## State machine

### Media buy states

| State | Meaning | Terminal? |
|-------|---------|-----------|
| `pending_creatives` | Approved; no creatives assigned yet | No |
| `pending_start` | Creatives assigned; waiting for flight date | No |
| `active` | Delivering impressions | No |
| `paused` | Temporarily halted | No |
| `completed` | Flight ended, goal met, or budget exhausted | Yes |
| `rejected` | Seller declined the buy | Yes |
| `canceled` | Buyer or seller terminated before completion | Yes |

<Note>
`pending_manual` and `pending_permission` are **task-level** statuses — they describe whether the *operation* (e.g., `create_media_buy`) is queued for human review, not the media buy's own state. The media buy enters `pending_creatives`, `pending_start`, or `active` once the operation completes. See [Asynchronous Operations](/docs/media-buy/media-buys/#asynchronous-operations-and-human-in-the-loop).
</Note>

### Transitions

```mermaid
stateDiagram-v2
[*] --> pending_creatives : create_media_buy\n(no creatives)
[*] --> pending_start : create_media_buy\n(creatives present,\nflight future)
[*] --> active : create_media_buy\n(creatives present,\nflight started)

pending_creatives --> pending_start : sync_creatives
pending_start --> active : flight date reached

active --> paused : update_media_buy\n(paused: true)
paused --> active : update_media_buy\n(paused: false)

active --> completed : flight ended /\ngoal met / budget exhausted
paused --> completed : flight ended /\ngoal met / budget exhausted

pending_creatives --> rejected : seller declines
pending_start --> rejected : seller declines

pending_creatives --> canceled : update_media_buy\n(canceled: true)
pending_start --> canceled : update_media_buy\n(canceled: true)
active --> canceled : update_media_buy\n(canceled: true)
paused --> canceled : update_media_buy\n(canceled: true)

completed --> [*]
rejected --> [*]
canceled --> [*]
```

### Discovering valid actions at runtime

Rather than hardcoding the state machine, read `valid_actions` from `get_media_buys`. The seller returns exactly what the buyer can do in the current state:

```json
{
"media_buy_id": "mb_12345",
"status": "active",
"revision": 3,
"valid_actions": ["pause", "cancel", "update_budget", "update_dates", "update_packages", "add_packages", "sync_creatives"]
}
```

Always pass `revision` in `update_media_buy` calls. The seller rejects with `CONFLICT` if the revision has changed since your last read.

## Guaranteed / PG deal variation

Products with `delivery_type: "guaranteed"` require contractual commitment before delivery begins. The flow diverges after `create_media_buy`:

```mermaid
flowchart TD
A[get_products\ndelivery_type: guaranteed] --> B[create_media_buy\nwith accountability_terms]
B --> C{task status}
C -->|IO signing needed| D[submitted\ntask_id returned]
C -->|IO pre-signed| E[pending_creatives\nor pending_start]
D --> F[IO signed\nout-of-band]
F --> E
E --> G[sync_creatives\nif needed]
G --> H[active — guaranteed delivery]
H --> I{performance}
I -->|standards met| J[completed]
I -->|under-delivery| K[makegood / remediation]
K --> J
```

### What makes a guaranteed buy different

**`accountability_terms` are required** on each package with a guaranteed product. Three fields are required:

- `performance_standards` — viewability, IVT, completion rate, and other thresholds with measurement vendor
- `measurement_terms` — who counts the billing metric, acceptable variance, and makegood remedies
- `cancellation_policy` — notice period and cancellation fee for early termination

Omitting any of these on a guaranteed package causes the seller to return `TERMS_REJECTED`.

**IO signing** — `create_media_buy` for a guaranteed product may return task status `submitted` with a `task_id` rather than completing synchronously. This means the seller's system is awaiting insertion order (IO) acceptance. Poll with `tasks/get` or configure a webhook. Once the IO is signed, the completion artifact carries the `media_buy_id` and the media buy enters `pending_creatives` or `pending_start`.

**Makegoods** — if the seller under-delivers against agreed `performance_standards`, they propose a remedy from the `makegood_policy`: `additional_delivery`, `credit`, or `invoice_adjustment`. The buyer accepts or disputes.

<Note>
A seller who accepts without under-delivering earns a favorable accountability signal. See [Accountability](/docs/media-buy/advanced-topics/accountability) for the full negotiation flow including how buyers can propose non-default terms at `create_media_buy` time.
</Note>

## Creative sync timing

### When creatives are required

`create_media_buy` accepts inline `creative_assignments` or `creatives` per package. If you supply them at creation time and the flight date has passed, the buy enters `active` directly. If the flight date is in the future, it enters `pending_start`.

If no creatives are assigned at creation, the buy enters `pending_creatives`. Delivery cannot begin until `sync_creatives` is called to assign at least one creative per package.

### The `creative_deadline`

`create_media_buy` returns a `creative_deadline` timestamp on the media buy response. Individual packages may carry their own `creative_deadline`. **Package-level deadlines take precedence over the media buy deadline.** This matters for mixed-channel orders — a print package may have a material deadline days before the digital packages in the same buy.

After the deadline, `sync_creatives` calls for that package return `CREATIVE_REJECTED`. Creative changes are blocked; delivery continues with whatever creatives are currently assigned (or the package remains in `pending_creatives` if none were ever assigned).

```
Deadline hierarchy:
package.creative_deadline (if present — wins)
↓ else
media_buy.creative_deadline
```

### Effect on creatives when a buy ends

When a media buy reaches `rejected`, `canceled`, or `completed`, creative assignments are released. The creatives themselves are not deleted — they remain in the library with their existing review status and are available for assignment to other media buys.

<Tip>
Creative library state and creative assignment state are tracked independently. A creative that was assigned to a canceled buy still has whatever review status it earned and can be immediately assigned to a new buy. See [creative state and assignment state](/docs/creative/creative-libraries#creative-state-and-assignment-state-are-separate).
</Tip>

## See also

- [Media Buy Lifecycle](/docs/media-buy/media-buys/) — full lifecycle reference: campaign structure, package model, async operations
- [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) — task reference with request parameters, response shapes, and examples
- [`sync_creatives`](/docs/creative/task-reference/sync_creatives) — assign and update creative assets on active packages
- [Accountability](/docs/media-buy/advanced-topics/accountability) — performance standards, measurement terms, makegood resolution
- [Optimization & Reporting](/docs/media-buy/media-buys/optimization-reporting) — delivery monitoring, dimensional reporting, campaign updates
1 change: 1 addition & 0 deletions docs/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ console.log(result.data.products);
- **[Compliance Catalog](/docs/building/compliance-catalog)** — the domains and specialisms an agent can claim, and the storyboards that verify each claim
- **[MCP integration guide](/docs/building/integration/mcp-guide)** — transport, sessions, auth details
- **[A2A integration guide](/docs/building/integration/a2a-guide)** — streaming, artifacts, push notifications
- **[Media Buy Lifecycle](/docs/media-buy/media-buys/lifecycle)** — state machine, sequenced flow, guaranteed deal IO path, creative sync timing
- **[Task reference](/docs/media-buy/task-reference)** — all available tasks with testable examples
- **[Error handling](/docs/building/implementation/transport-errors)** — error codes, recovery strategies
- **[Authentication](/docs/building/integration/authentication)** — production credential setup
2 changes: 1 addition & 1 deletion server/src/addie/config-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { loadRules } from './rules/index.js';
* Format: YYYY.MM.N where N is incremented for multiple changes in a month
* Example: 2025.01.1, 2025.01.2, 2025.02.1
*/
export const CODE_VERSION = '2026.04.5';
export const CODE_VERSION = '2026.04.6';

// Types
export interface ConfigVersion {
Expand Down
44 changes: 36 additions & 8 deletions server/src/addie/mcp/billing-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,15 @@ You should ask about their company type and approximate revenue to find the righ
The link is issued to the signed-in member only — the customer email and identity are taken from the
authenticated session, never from caller-supplied input. The member must be signed in at
agenticadvertising.org and have a workspace; if not, refuse and direct them to sign up first.
This tool cannot generate payment links on behalf of other people or organizations.`,
This tool cannot generate payment links on behalf of other people or organizations.
IMPORTANT: Pass lookup_key verbatim from find_membership_products. Do NOT construct it from the tier
name and billing interval (e.g. do not pass "explorer_annual" — pass "aao_membership_explorer_50").`,
input_schema: {
type: 'object' as const,
properties: {
lookup_key: {
type: 'string',
description: 'The product lookup key from find_membership_products',
description: 'The exact lookup_key value returned by find_membership_products — do not construct or guess this value',
},
},
required: ['lookup_key'],
Expand All @@ -79,13 +81,15 @@ This tool cannot generate payment links on behalf of other people or organizatio
description: `Preview an invoice for the authenticated member's own organization so they can
confirm the amount and billing email before it is sent. The contact email and company are taken from
the signed-in session, never from caller-supplied input. After calling this and the member confirms,
call confirm_send_invoice to send.`,
call confirm_send_invoice to send.
IMPORTANT: Pass lookup_key verbatim from find_membership_products. Do NOT construct it from the tier
name and billing interval (e.g. do not pass "explorer_annual" — pass "aao_membership_explorer_50").`,
input_schema: {
type: 'object' as const,
properties: {
lookup_key: {
type: 'string',
description: 'The product lookup key from find_membership_products',
description: 'The exact lookup_key value returned by find_membership_products — do not construct or guess this value',
},
coupon_id: {
type: 'string',
Expand All @@ -105,13 +109,15 @@ call confirm_send_invoice to send.`,
description: `Send an invoice for the authenticated member's own organization after they have
confirmed the details shown by send_invoice. The contact email, company, and billing address come
from the signed-in session — they cannot be overridden. The org must already have a billing address
on file (set via the dashboard or invite-acceptance flow).`,
on file (set via the dashboard or invite-acceptance flow).
IMPORTANT: Pass lookup_key verbatim from find_membership_products. Do NOT construct it from the tier
name and billing interval (e.g. do not pass "explorer_annual" — pass "aao_membership_explorer_50").`,
input_schema: {
type: 'object' as const,
properties: {
lookup_key: {
type: 'string',
description: 'The product lookup key from find_membership_products',
description: 'The exact lookup_key value returned by find_membership_products — do not construct or guess this value',
},
coupon_id: {
type: 'string',
Expand Down Expand Up @@ -261,13 +267,14 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
logger.info({ lookupKey, orgId, workosUserId }, 'Addie: Creating payment link for signed-in member');

try {
const priceId = await getPriceByLookupKey(lookupKey);
if (!priceId) {
const priceResult = await getPriceByLookupKey(lookupKey);
if (!priceResult) {
return JSON.stringify({
success: false,
error: `No product matches lookup_key "${lookupKey}". Call find_membership_products first, then pass the exact lookup_key from the result.`,
});
}
const { priceId, canonicalKey } = priceResult;

const org = await orgDb.getOrganization(orgId);

Expand Down Expand Up @@ -305,6 +312,7 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
success: true,
payment_url: session.url,
message: 'Payment link created. Share this URL with the signed-in member to complete checkout.',
...(canonicalKey !== lookupKey && { did_you_mean: canonicalKey }),
});
} catch (error) {
logger.error({ error }, 'Addie: Error creating payment link');
Expand Down Expand Up @@ -357,6 +365,15 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
logger.info({ lookupKey, orgId, hasCoupon: !!effectiveCouponId }, 'Addie: Previewing invoice for signed-in member');

try {
const priceResult = await getPriceByLookupKey(lookupKey);
if (!priceResult) {
return JSON.stringify({
success: false,
error: `No product matches lookup_key "${lookupKey}". Call find_membership_products first, then pass the exact lookup_key from the result.`,
});
}
const { canonicalKey } = priceResult;

const preview = await validateInvoiceDetails({
lookupKey,
contactEmail: memberEmail,
Expand Down Expand Up @@ -385,6 +402,7 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
discount_description: orgDiscount,
discount_warning: preview.discountWarning,
payment_terms: paymentTerms ?? 30,
...(canonicalKey && canonicalKey !== lookupKey && { did_you_mean: canonicalKey }),
});
} catch (error) {
logger.error({ error }, 'Addie: Error previewing invoice');
Expand Down Expand Up @@ -455,6 +473,15 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
);

try {
const priceResult = await getPriceByLookupKey(lookupKey);
if (!priceResult) {
return JSON.stringify({
success: false,
error: `No product matches lookup_key "${lookupKey}". Call find_membership_products first, then pass the exact lookup_key from the result.`,
});
}
const { canonicalKey } = priceResult;

const result = await createAndSendInvoice({
lookupKey,
companyName: org.name,
Expand All @@ -480,6 +507,7 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
discount_applied: result.discountApplied,
discount_description: orgDiscount,
discount_warning: result.discountWarning,
...(canonicalKey && canonicalKey !== lookupKey && { did_you_mean: canonicalKey }),
});
} catch (error) {
logger.error({ error }, 'Addie: Error sending invoice');
Expand Down
Loading
Loading