` (descriptive, not abbreviated)
+- Fixtures: descriptive names matching what they provide (e.g., `resolved_identity`, `seeded_db`)
+
+### Async Test Pattern
+
+The Sales Agent uses `pytest-asyncio` for testing async functions. Mark async tests explicitly:
+
+```python
+import pytest
+
+@pytest.mark.asyncio
+async def test_get_products_returns_catalog(mock_db_session, resolved_identity):
+ """Verify that get_products returns all products for the tenant."""
+ mock_db_session.execute.return_value.scalars.return_value.all.return_value = [
+ Product(id="p1", name="Display Ads"),
+ Product(id="p2", name="Video Ads"),
+ ]
+
+ result = await get_products_impl(resolved_identity)
+
+ assert len(result) == 2
+ assert result[0].name == "Display Ads"
+```
+
+### Using freezegun for Time-Dependent Tests
+
+For tests that depend on the current time (e.g., campaign start/end dates, token expiry):
+
+```python
+from freezegun import freeze_time
+
+@freeze_time("2026-04-15")
+async def test_campaign_is_active_during_flight(resolved_identity):
+ """Verify a campaign with dates spanning the current time is active."""
+ media_buy = MediaBuy(
+ start_date=date(2026, 4, 1),
+ end_date=date(2026, 4, 30),
+ )
+ assert media_buy.is_within_flight() is True
+```
+
+## Coverage
+
+### Running Coverage Reports
+
+```bash
+# Terminal summary
+uv run pytest tests/unit --cov=src --cov-report=term-missing
+
+# HTML report
+uv run pytest tests/unit --cov=src --cov-report=html
+
+# XML report (for CI integration)
+uv run pytest tests/unit --cov=src --cov-report=xml
+```
+
+### Coverage Targets
+
+While there is no enforced minimum coverage threshold, the following guidelines apply:
+
+{: .table .table-bordered .table-striped }
+| Component | Target | Rationale |
+|-----------|--------|-----------|
+| `_impl` functions | 90%+ | Core business logic must be thoroughly tested |
+| Adapters | 80%+ | Adapter logic is critical but depends on external APIs |
+| Schemas | 95%+ | Validation logic should cover all edge cases |
+| Admin blueprints | 70%+ | UI logic has more integration dependencies |
+
+## CI Integration
+
+The project uses GitHub Actions for continuous integration. The `test.yml` workflow runs on every pull request.
+
+### What CI Runs
+
+{: .table .table-bordered .table-striped }
+| Step | Command | Purpose |
+|------|---------|---------|
+| Lint | `ruff check src/ tests/` | Code quality and style |
+| Type check | `mypy src/` | Static type analysis |
+| Unit tests | `pytest tests/unit` | Fast functional tests |
+| Integration tests | `pytest tests/integration` | Database tests (uses CI PostgreSQL service) |
+| Coverage | `pytest --cov=src --cov-report=xml` | Coverage reporting |
+
+### PR Requirements
+
+All checks must pass before a pull request can be merged:
+
+1. All tests pass (unit and integration).
+2. Ruff reports no lint errors.
+3. mypy reports no type errors.
+4. No decrease in test coverage for changed files.
+
+## Next Steps
+
+- [Development Environment Setup](/agents/salesagent/developers/dev-setup.html) -- Set up your local environment
+- [Database Migrations](/agents/salesagent/developers/migrations.html) -- Working with Alembic migrations
+- [Contributing Guide](/agents/salesagent/developers/contributing.html) -- Branching, commits, and PR workflow
+- [Campaign Lifecycle Tutorial](/agents/salesagent/tutorials/campaign-lifecycle.html) -- Walk through a complete campaign
diff --git a/agents/salesagent/getting-started/buy-side-integration.md b/agents/salesagent/getting-started/buy-side-integration.md
new file mode 100644
index 0000000000..b80781b07b
--- /dev/null
+++ b/agents/salesagent/getting-started/buy-side-integration.md
@@ -0,0 +1,532 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Buy-Side Integration Guide
+description: Guide for AI agent developers to discover inventory and purchase advertising through the Prebid Sales Agent
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Buy-Side Integration Guide
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+This guide is for developers building AI agents that buy advertising. The Prebid Sales Agent exposes publisher inventory through three protocols — MCP, A2A, and REST — so your agent can discover products, create campaigns, upload creatives, and monitor delivery through a standardized interface.
+
+Your agent needs:
+
+1. A **principal token** issued by the publisher (the authentication credential).
+2. The **Sales Agent URL** (e.g., `https://publisher.salesagent.example.com`).
+3. A client for one of the three supported protocols.
+
+## Authentication
+
+All execution operations require a valid principal token. The publisher creates an advertiser account (principal) for you and provides the token.
+
+Include the token in every request using one of these headers:
+
+```text
+x-adcp-auth: your-principal-token
+```
+
+or:
+
+```text
+Authorization: Bearer your-principal-token
+```
+
+Both headers are equivalent. The `x-adcp-auth` header is the AdCP convention; `Authorization: Bearer` is the standard HTTP alternative.
+
+
+ Discovery operations (get_adcp_capabilities, get_products, list_creative_formats, list_authorized_properties) can be called without authentication to browse available inventory. However, execution operations (create_media_buy, sync_creatives, etc.) always require a valid token.
+
+
+## Connecting via MCP
+
+The Model Context Protocol (MCP) is the recommended protocol for AI assistants like Claude Desktop, Cursor, and custom LLM-based agents.
+
+### Python Client (FastMCP)
+
+```python
+from fastmcp import Client
+from fastmcp.client.transports import StreamableHttpTransport
+
+transport = StreamableHttpTransport(
+ "https://publisher.salesagent.example.com/mcp/",
+ headers={"x-adcp-auth": "your-principal-token"}
+)
+
+async with Client(transport=transport) as client:
+ # Discover available tools
+ tools = await client.list_tools()
+
+ # Search for products
+ products = await client.call_tool(
+ "get_products",
+ {"brief": "display ads targeting US tech professionals"}
+ )
+
+ # Create a media buy
+ media_buy = await client.call_tool(
+ "create_media_buy",
+ {
+ "product_id": "prod-001",
+ "name": "Q2 Tech Campaign",
+ "start_date": "2026-04-01",
+ "end_date": "2026-06-30",
+ "budget": 50000,
+ "currency": "USD",
+ "pricing_model": "cpm"
+ }
+ )
+```
+
+### Claude Desktop Configuration
+
+Add the Sales Agent to Claude Desktop's MCP server configuration:
+
+**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
+
+**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
+
+```json
+{
+ "mcpServers": {
+ "publisher-salesagent": {
+ "url": "https://publisher.salesagent.example.com/mcp/",
+ "headers": {
+ "x-adcp-auth": "your-principal-token"
+ }
+ }
+ }
+}
+```
+
+After restarting Claude Desktop, the Sales Agent's tools become available in conversation.
+
+## Connecting via A2A
+
+The Agent-to-Agent (A2A) protocol is designed for agent-to-agent orchestration using JSON-RPC 2.0.
+
+### AgentCard Discovery
+
+Before sending requests, fetch the AgentCard to discover the agent's capabilities:
+
+```bash
+curl https://publisher.salesagent.example.com/.well-known/agent-card.json
+```
+
+The AgentCard returns the agent's identity, supported skills (including four auth-optional discovery skills), authentication requirements, and endpoint URLs.
+
+### Sending a Task
+
+```bash
+curl -X POST https://publisher.salesagent.example.com/a2a \
+ -H "Content-Type: application/json" \
+ -H "x-adcp-auth: your-principal-token" \
+ -d '{
+ "jsonrpc": "2.0",
+ "id": "req-001",
+ "method": "tasks/send",
+ "params": {
+ "id": "task-001",
+ "message": {
+ "role": "user",
+ "parts": [
+ {
+ "type": "text",
+ "text": "Find premium video ad products with at least 80% viewability"
+ }
+ ]
+ }
+ }
+ }'
+```
+
+### Task Lifecycle
+
+A2A tasks follow a state machine:
+
+```text
+submitted → working → completed
+ → failed
+ → canceled
+```
+
+- **submitted**: Your request was received.
+- **working**: The Sales Agent is processing the request. For long operations, subscribe to push notifications.
+- **completed**: Results are available in the task's `artifacts` array.
+- **failed**: An error occurred. Check the task's error details.
+- **canceled**: The task was canceled (by your agent or the server).
+
+### Push Notifications
+
+For long-running operations, include a `pushNotification` configuration in your task to receive webhook callbacks when the task state changes:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "req-002",
+ "method": "tasks/send",
+ "params": {
+ "id": "task-002",
+ "message": {
+ "role": "user",
+ "parts": [{"type": "text", "text": "Create a media buy..."}]
+ },
+ "pushNotification": {
+ "url": "https://your-agent.example.com/webhooks/a2a"
+ }
+ }
+}
+```
+
+## Connecting via REST API
+
+The REST API provides standard HTTP endpoints for programmatic access.
+
+### Base URL
+
+```text
+https://publisher.salesagent.example.com/api/v1
+```
+
+### Example: List Products
+
+```bash
+curl https://publisher.salesagent.example.com/api/v1/products \
+ -H "x-adcp-auth: your-principal-token"
+```
+
+### Example: Create a Media Buy
+
+```bash
+curl -X POST https://publisher.salesagent.example.com/api/v1/media-buys \
+ -H "Content-Type: application/json" \
+ -H "x-adcp-auth: your-principal-token" \
+ -d '{
+ "product_id": "prod-001",
+ "name": "Q2 Tech Campaign",
+ "start_date": "2026-04-01",
+ "end_date": "2026-06-30",
+ "budget": 50000,
+ "currency": "USD",
+ "pricing_model": "cpm"
+ }'
+```
+
+### Example: Get Delivery Metrics
+
+```bash
+curl https://publisher.salesagent.example.com/api/v1/media-buys/mb-001/delivery \
+ -H "x-adcp-auth: your-principal-token"
+```
+
+### Key REST Endpoints
+
+{: .table .table-bordered .table-striped }
+| Method | Path | Auth | Description |
+|--------|------|------|-------------|
+| GET | `/api/v1/capabilities` | Optional | Tenant capabilities and adapter info |
+| GET | `/api/v1/products` | Optional | List or search products |
+| GET | `/api/v1/creative-formats` | Optional | Supported creative formats |
+| GET | `/api/v1/properties` | Optional | Authorized properties |
+| POST | `/api/v1/media-buys` | Required | Create a media buy |
+| PATCH | `/api/v1/media-buys/{id}` | Required | Update a media buy |
+| GET | `/api/v1/media-buys` | Required | List your media buys |
+| GET | `/api/v1/media-buys/{id}/delivery` | Required | Delivery metrics |
+| POST | `/api/v1/media-buys/{id}/creatives` | Required | Sync creatives |
+| GET | `/api/v1/media-buys/{id}/creatives` | Required | List creatives |
+| POST | `/api/v1/performance-index` | Required | Update performance index |
+
+## Discovery Workflow
+
+Before buying, your agent should discover what the publisher offers. These four calls can be made without authentication:
+
+### Step 1: Get Capabilities
+
+```python
+capabilities = await client.call_tool("get_adcp_capabilities", {})
+```
+
+Returns the tenant's supported features, configured adapter, available channels, and targeting capabilities.
+
+### Step 2: Browse Products
+
+```python
+products = await client.call_tool("get_products", {
+ "brief": "video ads for sports fans aged 18-34"
+})
+```
+
+Returns matching products ranked by relevance to your brief. Each product includes name, description, pricing options, accepted creative formats, targeting parameters, and delivery type.
+
+### Step 3: Check Creative Formats
+
+```python
+formats = await client.call_tool("list_creative_formats", {})
+```
+
+Returns the full list of supported creative specifications (dimensions, file types, max file sizes, duration limits for video/audio).
+
+### Step 4: List Properties
+
+```python
+properties = await client.call_tool("list_authorized_properties", {})
+```
+
+Returns the sites and apps where you can target ads, along with their audience profiles and available inventory.
+
+## Buying Workflow
+
+Once you have discovered the right product, follow this workflow to purchase:
+
+### Step 1: Create a Media Buy
+
+```python
+media_buy = await client.call_tool("create_media_buy", {
+ "product_id": "prod-001",
+ "name": "Q2 Sports Video Campaign",
+ "start_date": "2026-04-01",
+ "end_date": "2026-06-30",
+ "budget": 50000,
+ "currency": "USD",
+ "pricing_model": "cpm",
+ "targeting": {
+ "geo": ["US"],
+ "audience": ["sports-enthusiasts"],
+ "age_range": "18-34"
+ }
+})
+media_buy_id = media_buy["media_buy_id"]
+```
+
+### Step 2: Upload Creatives
+
+```python
+creatives = await client.call_tool("sync_creatives", {
+ "media_buy_id": media_buy_id,
+ "creatives": [
+ {
+ "name": "Sports Hero Video 30s",
+ "format_id": "video-preroll-30s",
+ "asset_url": "https://cdn.example.com/ads/sports-hero-30s.mp4",
+ "click_through_url": "https://advertiser.example.com/landing"
+ }
+ ]
+})
+```
+
+### Step 3: Monitor Delivery
+
+```python
+delivery = await client.call_tool("get_media_buy_delivery", {
+ "media_buy_id": media_buy_id
+})
+# Returns: impressions, clicks, spend, CTR, viewability, pacing data
+```
+
+## Handling Approval Workflows
+
+Publishers may configure human-in-the-loop approval for media buys and creatives. When approval is required, the media buy enters `pending_activation` status and publishers review it through the Admin UI or Slack notifications.
+
+### Waiting for Publisher Approval
+
+Your agent should poll the media buy status periodically:
+
+```python
+import asyncio
+
+while True:
+ media_buy = await client.call_tool("get_media_buys", {
+ "media_buy_id": media_buy_id
+ })
+ status = media_buy["status"]
+ if status in ("approved", "active", "delivering"):
+ print("Media buy approved!")
+ break
+ elif status in ("rejected", "canceled"):
+ print(f"Media buy {status}.")
+ break
+ await asyncio.sleep(60) # Poll every minute
+```
+
+## Complete Example
+
+This Python example demonstrates the full workflow from discovery through delivery monitoring:
+
+```python
+import asyncio
+from fastmcp import Client
+from fastmcp.client.transports import StreamableHttpTransport
+
+SALES_AGENT_URL = "https://publisher.salesagent.example.com/mcp/"
+AUTH_TOKEN = "your-principal-token"
+
+async def run_campaign():
+ transport = StreamableHttpTransport(
+ SALES_AGENT_URL,
+ headers={"x-adcp-auth": AUTH_TOKEN}
+ )
+
+ async with Client(transport=transport) as client:
+
+ # --- Step 1: Discovery ---
+ print("Discovering capabilities...")
+ capabilities = await client.call_tool("get_adcp_capabilities", {})
+ print(f"Adapter: {capabilities}")
+
+ # --- Step 2: Find Products ---
+ print("Searching for products...")
+ products = await client.call_tool("get_products", {
+ "brief": "premium video ads for US sports fans, $50k budget"
+ })
+ if not products:
+ print("No matching products found.")
+ return
+
+ # Select the top-ranked product
+ product = products[0]
+ product_id = product["id"]
+ print(f"Selected product: {product['name']}")
+
+ # --- Step 3: Check Creative Requirements ---
+ formats = await client.call_tool("list_creative_formats", {})
+ print(f"Supported formats: {[f['name'] for f in formats]}")
+
+ # --- Step 4: Create Media Buy ---
+ print("Creating media buy...")
+ media_buy = await client.call_tool("create_media_buy", {
+ "product_id": product_id,
+ "name": "Q2 Sports Video Campaign",
+ "start_date": "2026-04-01",
+ "end_date": "2026-06-30",
+ "budget": 50000,
+ "currency": "USD",
+ "pricing_model": "cpm"
+ })
+ media_buy_id = media_buy["media_buy_id"]
+ print(f"Media buy created: {media_buy_id}")
+
+ # --- Step 5: Upload Creatives ---
+ print("Uploading creatives...")
+ await client.call_tool("sync_creatives", {
+ "media_buy_id": media_buy_id,
+ "creatives": [
+ {
+ "name": "Sports Hero Video 30s",
+ "format_id": "video-preroll-30s",
+ "asset_url": "https://cdn.example.com/ads/sports-30s.mp4",
+ "click_through_url": "https://advertiser.example.com/sports"
+ }
+ ]
+ })
+ print("Creatives uploaded.")
+
+ # --- Step 6: Wait for Approval ---
+ print("Waiting for publisher approval...")
+ while True:
+ buys = await client.call_tool("get_media_buys", {})
+ current = next(
+ (b for b in buys if b["media_buy_id"] == media_buy_id),
+ None
+ )
+ if current and current["status"] in (
+ "approved", "active", "delivering"
+ ):
+ print(f"Status: {current['status']}")
+ break
+ elif current and current["status"] in ("rejected", "canceled"):
+ print(f"Media buy {current['status']}.")
+ return
+ await asyncio.sleep(30)
+
+ # --- Step 7: Monitor Delivery ---
+ print("Monitoring delivery...")
+ delivery = await client.call_tool("get_media_buy_delivery", {
+ "media_buy_id": media_buy_id
+ })
+ print(f"Impressions: {delivery.get('impressions', 0)}")
+ print(f"Spend: ${delivery.get('spend', 0):.2f}")
+ print(f"CTR: {delivery.get('ctr', 0):.2%}")
+ print(f"Pacing: {delivery.get('pacing', 'N/A')}")
+
+asyncio.run(run_campaign())
+```
+
+## Error Handling
+
+The Sales Agent returns structured errors with codes and recovery classifications.
+
+### Error Types
+
+All errors are subclasses of `AdCPError` and include a `recovery` hint for the calling agent.
+
+{: .table .table-bordered .table-striped }
+| Error Class | HTTP Status | Description |
+|-------------|-------------|-------------|
+| `AdCPAuthenticationError` | 401 | No valid token provided or token expired/revoked |
+| `AdCPAuthorizationError` | 403 | Token valid but lacks permission for this operation |
+| `AdCPNotFoundError` | 404 | Requested resource does not exist |
+| `AdCPValidationError` | 422 | Request parameters failed validation |
+| `AdCPPolicyError` | 422 | Request violates publisher advertising policies |
+| `AdCPBudgetError` | 422 | Requested budget exceeds configured limits |
+| `AdCPAdapterError` | 502 | The underlying ad server returned an error |
+| `AdCPConfigurationError` | 500 | Server misconfiguration (e.g., missing adapter) |
+| `AdCPRateLimitError` | 429 | Too many requests |
+| `AdCPInternalError` | 500 | Unexpected server error |
+
+For the full error catalog with format examples across all protocols, see the [Error Codes Reference](/agents/salesagent/reference/error-codes.html).
+
+### Recovery Classification
+
+Each error carries a `recovery` hint:
+
+{: .table .table-bordered .table-striped }
+| Classification | Meaning | Action |
+|---------------|---------|--------|
+| **terminal** | The request cannot succeed as-is | Do not retry (e.g., authentication failure, authorization denied) |
+| **correctable** | The request can succeed with changes | Modify parameters and retry (e.g., validation error, policy violation, budget exceeded) |
+| **transient** | Temporary failure | Retry with exponential backoff (e.g., ad server timeout, rate limit, internal error) |
+
+### Example Error Response (MCP)
+
+```json
+{
+ "isError": true,
+ "content": [
+ {
+ "type": "text",
+ "text": "POLICY_VIOLATION: Creative contains content in blocked category 'gambling'. Remove gambling references and resubmit."
+ }
+ ]
+}
+```
+
+### Retry Strategy
+
+For transient errors, implement exponential backoff:
+
+```python
+import asyncio
+import random
+
+async def call_with_retry(client, tool, params, max_retries=3):
+ for attempt in range(max_retries):
+ try:
+ return await client.call_tool(tool, params)
+ except Exception as e:
+ if "transient" not in str(e) or attempt == max_retries - 1:
+ raise
+ delay = (2 ** attempt) + random.uniform(0, 1)
+ await asyncio.sleep(delay)
+```
+
+## Further Reading
+
+- [Architecture & Protocols](/agents/salesagent/architecture.html) — Protocol details, transport parity, database design
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) — Complete catalog of all MCP tools with parameters and examples
+- [Quick Start](/agents/salesagent/getting-started/quickstart.html) — Set up a local Sales Agent for testing
+- [Configuration Reference](/agents/salesagent/getting-started/configuration.html) — Environment variables and settings
diff --git a/agents/salesagent/getting-started/configuration.md b/agents/salesagent/getting-started/configuration.md
new file mode 100644
index 0000000000..14a9c43165
--- /dev/null
+++ b/agents/salesagent/getting-started/configuration.md
@@ -0,0 +1,433 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Configuration Reference
+description: Complete reference for environment variables, Docker Compose, nginx, and per-tenant settings for the Prebid Sales Agent
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Configuration Reference
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Environment Variables
+
+The Sales Agent is configured primarily through environment variables. This section documents every variable organized by category.
+
+### Database
+
+{: .table .table-bordered .table-striped }
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `DATABASE_URL` | (required) | PostgreSQL connection string (e.g., `postgresql://user:pass@host:5432/dbname`) |
+| `DATABASE_QUERY_TIMEOUT` | `30` | Maximum seconds for a single database query before timeout |
+| `DATABASE_CONNECT_TIMEOUT` | `10` | Maximum seconds to wait for a database connection from the pool |
+| `USE_PGBOUNCER` | `false` | Set to `true` if connecting through PgBouncer; adjusts connection pooling behavior |
+
+### Server
+
+{: .table .table-bordered .table-striped }
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `ADCP_SALES_PORT` | `8080` | Port the FastAPI application listens on (nginx proxies to this port) |
+| `ADCP_SALES_HOST` | `0.0.0.0` | Host address the application binds to |
+| `ENVIRONMENT` | `development` | Environment name used for logging and configuration (e.g., `development`, `staging`, `production`) |
+| `PRODUCTION` | `false` | Set to `true` for production deployments; enables stricter security defaults |
+
+### Multi-Tenant
+
+{: .table .table-bordered .table-striped }
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `ADCP_MULTI_TENANT` | `false` | Enable multi-tenant mode with subdomain-based routing |
+| `SALES_AGENT_DOMAIN` | (none) | Full domain for the Sales Agent (e.g., `salesagent.example.com`) |
+| `BASE_DOMAIN` | (none) | Base domain for tenant subdomains (e.g., `example.com`; tenants get `tenant1.example.com`) |
+
+### Authentication
+
+{: .table .table-bordered .table-striped }
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `ADCP_AUTH_TEST_MODE` | `false` | Enable test authentication mode with pre-configured credentials. **Never enable in production.** |
+| `CREATE_DEMO_TENANT` | `false` | Automatically create a demo tenant with sample data on startup |
+| `SUPER_ADMIN_EMAILS` | (none) | Comma-separated list of email addresses granted super admin access (e.g., `admin@co.com,ops@co.com`) |
+| `SUPER_ADMIN_DOMAINS` | (none) | Comma-separated list of email domains granted super admin access (e.g., `yourcompany.com`) |
+
+### Google Ad Manager
+
+{: .table .table-bordered .table-striped }
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `GAM_OAUTH_CLIENT_ID` | (none) | Google OAuth 2.0 client ID for Ad Manager API access |
+| `GAM_OAUTH_CLIENT_SECRET` | (none) | Google OAuth 2.0 client secret for Ad Manager API access |
+| `GCP_PROJECT_ID` | (none) | Google Cloud project ID associated with the Ad Manager API |
+| `GOOGLE_APPLICATION_CREDENTIALS` | (none) | Path to the GCP service account JSON key file |
+
+### AI and Observability
+
+{: .table .table-bordered .table-striped }
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `GEMINI_API_KEY` | (none) | Google Gemini API key for AI agents (naming, review, ranking, policy) |
+| `LOGFIRE_TOKEN` | (none) | [Logfire](https://logfire.pydantic.dev/) token for structured observability and tracing |
+
+
+ AI provider, model, and API key can also be configured per-tenant through the Admin UI, overriding these environment variables. This allows each publisher to use their own AI provider and control costs independently.
+
+
+### Security
+
+{: .table .table-bordered .table-striped }
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `ENCRYPTION_KEY` | (none) | Fernet encryption key for encrypting sensitive configuration values in the database (e.g., adapter credentials). Generate with `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` |
+| `FLASK_SECRET_KEY` | (none) | Secret key for Flask session signing in the Admin UI. Use a strong random string. |
+| `WEBHOOK_SECRET` | (none) | Shared secret for verifying A2A push notification webhook signatures |
+
+### Service Control
+
+{: .table .table-bordered .table-striped }
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `SKIP_MIGRATIONS` | `false` | Skip Alembic database migrations on startup. Useful when running migrations separately. |
+| `SKIP_NGINX` | `false` | Skip starting the nginx reverse proxy. Use when nginx is managed externally. |
+| `SKIP_CRON` | `false` | Skip starting background cron jobs (e.g., delivery metric syncing) |
+
+## Docker Compose Configuration
+
+The default `docker-compose.yml` provides a complete development environment. Here is an annotated example:
+
+```yaml
+version: "3.8"
+
+services:
+ salesagent:
+ build: .
+ ports:
+ - "8000:8000" # nginx reverse proxy
+ environment:
+ # Database
+ DATABASE_URL: postgresql://salesagent:salesagent@postgres:5432/salesagent
+
+ # Server
+ ENVIRONMENT: development
+ ADCP_SALES_PORT: "8080"
+ ADCP_SALES_HOST: "0.0.0.0"
+
+ # Auth (test mode for development)
+ ADCP_AUTH_TEST_MODE: "true"
+ CREATE_DEMO_TENANT: "true"
+ SUPER_ADMIN_EMAILS: "test_super_admin@example.com"
+
+ # Security (development values — replace in production)
+ ENCRYPTION_KEY: "dev-encryption-key-replace-in-prod"
+ FLASK_SECRET_KEY: "dev-flask-secret-replace-in-prod"
+
+ # AI (optional)
+ # GEMINI_API_KEY: "your-gemini-key"
+
+ # Observability (optional)
+ # LOGFIRE_TOKEN: "your-logfire-token"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ postgres:
+ image: postgres:17
+ environment:
+ POSTGRES_USER: salesagent
+ POSTGRES_PASSWORD: salesagent
+ POSTGRES_DB: salesagent
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U salesagent"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+volumes:
+ pgdata:
+```
+
+### Production Overrides
+
+For production, create a `docker-compose.prod.yml` override:
+
+```yaml
+version: "3.8"
+
+services:
+ salesagent:
+ environment:
+ ENVIRONMENT: production
+ PRODUCTION: "true"
+ ADCP_AUTH_TEST_MODE: "false"
+ CREATE_DEMO_TENANT: "false"
+
+ # Real credentials
+ SUPER_ADMIN_EMAILS: "admin@yourpublisher.com"
+ ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
+ FLASK_SECRET_KEY: "${FLASK_SECRET_KEY}"
+ DATABASE_URL: "${DATABASE_URL}"
+
+ # Ad server (example: GAM)
+ GAM_OAUTH_CLIENT_ID: "${GAM_OAUTH_CLIENT_ID}"
+ GAM_OAUTH_CLIENT_SECRET: "${GAM_OAUTH_CLIENT_SECRET}"
+ GCP_PROJECT_ID: "${GCP_PROJECT_ID}"
+ GOOGLE_APPLICATION_CREDENTIALS: /secrets/gcp-sa.json
+
+ # AI
+ GEMINI_API_KEY: "${GEMINI_API_KEY}"
+ volumes:
+ - ./secrets/gcp-sa.json:/secrets/gcp-sa.json:ro
+```
+
+Run with:
+
+```bash
+docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
+```
+
+## Nginx Configuration
+
+The Sales Agent includes an nginx reverse proxy that handles TLS termination, streaming support, and path-based routing to the FastAPI application.
+
+### Default Configuration
+
+nginx listens on port 8000 and proxies to the FastAPI app on port 8080:
+
+```nginx
+upstream app {
+ server 127.0.0.1:8080;
+}
+
+server {
+ listen 8000;
+
+ # Streaming support — disable buffering for streaming responses
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_set_header Connection '';
+ proxy_http_version 1.1;
+ chunked_transfer_encoding off;
+
+ # MCP endpoint (StreamableHTTP transport)
+ location /mcp/ {
+ proxy_pass http://app/mcp/;
+ proxy_read_timeout 86400s; # 24h for long-lived streaming connections
+ proxy_send_timeout 86400s;
+ }
+
+ # A2A endpoint
+ location /a2a {
+ proxy_pass http://app/a2a;
+ }
+
+ # Admin UI
+ location /admin {
+ proxy_pass http://app/admin;
+ }
+
+ # REST API
+ location /api/ {
+ proxy_pass http://app/api/;
+ }
+
+ # Health check
+ location /health {
+ proxy_pass http://app/health;
+ }
+
+ # Agent Card (A2A discovery)
+ location /.well-known/ {
+ proxy_pass http://app/.well-known/;
+ }
+
+ # Landing pages
+ location / {
+ proxy_pass http://app/;
+ }
+}
+```
+
+### SSL/TLS Configuration
+
+For production deployments with SSL:
+
+```nginx
+server {
+ listen 443 ssl http2;
+ server_name salesagent.yourpublisher.com;
+
+ ssl_certificate /etc/ssl/certs/salesagent.crt;
+ ssl_certificate_key /etc/ssl/private/salesagent.key;
+
+ # ... same proxy configuration as above ...
+}
+
+server {
+ listen 80;
+ server_name salesagent.yourpublisher.com;
+ return 301 https://$host$request_uri;
+}
+```
+
+### Streaming Considerations
+
+The MCP protocol uses StreamableHTTP for streaming. Key nginx settings for streaming responses:
+
+- `proxy_buffering off` — Disables response buffering so streaming events are forwarded immediately.
+- `proxy_cache off` — Prevents caching of streaming responses.
+- `proxy_read_timeout 86400s` — Allows long-lived streaming connections (24 hours).
+- `proxy_http_version 1.1` — Required for chunked transfer encoding.
+- `Connection ''` — Prevents nginx from closing the connection prematurely.
+
+
+ If you skip the built-in nginx (SKIP_NGINX=true) and use an external reverse proxy (e.g., Cloudflare, AWS ALB), ensure it supports streaming responses with the settings above. Many CDNs buffer responses by default, which breaks streaming.
+
+
+## Per-Tenant Configuration via Admin UI
+
+Many settings are configurable per-tenant through the Admin UI, allowing each publisher to customize their Sales Agent independently.
+
+### Ad Server Adapter
+
+Configured under **Settings > Ad Server**:
+
+- **Adapter selection**: Choose from GAM, Kevel, Triton Digital, Broadstreet, or Mock.
+- **Connection credentials**: Adapter-specific fields validated by the adapter's `connection_config_class`.
+- **Default channels**: Override the adapter's default media channels.
+
+### SSO / Authentication
+
+Configured under **Settings > Authentication**:
+
+- **OIDC Provider**: Google, Microsoft, Okta, Auth0, or Keycloak.
+- **Client ID and Secret**: OAuth application credentials.
+- **Provider-specific settings**: Tenant ID (Microsoft), domain (Okta/Auth0), realm URL (Keycloak).
+
+### AI Agents
+
+Configured under **Settings > AI**:
+
+- **Provider**: AI model provider (e.g., Google Gemini, OpenAI).
+- **Model**: Specific model version (e.g., `gemini-2.0-flash`, `gpt-4o`).
+- **API Key**: Tenant's own API key for cost isolation.
+- **Creative review thresholds**: Auto-approve and auto-reject confidence scores.
+
+### Advertising Policies
+
+Configured under **Settings > Policies**:
+
+- **Blocked categories**: IAB content categories to reject.
+- **Blocked brands**: Specific advertiser names or domains.
+- **Blocked tactics**: Prohibited ad tactics.
+
+### Naming Templates
+
+Configured under **Settings > Naming**:
+
+- **Campaign naming template**: Pattern for AI-generated campaign names (e.g., `{advertiser}_{product}_{date}`).
+- **Creative naming template**: Pattern for creative asset names.
+
+### Measurement Providers
+
+Configured under **Settings > Measurement**:
+
+- **Viewability provider**: Third-party viewability measurement integration.
+- **Verification provider**: Ad verification service configuration.
+
+## Example .env Files
+
+### Development
+
+```bash
+# Database
+DATABASE_URL=postgresql://salesagent:salesagent@localhost:5432/salesagent
+
+# Server
+ENVIRONMENT=development
+ADCP_SALES_PORT=8080
+ADCP_SALES_HOST=0.0.0.0
+
+# Auth (test mode)
+ADCP_AUTH_TEST_MODE=true
+CREATE_DEMO_TENANT=true
+SUPER_ADMIN_EMAILS=test_super_admin@example.com
+
+# Security (development values)
+ENCRYPTION_KEY=dev-only-encryption-key-32-chars!!
+FLASK_SECRET_KEY=dev-only-flask-secret-key
+
+# AI (optional for development)
+# GEMINI_API_KEY=your-dev-gemini-key
+
+# Observability (optional)
+# LOGFIRE_TOKEN=your-dev-logfire-token
+```
+
+### Production
+
+```bash
+# Database
+DATABASE_URL=postgresql://salesagent:STRONG_PASSWORD@db.internal:5432/salesagent
+DATABASE_QUERY_TIMEOUT=30
+DATABASE_CONNECT_TIMEOUT=10
+USE_PGBOUNCER=true
+
+# Server
+ENVIRONMENT=production
+PRODUCTION=true
+ADCP_SALES_PORT=8080
+ADCP_SALES_HOST=0.0.0.0
+
+# Multi-Tenant (optional)
+# ADCP_MULTI_TENANT=true
+# SALES_AGENT_DOMAIN=salesagent.yourpublisher.com
+# BASE_DOMAIN=yourpublisher.com
+
+# Auth
+ADCP_AUTH_TEST_MODE=false
+CREATE_DEMO_TENANT=false
+SUPER_ADMIN_EMAILS=admin@yourpublisher.com,ops@yourpublisher.com
+
+# Google Ad Manager (if using GAM adapter)
+GAM_OAUTH_CLIENT_ID=your-production-client-id
+GAM_OAUTH_CLIENT_SECRET=your-production-client-secret
+GCP_PROJECT_ID=your-gcp-project
+GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
+
+# AI
+GEMINI_API_KEY=your-production-gemini-key
+
+# Security (use strong random values)
+ENCRYPTION_KEY=generate-with-fernet-generate-key
+FLASK_SECRET_KEY=generate-with-python-secrets-module
+WEBHOOK_SECRET=generate-with-python-secrets-module
+
+# Observability
+LOGFIRE_TOKEN=your-production-logfire-token
+
+# Service Control
+SKIP_MIGRATIONS=false
+SKIP_NGINX=false
+SKIP_CRON=false
+```
+
+
+ Never commit .env files containing real credentials to version control. Use a secrets manager (e.g., Docker secrets, HashiCorp Vault, AWS Secrets Manager) for production deployments.
+
+
+## Further Reading
+
+- [Quick Start](/agents/salesagent/getting-started/quickstart.html) — Get running with default configuration
+- [Publisher Onboarding](/agents/salesagent/getting-started/publisher-onboarding.html) — Step-by-step publisher setup
+- [Architecture & Protocols](/agents/salesagent/architecture.html) — System design and protocol details
+- [Deployment Overview](/agents/salesagent/deployment/deployment-overview.html) — Production deployment guides
diff --git a/agents/salesagent/getting-started/publisher-onboarding.md b/agents/salesagent/getting-started/publisher-onboarding.md
new file mode 100644
index 0000000000..ba1b8af0db
--- /dev/null
+++ b/agents/salesagent/getting-started/publisher-onboarding.md
@@ -0,0 +1,321 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Publisher Onboarding
+description: End-to-end guide for publishers to configure the Prebid Sales Agent with products, pricing, ad server, and policies
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Publisher Onboarding
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+This guide walks publishers through the complete process of setting up the Prebid Sales Agent, from initial deployment through go-live. By the end, you will have a fully configured Sales Agent that AI buying agents can use to discover your inventory and purchase advertising.
+
+The onboarding process follows ten steps:
+
+1. Deploy the Sales Agent
+2. Access the Admin UI
+3. Configure your ad server
+4. Build your product catalog
+5. Configure pricing options
+6. Create advertiser accounts
+7. Configure SSO authentication
+8. Set advertising policies
+9. Configure approval workflows
+10. Test with the mock adapter
+
+## Step 1: Deploy the Sales Agent
+
+Start by deploying the Sales Agent using Docker. For local development and testing:
+
+```bash
+git clone https://github.com/prebid/salesagent.git
+cd salesagent
+docker compose up -d
+```
+
+For production deployments, see the [Deployment Overview](/agents/salesagent/deployment/deployment-overview.html) for Docker, Fly.io, and Google Cloud Run options.
+
+
+ The default Docker Compose configuration runs in test mode with pre-configured demo data. For production, you will need to set
ADCP_AUTH_TEST_MODE=false and configure real authentication. See the
Configuration Reference for all environment variables.
+
+
+## Step 2: Access the Admin UI
+
+Navigate to `http://localhost:8000/admin` (or your production URL) to access the Admin UI.
+
+### First Login
+
+In test mode, log in with:
+
+- **Email**: `test_super_admin@example.com`
+- **Password**: `test123`
+
+### Super Admin Setup
+
+Super admins have unrestricted access to all tenants and system settings. Configure super admin access using environment variables:
+
+```bash
+# Grant super admin to specific email addresses
+SUPER_ADMIN_EMAILS=admin@yourcompany.com,ops@yourcompany.com
+
+# Or grant super admin to all users from a domain
+SUPER_ADMIN_DOMAINS=yourcompany.com
+```
+
+Once logged in, the Admin UI dashboard provides:
+
+- **Activity Stream**: Live SSE-powered feed of all agent interactions
+- **Product Catalog**: Manage your advertising products
+- **Media Buys**: View and approve campaign proposals
+- **Creatives**: Review uploaded creative assets
+- **Settings**: Configure ad server, SSO, AI, and policies
+
+## Step 3: Configure Your Ad Server
+
+Navigate to **Settings > Ad Server** in the Admin UI to select and configure your ad server adapter.
+
+### Available Adapters
+
+{: .table .table-bordered .table-striped }
+| Adapter | Channels | Requirements |
+|---------|----------|-------------|
+| **Google Ad Manager** | Display, OLV, Social | GAM API access, OAuth credentials, GCP project |
+| **Kevel** | Display | Kevel API key |
+| **Triton Digital** | Audio | Triton Digital account credentials |
+| **Broadstreet** | Display | Broadstreet API credentials |
+| **Mock Ad Server** | All | None (in-memory, for testing only) |
+
+### Google Ad Manager Setup
+
+To connect Google Ad Manager:
+
+1. Create a GCP project and enable the Ad Manager API.
+2. Set up OAuth 2.0 credentials (client ID and secret).
+3. Create a service account with Ad Manager access.
+4. In the Admin UI, select **Google Ad Manager** as the adapter.
+5. Enter your GAM network code, OAuth client ID, client secret, and GCP project ID.
+
+Required environment variables for GAM:
+
+```bash
+GAM_OAUTH_CLIENT_ID=your-client-id
+GAM_OAUTH_CLIENT_SECRET=your-client-secret
+GCP_PROJECT_ID=your-gcp-project
+GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
+```
+
+### Kevel, Triton Digital, Broadstreet
+
+For other adapters, select the adapter in the Admin UI and provide the required API credentials in the connection configuration form. Each adapter has a typed configuration class that validates the required fields.
+
+## Step 4: Build Your Product Catalog
+
+Products are the advertising offerings that AI agents discover and purchase. Navigate to **Products** in the Admin UI to create your catalog.
+
+Each product includes:
+
+{: .table .table-bordered .table-striped }
+| Field | Required | Description |
+|-------|----------|-------------|
+| `name` | Yes | Display name shown to buying agents (e.g., "Premium Homepage Leaderboard") |
+| `description` | Yes | Detailed description including audience, placement, and value proposition |
+| `format_ids` | Yes | Creative formats accepted (e.g., display 728x90, video pre-roll) |
+| `pricing_options` | Yes | One or more pricing models with rates (see Step 5) |
+| `targeting` | No | Available targeting parameters (geo, audience, contextual) |
+| `delivery_type` | Yes | How impressions are delivered (e.g., standard, guaranteed, sponsorship) |
+| `delivery_measurement` | Yes | How delivery is measured (e.g., impressions, clicks, completions) |
+| `properties` | No | Specific sites or apps where the product runs |
+
+### Writing Effective Product Descriptions
+
+AI agents use product descriptions to match buyer briefs to your inventory. Write descriptions that clearly communicate:
+
+- **What**: The ad format and placement (e.g., "Full-page interstitial video ad")
+- **Where**: The property and context (e.g., "displayed between articles on SportsFan.com")
+- **Who**: The audience (e.g., "reaching 2M monthly unique visitors aged 18-34")
+- **Why**: The value proposition (e.g., "92% viewability rate, 3.2% average CTR")
+
+## Step 5: Configure Pricing Options
+
+Each product needs at least one pricing option. Pricing options define how advertisers pay for the product.
+
+### Pricing Models
+
+{: .table .table-bordered .table-striped }
+| Model | Code | Description |
+|-------|------|-------------|
+| Cost Per Mille | `cpm` | Price per 1,000 impressions |
+| Cost Per Click | `cpc` | Price per click |
+| Cost Per Completed View | `cpcv` | Price per completed video view |
+| Cost Per Point | `cpp` | Price per rating point |
+| Cost Per View | `cpv` | Price per video view (any duration) |
+| Flat Rate | `flat_rate` | Fixed price for a sponsorship or time period |
+| Viewable CPM | `vcpm` | Price per 1,000 viewable impressions |
+
+### Pricing Option Fields
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `pricing_model` | enum | One of: `cpm`, `cpc`, `cpcv`, `cpp`, `cpv`, `flat_rate`, `vcpm` |
+| `is_fixed` | boolean | `true` for fixed pricing, `false` for negotiable/floor pricing |
+| `rate` | decimal | The price per unit (e.g., $12.50 CPM) |
+| `currency` | string | ISO 4217 currency code (e.g., `USD`, `EUR`, `GBP`) |
+| `min_budget` | decimal | Minimum total spend required for this product |
+| `max_budget` | decimal | Maximum total spend allowed (optional cap) |
+
+### Example Pricing Configuration
+
+A product might have multiple pricing options:
+
+- **Standard CPM**: $12.50 CPM, minimum budget $5,000, fixed rate
+- **Performance CPC**: $2.00 CPC, minimum budget $1,000, negotiable
+- **Sponsorship**: $50,000 flat rate for a one-month homepage takeover
+
+## Step 6: Create Advertiser Accounts
+
+Advertisers (called "principals" in the system) are the entities that buy media through AI agents. Navigate to **Principals** in the Admin UI to create accounts.
+
+Each principal receives an authentication token that their AI agent uses to identify itself when calling the Sales Agent.
+
+### Creating a Principal
+
+1. Click **New Principal** in the Admin UI.
+2. Enter the advertiser's name and contact information.
+3. The system generates an authentication token automatically.
+4. Share the token with the advertiser for use in their AI agent's configuration.
+
+### Token Usage
+
+The advertiser's AI agent includes the token in every request:
+
+```bash
+# As a custom header
+x-adcp-auth: principal-token-here
+
+# Or as a standard Bearer token
+Authorization: Bearer principal-token-here
+```
+
+The token identifies both the tenant (your publisher account) and the specific principal (the advertiser), ensuring proper access control and audit logging.
+
+## Step 7: Configure SSO
+
+For the Admin UI, you can configure Single Sign-On (SSO) so your team members authenticate with your existing identity provider. Navigate to **Settings > Authentication** in the Admin UI.
+
+### Supported Providers
+
+{: .table .table-bordered .table-striped }
+| Provider | Protocol | Configuration Required |
+|----------|----------|----------------------|
+| **Google** | OIDC | Client ID, Client Secret |
+| **Microsoft** | OIDC | Client ID, Client Secret, Tenant ID |
+| **Okta** | OIDC | Client ID, Client Secret, Okta Domain |
+| **Auth0** | OIDC | Client ID, Client Secret, Auth0 Domain |
+| **Keycloak** | OIDC | Client ID, Client Secret, Realm URL |
+
+### Setup Steps
+
+1. Register the Sales Agent as an application in your identity provider.
+2. Set the redirect URI to `https://your-salesagent-domain.com/admin/auth/callback`.
+3. Copy the Client ID and Client Secret from your identity provider.
+4. In the Admin UI, navigate to **Settings > Authentication**.
+5. Select your provider and enter the credentials.
+6. Test the SSO flow by logging out and logging back in.
+
+
+ SSO configuration is per-tenant. In a multi-tenant deployment, each publisher can configure their own identity provider independently.
+
+
+## Step 8: Set Advertising Policies
+
+Advertising policies control what types of campaigns and creatives are allowed on your properties. Navigate to **Settings > Policies** in the Admin UI.
+
+### Policy Types
+
+- **Blocked Categories**: Prevent ads in specific IAB content categories (e.g., alcohol, gambling, political).
+- **Blocked Brands**: Block specific advertisers or brands by name or domain.
+- **Blocked Tactics**: Prohibit specific ad tactics (e.g., auto-play audio, pop-ups, crypto advertising).
+
+### Policy Enforcement
+
+Policies are enforced at two points:
+
+1. **Campaign Creation**: When an AI agent calls `create_media_buy`, the policy agent checks the proposed campaign against your policies. Non-compliant proposals are rejected with clear error messages.
+2. **Creative Review**: When creatives are uploaded via `sync_creatives`, the review agent checks them against your policies and returns compliance results.
+
+## Step 9: Configure Workflows
+
+Workflows control whether campaigns and creatives require human approval before going live. Navigate to **Settings > Workflows** in the Admin UI.
+
+### Approval Modes
+
+{: .table .table-bordered .table-striped }
+| Mode | Behavior |
+|------|----------|
+| `require-human` | All media buys and creatives require manual approval by a publisher admin |
+| `auto-approve` | Media buys and creatives are automatically approved if they meet configured thresholds |
+
+### Creative Auto-Approve Thresholds
+
+When using `auto-approve` mode, the AI review agent evaluates each creative and assigns a confidence score. You can set thresholds to control auto-approval:
+
+- **Auto-approve threshold**: Creatives scoring above this threshold (e.g., 0.95) are approved automatically.
+- **Auto-reject threshold**: Creatives scoring below this threshold (e.g., 0.30) are rejected automatically.
+- **Manual review range**: Creatives scoring between the two thresholds are queued for human review.
+
+### Workflow Tasks
+
+When human approval is required, the system creates workflow tasks that appear in:
+
+- The Admin UI under **Workflow Tasks**
+- Slack notifications via `hitl_webhook_url` (if configured)
+
+AI agents can poll media buy status via `get_media_buys` and notify their operators when approval is needed.
+
+## Step 10: Test with Mock Adapter
+
+Before connecting your production ad server, validate your entire configuration using the Mock Ad Server adapter.
+
+1. In **Settings > Ad Server**, select **Mock Ad Server**.
+2. Have a test AI agent call `get_products` to verify your product catalog appears correctly.
+3. Create a test media buy with `create_media_buy` and verify it flows through your approval workflow.
+4. Upload test creatives with `sync_creatives` and confirm they pass policy checks.
+5. Call `get_media_buy_delivery` to verify mock delivery metrics are returned.
+6. Review the audit logs in the Admin UI to confirm all operations are tracked.
+
+Once everything works with the mock adapter, switch to your production ad server adapter.
+
+## Go-Live Checklist
+
+{: .table .table-bordered .table-striped }
+| Item | Status | Notes |
+|------|--------|-------|
+| Sales Agent deployed to production infrastructure | | See [Deployment Overview](/agents/salesagent/deployment/deployment-overview.html) |
+| `ADCP_AUTH_TEST_MODE=false` set | | Disables test credentials |
+| Production ad server adapter configured | | GAM, Kevel, Triton, or Broadstreet |
+| Ad server API credentials validated | | Test connection from Settings page |
+| Product catalog populated | | At least one product with pricing |
+| Pricing options configured per product | | Include `min_budget` for spend guardrails |
+| At least one principal (advertiser) account created | | Token shared with buyer |
+| SSO configured for admin team | | Test login/logout flow |
+| Advertising policies set | | Blocked categories, brands, tactics |
+| Workflow approval mode selected | | `require-human` recommended for launch |
+| SSL/TLS configured on production domain | | Required for secure token transmission |
+| `ENCRYPTION_KEY` set to a strong random value | | Used for encrypting sensitive config |
+| `FLASK_SECRET_KEY` set to a strong random value | | Used for session security |
+| Monitoring and alerting configured | | Health check at `/health` |
+| Backup strategy for PostgreSQL database | | Point-in-time recovery recommended |
+| End-to-end test completed with real AI agent | | Full workflow: discover → buy → deliver |
+
+## Further Reading
+
+- [Quick Start](/agents/salesagent/getting-started/quickstart.html) — Docker setup and first MCP call
+- [Buy-Side Integration](/agents/salesagent/getting-started/buy-side-integration.html) — Guide for AI agent developers
+- [Configuration Reference](/agents/salesagent/getting-started/configuration.html) — All environment variables
+- [Architecture & Protocols](/agents/salesagent/architecture.html) — System design deep dive
diff --git a/agents/salesagent/getting-started/quickstart.md b/agents/salesagent/getting-started/quickstart.md
new file mode 100644
index 0000000000..c7a50ab261
--- /dev/null
+++ b/agents/salesagent/getting-started/quickstart.md
@@ -0,0 +1,267 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Quick Start
+description: Get the Prebid Sales Agent running locally with Docker in under 5 minutes
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Quick Start
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Prerequisites
+
+Before you begin, ensure you have the following installed:
+
+{: .table .table-bordered .table-striped }
+| Requirement | Minimum Version | Check Command |
+|-------------|----------------|---------------|
+| Docker | 20.10+ | `docker --version` |
+| Docker Compose | 2.0+ | `docker compose version` |
+| git | Any recent version | `git --version` |
+
+
+ The Sales Agent runs entirely in Docker containers. You do not need Python, PostgreSQL, or nginx installed on your host machine.
+
+
+## Clone and Start
+
+```bash
+git clone https://github.com/prebid/salesagent.git
+cd salesagent
+docker compose up -d
+```
+
+Docker Compose will start the following containers:
+
+- **salesagent** — The unified FastAPI application (nginx + app)
+- **postgres** — PostgreSQL database
+- Alembic migrations run automatically on first startup
+
+The first build may take a few minutes while Docker downloads base images and installs dependencies.
+
+## Verify Installation
+
+Once the containers are running, verify that all services are accessible at `http://localhost:8000`:
+
+{: .table .table-bordered .table-striped }
+| Service | URL | Purpose |
+|---------|-----|---------|
+| Admin UI | [http://localhost:8000/admin](http://localhost:8000/admin) | Publisher administration dashboard |
+| MCP Server | [http://localhost:8000/mcp/](http://localhost:8000/mcp/) | FastMCP StreamableHTTP endpoint for AI agents |
+| A2A Server | [http://localhost:8000/a2a](http://localhost:8000/a2a) | JSON-RPC 2.0 Agent-to-Agent endpoint |
+| Health Check | [http://localhost:8000/health](http://localhost:8000/health) | Service readiness status |
+| Agent Card | [http://localhost:8000/.well-known/agent-card.json](http://localhost:8000/.well-known/agent-card.json) | A2A discovery document |
+
+## Test Credentials
+
+The development environment comes pre-configured with test credentials:
+
+{: .table .table-bordered .table-striped }
+| Interface | Credential | Value |
+|-----------|------------|-------|
+| Admin UI | Email | `test_super_admin@example.com` |
+| Admin UI | Password | `test123` |
+| MCP / A2A / REST | Auth Token | `test-token` |
+
+
+ Test credentials are only available when ADCP_AUTH_TEST_MODE=true (the default in Docker Compose). Never use test mode in production.
+
+
+## Your First MCP Call
+
+The easiest way to interact with the Sales Agent is through the `adcp` CLI tool, which communicates over the MCP protocol.
+
+### List Available Tools
+
+```bash
+uvx adcp http://localhost:8000/mcp/ --auth test-token list_tools
+```
+
+This returns the 11 registered MCP tools: `get_adcp_capabilities`, `get_products`, `list_creative_formats`, `list_authorized_properties`, `create_media_buy`, `update_media_buy`, `get_media_buys`, `get_media_buy_delivery`, `sync_creatives`, `list_creatives`, and `update_performance_index`.
+
+### Browse Products
+
+```bash
+uvx adcp http://localhost:8000/mcp/ --auth test-token get_products
+```
+
+This returns the product catalog configured for the demo tenant, including product names, descriptions, pricing options, creative formats, and targeting parameters.
+
+### Search with a Brief
+
+```bash
+uvx adcp http://localhost:8000/mcp/ --auth test-token get_products \
+ --brief "video ads targeting US sports fans with a $25,000 monthly budget"
+```
+
+When a `brief` parameter is provided, the AI ranking agent scores and ranks products by relevance to the buyer's intent.
+
+## Your First Campaign
+
+Create a media buy by calling `create_media_buy` with the required parameters:
+
+```bash
+uvx adcp http://localhost:8000/mcp/ --auth test-token create_media_buy \
+ --product_id "demo-product-001" \
+ --name "Q1 Sports Campaign" \
+ --start_date "2026-04-01" \
+ --end_date "2026-04-30" \
+ --budget 25000 \
+ --currency "USD" \
+ --pricing_model "cpm"
+```
+
+The response includes:
+
+- A `media_buy_id` for tracking the campaign
+- Current `status` (typically `pending_activation` until publisher approval)
+- Any `workflow_tasks` that require completion (e.g., publisher review)
+
+## Connecting an AI Agent
+
+### Python Client (FastMCP)
+
+Use the FastMCP Python client to connect programmatically:
+
+```python
+from fastmcp import Client
+from fastmcp.client.transports import StreamableHttpTransport
+
+transport = StreamableHttpTransport(
+ "http://localhost:8000/mcp/",
+ headers={"x-adcp-auth": "test-token"}
+)
+
+async with Client(transport=transport) as client:
+ # List available tools
+ tools = await client.list_tools()
+ for tool in tools:
+ print(f"{tool.name}: {tool.description}")
+
+ # Search for products
+ result = await client.call_tool(
+ "get_products",
+ {"brief": "display ads for tech audience"}
+ )
+ print(result)
+```
+
+### A2A Client (curl)
+
+```bash
+curl -X POST http://localhost:8000/a2a \
+ -H "Content-Type: application/json" \
+ -H "x-adcp-auth: test-token" \
+ -d '{
+ "jsonrpc": "2.0",
+ "id": "1",
+ "method": "tasks/send",
+ "params": {
+ "id": "task-001",
+ "message": {
+ "role": "user",
+ "parts": [{"type": "text", "text": "List available products"}]
+ }
+ }
+ }'
+```
+
+## Claude Desktop Integration
+
+Add the Sales Agent as an MCP server in your Claude Desktop configuration file.
+
+**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
+
+**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
+
+```json
+{
+ "mcpServers": {
+ "salesagent": {
+ "url": "http://localhost:8000/mcp/",
+ "headers": {
+ "x-adcp-auth": "test-token"
+ }
+ }
+ }
+}
+```
+
+After saving the configuration and restarting Claude Desktop, you can interact with the Sales Agent directly in conversation:
+
+> "Show me the available advertising products."
+>
+> "Create a display campaign for $10,000 targeting US tech audiences starting next month."
+>
+> "What's the delivery status of my active campaigns?"
+
+## Common Docker Commands
+
+{: .table .table-bordered .table-striped }
+| Action | Command |
+|--------|---------|
+| View logs (all services) | `docker compose logs -f` |
+| View logs (app only) | `docker compose logs -f salesagent` |
+| Restart all services | `docker compose restart` |
+| Stop all services | `docker compose down` |
+| Stop and remove volumes | `docker compose down -v` |
+| Rebuild after code changes | `docker compose up -d --build` |
+| Run database migrations | `docker compose exec salesagent alembic upgrade head` |
+| Open a shell in the container | `docker compose exec salesagent bash` |
+| Check container status | `docker compose ps` |
+
+## Troubleshooting
+
+### Port 8000 Already in Use
+
+If port 8000 is occupied by another service, either stop the conflicting service or change the port mapping in `docker-compose.yml`:
+
+```yaml
+ports:
+ - "9000:8000" # Map to port 9000 instead
+```
+
+Then access services at `http://localhost:9000`.
+
+### Database Connection Errors
+
+If you see database connection errors on startup:
+
+1. Check that the PostgreSQL container is running: `docker compose ps`
+2. View PostgreSQL logs: `docker compose logs postgres`
+3. If the database is corrupt, reset it: `docker compose down -v && docker compose up -d`
+
+### Migration Failures
+
+If Alembic migrations fail on startup:
+
+1. Check migration logs: `docker compose logs salesagent | grep -i alembic`
+2. Ensure you are on the latest version: `git pull && docker compose up -d --build`
+3. For a clean start, remove the database volume: `docker compose down -v && docker compose up -d`
+
+### Container Won't Start
+
+If the `salesagent` container exits immediately:
+
+1. Check exit logs: `docker compose logs salesagent`
+2. Verify Docker has sufficient resources (at least 2 GB RAM recommended)
+3. Try rebuilding: `docker compose build --no-cache && docker compose up -d`
+
+### MCP Connection Refused
+
+If `uvx adcp` returns a connection error:
+
+1. Verify the container is running: `docker compose ps`
+2. Check that nginx is proxying correctly: `curl http://localhost:8000/health`
+3. Ensure you include the trailing slash on the MCP URL: `/mcp/` (not `/mcp`)
+
+## Next Steps
+
+- [Publisher Onboarding](/agents/salesagent/getting-started/publisher-onboarding.html) — Configure your products, pricing, and ad server
+- [Buy-Side Integration](/agents/salesagent/getting-started/buy-side-integration.html) — Build an AI agent that buys media
+- [Configuration Reference](/agents/salesagent/getting-started/configuration.html) — Environment variables and advanced settings
+- [Architecture & Protocols](/agents/salesagent/architecture.html) — Deep dive into the system design
+- [Deployment Overview](/agents/salesagent/deployment/deployment-overview.html) — Production deployment on Fly.io or Google Cloud Run
diff --git a/agents/salesagent/glossary.md b/agents/salesagent/glossary.md
new file mode 100644
index 0000000000..e4f0f9d6f4
--- /dev/null
+++ b/agents/salesagent/glossary.md
@@ -0,0 +1,168 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Glossary
+description: Definitions of key terms used in Sales Agent documentation.
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Glossary
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## A
+
+### A2A (Agent-to-Agent Protocol)
+
+A JSON-RPC 2.0 based protocol for autonomous agent-to-agent communication. The Sales Agent uses A2A as a transport layer alongside MCP, enabling other AI agents to discover and invoke tools without human intervention.
+
+### AdCP (Ad Context Protocol)
+
+An open standard that normalizes how AI agents interact with advertising platforms. AdCP defines tools for product discovery, media buying, creative management, and signal activation, providing a unified interface regardless of the underlying ad server.
+
+### Adapter
+
+An implementation of the `AdServerAdapter` interface that translates Sales Agent operations into ad server-specific API calls. Available adapters include GAM, Kevel, Triton Digital, Broadstreet, and Mock.
+
+### AdapterCapabilities
+
+A dataclass describing what features a specific adapter supports, such as inventory sync, geo targeting, dynamic products, and webhooks. Agents can query capabilities via `get_adcp_capabilities` to understand what a publisher's ad server supports.
+
+### AgentCard
+
+An A2A discovery document served at `/.well-known/agent-card.json` that describes an agent's capabilities, supported tools, and authentication requirements. Buyer agents use the AgentCard to learn how to interact with a Sales Agent instance.
+
+### Audit Log
+
+A record in the `audit_logs` database table tracking every operation performed against the system. Each entry includes the tenant, principal, operation type, success status, and structured details for compliance and debugging.
+
+## B
+
+### Brief
+
+A natural language description of advertising needs passed to the `get_products` tool for AI-powered product matching and ranking. The brief allows buyer agents to describe campaign goals in plain language rather than specifying exact targeting parameters.
+
+## C
+
+### Creative
+
+An advertising asset such as an image, video, HTML snippet, or audio file uploaded via the `sync_creatives` tool. Creatives are subject to format validation and optional approval workflows before they can serve in campaigns.
+
+### Creative Agent
+
+An external service that provides creative format specifications for a tenant. Creative agents are registered per-tenant in the Admin UI and queried by the `list_creative_formats` tool to return available ad formats.
+
+## D
+
+### Delivery Measurement
+
+A configuration specifying how campaign delivery is measured and reported. Each product defines a measurement provider that determines how impressions, clicks, and other metrics are tracked.
+
+### Dynamic Product
+
+A product template with `is_dynamic` set to `true` that automatically generates targeted variants by querying signals agents based on buyer briefs. Dynamic products combine a base product definition with real-time audience data to create tailored offerings.
+
+## F
+
+### Fernet Encryption
+
+Symmetric encryption used to protect sensitive database fields such as API keys and OAuth credentials. Configured via the `ENCRYPTION_KEY` environment variable, Fernet ensures that secrets are encrypted at rest in the database.
+
+### Flight Dates
+
+The start and end dates of a media buy campaign, specified as `flight_start_date` and `flight_end_date`. Flight dates define the active delivery window for all packages within a media buy.
+
+### Format ID
+
+A namespaced creative format identifier consisting of two parts: `agent_url` (the creative agent endpoint) and `id` (the format name within that agent). This namespacing allows multiple creative agents to define formats without name collisions.
+
+## H
+
+### HITL (Human-in-the-Loop)
+
+An approval workflow where a human publisher reviews and approves or rejects media buys or creatives before they go live. HITL is managed through the Admin UI workflow queue and optional Slack notifications via `hitl_webhook_url`, and can be configured per-tenant.
+
+## M
+
+### MCP (Model Context Protocol)
+
+A protocol for AI assistant tool integration that the Sales Agent uses as its primary interface. The Sales Agent implements MCP via FastMCP with StreamableHTTP transport, allowing AI assistants to discover and invoke tools through a standardized protocol.
+
+### Media Buy
+
+A campaign booking that includes one or more packages, a total budget, flight dates, and targeting parameters. Media buys are created via `create_media_buy` and progress through status transitions from draft to live to completed.
+
+## P
+
+### Package
+
+A line item within a media buy that is linked to a specific product. Each package has its own budget allocation, targeting overlay, and creative assignments, allowing a single media buy to span multiple products or audiences.
+
+### Pacing Index
+
+A metric ranging from 0.0 to 2.0+ that indicates delivery speed relative to the allocated budget. A value of 1.0 means the campaign is on pace, values below 1.0 indicate under-delivery, and values above 1.0 indicate over-delivery.
+
+### Pricing Option
+
+A pricing model attached to a product, such as CPM at $15.00 or CPC at $2.50. Products can have multiple pricing options, giving buyers flexibility in how they pay for inventory.
+
+### Principal
+
+An advertiser or buyer entity within a tenant. Principals authenticate via tokens, own media buys and creatives, and have scoped permissions that control which operations they can perform.
+
+### Provenance
+
+AI content metadata for EU AI Act Article 50 compliance. Provenance tracks the digital source type, AI tool used, human oversight status, and C2PA manifests to ensure transparency about AI-generated or AI-assisted advertising content.
+
+## R
+
+### ResolvedIdentity
+
+The internal identity object created after token authentication, containing `tenant_id`, `principal_id`, and permissions. All `_impl` functions receive a `ResolvedIdentity` to enforce authorization and tenant isolation.
+
+## S
+
+### Signals Agent
+
+An external service that provides audience segments and targeting data to the Sales Agent. Products can be configured with `signals_agent_ids` to query signals agents for dynamic variant generation based on buyer briefs.
+
+### Strategy
+
+A configuration object for campaign simulation and testing scenarios, used primarily with the Mock adapter. Strategies allow developers to define predictable delivery patterns for integration testing.
+
+### Super Admin
+
+A platform-level administrator with access to all tenants in a multi-tenant deployment. Super admins are configured via `SUPER_ADMIN_EMAILS` and `SUPER_ADMIN_DOMAINS` environment variables and can manage any tenant's configuration.
+
+## T
+
+### Targeting Overlay
+
+Per-package targeting specifications that layer on top of a product's default targeting template. Targeting overlays support geo, device, audience, and custom dimensions, allowing buyers to refine a product's built-in targeting for their specific campaign needs.
+
+### Tenant
+
+The primary isolation boundary in a multi-tenant deployment. Each publisher gets their own tenant with separate products, principals, configuration, and data, ensuring complete separation between publishers on the same platform.
+
+### ToolContext
+
+The FastMCP context object passed to MCP tool handlers. `ToolContext` contains request metadata -- including authentication headers -- used to resolve the caller's identity into a `ResolvedIdentity`.
+
+### Transport Parity
+
+The architectural principle that all transports (MCP, A2A, REST) call the same `_impl` functions, ensuring identical behavior regardless of how a tool is invoked. This principle is enforced by structural test guards that verify all transports share the same code path.
+
+## W
+
+### Workflow Step
+
+A task in the human-in-the-loop approval system. Workflow steps have types such as `creative_approval` and `manual_approval`, and progress through states including `pending`, `completed`, and `failed`. They are managed through the Admin UI workflow queue.
+
+## Further Reading
+
+- [Architecture & Protocols](/agents/salesagent/architecture.html) -- System design and protocol details
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- Complete catalog of all MCP tools
+- [Quick Start](/agents/salesagent/getting-started/quickstart.html) -- Get running in 2 minutes
+- [API Schema Reference](/agents/salesagent/schemas/api-schemas.html) -- Pydantic model definitions
+- [Database Models](/agents/salesagent/schemas/database-models.html) -- SQLAlchemy model reference
diff --git a/agents/salesagent/integrations/broadstreet.md b/agents/salesagent/integrations/broadstreet.md
new file mode 100644
index 0000000000..1933b8d824
--- /dev/null
+++ b/agents/salesagent/integrations/broadstreet.md
@@ -0,0 +1,144 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Broadstreet Integration
+description: Configure and use the Broadstreet adapter for local and community publisher ad serving
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Broadstreet Integration
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+The Broadstreet integration connects the Prebid Sales Agent to the [Broadstreet](https://broadstreetads.com/) ad serving platform. Broadstreet is purpose-built for local and community publishers, offering a simplified ad management experience tailored to direct-sold display advertising.
+
+The adapter is identified by `adapter_name: "broadstreet"` and is implemented as a multi-module package under `src/adapters/broadstreet/`.
+
+{: .alert.alert-warning :}
+The Broadstreet adapter is in design phase and not yet production-ready. The module structure and API client exist but the integration has not been validated against a live Broadstreet account. Use the [Mock Adapter](/agents/salesagent/integrations/mock-adapter.html) for testing until this adapter reaches production status.
+
+## Prerequisites
+
+Before configuring the Broadstreet integration, ensure you have:
+
+- An active Broadstreet account
+- API access credentials from Broadstreet
+- Your Broadstreet network configured with zones and advertisers
+
+## Configuration
+
+The Broadstreet adapter is configured through the Admin UI. Navigate to **Settings > Ad Server** and select **Broadstreet** as the adapter type.
+
+Enter your Broadstreet API credentials and network configuration in the adapter config JSONB field. The exact settings required are defined by the adapter's `config_schema.py` module, which validates the configuration on save.
+
+## Architecture
+
+The Broadstreet adapter follows a modular architecture similar to the GAM adapter, with separate components for different responsibilities:
+
+{: .table .table-bordered .table-striped }
+| Module | File | Responsibility |
+|--------|------|----------------|
+| Adapter | `adapter.py` | Main `Broadstreet(AdServerAdapter)` implementation |
+| API Client | `client.py` | HTTP client for the Broadstreet API |
+| Config Schema | `config_schema.py` | Pydantic validation for adapter configuration |
+| Schemas | `schemas.py` | Broadstreet-specific data models and type definitions |
+| Managers | `managers/` | Specialized managers for inventory and other operations |
+
+### Request Flow
+
+1. The Sales Agent receives a tool call (e.g., `create_media_buy`).
+2. The business logic layer invokes the Broadstreet adapter's corresponding method.
+3. The adapter delegates to the appropriate manager module.
+4. The manager uses `client.py` to make API calls to Broadstreet.
+5. Responses are parsed through `schemas.py` models and returned to the business logic layer.
+
+## Supported Features
+
+### Direct-Sold Display Advertising
+
+The Broadstreet adapter is optimized for the direct-sold advertising workflow that local publishers use:
+
+- **Campaign creation** — Create advertiser campaigns mapped to Broadstreet orders
+- **Creative management** — Upload display ad creatives and associate them with campaigns
+- **Zone targeting** — Target specific ad zones configured in Broadstreet
+- **Delivery reporting** — Pull impression and click metrics from Broadstreet
+- **Advertiser management** — Manage advertiser records within Broadstreet
+
+### Capabilities
+
+{: .table .table-bordered .table-striped }
+| Capability | Supported | Notes |
+|------------|-----------|-------|
+| Display Advertising | Yes | Standard banner and display ad formats |
+| Zone Targeting | Yes | Target Broadstreet ad zones |
+| Geo Targeting | Limited | Basic geographic targeting |
+| Inventory Sync | No | Inventory managed in Broadstreet |
+| Inventory Profiles | No | Not applicable |
+| Custom Targeting | Limited | Zone-based targeting |
+| Dynamic Products | No | Products statically configured |
+| Frequency Capping | No | Not supported by the adapter |
+| Webhooks | No | Use polling for delivery data |
+
+## Use Cases
+
+### Local News Publishers
+
+Broadstreet is widely used by local news organizations that sell advertising directly to local businesses. The Sales Agent integration allows these publishers to:
+
+- Offer their ad inventory to AI buying agents
+- Automate campaign setup that would otherwise require manual Broadstreet configuration
+- Provide self-service buying for local advertisers through AI-powered interfaces
+
+### Community Media
+
+Community-focused media outlets (neighborhood blogs, hyperlocal news, community radio websites) benefit from Broadstreet's simplicity. The Sales Agent adapter enables:
+
+- Small publishers to participate in the AI-driven advertising ecosystem
+- Automated campaign fulfillment without dedicated ad operations staff
+- Standardized reporting through the Sales Agent's delivery metrics tools
+
+### Niche Vertical Publishers
+
+Publishers in niche verticals (e.g., local sports, community events, regional business news) that use Broadstreet for its ease of management can extend their reach through the Sales Agent.
+
+## Limitations
+
+{: .table .table-bordered .table-striped }
+| Area | Limitation |
+|------|-----------|
+| Channels | Display only (no video, audio, or native) |
+| Targeting | Zone-based and basic geo; no audience or contextual targeting |
+| Scale | Designed for small to mid-size publishers; not intended for high-volume programmatic |
+| Frequency Capping | Not supported |
+| Inventory Sync | Not supported — inventory managed in Broadstreet |
+| Inventory Profiles | Not supported |
+| Dynamic Products | Not supported |
+| Reporting | Basic delivery metrics (impressions, clicks) |
+
+## Troubleshooting
+
+### Common Errors
+
+{: .table .table-bordered .table-striped }
+| Error | Cause | Resolution |
+|-------|-------|------------|
+| `AuthenticationError` | Invalid API credentials | Verify API credentials in Admin UI adapter config |
+| `ConfigValidationError` | Invalid adapter configuration | Check that all required fields defined in `config_schema.py` are present |
+| `ZoneNotFound` | Referenced zone does not exist in Broadstreet | Verify zone IDs in product configuration match zones in your Broadstreet account |
+| `CreativeUploadFailed` | Creative asset rejected | Ensure creative dimensions and file format are supported by Broadstreet |
+
+### Debugging Tips
+
+1. Review the Sales Agent logs for Broadstreet API response codes and error messages.
+2. Verify your Broadstreet account has the necessary zones and advertisers configured before creating media buys.
+3. Test the adapter configuration by using the Admin UI health check or calling `get_adcp_capabilities` through the MCP/A2A/REST interface.
+4. Use the [Mock Adapter](/agents/salesagent/integrations/mock-adapter.html) to validate media buy flows before switching to the live Broadstreet adapter.
+
+## Further Reading
+
+- [Architecture & Protocols](/agents/salesagent/architecture.html) — Adapter pattern overview
+- [Google Ad Manager Integration](/agents/salesagent/integrations/gam.html) — Full-featured ad server integration
+- [Building a Custom Adapter](/agents/salesagent/integrations/custom-adapter.html) — Extend the adapter pattern
diff --git a/agents/salesagent/integrations/custom-adapter.md b/agents/salesagent/integrations/custom-adapter.md
new file mode 100644
index 0000000000..9e3d69de20
--- /dev/null
+++ b/agents/salesagent/integrations/custom-adapter.md
@@ -0,0 +1,608 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Building a Custom Adapter
+description: How to implement a custom ad server adapter for the Prebid Sales Agent
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Building a Custom Adapter
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+The Prebid Sales Agent uses an abstract adapter pattern to integrate with ad servers. If your ad server is not one of the built-in options (Google Ad Manager, Kevel, Triton Digital, Broadstreet), you can build a custom adapter by implementing the `AdServerAdapter` interface.
+
+This guide walks through the full process of creating, testing, and registering a custom adapter.
+
+## The AdServerAdapter Interface
+
+All adapters extend the abstract base class `AdServerAdapter` defined in `src/adapters/base.py`. The interface consists of required abstract methods, class variables, and optional configuration hooks.
+
+### Required Abstract Methods
+
+Every adapter must implement these five methods:
+
+```python
+from src.adapters.base import AdServerAdapter
+
+class MyAdapter(AdServerAdapter):
+
+ async def create_media_buy(self, media_buy, products, creatives=None):
+ """Push a new media buy to the ad server.
+
+ Creates the corresponding campaign/order/flight in the ad server
+ and returns a result with the external IDs.
+
+ Args:
+ media_buy: The MediaBuy object to create.
+ products: List of Product objects referenced by the media buy.
+ creatives: Optional list of Creative objects to attach.
+
+ Returns:
+ MediaBuyResult with ad server IDs and status.
+ """
+ ...
+
+ async def update_media_buy(self, media_buy, products, creatives=None):
+ """Modify an existing media buy in the ad server.
+
+ Updates the campaign/order/flight with changed fields
+ (budget, dates, targeting, etc.).
+
+ Args:
+ media_buy: The updated MediaBuy object.
+ products: List of Product objects referenced by the media buy.
+ creatives: Optional list of Creative objects to attach.
+
+ Returns:
+ MediaBuyResult with updated status.
+ """
+ ...
+
+ async def get_media_buy_delivery(self, media_buy):
+ """Fetch delivery metrics for a media buy.
+
+ Pulls impressions, clicks, spend, and other delivery data
+ from the ad server.
+
+ Args:
+ media_buy: The MediaBuy to get delivery for.
+
+ Returns:
+ DeliveryResult with current metrics.
+ """
+ ...
+
+ async def check_media_buy_status(self, media_buy):
+ """Check the current status of a media buy in the ad server.
+
+ Returns whether the campaign is active, paused, completed, etc.
+
+ Args:
+ media_buy: The MediaBuy to check.
+
+ Returns:
+ StatusResult with current ad server status.
+ """
+ ...
+
+ async def upload_creatives(self, media_buy, creatives):
+ """Upload creative assets to the ad server.
+
+ Uploads files, creates creative entities, and associates
+ them with the campaign.
+
+ Args:
+ media_buy: The MediaBuy to associate creatives with.
+ creatives: List of Creative objects to upload.
+
+ Returns:
+ CreativeUploadResult with ad server creative IDs.
+ """
+ ...
+```
+
+### Required Class Variables
+
+Every adapter must declare the following class-level variables:
+
+{: .table .table-bordered .table-striped }
+| Variable | Type | Description |
+|----------|------|-------------|
+| `adapter_name` | `ClassVar[str]` | Unique identifier string (e.g., `"my_ad_server"`). Used in the `adapter_config` table and Admin UI. |
+| `default_channels` | `list[str]` | Default media channels this adapter supports (e.g., `["display", "video"]`). |
+| `default_delivery_measurement` | `dict` | Default delivery measurement configuration for reporting. |
+
+### Optional Configuration
+
+{: .table .table-bordered .table-striped }
+| Property | Type | Description |
+|----------|------|-------------|
+| `connection_config_class` | Pydantic `BaseModel` subclass | Validates adapter-specific connection settings from the JSONB config. |
+| `product_config_class` | Pydantic `BaseModel` subclass | Validates product-level adapter configuration. |
+| `capabilities` | `AdapterCapabilities` | Declares what the adapter supports (targeting, sync, pricing models, etc.). |
+
+### Instance Properties
+
+The base class provides these properties to all adapter instances:
+
+{: .table .table-bordered .table-striped }
+| Property | Type | Description |
+|----------|------|-------------|
+| `config` | `dict` | Adapter configuration from the `adapter_config` table |
+| `principal` | `Principal` | The authenticated advertiser making the request |
+| `dry_run` | `bool` | Whether the adapter should execute in dry-run mode |
+| `creative_engine` | `CreativeEngineAdapter \| None` | Optional creative processing engine |
+| `tenant_id` | `str \| None` | The current tenant's ID |
+
+### Helper Methods
+
+The base class provides helper methods available to all adapters:
+
+- **`log()`** — Structured logging with adapter context
+- **`get_supported_pricing_models()`** — Returns the list of pricing models from capabilities
+- **`get_targeting_capabilities()`** — Returns the `TargetingCapabilities` for this adapter
+- **`validate_media_buy_request()`** — Validates a media buy request against adapter capabilities
+
+## Step-by-Step Guide
+
+### Step 1: Create the Adapter File
+
+Create a new file in `src/adapters/`. For simple adapters, a single file is sufficient. For complex integrations, create a package directory (like `src/adapters/gam/` or `src/adapters/broadstreet/`).
+
+```bash
+# Simple adapter
+touch src/adapters/my_ad_server.py
+
+# Complex adapter with managers
+mkdir -p src/adapters/my_ad_server
+touch src/adapters/my_ad_server/__init__.py
+touch src/adapters/my_ad_server/adapter.py
+touch src/adapters/my_ad_server/client.py
+```
+
+### Step 2: Implement the Abstract Interface
+
+Start with the class declaration and required abstract methods:
+
+```python
+from typing import ClassVar
+from src.adapters.base import (
+ AdServerAdapter,
+ AdapterCapabilities,
+ TargetingCapabilities,
+)
+
+
+class MyAdServer(AdServerAdapter):
+ adapter_name: ClassVar[str] = "my_ad_server"
+ default_channels: list[str] = ["display"]
+ default_delivery_measurement: dict = {
+ "impressions": True,
+ "clicks": True,
+ "spend": True,
+ }
+
+ async def create_media_buy(self, media_buy, products, creatives=None):
+ # Your implementation here
+ ...
+
+ async def update_media_buy(self, media_buy, products, creatives=None):
+ ...
+
+ async def get_media_buy_delivery(self, media_buy):
+ ...
+
+ async def check_media_buy_status(self, media_buy):
+ ...
+
+ async def upload_creatives(self, media_buy, creatives):
+ ...
+```
+
+### Step 3: Define a Connection Config Class
+
+Create a Pydantic model that validates the adapter-specific settings stored in the `adapter_config` table's JSONB `config` column:
+
+```python
+from pydantic import BaseModel, Field
+from src.adapters.base import BaseConnectionConfig
+
+
+class MyAdServerConnectionConfig(BaseConnectionConfig):
+ api_url: str = Field(..., description="Base URL for the ad server API")
+ api_key: str = Field(..., description="API authentication key")
+ network_id: int = Field(..., description="Ad server network identifier")
+ timeout_seconds: int = Field(default=30, description="API request timeout")
+
+
+class MyAdServer(AdServerAdapter):
+ adapter_name: ClassVar[str] = "my_ad_server"
+ connection_config_class = MyAdServerConnectionConfig
+ ...
+```
+
+When the adapter is instantiated, the JSONB config is validated against this model. Invalid configurations raise a validation error at startup rather than at request time.
+
+### Step 4: Define Capabilities
+
+Declare what your adapter supports by creating an `AdapterCapabilities` instance:
+
+```python
+class MyAdServer(AdServerAdapter):
+ adapter_name: ClassVar[str] = "my_ad_server"
+
+ capabilities = AdapterCapabilities(
+ supports_inventory_sync=False,
+ supports_inventory_profiles=False,
+ inventory_entity_label="placement",
+ supports_custom_targeting=True,
+ supports_geo_targeting=True,
+ supports_dynamic_products=False,
+ supported_pricing_models=["cpm", "cpc"],
+ supports_webhooks=False,
+ supports_realtime_reporting=False,
+ )
+```
+
+### Step 5: Implement Targeting
+
+If your adapter supports targeting, define a `TargetingCapabilities` and implement the targeting methods:
+
+```python
+class MyAdServer(AdServerAdapter):
+ ...
+
+ def get_targeting_capabilities(self) -> TargetingCapabilities:
+ return TargetingCapabilities(
+ geo_countries=True,
+ geo_regions=True,
+ geo_nielsen_dma=False,
+ geo_eurostat_nuts2=False,
+ geo_uk_itl1=False,
+ geo_uk_itl2=False,
+ postal_us_zip=True,
+ postal_us_zip4=False,
+ postal_canadian=False,
+ postal_uk=False,
+ postal_german=False,
+ postal_french=False,
+ postal_australian=False,
+ )
+
+ def validate_media_buy_request(self, media_buy, products):
+ """Validate targeting rules against adapter capabilities."""
+ capabilities = self.get_targeting_capabilities()
+ # Check that requested targeting dimensions are supported
+ # Raise AdCPAdapterError if validation fails
+ ...
+```
+
+### Step 6: Register the Adapter
+
+Add your adapter to the `AVAILABLE_ADAPTERS` registry in `src/core/main.py`:
+
+```python
+from src.adapters.my_ad_server import MyAdServer
+
+AVAILABLE_ADAPTERS = {
+ "google_ad_manager": GoogleAdManager,
+ "kevel": Kevel,
+ "triton_digital": TritonDigital,
+ "broadstreet": Broadstreet,
+ "mock": MockAdServer,
+ "my_ad_server": MyAdServer, # Add your adapter here
+}
+```
+
+After registration, the adapter appears as an option in the Admin UI under **Settings > Ad Server**.
+
+## AdapterCapabilities Fields
+
+The `AdapterCapabilities` dataclass declares what features an adapter supports. The Sales Agent uses these declarations to validate requests and expose accurate capability information to AI buying agents.
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `supports_inventory_sync` | `bool` | `False` | Whether the adapter can synchronize inventory (ad units, placements) from the ad server |
+| `supports_inventory_profiles` | `bool` | `False` | Whether the adapter supports reusable inventory profile configurations |
+| `inventory_entity_label` | `str` | `"ad_unit"` | Human-readable label for the ad server's inventory entity (e.g., "ad unit", "placement", "zone") |
+| `supports_custom_targeting` | `bool` | `False` | Whether the adapter supports custom key-value targeting |
+| `supports_geo_targeting` | `bool` | `False` | Whether the adapter supports geographic targeting |
+| `supports_dynamic_products` | `bool` | `False` | Whether products can dynamically reference ad server inventory |
+| `supported_pricing_models` | `list[str]` | `[]` | List of supported pricing models (e.g., `["cpm", "cpc", "cpd"]`) |
+| `supports_webhooks` | `bool` | `False` | Whether the adapter can send webhook notifications for status changes |
+| `supports_realtime_reporting` | `bool` | `False` | Whether the adapter provides real-time (vs. delayed) delivery reporting |
+
+## TargetingCapabilities Fields
+
+The `TargetingCapabilities` dataclass describes the geographic and postal targeting dimensions an adapter supports:
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `geo_countries` | `bool` | `False` | Country-level geographic targeting |
+| `geo_regions` | `bool` | `False` | Region/state-level geographic targeting |
+| `geo_nielsen_dma` | `bool` | `False` | Nielsen DMA (Designated Market Area) targeting — US broadcast markets |
+| `geo_eurostat_nuts2` | `bool` | `False` | EUROSTAT NUTS2 region targeting — European statistical regions |
+| `geo_uk_itl1` | `bool` | `False` | UK ITL1 (International Territorial Level 1) targeting — UK nations/regions |
+| `geo_uk_itl2` | `bool` | `False` | UK ITL2 targeting — UK sub-regions |
+| `postal_us_zip` | `bool` | `False` | US ZIP code targeting |
+| `postal_us_zip4` | `bool` | `False` | US ZIP+4 (9-digit) targeting |
+| `postal_canadian` | `bool` | `False` | Canadian postal code targeting |
+| `postal_uk` | `bool` | `False` | UK postcode targeting |
+| `postal_german` | `bool` | `False` | German PLZ (Postleitzahl) targeting |
+| `postal_french` | `bool` | `False` | French Code Postal targeting |
+| `postal_australian` | `bool` | `False` | Australian postcode targeting |
+
+## Optional Base Mixins
+
+Beyond the core `AdServerAdapter` interface, the Sales Agent provides two optional mixin classes for adapters that need inventory discovery or workflow management capabilities. These are not required -- a minimal adapter only needs `AdServerAdapter`.
+
+### Inventory Discovery Mixin
+
+`BaseInventoryManager` (`src/adapters/base_inventory.py`) provides inventory discovery capabilities for adapters that can browse their ad server's available inventory.
+
+**Abstract methods to implement:**
+
+{: .table .table-bordered .table-striped }
+| Method | Description |
+|--------|-------------|
+| `discover_inventory()` | Fetch available inventory (ad units, placements) from the ad server |
+| `validate_inventory_ids(ids: list[str])` | Verify that inventory IDs exist and are accessible |
+| `build_inventory_response()` | Format inventory data for API responses |
+| `suggest_products()` | Generate product suggestions based on available inventory |
+
+**Example usage:**
+
+```python
+from src.adapters.base import AdServerAdapter
+from src.adapters.base_inventory import BaseInventoryManager
+
+
+class MyAdServer(AdServerAdapter, BaseInventoryManager):
+ adapter_name: ClassVar[str] = "my_ad_server"
+
+ async def discover_inventory(self):
+ # Fetch ad units from your ad server API
+ return await self._client.list_ad_units()
+
+ async def validate_inventory_ids(self, ids: list[str]):
+ # Verify each ID exists in the ad server
+ ...
+
+ async def build_inventory_response(self):
+ # Format for the Sales Agent API
+ ...
+
+ async def suggest_products(self):
+ # Generate product suggestions from inventory
+ ...
+```
+
+Used by: The GAM adapter (for syncing ad units and placements from Google Ad Manager).
+
+### Workflow Management Mixin
+
+`BaseWorkflowManager` (`src/adapters/base_workflow.py`) provides human-in-the-loop workflow support for adapters that require manual approval steps before executing operations.
+
+**Methods provided:**
+
+{: .table .table-bordered .table-striped }
+| Method | Description |
+|--------|-------------|
+| `create_workflow_step(step_type, owner, request_data)` | Create a new approval task with the given type, owner, and request data |
+| `_create_context()` | Initialize a workflow context for grouping related steps |
+| `_generate_step_id()` | Generate unique step identifiers |
+
+**Example usage:**
+
+```python
+from src.adapters.base import AdServerAdapter
+from src.adapters.base_workflow import BaseWorkflowManager
+
+
+class MyAdServer(AdServerAdapter, BaseWorkflowManager):
+ adapter_name: ClassVar[str] = "my_ad_server"
+
+ async def create_media_buy(self, media_buy, products, creatives=None):
+ # Require approval before pushing to ad server
+ step = await self.create_workflow_step(
+ step_type="media_buy_approval",
+ owner="ops_team",
+ request_data={"media_buy_id": media_buy.id, "budget": media_buy.budget},
+ )
+ return {"status": "pending_approval", "workflow_step_id": step.id}
+```
+
+Used by: Adapters that need manual approval workflows before executing ad server operations.
+
+
+These mixins are optional. A minimal adapter only needs to extend AdServerAdapter. Add BaseInventoryManager if your ad server supports inventory browsing, or BaseWorkflowManager if your integration requires approval workflows.
+
+
+## Error Handling
+
+Adapters should raise `AdCPAdapterError` (or its subclasses) for all error conditions. The Sales Agent's error handling infrastructure uses the error's **recovery classification** to determine whether to retry the operation or return an error to the AI buying agent.
+
+```python
+from src.adapters.base import AdCPAdapterError
+
+class MyAdServer(AdServerAdapter):
+ async def create_media_buy(self, media_buy, products, creatives=None):
+ try:
+ response = await self._call_api(...)
+ except TimeoutError:
+ raise AdCPAdapterError(
+ "Ad server API timed out",
+ recovery="retry", # Transient error, safe to retry
+ )
+ except AuthError:
+ raise AdCPAdapterError(
+ "Invalid API credentials",
+ recovery="permanent", # Not retryable
+ )
+```
+
+### Recovery Classifications
+
+{: .table .table-bordered .table-striped }
+| Classification | Meaning | Behavior |
+|----------------|---------|----------|
+| `retry` | Transient error (timeout, rate limit, temporary unavailability) | The Sales Agent may retry with exponential backoff |
+| `permanent` | Non-recoverable error (invalid credentials, missing permissions, bad input) | The error is returned immediately to the caller |
+
+## Testing Your Adapter
+
+### Unit Tests
+
+Write unit tests that exercise your adapter methods without making real API calls. Use mocking to simulate ad server responses:
+
+```python
+import pytest
+from unittest.mock import AsyncMock, patch
+from src.adapters.my_ad_server import MyAdServer
+
+
+@pytest.fixture
+def adapter():
+ config = {
+ "api_url": "https://api.example.com",
+ "api_key": "test-key",
+ "network_id": 12345,
+ }
+ return MyAdServer(config=config, dry_run=True)
+
+
+async def test_create_media_buy(adapter, sample_media_buy, sample_products):
+ with patch.object(adapter, "_call_api", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"order_id": "ext-001", "status": "created"}
+ result = await adapter.create_media_buy(sample_media_buy, sample_products)
+ assert result.external_id == "ext-001"
+ mock_api.assert_called_once()
+```
+
+### Integration Tests
+
+If your ad server provides a sandbox or test environment, write integration tests that make real API calls:
+
+```python
+@pytest.mark.integration
+async def test_end_to_end_flow(live_adapter, sample_media_buy, sample_products):
+ # Create
+ create_result = await live_adapter.create_media_buy(
+ sample_media_buy, sample_products
+ )
+ assert create_result.external_id is not None
+
+ # Check status
+ status = await live_adapter.check_media_buy_status(sample_media_buy)
+ assert status.state in ("draft", "pending")
+
+ # Get delivery (may be zero for new campaigns)
+ delivery = await live_adapter.get_media_buy_delivery(sample_media_buy)
+ assert delivery.impressions >= 0
+```
+
+### Structural Tests
+
+The Sales Agent includes structural tests that verify all registered adapters conform to the interface contract. Once you register your adapter in `AVAILABLE_ADAPTERS`, these tests will automatically validate it.
+
+## Example: Minimal Adapter Implementation
+
+Below is a complete minimal adapter that implements all required methods. Use this as a starting point for your own adapter:
+
+```python
+from typing import ClassVar
+from src.adapters.base import (
+ AdServerAdapter,
+ AdapterCapabilities,
+ AdCPAdapterError,
+)
+
+
+class MinimalAdapter(AdServerAdapter):
+ """A minimal adapter implementation for reference."""
+
+ adapter_name: ClassVar[str] = "minimal"
+ default_channels: list[str] = ["display"]
+ default_delivery_measurement: dict = {
+ "impressions": True,
+ "clicks": True,
+ "spend": True,
+ }
+
+ capabilities = AdapterCapabilities(
+ supports_inventory_sync=False,
+ supports_inventory_profiles=False,
+ inventory_entity_label="placement",
+ supports_custom_targeting=False,
+ supports_geo_targeting=False,
+ supports_dynamic_products=False,
+ supported_pricing_models=["cpm"],
+ supports_webhooks=False,
+ supports_realtime_reporting=False,
+ )
+
+ async def create_media_buy(self, media_buy, products, creatives=None):
+ self.log(f"Creating media buy {media_buy.id}")
+ try:
+ # Call your ad server API to create the campaign
+ external_id = await self._create_campaign(media_buy, products)
+ return {"external_id": external_id, "status": "created"}
+ except Exception as e:
+ raise AdCPAdapterError(
+ f"Failed to create campaign: {e}",
+ recovery="retry",
+ )
+
+ async def update_media_buy(self, media_buy, products, creatives=None):
+ self.log(f"Updating media buy {media_buy.id}")
+ # Update the campaign in your ad server
+ return {"status": "updated"}
+
+ async def get_media_buy_delivery(self, media_buy):
+ self.log(f"Fetching delivery for {media_buy.id}")
+ # Pull delivery metrics from your ad server
+ return {
+ "impressions": 0,
+ "clicks": 0,
+ "spend": 0.0,
+ }
+
+ async def check_media_buy_status(self, media_buy):
+ self.log(f"Checking status for {media_buy.id}")
+ # Check campaign status in your ad server
+ return {"state": "active"}
+
+ async def upload_creatives(self, media_buy, creatives):
+ self.log(f"Uploading {len(creatives)} creatives for {media_buy.id}")
+ # Upload creative assets to your ad server
+ return {"uploaded": len(creatives)}
+
+ async def _create_campaign(self, media_buy, products):
+ """Private helper to call the ad server API."""
+ # Your API integration logic here
+ raise NotImplementedError("Replace with real API call")
+```
+
+## Registering with the Admin UI
+
+Once your adapter is registered in `AVAILABLE_ADAPTERS`, it automatically appears in the Admin UI:
+
+1. Navigate to **Settings > Ad Server**.
+2. Your adapter's `adapter_name` value appears in the adapter type dropdown.
+3. When selected, the Admin UI presents a JSONB configuration field where publishers enter your adapter's connection settings.
+4. The configuration is validated against your `connection_config_class` when saved.
+
+If you want to provide a richer Admin UI experience (custom form fields, validation messages, or help text), you can extend the adapters blueprint in `src/admin/blueprints/adapters/`.
+
+## Further Reading
+
+- [Architecture & Protocols](/agents/salesagent/architecture.html) — Adapter pattern and system design
+- [Google Ad Manager Integration](/agents/salesagent/integrations/gam.html) — Reference implementation for a full-featured adapter
+- [Mock Adapter](/agents/salesagent/integrations/mock-adapter.html) — Reference implementation for a simple adapter
+- [Kevel Integration](/agents/salesagent/integrations/kevel.html) — Single-file adapter example
diff --git a/agents/salesagent/integrations/gam.md b/agents/salesagent/integrations/gam.md
new file mode 100644
index 0000000000..18c3d255fb
--- /dev/null
+++ b/agents/salesagent/integrations/gam.md
@@ -0,0 +1,244 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Google Ad Manager Integration
+description: Configure and use the Google Ad Manager adapter with the Prebid Sales Agent
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Google Ad Manager Integration
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+The Google Ad Manager (GAM) integration is the most full-featured production adapter for the Prebid Sales Agent. It connects the Sales Agent to the GAM API (using the `googleads` 49.0.0 SDK), enabling AI buying agents to create orders, manage line items, upload creatives, set targeting, and pull performance reports — all through the standard Sales Agent tool interface.
+
+The adapter is identified by `adapter_name: "google_ad_manager"` and is implemented as a multi-module package under `src/adapters/gam/`.
+
+## Prerequisites
+
+Before configuring the GAM integration, ensure you have:
+
+- A Google Ad Manager account with API access enabled
+- A GAM network with an active Ad Manager 360 or Small Business subscription
+- One of the following authentication credentials:
+ - A GCP service account JSON key file with GAM API permissions, **or**
+ - OAuth 2.0 client credentials (client ID and client secret)
+- The GAM network code for your account
+
+
+ API access must be explicitly enabled in your GAM network settings under Admin > Global settings > Network settings > API access. Contact your Google account representative if this option is not available.
+
+
+## Authentication Setup
+
+The GAM adapter supports two authentication methods. Choose the one that best fits your deployment model.
+
+### Option 1: Service Account (Recommended for Production)
+
+Service account authentication is the recommended approach for server-to-server deployments. It does not require user interaction and credentials do not expire.
+
+1. Create a service account in the [Google Cloud Console](https://console.cloud.google.com/iam-admin/serviceaccounts).
+2. Download the JSON key file.
+3. In GAM, go to **Admin > Global settings > Network settings > API access** and add the service account email address.
+4. Set the environment variable pointing to the key file:
+
+```bash
+GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
+```
+
+### Option 2: OAuth 2.0 Client Credentials
+
+OAuth 2.0 is useful for development environments or when a service account is not available.
+
+1. Create OAuth 2.0 credentials in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials).
+2. Set the following environment variables:
+
+```bash
+GAM_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
+GAM_OAUTH_CLIENT_SECRET=your-client-secret
+```
+
+## Configuration
+
+### Environment Variables
+
+{: .table .table-bordered .table-striped }
+| Variable | Required | Description |
+|----------|----------|-------------|
+| `GOOGLE_APPLICATION_CREDENTIALS` | Yes (Option 1) | Path to the GCP service account JSON key file |
+| `GAM_OAUTH_CLIENT_ID` | Yes (Option 2) | OAuth 2.0 client ID from Google Cloud Console |
+| `GAM_OAUTH_CLIENT_SECRET` | Yes (Option 2) | OAuth 2.0 client secret |
+| `GCP_PROJECT_ID` | No | GCP project ID (required for some reporting features) |
+
+### Admin UI Configuration
+
+1. Log in to the Admin UI at `http://your-host:8000/admin`.
+2. Navigate to **Settings > Ad Server**.
+3. Select **Google Ad Manager** from the adapter type dropdown.
+4. Enter your GAM network code and any additional configuration in the JSONB config field.
+5. Save the configuration.
+
+The adapter configuration is stored in the `adapter_config` table with `adapter_type` set to `"google_ad_manager"`.
+
+## Supported Channels
+
+The GAM adapter supports the following default media channels:
+
+{: .table .table-bordered .table-striped }
+| Channel | Description |
+|---------|-------------|
+| `display` | Standard banner and rich media display ads |
+| `olv` | Online video (pre-roll, mid-roll, post-roll) |
+| `social` | Social media inventory |
+
+## Capabilities
+
+The GAM adapter declares the following capabilities via its `AdapterCapabilities` configuration:
+
+{: .table .table-bordered .table-striped }
+| Capability | Supported | Notes |
+|------------|-----------|-------|
+| Inventory Sync | Yes | Automatic background synchronization of ad units and placements |
+| Inventory Profiles | Yes | Reusable inventory configurations that products reference |
+| Custom Targeting | Yes | Key-value targeting via GAM custom targeting keys |
+| Geo Targeting | Yes | Full geographic targeting including countries, regions, metros, and postal codes |
+| Dynamic Products | Yes | Products that dynamically reference GAM inventory |
+| All Pricing Models | Yes | CPM, CPC, CPD, and other GAM-supported pricing models |
+| Webhooks | No | Use polling-based delivery reporting instead |
+| Real-Time Reporting | No | Reporting pulled on demand via the GAM Reporting API |
+
+## How It Works
+
+### Inventory Management
+
+The GAM adapter keeps the Sales Agent's inventory data synchronized with your GAM network through background sync operations.
+
+- The `BackgroundSync` operation periodically pulls ad units, placements, and targeting keys from GAM.
+- Synced inventory data is stored locally in the Sales Agent database, enabling fast product matching without live GAM API calls.
+- Sync frequency is configurable through the Admin UI.
+
+### Order Creation
+
+When an AI buying agent calls `create_media_buy`, the GAM adapter:
+
+1. Maps the media buy to a GAM **Order** with one or more **Line Items**.
+2. Applies the naming template configured on the tenant (see [Naming Templates](#naming-templates) below).
+3. Sets pricing, flight dates, and delivery goals on each line item.
+4. Returns the GAM order ID and line item IDs in the media buy response.
+
+### Creative Upload
+
+The `upload_creatives` operation handles creative asset management:
+
+1. Accepts creative assets (images, video, HTML5) from the `sync_creatives` tool.
+2. Uploads assets to GAM via the Creatives API.
+3. Associates creatives with the appropriate line items using `AssignCreatives`.
+4. Tracks creative approval status from GAM.
+
+### Targeting
+
+The GAM adapter supports multiple targeting dimensions:
+
+- **Geographic targeting** — Countries, regions, metro areas (Nielsen DMA, EUROSTAT NUTS2, UK ITL1/ITL2), and postal codes (US ZIP, ZIP+4, Canadian, UK, German, French, Australian)
+- **Custom targeting** — Key-value pairs from GAM custom targeting keys
+- **Audience targeting** — First-party and third-party audience segments
+- **Contextual targeting** — Content categories and page-level targeting
+- **Inventory targeting** — Specific ad units, placements, or inventory profiles
+
+The adapter exposes these through the `get_targeting_capabilities()` method, which returns a `TargetingCapabilities` instance describing all supported targeting dimensions.
+
+### Reporting
+
+The GAM adapter pulls delivery and performance metrics through the GAM Reporting API:
+
+- The `GetPerformanceReport` operation fetches impressions, clicks, revenue, and other metrics.
+- Reports can be filtered by date range, line item, and creative.
+- The `get_media_buy_delivery` tool surfaces these metrics to AI buying agents.
+
+### Workflow
+
+The adapter manages order and line item lifecycle transitions:
+
+- Draft to pending approval
+- Pending approval to approved (after human-in-the-loop review)
+- Approved to delivering
+- Paused, resumed, and completed states
+
+## GAM Manager Modules
+
+The GAM adapter is organized into specialized manager modules, each handling a distinct area of GAM API interaction:
+
+{: .table .table-bordered .table-striped }
+| Module | File | Responsibility |
+|--------|------|----------------|
+| Inventory Manager | `inventory.py` | Ad unit and placement sync, inventory queries |
+| Orders Manager | `orders.py` | Order and line item CRUD, status transitions |
+| Creatives Manager | `creatives.py` | Creative upload, assignment, approval tracking |
+| Targeting Manager | `targeting.py` | Geographic, custom, and audience targeting |
+| Reporting Manager | `reporting.py` | Performance report generation and parsing |
+| Sync Manager | `sync.py` | Background synchronization orchestration |
+| Workflow Manager | `workflow.py` | Order approval and lifecycle management |
+
+## Error Handling
+
+The GAM adapter implements robust error handling for API interactions:
+
+- **Timeout with exponential backoff** — Retries transient GAM API errors with increasing delays.
+- **Error recovery classification** — Errors are classified as retryable (rate limits, timeouts) or permanent (invalid credentials, missing permissions) using the `AdCPAdapterError` hierarchy.
+- **API quota management** — The adapter respects GAM API rate limits and queues requests when approaching quota thresholds.
+
+## Naming Templates
+
+Publishers can configure naming templates that control how GAM orders and line items are named. These are set on the tenant configuration:
+
+- `order_name_template` — Template for GAM order names (e.g., `"{advertiser} - {product} - {date}"`)
+- `line_item_name_template` — Template for GAM line item names
+
+Templates support variable substitution with fields from the media buy, product, and principal. The Naming Agent (AI) can also generate names from briefs when templates are not sufficient.
+
+## Inventory Profiles
+
+Inventory profiles are reusable GAM inventory configurations that products can reference. They allow publishers to define named collections of ad units, placements, and targeting criteria that can be shared across multiple products.
+
+- Created and managed through the Admin UI under **Inventory Profiles**.
+- Products reference inventory profiles by ID rather than specifying raw GAM ad unit IDs.
+- When GAM inventory changes (ad units added or removed), updating the inventory profile automatically updates all referencing products.
+
+## Troubleshooting
+
+### Common Errors
+
+{: .table .table-bordered .table-striped }
+| Error | Cause | Resolution |
+|-------|-------|------------|
+| `AuthenticationError` | Invalid or expired credentials | Verify `GOOGLE_APPLICATION_CREDENTIALS` path or refresh OAuth tokens |
+| `PermissionDenied` | Service account lacks GAM API access | Add the service account email in GAM Admin > API access |
+| `QuotaExceeded` | GAM API rate limit hit | The adapter retries automatically; reduce sync frequency if persistent |
+| `NetworkNotFound` | Incorrect GAM network code | Verify the network code in Admin UI adapter config |
+| `InvalidCreativeSize` | Creative dimensions do not match line item | Ensure creative assets match the sizes defined in the product |
+
+### API Quota Issues
+
+GAM enforces API request quotas per network. If you encounter persistent quota errors:
+
+1. Check your current quota usage in the Google Cloud Console.
+2. Reduce the background sync frequency in the Admin UI.
+3. Avoid calling `get_media_buy_delivery` in tight loops — use reasonable polling intervals.
+4. Contact Google support to request a quota increase if needed.
+
+### Authentication Failures
+
+If authentication fails after initial setup:
+
+1. For service accounts: verify the JSON key file exists at the path specified in `GOOGLE_APPLICATION_CREDENTIALS` and has not been revoked.
+2. For OAuth: check that the client ID and secret are correct and that the OAuth consent screen is properly configured.
+3. Verify that the authenticated identity has been granted access in GAM under **Admin > Global settings > Network settings > API access**.
+
+## Further Reading
+
+- [Architecture & Protocols](/agents/salesagent/architecture.html) — Adapter pattern overview
+- [Mock Adapter](/agents/salesagent/integrations/mock-adapter.html) — Test without a live GAM account
+- [Building a Custom Adapter](/agents/salesagent/integrations/custom-adapter.html) — Extend the adapter pattern
diff --git a/agents/salesagent/integrations/kevel.md b/agents/salesagent/integrations/kevel.md
new file mode 100644
index 0000000000..a9a0b9c480
--- /dev/null
+++ b/agents/salesagent/integrations/kevel.md
@@ -0,0 +1,163 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Kevel Integration
+description: Configure and use the Kevel adapter with the Prebid Sales Agent
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Kevel Integration
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+The Kevel integration connects the Prebid Sales Agent to the [Kevel](https://www.kevel.com/) (formerly Adzerk) ad serving platform. Kevel is designed for native and display advertising and is commonly used by publishers building custom ad experiences, retail media networks, and social platforms.
+
+The adapter is identified by `adapter_name: "kevel"` and is implemented in a single module at `src/adapters/kevel.py`.
+
+## Prerequisites
+
+Before configuring the Kevel integration, ensure you have:
+
+- An active Kevel account
+- Your Kevel **network ID**
+- A Kevel **API key** with permissions to manage flights, campaigns, and creatives
+
+## Configuration
+
+The Kevel adapter is configured through the Admin UI. Navigate to **Settings > Ad Server** and select **Kevel** as the adapter type.
+
+### Required Settings
+
+{: .table .table-bordered .table-striped }
+| Setting | Type | Required | Description |
+|---------|------|----------|-------------|
+| `network_id` | integer | Yes | Your Kevel network ID |
+| `api_key` | string | Yes | Kevel API key for authentication |
+| `userdb_enabled` | boolean | No | Enable Kevel UserDB for audience targeting (default: `false`) |
+| `frequency_capping_enabled` | boolean | No | Enable frequency capping on flights (default: `false`) |
+
+### Example Configuration
+
+In the Admin UI adapter config JSONB field:
+
+```json
+{
+ "network_id": 12345,
+ "api_key": "your-kevel-api-key",
+ "userdb_enabled": false,
+ "frequency_capping_enabled": true
+}
+```
+
+## Supported Channels
+
+{: .table .table-bordered .table-striped }
+| Channel | Description |
+|---------|-------------|
+| `social` | Social media and social-style content feeds |
+| `retail_media` | Retail media network placements (sponsored products, sponsored listings) |
+
+## Supported Media Types
+
+{: .table .table-bordered .table-striped }
+| Media Type | Description |
+|------------|-------------|
+| `display` | Standard banner ads and rich media |
+| `native` | Native ad formats that match surrounding content |
+
+## Supported Device Types
+
+{: .table .table-bordered .table-striped }
+| Device Type | Description |
+|-------------|-------------|
+| `mobile` | Mobile phones |
+| `desktop` | Desktop and laptop computers |
+| `tablet` | Tablets |
+
+## Capabilities
+
+{: .table .table-bordered .table-striped }
+| Capability | Supported | Notes |
+|------------|-----------|-------|
+| Inventory Sync | No | Inventory is managed directly in Kevel |
+| Inventory Profiles | No | Not applicable to Kevel's architecture |
+| Custom Targeting | Limited | Via Kevel's site and zone targeting |
+| Geo Targeting | Limited | Kevel supports country and region-level geo targeting |
+| Dynamic Products | No | Products are statically configured |
+| Pricing Models | Subset | CPM and flat-rate pricing |
+| Webhooks | No | Use polling for delivery data |
+| Real-Time Reporting | No | Reporting pulled on demand |
+
+## Targeting
+
+The Kevel adapter supports a subset of targeting dimensions. Targeting rules are validated by the adapter before being sent to the Kevel API.
+
+### Available Targeting Dimensions
+
+- **Device type** — Target by mobile, desktop, or tablet
+- **Geographic** — Country and region-level targeting
+- **Site and zone** — Target specific Kevel sites and zones
+- **Keyword** — Keyword-based contextual targeting
+
+### Targeting Validation
+
+The adapter includes `_validate_targeting()` and `_build_targeting()` methods that:
+
+1. Validate that requested targeting dimensions are supported by Kevel.
+2. Transform the Sales Agent's normalized targeting format into Kevel's native targeting structure.
+3. Reject unsupported targeting combinations with descriptive error messages.
+
+## Frequency Capping
+
+Kevel supports frequency capping at the **flight level only**. Campaign-level frequency capping is not available.
+
+
+ Frequency capping must be explicitly enabled by setting frequency_capping_enabled: true in the adapter configuration. When disabled, no frequency caps are applied even if media buy requests include them.
+
+
+When enabled, frequency caps are set on individual flights (Kevel's equivalent of line items) and control how many times a single user sees an ad within a specified time window.
+
+## Limitations
+
+Compared to the GAM adapter, the Kevel integration has the following limitations:
+
+{: .table .table-bordered .table-striped }
+| Area | Limitation |
+|------|-----------|
+| Channels | Only `social` and `retail_media` (vs. 5 channels in GAM) |
+| Media Types | Only `display` and `native` (no video or audio) |
+| Targeting | No metro-level targeting, limited geo granularity |
+| Inventory Sync | Not supported — inventory must be managed in Kevel directly |
+| Inventory Profiles | Not supported |
+| Frequency Capping | Flight-level only, not campaign-level |
+| Dynamic Products | Not supported |
+| Reporting | Basic delivery metrics; no equivalent to GAM's advanced report builder |
+
+These limitations reflect the differences between Kevel's API capabilities and those of a full-featured ad server like Google Ad Manager. For use cases centered on native advertising, retail media, and social feeds, Kevel provides a streamlined and effective integration.
+
+## Troubleshooting
+
+### Common Errors
+
+{: .table .table-bordered .table-striped }
+| Error | Cause | Resolution |
+|-------|-------|------------|
+| `AuthenticationError` | Invalid API key | Verify the `api_key` in adapter config |
+| `NetworkNotFound` | Incorrect network ID | Check `network_id` matches your Kevel account |
+| `UnsupportedTargeting` | Requested targeting not available in Kevel | Review the targeting dimensions supported by the Kevel adapter |
+| `FrequencyCappingDisabled` | Frequency cap requested but not enabled | Set `frequency_capping_enabled: true` in config |
+
+### Debugging Tips
+
+1. Verify your Kevel API key has the necessary permissions by testing it directly against the Kevel API.
+2. Check the Sales Agent logs for targeting validation errors — the adapter logs detailed messages when targeting rules are rejected.
+3. Use the [Mock Adapter](/agents/salesagent/integrations/mock-adapter.html) to test media buy flows before switching to the live Kevel adapter.
+
+## Further Reading
+
+- [Architecture & Protocols](/agents/salesagent/architecture.html) — Adapter pattern overview
+- [Google Ad Manager Integration](/agents/salesagent/integrations/gam.html) — Full-featured ad server integration
+- [Building a Custom Adapter](/agents/salesagent/integrations/custom-adapter.html) — Extend the adapter pattern
diff --git a/agents/salesagent/integrations/mock-adapter.md b/agents/salesagent/integrations/mock-adapter.md
new file mode 100644
index 0000000000..0d25ffa6fc
--- /dev/null
+++ b/agents/salesagent/integrations/mock-adapter.md
@@ -0,0 +1,253 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Mock Adapter
+description: Use the mock adapter for testing, development, and demos without a real ad server
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Mock Adapter
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+The Mock Adapter is a fully functional in-memory ad server adapter included with the Prebid Sales Agent for testing, development, and demonstration purposes. It implements the complete `AdServerAdapter` interface without requiring any external ad server, making it the fastest way to explore and validate Sales Agent functionality.
+
+The adapter is identified by `adapter_name: "mock"` and is implemented at `src/adapters/mock_ad_server.py`.
+
+## When to Use
+
+The Mock Adapter is appropriate for:
+
+- **Development** — Build and test AI buying agent integrations without a live ad server
+- **Testing** — Run unit tests, integration tests, and end-to-end tests in CI/CD pipelines
+- **Demos** — Demonstrate Sales Agent capabilities to stakeholders without configuring a real ad server
+- **Evaluation** — Evaluate the Sales Agent before committing to a production ad server integration
+- **Training** — Familiarize teams with the Sales Agent's tool interface and media buy workflow
+
+
+ The Mock Adapter is the default adapter when you first start the Sales Agent with docker compose up. No additional configuration is needed to get started.
+
+
+## Configuration
+
+### Connection Configuration
+
+The `MockConnectionConfig` accepts a single setting:
+
+{: .table .table-bordered .table-striped }
+| Setting | Type | Default | Description |
+|---------|------|---------|-------------|
+| `dry_run` | boolean | `false` | When `true`, all operations are validated but no state is persisted |
+
+### Product Configuration
+
+The `MockProductConfig` controls how the mock adapter simulates delivery for products:
+
+{: .table .table-bordered .table-striped }
+| Setting | Type | Default | Description |
+|---------|------|---------|-------------|
+| `daily_impressions` | integer | Varies | Simulated daily impression volume |
+| `fill_rate` | float | Varies | Simulated fill rate (0.0 to 1.0) |
+| `ctr` | float | Varies | Simulated click-through rate |
+| `viewability` | float | Varies | Simulated viewability percentage |
+| `scenario` | string | `null` | Named simulation scenario (e.g., `"high_performance"`, `"slow_start"`) |
+
+### Admin UI Setup
+
+1. Navigate to **Settings > Ad Server** in the Admin UI.
+2. Select **Mock** from the adapter type dropdown.
+3. Optionally configure `dry_run` in the JSONB config field:
+
+```json
+{
+ "dry_run": false
+}
+```
+
+## Supported Channels
+
+The Mock Adapter supports all major channels for comprehensive testing:
+
+{: .table .table-bordered .table-striped }
+| Channel | Description |
+|---------|-------------|
+| `display` | Standard banner and display ads |
+| `olv` | Online video advertising |
+| `streaming_audio` | Audio and streaming audio ads |
+| `social` | Social media advertising |
+
+## Test Headers
+
+The Mock Adapter responds to special HTTP headers that control simulation behavior. Pass these headers in your API, MCP, or A2A requests to exercise specific scenarios.
+
+{: .table .table-bordered .table-striped }
+| Header | Value | Description |
+|--------|-------|-------------|
+| `X-Dry-Run` | `true` / `false` | Test operations without persisting side effects. The adapter validates inputs and returns realistic responses but does not create or modify any state. |
+| `X-Mock-Time` | ISO 8601 datetime (e.g., `2025-01-15T14:30:00Z`) | Set the simulated current time. Useful for testing delivery progression, flight dates, and time-dependent logic without waiting for real time to pass. |
+| `X-Jump-To-Event` | Event name (e.g., `"mid_flight"`, `"end_of_campaign"`) | Skip the simulation forward to a specific campaign event. The adapter advances delivery metrics to match the requested event state. |
+| `X-Test-Session-ID` | Unique string | Isolate test execution by scoping all mock state to the given session ID. Different session IDs see independent state, enabling parallel test execution. |
+| `X-Auto-Advance` | `true` / `false` | Automatically progress campaign events over successive requests. Each call to `get_media_buy_delivery` advances the simulation by one time step. |
+| `X-Force-Error` | Error type (e.g., `"timeout"`, `"auth_failure"`, `"rate_limit"`) | Force the adapter to simulate a specific error condition. Useful for testing error handling and recovery logic in AI buying agents. |
+
+### Example: Simulating a Campaign Lifecycle
+
+```bash
+# Create a media buy
+uvx adcp http://localhost:8000/mcp/ --auth test-token create_media_buy \
+ --product-id prod-001 --budget 10000
+
+# Jump to mid-flight and check delivery
+uvx adcp http://localhost:8000/mcp/ --auth test-token get_media_buy_delivery \
+ --media-buy-id mb-001 \
+ --header "X-Jump-To-Event: mid_flight"
+
+# Force a timeout error to test error handling
+uvx adcp http://localhost:8000/mcp/ --auth test-token get_media_buy_delivery \
+ --media-buy-id mb-001 \
+ --header "X-Force-Error: timeout"
+```
+
+## Delivery Simulation
+
+The Mock Adapter includes a delivery simulator that produces realistic mock delivery data over time. Rather than returning static values, delivery metrics progress naturally based on the product configuration and campaign parameters.
+
+### How Simulation Works
+
+1. When a media buy is created, the simulator initializes delivery state based on the `MockProductConfig` (daily impressions, fill rate, CTR, viewability).
+2. As time progresses (real or simulated via `X-Mock-Time`), the simulator calculates accumulated impressions, clicks, and spend.
+3. Delivery metrics reflect realistic pacing — campaigns ramp up, hit steady state, and wind down.
+4. The `_simulate_time_progression()` method advances the internal clock and updates metrics accordingly.
+
+### Simulation Modes
+
+The adapter provides two methods for controlling simulation state:
+
+- **`_is_simulation()`** — Returns `true` when the adapter is running in simulation mode (the default). In simulation mode, delivery data is generated algorithmically.
+- **`set_simulation_time()`** — Programmatically set the simulation clock, equivalent to passing the `X-Mock-Time` header.
+
+## Strategy System
+
+The Mock Adapter supports a strategy system that applies multipliers to simulated delivery metrics, enabling testing of different performance scenarios.
+
+### Simulation Scenarios
+
+Set the `scenario` field in `MockProductConfig` or use the `_get_simulation_scenario()` method to activate predefined scenarios:
+
+- **High performance** — Above-average CTR, viewability, and fill rate
+- **Slow start** — Campaign ramps up gradually before reaching target delivery
+- **Underdelivery** — Campaign falls behind pacing goals
+- **Overdelivery** — Campaign exceeds expected delivery
+
+### Strategy Multipliers
+
+The `_apply_strategy_multipliers()` method adjusts base delivery metrics according to the active strategy context. This allows testing how AI buying agents respond to different campaign performance patterns.
+
+## Using with the uvx adcp CLI
+
+The `uvx adcp` CLI tool is the fastest way to interact with the Mock Adapter during development:
+
+```bash
+# Discover available tools
+uvx adcp http://localhost:8000/mcp/ --auth test-token list_tools
+
+# Browse products
+uvx adcp http://localhost:8000/mcp/ --auth test-token get_products
+
+# Create a media buy
+uvx adcp http://localhost:8000/mcp/ --auth test-token create_media_buy \
+ --product-id prod-001 --budget 5000 --start-date 2025-02-01 --end-date 2025-02-28
+
+# Check delivery with auto-advance
+uvx adcp http://localhost:8000/mcp/ --auth test-token get_media_buy_delivery \
+ --media-buy-id mb-001 \
+ --header "X-Auto-Advance: true"
+```
+
+## Using in Automated Tests
+
+The Mock Adapter is designed for use in automated test suites. The Sales Agent's test infrastructure uses it extensively.
+
+### Test Isolation with Session IDs
+
+Use the `X-Test-Session-ID` header to isolate test state between parallel test executions:
+
+```python
+import httpx
+
+async def test_media_buy_creation():
+ headers = {
+ "x-adcp-auth": "test-token",
+ "X-Test-Session-ID": "test-run-abc123"
+ }
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ "http://localhost:8000/api/v1/media-buys",
+ json={"product_id": "prod-001", "budget": 5000},
+ headers=headers
+ )
+ assert response.status_code == 201
+```
+
+### Forcing Errors in Tests
+
+Use `X-Force-Error` to test error handling paths:
+
+```python
+async def test_timeout_recovery():
+ headers = {
+ "x-adcp-auth": "test-token",
+ "X-Force-Error": "timeout"
+ }
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ "http://localhost:8000/api/v1/media-buys/mb-001/delivery",
+ headers=headers
+ )
+ assert response.status_code == 504
+```
+
+### Dry Run Mode
+
+Enable dry run mode to validate request payloads without creating any state:
+
+```python
+async def test_media_buy_validation():
+ headers = {
+ "x-adcp-auth": "test-token",
+ "X-Dry-Run": "true"
+ }
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ "http://localhost:8000/api/v1/media-buys",
+ json={"product_id": "prod-001", "budget": -100},
+ headers=headers
+ )
+ # Validation error returned without creating anything
+ assert response.status_code == 422
+```
+
+## Transitioning to Production
+
+When you are ready to move from the Mock Adapter to a production ad server, the transition is straightforward:
+
+1. **Choose a production adapter** — Select from [Google Ad Manager](/agents/salesagent/integrations/gam.html), [Kevel](/agents/salesagent/integrations/kevel.html), [Triton Digital](/agents/salesagent/integrations/triton-digital.html), [Broadstreet](/agents/salesagent/integrations/broadstreet.html), or a [custom adapter](/agents/salesagent/integrations/custom-adapter.html).
+2. **Configure credentials** — Set up the required environment variables and authentication for your chosen adapter.
+3. **Update the Admin UI** — Navigate to **Settings > Ad Server** and change the adapter type from **Mock** to your production adapter. Enter the adapter-specific configuration.
+4. **Reconfigure products** — Review and update product configurations to reference real inventory in your ad server.
+5. **Test with dry run** — Many production adapters support a dry run or sandbox mode. Use this to validate the integration before going live.
+6. **Switch over** — Once validated, disable dry run mode. New media buys will be created in your production ad server.
+
+
+ Changing the adapter type does not affect existing media buys. Media buys created with the Mock Adapter remain in the database but will not be synced to the new production ad server. Create new media buys after switching adapters.
+
+
+## Further Reading
+
+- [Quick Start](/agents/salesagent/getting-started/quickstart.html) — Get running with Docker and the Mock Adapter
+- [Architecture & Protocols](/agents/salesagent/architecture.html) — Adapter pattern overview
+- [Google Ad Manager Integration](/agents/salesagent/integrations/gam.html) — Production GAM integration
+- [Building a Custom Adapter](/agents/salesagent/integrations/custom-adapter.html) — Create your own adapter
diff --git a/agents/salesagent/integrations/triton-digital.md b/agents/salesagent/integrations/triton-digital.md
new file mode 100644
index 0000000000..031c1ee876
--- /dev/null
+++ b/agents/salesagent/integrations/triton-digital.md
@@ -0,0 +1,151 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Triton Digital Integration
+description: Configure and use the Triton Digital adapter for audio and streaming audio advertising
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Triton Digital Integration
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+The Triton Digital integration connects the Prebid Sales Agent to the [Triton Digital](https://www.tritondigital.com/) platform, enabling AI buying agents to plan, execute, and measure audio and streaming audio advertising campaigns. Triton Digital is the leading technology platform for the digital audio industry, powering ad insertion for podcasts, internet radio, and streaming audio services.
+
+The adapter is identified by `adapter_name: "triton_digital"` and is implemented at `src/adapters/triton_digital.py`.
+
+## Prerequisites
+
+Before configuring the Triton Digital integration, ensure you have:
+
+- An active Triton Digital account with API access
+- API credentials (provided by your Triton Digital account representative)
+- Network configuration details for your Triton Digital setup
+
+## Configuration
+
+The Triton Digital adapter is configured through the Admin UI. Navigate to **Settings > Ad Server** and select **Triton Digital** as the adapter type.
+
+### Required Settings
+
+{: .table .table-bordered .table-striped }
+| Setting | Type | Required | Description |
+|---------|------|----------|-------------|
+| API credentials | string | Yes | Authentication credentials for the Triton Digital API |
+| Network configuration | object | Yes | Network-specific configuration for your Triton Digital account |
+
+Enter these values in the adapter config JSONB field in the Admin UI.
+
+## Supported Channels
+
+{: .table .table-bordered .table-striped }
+| Channel | Description |
+|---------|-------------|
+| `audio` | Standard digital audio advertising (pre-roll, mid-roll, post-roll audio ads) |
+| `streaming_audio` | Live streaming audio and internet radio ad insertion |
+
+## Capabilities
+
+{: .table .table-bordered .table-striped }
+| Capability | Supported | Notes |
+|------------|-----------|-------|
+| Audio Targeting | Yes | Target by genre, station, content type, and listener demographics |
+| Frequency Capping | Yes | Control ad exposure per listener |
+| Advertiser Management | Yes | Manage advertiser accounts within Triton Digital |
+| Inventory Sync | No | Audio inventory is managed in Triton Digital |
+| Inventory Profiles | No | Not applicable to audio ad serving |
+| Custom Targeting | Limited | Audio-specific targeting dimensions |
+| Geo Targeting | Yes | Geographic targeting for audio campaigns |
+| Dynamic Products | No | Products are statically configured |
+| Webhooks | No | Use polling for delivery data |
+| Real-Time Reporting | No | Reporting pulled on demand |
+
+## How It Works
+
+### Audio Ad Serving
+
+When an AI buying agent creates a media buy through the Sales Agent, the Triton Digital adapter:
+
+1. Creates a campaign in Triton Digital with the specified advertiser, budget, and flight dates.
+2. Configures audio ad placements based on the product's channel and targeting settings.
+3. Uploads audio creative assets (typically MP3 or companion display banners).
+4. Activates the campaign for ad insertion into audio streams.
+
+### Targeting
+
+The Triton Digital adapter supports audio-specific targeting dimensions:
+
+- **Content targeting** — Target specific stations, shows, podcasts, or genres
+- **Geographic targeting** — Country, region, and metro-level targeting for audio listeners
+- **Daypart targeting** — Schedule ads during specific times of day or days of week
+- **Device targeting** — Target by listening device (smart speaker, mobile, desktop, connected car)
+- **Listener demographics** — Age, gender, and interest-based targeting where available
+
+### Frequency Capping
+
+Frequency capping controls how many times a listener hears an ad within a given time window. The Triton Digital adapter supports:
+
+- Per-listener frequency caps at the campaign level
+- Time-window-based caps (e.g., maximum 3 impressions per listener per 24 hours)
+
+### Advertiser Management
+
+The adapter can manage advertiser entities within Triton Digital, including:
+
+- Creating and updating advertiser records
+- Associating campaigns with advertisers
+- Tracking advertiser-level delivery metrics
+
+## Use Cases
+
+### Podcast Advertising
+
+Monetize podcast content with dynamically inserted audio ads. The Sales Agent enables AI buying agents to target specific podcast categories, shows, or audience segments through Triton Digital's podcast ad insertion technology.
+
+### Streaming Audio / Internet Radio
+
+Serve audio ads into live streaming audio feeds. Triton Digital powers ad insertion for many internet radio stations, enabling real-time ad serving to listeners across devices.
+
+### Radio Digital Extension
+
+Extend terrestrial radio campaigns into digital audio channels. Publishers can offer audio inventory alongside display and video through the Sales Agent's multi-adapter architecture.
+
+## Limitations
+
+{: .table .table-bordered .table-striped }
+| Area | Limitation |
+|------|-----------|
+| Channels | Audio and streaming audio only (no display, video, or native) |
+| Creative Types | Audio files (MP3) and companion banners only |
+| Inventory Sync | Not supported — inventory managed in Triton Digital |
+| Inventory Profiles | Not supported |
+| Dynamic Products | Not supported |
+| Reporting | Audio-specific metrics; delivery reporting may have longer latency than display |
+
+## Troubleshooting
+
+### Common Errors
+
+{: .table .table-bordered .table-striped }
+| Error | Cause | Resolution |
+|-------|-------|------------|
+| `AuthenticationError` | Invalid API credentials | Verify credentials with your Triton Digital account representative |
+| `NetworkConfigError` | Incorrect network configuration | Check network settings in Admin UI adapter config |
+| `UnsupportedCreativeFormat` | Non-audio creative uploaded | Ensure creatives are audio files (MP3) or companion banners |
+| `CampaignCreationFailed` | Missing required campaign fields | Verify that the media buy includes all required fields (dates, budget, targeting) |
+
+### Debugging Tips
+
+1. Confirm your API credentials are active by contacting your Triton Digital account representative.
+2. Check the Sales Agent logs for detailed error messages from the Triton Digital API.
+3. Verify that products configured for Triton Digital use the `audio` or `streaming_audio` channel.
+4. Use the [Mock Adapter](/agents/salesagent/integrations/mock-adapter.html) with the `streaming_audio` channel to test audio media buy flows before connecting to Triton Digital.
+
+## Further Reading
+
+- [Architecture & Protocols](/agents/salesagent/architecture.html) — Adapter pattern overview
+- [Google Ad Manager Integration](/agents/salesagent/integrations/gam.html) — Display and video ad serving
+- [Building a Custom Adapter](/agents/salesagent/integrations/custom-adapter.html) — Extend the adapter pattern
diff --git a/agents/salesagent/operations/admin-ui.md b/agents/salesagent/operations/admin-ui.md
new file mode 100644
index 0000000000..0f321e56e6
--- /dev/null
+++ b/agents/salesagent/operations/admin-ui.md
@@ -0,0 +1,383 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Admin UI Guide
+description: Complete guide to the Prebid Sales Agent Admin UI for managing tenants, products, advertisers, and configuration
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Admin UI Guide
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+The Admin UI is a Flask-based web application that provides a graphical interface for managing every aspect of the Prebid Sales Agent. It is served at the `/admin` path and supports per-tenant OIDC SSO (Google, Microsoft, Okta, Auth0, Keycloak) in production or test mode for development.
+
+The Admin UI is composed of approximately 26 blueprints, each responsible for a functional area:
+
+| Blueprint | Purpose |
+| --- | --- |
+| tenants | Tenant creation and management |
+| products | Product catalog management |
+| creatives | Creative format and asset management |
+| media_buys | Media buy monitoring and management |
+| principals | Advertiser (principal) management |
+| adapters | Ad server adapter configuration |
+| settings | Tenant-level settings and preferences |
+| users | User management and roles |
+| activity_stream | SSE-powered live event feed |
+| workflows | Approval workflow management |
+| signals_agents | External audience signal source configuration |
+| creative_agents | External creative format provider configuration |
+| authorized_properties | Publisher domain and property management |
+| inventory | Ad inventory management |
+| inventory_profiles | Reusable inventory configuration profiles |
+| oidc | SSO / OIDC provider configuration |
+| operations | Operational tools and maintenance |
+| policy | Advertising policy management |
+| public | Public-facing tenant landing pages |
+| gam | Google Ad Manager-specific tools |
+| format_search | Creative format search and discovery |
+| publisher_partners | Publisher partner management |
+| schemas | Schema viewing and validation |
+{: .table .table-bordered .table-striped }
+
+## Accessing the Admin UI
+
+### URL
+
+The Admin UI is available at the `/admin` path of your deployment:
+
+- **Docker**: `http://localhost:8000/admin` (via nginx)
+- **Fly.io**: `https://your-app.fly.dev/admin`
+- **Cloud Run**: `https://your-service-url.run.app/admin`
+- **Multi-tenant**: `https://tenant.yourdomain.com/admin`
+
+### Authentication Methods
+
+| Method | When to Use | Configuration |
+| --- | --- | --- |
+| Test Mode | Development and initial setup | Set `ADCP_AUTH_TEST_MODE=true` |
+| Per-Tenant OIDC | Production | Configure via Admin UI > Settings > SSO |
+| Per-Tenant OIDC | Production multi-tenant | Configure via Admin UI > Settings > SSO |
+{: .table .table-bordered .table-striped }
+
+
+In test mode, the Admin UI allows login without OAuth credentials. Never enable test mode in production -- set ADCP_AUTH_TEST_MODE=false and configure a proper SSO provider.
+
+
+## Dashboard
+
+The dashboard is the landing page after login. It provides:
+
+- **Real-time activity stream**: An SSE (Server-Sent Events) powered live feed showing operations as they happen -- media buy creation, approval workflows, creative uploads, and more
+- **Summary metrics**: Active media buys, pending approvals, advertiser count, and recent activity
+- **Quick actions**: Links to common tasks like creating products, managing advertisers, and viewing pending workflows
+
+The activity stream updates in real time without page refreshes, using the `business_activity_service` to push events to connected browsers.
+
+## Product Catalog Management
+
+Navigate to **Products** to manage the publisher's ad product catalog. Products define what AI buying agents can purchase.
+
+### Creating a Product
+
+1. Click **Create Product**
+2. Fill in the required fields:
+ - **Name**: Display name shown to buying agents
+ - **Description**: Natural language description of the product
+ - **Pricing**: CPM, flat rate, or custom pricing options
+ - **Format IDs**: Supported creative format identifiers
+ - **Targeting**: Available targeting options (geo, audience, contextual)
+ - **Flight dates**: Default minimum and maximum campaign durations
+3. Save the product
+
+### Product Fields
+
+| Field | Description |
+| --- | --- |
+| Name | Product display name |
+| Description | Natural language description for AI agents |
+| Pricing Options | CPM, flat rate, sponsorship, or custom models |
+| Format IDs | Linked creative format specifications |
+| Targeting | Geographic, audience, contextual, and device targeting options |
+| Min/Max Flight | Default campaign duration constraints |
+| Active | Whether the product appears in catalog queries |
+{: .table .table-bordered .table-striped }
+
+Products are returned by the `get_products` tool when AI agents query the catalog. The description field is particularly important as AI agents use it to understand what the product offers.
+
+## Advertiser Management
+
+Navigate to **Advertisers** to manage principals (advertisers and buying agents).
+
+### Creating an Advertiser
+
+1. Click **Create Advertiser**
+2. Enter:
+ - **Name**: Advertiser or agency name
+ - **Contact information**: Email, company details
+ - **Adapter mappings**: Identifiers in the connected ad server (e.g., GAM advertiser ID)
+3. Save and **Generate Auth Token**
+
+
+Auth tokens are displayed only once at creation time. The token is hashed before storage and cannot be retrieved later. Copy and securely store the token immediately.
+
+
+### Token Management
+
+- **Generate new token**: Invalidates the previous token and creates a new one
+- **Set expiry**: Configure `auth_token_expires_at` to auto-expire tokens
+- **Revoke access**: Delete the token to immediately revoke API access
+
+## Ad Server Settings
+
+Navigate to **Settings** > **Ad Server** to configure the ad server adapter.
+
+### Available Adapters
+
+| Adapter | Description |
+| --- | --- |
+| GAM | Google Ad Manager (DFP) |
+| Kevel | Kevel (formerly Adzerk) ad serving platform |
+| Triton | Triton Digital audio ad serving |
+| Broadstreet | Broadstreet local ad management |
+| Mock | Test adapter for development (no real ad server) |
+{: .table .table-bordered .table-striped }
+
+### Configuring GAM
+
+1. Select **GAM** as the adapter
+2. Enter your GAM network code
+3. Provide OAuth client credentials (`GAM_OAUTH_CLIENT_ID`, `GAM_OAUTH_CLIENT_SECRET`)
+4. If using a service account, upload the JSON key file or set `GOOGLE_APPLICATION_CREDENTIALS`
+5. Test the connection to verify access
+
+## SSO Configuration
+
+Navigate to **Settings** > **SSO** to configure single sign-on for the Admin UI.
+
+### Supported Providers
+
+| Provider | Setup Steps |
+| --- | --- |
+| Google | Create OAuth 2.0 credentials in Google Cloud Console; set authorized redirect URI to `https://yourdomain.com/admin/oidc/callback` |
+| Microsoft / Entra ID | Register an application in Azure AD; set redirect URI; note client ID and tenant ID |
+| Okta | Create an OIDC application in Okta admin; set redirect URI; note client ID and Okta domain |
+| Auth0 | Create a Regular Web Application in Auth0; set callback URL; note domain, client ID, and secret |
+| Keycloak | Create a client in your Keycloak realm; set redirect URI; note realm URL, client ID, and secret |
+{: .table .table-bordered .table-striped }
+
+### Configuration Fields
+
+For each provider, enter:
+
+- **Provider type**: Select from the dropdown
+- **Client ID**: OAuth client identifier
+- **Client Secret**: OAuth client secret (encrypted at rest)
+- **Discovery URL**: OIDC discovery endpoint (auto-populated for common providers)
+- **Allowed domains**: Restrict login to specific email domains
+
+### Provider-Specific Requirements
+
+| Provider | Required Fields |
+| --- | --- |
+| Google | Client ID, Client Secret (from Google Cloud Console OAuth 2.0 credentials) |
+| Microsoft / Entra ID | Client ID, Client Secret, Tenant ID (from Azure AD app registration) |
+| Okta | Client ID, Client Secret, Okta Domain (from Okta admin console) |
+| Auth0 | Client ID, Client Secret, Auth0 Domain (from Auth0 dashboard) |
+| Keycloak | Client ID, Client Secret, Keycloak URL, Realm Name |
+{: .table .table-bordered .table-striped }
+
+Configuration is stored encrypted in the `auth_config` table. Super admin access bypasses SSO and is configured via the `SUPER_ADMIN_EMAILS` environment variable.
+
+## User Management
+
+Navigate to **Users** to manage who can access the Admin UI.
+
+### Roles
+
+| Role | Permissions |
+| --- | --- |
+| Admin | Full access to all settings, products, advertisers, and configuration |
+| Editor | Create and edit products, advertisers, and media buys; cannot change settings |
+| Viewer | Read-only access to all sections |
+{: .table .table-bordered .table-striped }
+
+### Managing Users
+
+1. Click **Invite User**
+2. Enter the user's email address
+3. Select a role
+4. The user receives access on their next login via SSO
+
+## Workflow Approvals
+
+Navigate to **Operations** > **Workflows** to view and manage pending approval tasks.
+
+The Admin UI provides a workflow approval interface for human-in-the-loop operations. Workflows are triggered when operations exceed configured thresholds (e.g., budget limits, new advertiser onboarding) or when advertising policy violations are detected.
+
+### Viewing Pending Tasks
+
+Each pending task displays:
+
+| Field | Description |
+| --- | --- |
+| Step Type | The kind of approval needed (e.g., `media_buy_approval`, `creative_review`) |
+| Assignee / Owner | The principal who owns this workflow step |
+| Request Data | The original request payload that triggered the workflow |
+| Creation Date | When the workflow step was created |
+{: .table .table-bordered .table-striped }
+
+### Workflow Actions
+
+| Action | Effect |
+| --- | --- |
+| Approve | The pending operation proceeds and the media buy is created in the ad server. |
+| Reject | The operation is cancelled and the buying agent is notified. |
+| Request Changes | The buying agent receives feedback and can resubmit |
+{: .table .table-bordered .table-striped }
+
+When `hitl_webhook_url` is configured on the tenant, approval requests are also sent to Slack for notification.
+
+### Approval Behavior Configuration
+
+Configure approval behavior per-tenant under **Settings**:
+
+| Setting | Description |
+| --- | --- |
+| `approval_mode` | `"require-human"` (all media buys need approval) or `"auto-approve"` |
+| `creative_auto_approve_threshold` | AI confidence score above which creatives are auto-approved (default: `0.9`) |
+| `creative_auto_reject_threshold` | AI confidence score below which creatives are auto-rejected (default: `0.1`) |
+{: .table .table-bordered .table-striped }
+
+When creative review scores fall between the two thresholds, the creative is routed to human review in the workflow queue.
+
+## Inventory Profiles
+
+Navigate to **Settings** > **Inventory Profiles** to create reusable ad server inventory configurations.
+
+Inventory profiles are templates that define where ads can appear. They bundle format IDs, publisher properties, and placement rules into named configurations that products can reference via the `inventory_profile_id` foreign key. This saves time when multiple products share the same inventory configuration -- instead of duplicating settings across products, you configure the inventory once and link it.
+
+Inventory profiles are primarily used with the GAM adapter for mapping to specific ad units and placements.
+
+### Creating a Profile
+
+1. Click **Create Profile**
+2. Name the profile (e.g., "Homepage Leaderboard", "ROS Display")
+3. Define the profile fields:
+ - **Format IDs**: Supported creative format identifiers for this inventory
+ - **Publisher Properties**: Property configurations describing where ads will serve
+4. Save the profile
+5. Link the profile to one or more products (in the product editor, select the profile from the **Inventory Profile** dropdown)
+
+## Creative Agents
+
+Navigate to **Settings** > **Creative Agents** to configure external creative format providers.
+
+Creative agents are external services that define creative format specifications. When a buying agent calls `list_creative_formats`, the Sales Agent queries all enabled creative agents and aggregates the results. Format IDs returned use the pattern `{agent_url}/{format_id}` (the FormatId schema), which uniquely identifies each format across agents.
+
+### Registering a Creative Agent
+
+1. Click **Add Creative Agent**
+2. Enter the required fields:
+ - **Agent Name**: A unique identifier for this creative agent
+ - **Endpoint URL**: The URL providing format specs
+ - **Enabled**: Toggle to activate or deactivate the agent
+3. Configure authentication (API key or bearer token)
+4. Test the connection to verify format specs are returned correctly
+5. Save
+
+## Signals Agents
+
+Navigate to **Settings** > **Signals Agents** to configure external audience signal sources.
+
+Signals agents are external services that provide audience segments for targeting. They enrich the Sales Agent's capabilities by providing additional data for targeting and optimization. The system queries all enabled signals agents and aggregates the results when dynamic products generate targeted variants based on buyer briefs.
+
+### Registering a Signals Agent
+
+1. Click **Add Signals Agent**
+2. Enter the required fields:
+ - **Agent Name**: A unique identifier for this signals agent
+ - **Endpoint URL**: The URL where the signals agent API is hosted
+ - **Enabled**: Toggle to activate or deactivate the agent
+3. Configure authentication (API key or bearer token)
+4. Define the signal types provided (audience, contextual, behavioral)
+5. **Test connectivity** before enabling in production to verify the agent responds correctly
+6. Save
+
+
+Multiple signals agents can be configured per tenant. The system queries all enabled agents in parallel and merges the results. Disable an agent rather than deleting it if you need to temporarily remove it from the query pool.
+
+
+## Advertising Policies
+
+Navigate to **Settings** > **Advertising Policy** to configure advertising policies that control what can be sold and to whom.
+
+Publishers can define content policies that restrict what advertisers can promote. The system enforces these policies automatically when AI buying agents create or update media buys.
+
+### Policy Types
+
+| Policy | Description |
+| --- | --- |
+| Blocked Categories | Product/service categories not allowed (e.g., gambling, tobacco). Mapped to IAB content categories. |
+| Blocked Tactics | Advertising techniques not permitted (e.g., pop-ups, auto-play audio). |
+| Blocked Brands | Specific brands or advertisers not allowed. |
+| Budget Thresholds | Maximum budget amounts before approval is required |
+| Approval Rules | Conditions that trigger human-in-the-loop approval workflows |
+{: .table .table-bordered .table-striped }
+
+### Policy Enforcement
+
+The Policy Agent (AI) automatically checks new media buys against these policies. Each check produces one of three status outcomes:
+
+- **allowed** -- The media buy complies with all policies and proceeds normally
+- **restricted** -- The media buy may violate a policy and needs human review (routed to workflow approvals)
+- **blocked** -- The media buy clearly violates a policy and is rejected
+
+Violations trigger workflow approval tasks, giving publishers human-in-the-loop control over edge cases.
+
+## Tenant Settings
+
+Navigate to **Settings** to configure tenant-level preferences.
+
+### Available Settings
+
+| Setting | Description |
+| --- | --- |
+| Naming Templates | Templates for auto-naming entities in the ad server (orders, line items, creatives). Supports variable interpolation. |
+| Measurement Providers | Third-party measurement and verification providers |
+| Favicon | Custom favicon for the tenant's Admin UI and landing pages |
+| Product Ranking Prompt | Custom prompt used by AI to rank products for relevance |
+| Slack Webhooks | `slack_webhook_url` (general), `slack_audit_webhook_url` (audit), `hitl_webhook_url` (approval requests) |
+{: .table .table-bordered .table-striped }
+
+## Audit Log Viewer
+
+Navigate to **Activity** to view the audit trail.
+
+Every operation performed through the Sales Agent (via MCP, A2A, REST API, or Admin UI) is logged in the `audit_logs` table. The Admin UI provides a filterable, searchable view of this log.
+
+### Audit Log Fields
+
+| Field | Description |
+| --- | --- |
+| Timestamp | When the operation occurred |
+| Operation | The action performed (e.g., `create_media_buy`, `update_product`) |
+| Principal | The advertiser or user who performed the action |
+| Adapter | The ad server adapter involved |
+| Success | Whether the operation succeeded |
+| Details | Structured details about the operation |
+| Error | Error message if the operation failed |
+| IP Address | Source IP of the request |
+{: .table .table-bordered .table-striped }
+
+See [Monitoring & Audit Logging](monitoring.html) for more details on the audit system.
+
+## Next Steps
+
+- [Security Model](security.html) -- authentication layers and access control
+- [Monitoring & Audit Logging](monitoring.html) -- health checks, logging, and alerting
+- [Deployment Overview](../deployment/deployment-overview.html) -- deployment options and configuration
diff --git a/agents/salesagent/operations/monitoring.md b/agents/salesagent/operations/monitoring.md
new file mode 100644
index 0000000000..68ff45b0da
--- /dev/null
+++ b/agents/salesagent/operations/monitoring.md
@@ -0,0 +1,459 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Monitoring & Audit Logging
+description: Health checks, structured logging, audit trail, activity stream, Slack notifications, and alerting for the Prebid Sales Agent
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Monitoring & Audit Logging
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Health Checks
+
+### Endpoint
+
+The Sales Agent exposes a health check endpoint at:
+
+```text
+GET /health
+```
+
+This endpoint verifies:
+
+1. The FastAPI application is running and accepting requests
+2. The PostgreSQL database connection is active and responsive
+
+### Response
+
+A healthy response returns HTTP 200:
+
+```json
+{
+ "status": "healthy",
+ "database": "connected"
+}
+```
+
+An unhealthy response returns HTTP 503 with details about the failure:
+
+```json
+{
+ "status": "unhealthy",
+ "database": "connection failed",
+ "error": "connection to server at \"postgres\" (172.18.0.2), port 5432 failed"
+}
+```
+
+### Usage in Deployments
+
+| Platform | Health Check Configuration |
+| --- | --- |
+| Docker | `healthcheck` directive in docker-compose.yml: `curl -f http://localhost:8080/health` |
+| Fly.io | `[[http_service.checks]]` in fly.toml with `path = "/health"` |
+| Cloud Run | Startup probe on port 8080, path `/health` |
+| External monitoring | Point Pingdom, UptimeRobot, or similar at `https://yourdomain.com/health` |
+{: .table .table-bordered .table-striped }
+
+### Docker Health Check Example
+
+```yaml
+healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
+ start_period: 30s
+```
+
+## Structured Logging
+
+### Logfire Integration
+
+The Sales Agent uses [Logfire](https://logfire.pydantic.dev/) for structured, searchable logging. Logfire provides:
+
+- Structured log events with rich metadata
+- Distributed tracing across requests
+- Performance profiling
+- Error tracking and aggregation
+
+### Configuration
+
+Set the `LOGFIRE_TOKEN` environment variable to enable Logfire:
+
+```bash
+LOGFIRE_TOKEN=your-logfire-token
+```
+
+When `LOGFIRE_TOKEN` is set, the application emits structured log events to the Logfire service. Without this token, logs are written to stdout in a structured format.
+
+### Log Levels
+
+| Level | Usage |
+| --- | --- |
+| DEBUG | Detailed request tracing, SQL queries, adapter calls |
+| INFO | Standard operations: request handled, media buy created, token validated |
+| WARNING | Non-critical issues: deprecated API usage, approaching rate limits |
+| ERROR | Operation failures: database errors, adapter failures, authentication rejections |
+| CRITICAL | System-level failures: database unavailable, encryption key missing |
+{: .table .table-bordered .table-striped }
+
+### Viewing Logs
+
+| Platform | Command |
+| --- | --- |
+| Docker | `docker compose logs salesagent` |
+| Docker (follow) | `docker compose logs -f salesagent` |
+| Fly.io | `fly logs --app adcp-sales` |
+| Cloud Run | `gcloud run services logs read adcp-sales --region=us-central1` |
+| Logfire | Logfire web dashboard at `https://logfire.pydantic.dev/` |
+{: .table .table-bordered .table-striped }
+
+## Audit Trail
+
+### Overview
+
+Every operation performed through the Sales Agent is recorded in the `audit_logs` database table. This includes operations from all protocols (MCP, A2A, REST API) and the Admin UI. The audit trail is immutable -- records are inserted but never updated or deleted.
+
+### Audit Log Schema
+
+| Column | Type | Description |
+| --- | --- | --- |
+| `log_id` | String | Unique log entry identifier |
+| `tenant_id` | UUID | Tenant where the operation occurred |
+| `operation` | String | Operation name (e.g., `create_media_buy`, `update_product`, `sync_creatives`) |
+| `principal_id` | UUID | The principal who performed the action (null for admin operations) |
+| `principal_name` | String | Display name of the principal |
+| `adapter_id` | String | The ad server adapter involved in the operation |
+| `success` | Boolean | Whether the operation completed successfully |
+| `details` | JSON | Structured data about the operation (parameters, results) |
+| `error` | String | Error message if the operation failed |
+| `ip_address` | String | Source IP address of the request |
+| `created_at` | Timestamp | When the operation was logged |
+{: .table .table-bordered .table-striped }
+
+### What Events Are Logged
+
+| Category | Operations |
+| --- | --- |
+| Media Buys | `create_media_buy`, `update_media_buy`, `get_media_buys`, `get_media_buy_delivery` |
+| Creatives | `sync_creatives`, `list_creatives` |
+| Discovery | `get_adcp_capabilities`, `get_products`, `list_creative_formats`, `list_authorized_properties` |
+| Workflows | `workflow_approved`, `workflow_rejected`, `workflow_created` (Admin UI operations) |
+| Performance | `update_performance_index` |
+| Admin | `create_tenant`, `update_tenant`, `create_principal`, `update_product`, `update_settings` |
+| Authentication | `token_validated`, `token_rejected`, `token_expired`, `sso_login`, `sso_login_failed` |
+{: .table .table-bordered .table-striped }
+
+### Querying Audit Logs
+
+Through the Admin UI, navigate to **Activity** to view the audit log with filters for:
+
+- Date range
+- Operation type
+- Principal (advertiser)
+- Success / failure status
+
+For direct database queries:
+
+```sql
+-- Recent failures
+SELECT operation, principal_name, error, created_at
+FROM audit_logs
+WHERE tenant_id = 'your-tenant-id'
+ AND success = false
+ORDER BY created_at DESC
+LIMIT 50;
+
+-- Operations by a specific principal
+SELECT operation, success, details, created_at
+FROM audit_logs
+WHERE tenant_id = 'your-tenant-id'
+ AND principal_id = 'principal-uuid'
+ORDER BY created_at DESC;
+
+-- Authentication failures in the last 24 hours
+SELECT operation, principal_name, ip_address, error, created_at
+FROM audit_logs
+WHERE operation IN ('token_rejected', 'token_expired', 'sso_login_failed')
+ AND created_at > NOW() - INTERVAL '24 hours'
+ORDER BY created_at DESC;
+```
+
+## Activity Stream
+
+### Overview
+
+The Admin UI includes a real-time activity stream powered by Server-Sent Events (SSE). The stream shows business operations as they happen, without requiring page refreshes.
+
+### How It Works
+
+1. The `business_activity_service` captures events from all protocols (MCP, A2A, REST API)
+2. Events are pushed to connected SSE clients via the `/admin/activity/stream` endpoint
+3. The Admin UI dashboard renders events in a live feed
+
+### Event Types
+
+The activity stream displays:
+
+- Media buy creation and updates
+- Creative uploads and sync operations
+- Workflow task creation and completion
+- Advertiser registration and token generation
+- Product catalog changes
+- Configuration updates
+
+### nginx Configuration for SSE
+
+If using nginx as a reverse proxy, SSE requires specific configuration to prevent buffering:
+
+```nginx
+location /admin/activity/stream {
+ proxy_pass http://adcp_backend;
+ proxy_set_header Host $host;
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 86400s;
+}
+```
+
+## Slack Notifications
+
+The Sales Agent can send notifications to Slack via webhooks. Three webhook URLs can be configured per tenant:
+
+| Webhook | Setting | Purpose |
+| --- | --- | --- |
+| General | `slack_webhook_url` | General operational notifications (media buy created, campaign launched) |
+| Audit | `slack_audit_webhook_url` | Security and audit events (authentication failures, configuration changes) |
+| Human-in-the-Loop | `hitl_webhook_url` | Approval requests requiring human action (budget threshold exceeded, policy violation) |
+{: .table .table-bordered .table-striped }
+
+### Configuration
+
+Configure webhooks in the Admin UI under **Settings** > **Notifications**, or set them via the API:
+
+```bash
+curl -X PATCH https://yourdomain.com/api/v1/tenant/settings \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "slack_webhook_url": "https://hooks.slack.com/services/T.../B.../xxx",
+ "slack_audit_webhook_url": "https://hooks.slack.com/services/T.../B.../yyy",
+ "hitl_webhook_url": "https://hooks.slack.com/services/T.../B.../zzz"
+ }'
+```
+
+### Notification Content
+
+General notifications include:
+
+- Media buy status changes (created, approved, paused, completed)
+- Campaign delivery milestones
+- New advertiser registrations
+
+Audit notifications include:
+
+- Authentication failures and token expirations
+- Configuration changes (adapter, SSO, policies)
+- Super admin actions
+
+Human-in-the-loop notifications include:
+
+- Media buy approval requests with budget details
+- Policy violation alerts with recommended actions
+- Workflow tasks waiting for human decision
+
+## Background Schedulers
+
+The Sales Agent runs two background schedulers that automate campaign lifecycle operations. Both schedulers are started during the application lifespan (in `src/core/main.py`) and stopped on shutdown. They can be disabled with `SKIP_CRON=true`.
+
+### Media Buy Status Scheduler
+
+**Module**: `src/services/media_buy_status_scheduler.py`
+
+The Media Buy Status Scheduler automatically transitions media buy statuses based on flight dates. It runs on a configurable interval (default: 60 seconds) and works cross-tenant, querying all active tenants on each cycle.
+
+**Automatic status transitions:**
+
+{: .table .table-bordered .table-striped }
+| From Status | To Status | Condition |
+|-------------|-----------|-----------|
+| `pending_activation` | `active` | `flight_start_date` has been reached and all creatives are approved |
+| `active` | `completed` | `flight_end_date` has passed |
+
+| Setting | Value |
+| --- | --- |
+| Default interval | 60 seconds |
+| Scope | Cross-tenant (all active tenants) |
+| Manual pause | Respected -- paused media buys are not auto-transitioned |
+| On status change | Updates database, triggers Slack notification, creates audit log entry |
+{: .table .table-bordered .table-striped }
+
+### Delivery Webhook Scheduler
+
+**Module**: `src/services/delivery_webhook_scheduler.py`
+
+The Delivery Webhook Scheduler sends periodic delivery reports to configured webhook URLs. It integrates with the protocol webhook service for authentication and implements duplicate prevention.
+
+| Setting | Value |
+| --- | --- |
+| Default interval | 3600 seconds (1 hour) |
+| Payload | Media buy ID, delivery metrics, status |
+| Authentication | Integrates with the protocol webhook service (see below) |
+| Duplicate prevention | 24-hour window to avoid re-sending the same report |
+| Scope | Respects per-tenant webhook configuration |
+{: .table .table-bordered .table-striped }
+
+### Protocol Webhooks
+
+**Module**: `src/services/protocol_webhook_service.py`
+
+The Protocol Webhook Service sends push notifications for campaign events such as status changes and delivery updates. It handles both A2A `TaskStatusUpdateEvent` payloads and MCP webhook payloads.
+
+**Supported authentication schemes:**
+
+{: .table .table-bordered .table-striped }
+| Scheme | Header | Description |
+|--------|--------|-------------|
+| **Bearer Token** | `Authorization: Bearer ` | Token-based authentication |
+| **HMAC-SHA256** | `X-Signature` | Signed request body with a shared secret key |
+| **None** | -- | Unauthenticated (development only) |
+
+**Configuration** is managed via `PushNotificationConfig`:
+
+{: .table .table-bordered .table-striped }
+| Field | Description |
+|-------|-------------|
+| `url` | Webhook endpoint URL |
+| `authentication_type` | One of `bearer`, `hmac-sha256`, or `none` |
+| `authentication_token` | Bearer token or HMAC secret key |
+
+
+The webhook service performs Docker localhost normalization for local testing, automatically rewriting localhost URLs to the Docker host address when running inside a container.
+
+
+Additional behavior:
+
+- Automatic retry with logging on delivery failure.
+- All webhook dispatches are recorded in the audit log.
+
+## Database Monitoring
+
+### Connection Pool
+
+Monitor database connection health through:
+
+- **Health endpoint**: `GET /health` verifies active database connectivity
+- **Query timeout**: `DATABASE_QUERY_TIMEOUT` (default: 30 seconds) prevents long-running queries from blocking the pool
+- **Connect timeout**: `DATABASE_CONNECT_TIMEOUT` (default: 10 seconds) limits time spent establishing new connections
+
+### PgBouncer Support
+
+For high-traffic deployments, enable PgBouncer connection pooling:
+
+```bash
+USE_PGBOUNCER=true
+```
+
+When enabled, the application adjusts its connection handling to be compatible with PgBouncer's transaction pooling mode.
+
+### Key Metrics to Monitor
+
+| Metric | Source | Alert Threshold |
+| --- | --- | --- |
+| Database connectivity | `/health` endpoint | Any non-200 response |
+| Query latency | Logfire / application logs | p95 > 5 seconds |
+| Connection pool utilization | Database server metrics | > 80% of max connections |
+| Active connections | `pg_stat_activity` | Approaching `max_connections` |
+| Table bloat | `pg_stat_user_tables` | `n_dead_tup` > 10,000 |
+| Disk usage | Database server | > 80% of allocated storage |
+{: .table .table-bordered .table-striped }
+
+### Database Health Queries
+
+```sql
+-- Active connections by state
+SELECT state, count(*)
+FROM pg_stat_activity
+WHERE datname = 'adcp_sales'
+GROUP BY state;
+
+-- Slowest recent queries
+SELECT query, calls, mean_exec_time, max_exec_time
+FROM pg_stat_statements
+WHERE dbid = (SELECT oid FROM pg_database WHERE datname = 'adcp_sales')
+ORDER BY mean_exec_time DESC
+LIMIT 10;
+
+-- Table sizes
+SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
+FROM pg_catalog.pg_statio_user_tables
+ORDER BY pg_total_relation_size(relid) DESC
+LIMIT 10;
+```
+
+## Alerting Patterns
+
+### Health Check Monitoring
+
+Set up external monitoring to poll the `/health` endpoint and alert on failures:
+
+| Tool | Configuration |
+| --- | --- |
+| UptimeRobot | HTTP monitor on `https://yourdomain.com/health`, check every 1 minute, alert on 2 consecutive failures |
+| Pingdom | HTTPS check on `/health`, 1-minute interval |
+| AWS CloudWatch | Synthetic canary hitting `/health` endpoint |
+| GCP Cloud Monitoring | Uptime check on Cloud Run service URL |
+{: .table .table-bordered .table-striped }
+
+### Error Rate Tracking
+
+Monitor the audit log for elevated error rates:
+
+```sql
+-- Error rate in the last hour
+SELECT
+ COUNT(*) FILTER (WHERE success = false) AS errors,
+ COUNT(*) AS total,
+ ROUND(100.0 * COUNT(*) FILTER (WHERE success = false) / COUNT(*), 2) AS error_pct
+FROM audit_logs
+WHERE created_at > NOW() - INTERVAL '1 hour';
+```
+
+Set alerts when:
+
+- Error rate exceeds 5% over a 15-minute window
+- Any critical operation fails (e.g., `create_media_buy` with `success = false`)
+- Authentication failure count exceeds threshold (possible brute force)
+
+### Budget Alerts
+
+Monitor media buy budget utilization:
+
+- Configure budget thresholds in advertising policies
+- Workflow tasks are created automatically when thresholds are exceeded
+- Slack notifications via `hitl_webhook_url` alert operators to pending approvals
+
+### Recommended Alert Configuration
+
+| Alert | Condition | Severity | Channel |
+| --- | --- | --- | --- |
+| Health check failure | `/health` returns non-200 for 2+ checks | Critical | PagerDuty / SMS |
+| High error rate | > 5% failures in 15 minutes | High | Slack (`slack_webhook_url`) |
+| Authentication spike | > 10 auth failures in 5 minutes | High | Slack (`slack_audit_webhook_url`) |
+| Database connection failure | Health check reports database unhealthy | Critical | PagerDuty / SMS |
+| Disk usage high | Database disk > 80% | Medium | Email / Slack |
+| Pending approvals | Workflow tasks pending > 24 hours | Medium | Slack (`hitl_webhook_url`) |
+| Certificate expiry | TLS certificate expires in < 14 days | Medium | Email |
+{: .table .table-bordered .table-striped }
+
+## Next Steps
+
+- [Security Model](security.html) -- audit trail details and production hardening
+- [Admin UI Guide](admin-ui.html) -- activity stream and audit log viewer
+- [Deployment Overview](../deployment/deployment-overview.html) -- platform-specific monitoring setup
+- [Single-Tenant Deployment](../deployment/single-tenant.html) -- Docker health check configuration
diff --git a/agents/salesagent/operations/security.md b/agents/salesagent/operations/security.md
new file mode 100644
index 0000000000..34e8f2c356
--- /dev/null
+++ b/agents/salesagent/operations/security.md
@@ -0,0 +1,259 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Security Model
+description: Authentication layers, encryption, multi-tenant isolation, and production hardening for the Prebid Sales Agent
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Security Model
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Authentication Layers
+
+The Prebid Sales Agent uses a layered authentication model with four distinct levels. Each level serves a different actor and grants different capabilities.
+
+| Layer | Actor | Mechanism | Scope |
+| --- | --- | --- | --- |
+| Super Admin | Platform operator | `SUPER_ADMIN_EMAILS` / `SUPER_ADMIN_DOMAINS` env vars | Full platform access across all tenants |
+| OAuth / SSO | Admin UI users | Per-tenant OIDC (Google, Microsoft, Okta, Auth0, Keycloak) | Tenant-scoped Admin UI access |
+| Tenant Admin | API integrators | `admin_token` field on tenant record | Tenant-scoped API and management access |
+| Principal Token | AI buying agents | `auth_token` on principal record, sent via `x-adcp-auth` or `Bearer` header | Principal-scoped data access within a tenant |
+{: .table .table-bordered .table-striped }
+
+### Super Admin
+
+Super admin privileges are granted based on environment variables:
+
+- **`SUPER_ADMIN_EMAILS`**: Comma-separated list of specific email addresses (e.g., `alice@corp.com,bob@corp.com`)
+- **`SUPER_ADMIN_DOMAINS`**: Comma-separated list of email domains (e.g., `corp.com`). All users with an email at any listed domain receive super admin access.
+
+Super admins can:
+
+- Create, read, update, and delete tenants
+- Access any tenant's data and configuration
+- Manage platform-wide settings
+
+
+Changes to SUPER_ADMIN_EMAILS and SUPER_ADMIN_DOMAINS require a service restart to take effect.
+
+
+### OAuth / SSO
+
+Each tenant can configure its own OIDC provider for Admin UI authentication. Supported providers:
+
+| Provider | Discovery URL Pattern |
+| --- | --- |
+| Google | `https://accounts.google.com/.well-known/openid-configuration` |
+| Microsoft / Entra ID | `https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration` |
+| Okta | `https://{domain}.okta.com/.well-known/openid-configuration` |
+| Auth0 | `https://{domain}.auth0.com/.well-known/openid-configuration` |
+| Keycloak | `https://{host}/realms/{realm}/.well-known/openid-configuration` |
+{: .table .table-bordered .table-striped }
+
+SSO credentials (client ID, client secret) are encrypted at rest. Configure SSO in the Admin UI under **Settings** > **SSO**, or see the [Admin UI Guide](admin-ui.html) for details.
+
+### Tenant Admin Token
+
+Each tenant has an `admin_token` field used for programmatic API access. This token is separate from principal tokens and grants tenant-level management capabilities.
+
+### Principal Token
+
+Principals (advertisers and buying agents) authenticate using tokens sent in request headers:
+
+| Header | Format | Example |
+| --- | --- | --- |
+| `x-adcp-auth` | Raw token string | `x-adcp-auth: abc123def456` |
+| `Authorization` | `Bearer ` | `Authorization: Bearer abc123def456` |
+{: .table .table-bordered .table-striped }
+
+Either header is accepted. The `x-adcp-auth` header takes precedence if both are present.
+
+## Token Management
+
+### How Tokens Are Stored
+
+Auth tokens are **hashed** before storage in the database. The original plaintext token is displayed only once -- at creation time -- and cannot be retrieved afterward.
+
+### Token Lifecycle
+
+| Action | Method | Effect |
+| --- | --- | --- |
+| Create | Admin UI or API | Generates a new token, displays it once, stores the hash |
+| Rotate | Admin UI > Regenerate Token | Invalidates the old hash, creates a new token |
+| Expire | Set `auth_token_expires_at` | Token automatically rejected after the expiry timestamp |
+| Revoke | Delete the token via Admin UI | Immediately invalidates access |
+{: .table .table-bordered .table-striped }
+
+### Token Expiry
+
+The `auth_token_expires_at` field on the principal record sets an expiry time for the token. After this timestamp, the token is rejected even if the hash matches. This provides automatic credential rotation without manual intervention.
+
+
+Always set token expiry for production deployments. Tokens without expiry remain valid indefinitely until manually revoked.
+
+
+## Encryption at Rest
+
+### Fernet Symmetric Encryption
+
+The Sales Agent uses Fernet symmetric encryption (from the `cryptography` library) to encrypt sensitive fields before they are stored in the database. This provides authenticated encryption -- data is both encrypted and integrity-protected.
+
+### ENCRYPTION_KEY
+
+The `ENCRYPTION_KEY` environment variable holds the Fernet key used for all encryption operations. If not provided, a key is auto-generated on first run.
+
+Generate a key manually:
+
+```bash
+python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+```
+
+
+Critical: The ENCRYPTION_KEY is required to decrypt all encrypted fields. If this key is lost, encrypted data (API keys, OAuth credentials, webhook secrets) cannot be recovered. Store the key in a secure vault or secrets manager, and include it in your backup strategy.
+
+
+### Encrypted Fields
+
+The following fields are encrypted before database storage:
+
+| Field | Location | Purpose |
+| --- | --- | --- |
+| `gemini_api_key` | Tenant settings | Per-tenant Gemini API key for AI features |
+| OAuth client secret | SSO configuration | OIDC provider client secrets |
+| Webhook secrets | Tenant settings | Slack and delivery webhook authentication secrets |
+{: .table .table-bordered .table-striped }
+
+All other data (product definitions, media buys, audit logs) is stored unencrypted in PostgreSQL and protected by database-level access controls.
+
+## Multi-Tenant Isolation
+
+When `ADCP_MULTI_TENANT=true`, tenant isolation is enforced at the application layer through several mechanisms.
+
+### Composite Primary Keys
+
+Database tables that hold tenant-specific data use composite primary keys that include `tenant_id`. This ensures that records from different tenants cannot collide.
+
+### Tenant-Scoped Queries
+
+Every database query includes a `WHERE tenant_id = :tenant_id` clause. The tenant ID is resolved from the request's `Host` header (subdomain routing) and injected into the query context by middleware.
+
+### No Cross-Tenant Data Access
+
+The `UnifiedAuthMiddleware` (ASGI middleware) resolves the tenant from the request and creates an `AuthContext` that is passed through the entire request lifecycle. Business logic functions receive a `ResolvedIdentity` object that is always scoped to a single tenant. There is no API endpoint that returns data across tenants except for super admin tenant management.
+
+### Request Flow
+
+```text
+Request → Host header → Subdomain extraction → Tenant resolution
+ → Token validation (scoped to resolved tenant)
+ → AuthContext created (tenant_id + principal_id)
+ → ResolvedIdentity passed to business logic
+ → All queries filtered by tenant_id
+```
+
+## Network Security
+
+### Nginx Reverse Proxy
+
+In Docker deployments, nginx sits in front of the application and provides:
+
+- **TLS termination**: HTTPS with configurable certificates
+- **CORS**: Cross-origin request filtering
+- **Rate limiting**: Request rate limits to prevent abuse
+- **Request buffering**: Protection against slow-client attacks
+- **Header injection**: `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`
+
+### Port Exposure
+
+Only port 8000 (nginx) should be exposed to the public internet. Internal ports are:
+
+| Port | Service | Exposure |
+| --- | --- | --- |
+| 8000 | nginx | Public (external traffic) |
+| 8080 | FastAPI unified app | Internal (Docker network only) |
+| 5432 | PostgreSQL | Internal (Docker network only) |
+{: .table .table-bordered .table-striped }
+
+{: .alert.alert-info :}
+As of v1.5.0, all protocols (MCP, A2A, REST, Admin UI) are served by a single FastAPI process on port 8080. Ports 8001 and 8091 from the legacy multi-process architecture are no longer used.
+
+### Cloud Deployments
+
+On Fly.io and Cloud Run, TLS termination and load balancing are handled by the platform. Set `SKIP_NGINX=true` and let the platform manage network security.
+
+## Super Admin Access
+
+Super admin access is controlled entirely through environment variables and checked at authentication time.
+
+### Configuration
+
+```bash
+# Specific email addresses
+SUPER_ADMIN_EMAILS=alice@corp.com,bob@corp.com
+
+# All users at these domains
+SUPER_ADMIN_DOMAINS=corp.com,platform.io
+```
+
+### Security Considerations
+
+- Use `SUPER_ADMIN_EMAILS` for the tightest control -- only named individuals receive super admin access.
+- Use `SUPER_ADMIN_DOMAINS` cautiously -- anyone with an email at the listed domain can gain super admin access if they authenticate via SSO.
+- In multi-tenant mode, super admins are the only users who can access data across tenants.
+- Super admin status is not stored in the database -- it is evaluated at request time from the environment variables.
+
+## Audit Trail
+
+Every operation is logged in the `audit_logs` table, providing a complete trail for security review and compliance.
+
+### Audit Log Schema
+
+| Column | Type | Description |
+| --- | --- | --- |
+| `log_id` | String | Unique log entry identifier |
+| `tenant_id` | UUID | Tenant where the operation occurred |
+| `operation` | String | Operation name (e.g., `create_media_buy`, `update_product`) |
+| `principal_id` | UUID | The principal (advertiser) who performed the action |
+| `principal_name` | String | Display name of the principal |
+| `adapter_id` | String | The ad server adapter involved |
+| `success` | Boolean | Whether the operation succeeded |
+| `details` | JSON | Structured data about the operation |
+| `error` | String | Error message if the operation failed |
+| `ip_address` | String | Source IP address of the request |
+| `created_at` | Timestamp | When the operation occurred |
+{: .table .table-bordered .table-striped }
+
+See [Monitoring & Audit Logging](monitoring.html) for querying and alerting on audit data.
+
+## Production Hardening Checklist
+
+Use this checklist before deploying to production:
+
+| Item | Action | Priority |
+| --- | --- | --- |
+| Disable test mode | Set `ADCP_AUTH_TEST_MODE=false` | Critical |
+| Set encryption key | Set `ENCRYPTION_KEY` to a strong Fernet key; back it up securely | Critical |
+| Configure SSO | Set up OIDC provider (Google, Microsoft, Okta, Auth0, or Keycloak) | Critical |
+| Enable HTTPS | Configure TLS certificates on nginx, Fly.io, or Cloud Run | Critical |
+| Set super admin emails | Set `SUPER_ADMIN_EMAILS` to specific trusted email addresses | Critical |
+| Configure CORS | Restrict allowed origins in nginx configuration | High |
+| Enable audit logging | Verify `audit_logs` table is being populated (enabled by default) | High |
+| Set token expiry | Configure `auth_token_expires_at` on all principal tokens | High |
+| Database backups | Configure automated `pg_dump` or managed database backups | High |
+| Secure ENCRYPTION_KEY | Store in a secrets manager (AWS Secrets Manager, GCP Secret Manager, Vault) | High |
+| Rotate default passwords | Change all default database and service passwords | High |
+| Restrict port exposure | Only expose port 8000 (nginx) externally | Medium |
+| Enable Slack audit webhooks | Set `slack_audit_webhook_url` for security event notifications | Medium |
+| Review advertising policies | Configure blocked categories, tactics, and brands | Medium |
+| Set DATABASE_QUERY_TIMEOUT | Keep at 30s or lower to prevent long-running queries | Medium |
+{: .table .table-bordered .table-striped }
+
+## Next Steps
+
+- [Monitoring & Audit Logging](monitoring.html) -- health checks, structured logging, and alerting
+- [Admin UI Guide](admin-ui.html) -- SSO setup and user management
+- [Multi-Tenant Deployment](../deployment/multi-tenant.html) -- tenant isolation in practice
+- [Deployment Overview](../deployment/deployment-overview.html) -- platform-specific security configuration
diff --git a/agents/salesagent/protocols.md b/agents/salesagent/protocols.md
new file mode 100644
index 0000000000..a4ba79d22b
--- /dev/null
+++ b/agents/salesagent/protocols.md
@@ -0,0 +1,158 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Protocols
+description: MCP and A2A protocol comparison
+sidebarType: 10
+---
+
+# Protocols: MCP vs A2A
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+The Sales Agent exposes 11 tools through two AI agent protocols. Both call identical `_impl` business logic functions, so behavior is the same regardless of protocol. A REST API is also available for non-agent integrations.
+
+## Protocol Comparison
+
+{: .table .table-bordered .table-striped }
+| Feature | MCP | A2A |
+|---------|-----|-----|
+| Transport | StreamableHTTP | JSON-RPC 2.0 |
+| Library | FastMCP >= 3.0.2 | a2a-sdk >= 0.3.19 |
+| Endpoint | `/mcp/` | `/a2a` |
+| Discovery | `list_tools` via MCP protocol | Agent card at `/.well-known/agent-card.json` |
+| Auth | `x-adcp-auth` or `Authorization: Bearer` | Same |
+| Push notifications | Not supported | Supported (webhook) |
+| Best for | AI assistants (Claude, Cursor, GPT) | Agent-to-agent orchestration |
+
+## MCP (Model Context Protocol)
+
+The MCP interface uses [FastMCP](https://github.com/jlowin/fastmcp) with StreamableHTTP transport.
+
+### Connecting via CLI
+
+```bash
+# List all tools
+uvx adcp http://localhost:8000/mcp/ --auth test-token list_tools
+
+# Call a tool
+uvx adcp http://localhost:8000/mcp/ --auth test-token get_products
+```
+
+### Connecting via Python
+
+```python
+from fastmcp import Client
+from fastmcp.client.transports import StreamableHttpTransport
+
+transport = StreamableHttpTransport(
+ "http://localhost:8000/mcp/",
+ headers={"x-adcp-auth": "YOUR_TOKEN"}
+)
+async with Client(transport=transport) as client:
+ tools = await client.list_tools()
+ result = await client.call_tool("get_products", {"brief": "video ads"})
+```
+
+### Claude Desktop Configuration
+
+```json
+{
+ "mcpServers": {
+ "salesagent": {
+ "url": "http://localhost:8000/mcp/",
+ "headers": {
+ "x-adcp-auth": "YOUR_TOKEN"
+ }
+ }
+ }
+}
+```
+
+## A2A (Agent-to-Agent Protocol)
+
+The A2A interface uses the [a2a-sdk](https://google.github.io/A2A/) with JSON-RPC 2.0 transport.
+
+### Agent Card
+
+The agent card at `/.well-known/agent-card.json` describes the agent's identity, skills, and auth requirements. AI orchestrators fetch this before sending requests.
+
+### Task Lifecycle
+
+A2A operations follow a task lifecycle:
+
+```text
+submitted → working → completed
+ → failed
+ → canceled
+```
+
+### Push Notifications
+
+The A2A server supports push notifications for async task updates. When a task transitions state, the server POSTs a notification to a webhook URL provided in the original request.
+
+### Example JSON-RPC Request
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "req-001",
+ "method": "tasks/send",
+ "params": {
+ "id": "task-001",
+ "message": {
+ "role": "user",
+ "parts": [
+ {
+ "type": "text",
+ "text": "Find video ad products targeting US sports fans"
+ }
+ ]
+ }
+ }
+}
+```
+
+## Authentication
+
+Both protocols use the same authentication mechanism:
+
+1. Token via `x-adcp-auth` header (preferred) or `Authorization: Bearer`
+2. `UnifiedAuthMiddleware` extracts the token and resolves a `ResolvedIdentity`
+3. `x-adcp-auth` takes precedence if both headers are present
+
+{: .alert.alert-warning :}
+Discovery tools (`get_adcp_capabilities`, `get_products`, `list_creative_formats`, `list_authorized_properties`) may work without authentication depending on tenant configuration. All other tools require a valid token.
+
+## REST API
+
+A standard REST API at `/api/v1` provides HTTP access to all tools for traditional integrations and scripts.
+
+{: .table .table-bordered .table-striped }
+| Method | Path | Auth | Maps to |
+|--------|------|------|---------|
+| GET | `/api/v1/capabilities` | Optional | get_adcp_capabilities |
+| GET | `/api/v1/products` | Optional | get_products |
+| GET | `/api/v1/creative-formats` | Optional | list_creative_formats |
+| GET | `/api/v1/properties` | Optional | list_authorized_properties |
+| POST | `/api/v1/media-buys` | Required | create_media_buy |
+| PATCH | `/api/v1/media-buys/{id}` | Required | update_media_buy |
+| GET | `/api/v1/media-buys` | Required | get_media_buys |
+| GET | `/api/v1/media-buys/{id}/delivery` | Required | get_media_buy_delivery |
+| POST | `/api/v1/media-buys/{id}/creatives` | Required | sync_creatives |
+| GET | `/api/v1/media-buys/{id}/creatives` | Required | list_creatives |
+
+## When to Use Which Protocol
+
+- **MCP**: Use when integrating directly with an AI assistant (Claude Desktop, Cursor, custom chatbot). Simplest setup.
+- **A2A**: Use when building agent-to-agent workflows where push notifications and task lifecycle management matter.
+- **REST**: Use for scripts, dashboards, or any non-agent integration.
+
+## Further Reading
+
+- [Architecture](/agents/salesagent/architecture.html) -- Transport parity design
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools
+- [Security](/agents/salesagent/operations/security.html) -- Authentication details
diff --git a/agents/salesagent/reference/error-codes.md b/agents/salesagent/reference/error-codes.md
new file mode 100644
index 0000000000..6fc5dcac85
--- /dev/null
+++ b/agents/salesagent/reference/error-codes.md
@@ -0,0 +1,112 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Error Codes
+description: Error code reference for the Sales Agent
+sidebarType: 10
+---
+
+# Error Codes
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+All Sales Agent errors extend `AdCPError` and include a status code, error code string, and recovery hint.
+
+## Recovery Hints
+
+{: .table .table-bordered .table-striped }
+| Hint | Meaning | Action |
+|------|---------|--------|
+| `transient` | Temporary failure | Retry after a delay |
+| `correctable` | Invalid input or state | Fix the request and retry |
+| `terminal` | Cannot be resolved | Do not retry |
+
+## Error Types
+
+{: .table .table-bordered .table-striped }
+| Error Class | Status | Code | Recovery | Description |
+|-------------|--------|------|----------|-------------|
+| AdCPValidationError | 400 | VALIDATION_ERROR | correctable | Invalid parameters or missing required fields |
+| AdCPAuthenticationError | 401 | AUTHENTICATION_ERROR | correctable | Missing or invalid auth token |
+| AdCPAuthorizationError | 403 | AUTHORIZATION_ERROR | terminal | Valid token but insufficient permissions |
+| AdCPNotFoundError | 404 | NOT_FOUND | correctable | Resource does not exist |
+| AdCPConflictError | 409 | CONFLICT | correctable | Resource state conflict (duplicate buyer_ref, overlapping flights) |
+| AdCPGoneError | 410 | GONE | terminal | Resource permanently removed |
+| AdCPBudgetExhaustedError | 422 | BUDGET_EXHAUSTED | correctable | Budget limit reached |
+| AdCPRateLimitError | 429 | RATE_LIMIT_EXCEEDED | transient | Too many requests |
+| AdCPAdapterError | 502 | ADAPTER_ERROR | transient | Upstream ad server returned an error |
+| AdCPServiceUnavailableError | 503 | SERVICE_UNAVAILABLE | transient | Service temporarily unavailable |
+
+## Error Response Formats
+
+### MCP
+
+```json
+{
+ "error": {
+ "code": "VALIDATION_ERROR",
+ "message": "Field 'budget' is required for each package",
+ "recovery": "correctable",
+ "details": {"field": "packages[0].budget"}
+ }
+}
+```
+
+### A2A (JSON-RPC 2.0)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "req-001",
+ "error": {
+ "code": -32000,
+ "message": "VALIDATION_ERROR: Field 'budget' is required for each package",
+ "data": {
+ "recovery": "correctable",
+ "details": {"field": "packages[0].budget"}
+ }
+ }
+}
+```
+
+### REST
+
+```json
+{
+ "error": "VALIDATION_ERROR",
+ "message": "Field 'budget' is required for each package",
+ "recovery": "correctable",
+ "details": {"field": "packages[0].budget"}
+}
+```
+
+## Troubleshooting
+
+### 401 on discovery tools
+
+Discovery tools are auth-optional, but some tenants require authentication for all tools. Check the tenant's configuration in the Admin UI.
+
+### 502 from GAM adapter
+
+Usually means the GAM API quota is exceeded or the service account is misconfigured. Check the GAM API dashboard and verify the service account has the correct permissions.
+
+### 409 on create_media_buy
+
+A media buy with the same `buyer_ref` already exists for this principal. Use `get_media_buys` to find existing buys, or use a unique `buyer_ref`.
+
+### 422 budget exhausted
+
+The media buy or package budget limit has been reached. Use `get_media_buy_delivery` to check current spend, then `update_media_buy` to increase the budget if needed.
+
+### 429 rate limit
+
+The Sales Agent or the underlying ad server is rate-limiting requests. Implement exponential backoff with a minimum 1-second delay.
+
+## Further Reading
+
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools with auth requirements
+- [Architecture](/agents/salesagent/architecture.html) -- Error handling design
+- [Security](/agents/salesagent/operations/security.html) -- Authentication details
diff --git a/agents/salesagent/schemas/api-schemas.md b/agents/salesagent/schemas/api-schemas.md
new file mode 100644
index 0000000000..6aac687119
--- /dev/null
+++ b/agents/salesagent/schemas/api-schemas.md
@@ -0,0 +1,409 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - API Schema Reference
+description: Complete reference for all Pydantic request/response models used by the Sales Agent tools.
+sidebarType: 10
+---
+
+# Prebid Sales Agent - API Schema Reference
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+All request and response objects in the Sales Agent are defined as Pydantic models in `src/core/schemas/`. This page documents every model and enum, organised by domain. Field types use Python type annotation syntax; `None`-able fields are shown with `| None` and include their default value.
+
+See the [Tool Reference](../tools/tool-reference.html) for usage context and individual tool pages for parameters and responses.
+
+---
+
+## Product Domain
+
+### Product
+
+Represents a purchasable advertising product in the publisher's catalog.
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `product_id` | `str` | -- | Unique identifier within the tenant. |
+| `name` | `str` | -- | Human-readable product name. |
+| `description` | `str` | -- | Detailed product description. |
+| `format_ids` | `list[FormatId]` | -- | Creative formats accepted by this product. |
+| `delivery_type` | `str` | -- | `"guaranteed"` or `"non_guaranteed"`. See `DeliveryType` enum. |
+| `delivery_measurement` | `dict` | -- | How delivery is measured (e.g., `{"type": "impressions"}`). |
+| `pricing_options` | `list[PricingOption]` | -- | Available pricing models and rates. |
+| `publisher_properties` | `list[str]` or `None` | `None` | Sites/apps where this product serves. |
+| `is_custom` | `bool` | `False` | Whether this is a custom product built for a specific buyer. |
+{: .table .table-bordered .table-striped }
+
+Additional Product fields:
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `property_ids` | `list[str]` or `None` | `None` | AdCP 2.0.0 property authorization by ID. |
+| `property_tags` | `list[str]` or `None` | `None` | AdCP 2.0.0 property authorization by tag. |
+| `catalog_match` | `dict` or `None` | `None` | AdCP 3.6 catalog matching configuration. |
+| `catalog_types` | `list` or `None` | `None` | Catalog type classifications. |
+| `conversion_tracking` | `dict` or `None` | `None` | Conversion tracking configuration. |
+| `data_provider_signals` | `list` or `None` | `None` | Data provider signal references. |
+| `forecast` | `dict` or `None` | `None` | Inventory forecast data. |
+| `product_card` | `dict` or `None` | `None` | Visual card for product display. |
+| `product_card_detailed` | `dict` or `None` | `None` | Rich product presentation. |
+| `placements` | `list[dict]` or `None` | `None` | Ad placement configurations. |
+| `reporting_capabilities` | `dict` or `None` | `None` | What reporting dimensions are available. |
+| `property_targeting_allowed` | `bool` | `False` | Whether per-property targeting is available. |
+| `signal_targeting_allowed` | `bool` or `None` | `None` | Whether signal-based targeting is available. |
+| `allowed_principal_ids` | `list[str]` or `None` | `None` | Restrict product visibility to specific principals. |
+| `expires_at` | `datetime` or `None` | `None` | Product expiration date. |
+{: .table .table-bordered .table-striped }
+
+Internal fields (not returned in API responses but present in the database model):
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `implementation_config` | `dict` or `None` | `None` | Adapter-specific configuration for order/line-item creation. |
+| `countries` | `list[str]` or `None` | `None` | Countries where this product is available (ISO 3166-1 alpha-2). |
+| `device_types` | `list[str]` or `None` | `None` | Supported device types. |
+{: .table .table-bordered .table-striped }
+
+### Dynamic Product Fields
+
+When `is_dynamic` is set to `true`, a product acts as a template that generates targeted variants by querying signals agents. Dynamic products enable automated creation of product variants based on audience signals, contextual data, or other external inputs.
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `is_dynamic` | `bool` | `False` | When `true`, this product is a template that generates targeted variants by querying signals agents. |
+| `is_dynamic_variant` | `bool` | `False` | When `true`, this product was auto-generated from a dynamic template. |
+| `parent_product_id` | `str` or `None` | `None` | Points to the dynamic template product that generated this variant. |
+| `signals_agent_ids` | `list[str]` or `None` | `None` | Which signals agents to query for variant generation. `null` = query all configured agents. |
+| `max_signals` | `int` | `5` | Maximum number of signal variants to generate per brief. |
+| `variant_name_template` | `str` or `None` | `None` | Template for generating variant names. Supports `{signal_name}` and `{parent_name}` placeholders. |
+| `variant_description_template` | `str` or `None` | `None` | Template for generating variant descriptions. |
+| `activation_key` | `dict` or `None` | `None` | Signal activation configuration. |
+| `signal_metadata` | `dict` or `None` | `None` | Metadata from the signals agent that generated this variant. |
+| `last_synced_at` | `datetime` or `None` | `None` | When variants were last regenerated. |
+| `archived_at` | `datetime` or `None` | `None` | When expired variants were archived. |
+| `variant_ttl_days` | `int` or `None` | `None` | Days before a variant expires and is archived. |
+{: .table .table-bordered .table-striped }
+
+### FormatId
+
+Reference to a creative format specification served by a creative agent.
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `agent_url` | `str` | -- | Base URL of the creative agent that owns this format. |
+| `id` | `str` | -- | Format identifier (matches `CreativeFormat.id` returned by the agent). |
+{: .table .table-bordered .table-striped }
+
+### PricingOption
+
+A pricing model available for a product.
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `pricing_option_id` | `str` | -- | Unique identifier within the product. |
+| `pricing_model` | `str` | -- | Pricing model. See `PricingModel` enum. |
+| `is_fixed` | `bool` | `False` | Whether the rate is fixed (`True`) or a floor/guide (`False`). |
+| `rate` | `float` | -- | Price per unit in the specified currency. |
+| `currency` | `str` | `"USD"` | ISO 4217 currency code. |
+| `min_budget` | `float` or `None` | `None` | Minimum budget required for this pricing option. |
+| `max_budget` | `float` or `None` | `None` | Maximum budget allowed for this pricing option. |
+| `metadata` | `dict` or `None` | `None` | Additional pricing metadata (e.g., volume discounts, frequency caps). |
+{: .table .table-bordered .table-striped }
+
+---
+
+## Media Buy Domain
+
+### CreateMediaBuyRequest
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `buyer_ref` | `str` or `None` | `None` | Buyer's external reference ID. |
+| `brand` | `dict` or `None` | `None` | Brand context for audit and policy checks. |
+| `packages` | `list[Package]` | -- | One or more packages (required). |
+| `start_time` | `str` (ISO 8601) or `None` | `None` | Flight start. |
+| `end_time` | `str` (ISO 8601) or `None` | `None` | Flight end. |
+| `budget` | `float` or `None` | `None` | Total budget. |
+| `total_budget` | `float` or `None` | `None` | Alias for `budget`. |
+| `po_number` | `str` or `None` | `None` | Purchase order number. |
+{: .table .table-bordered .table-striped }
+
+### Package
+
+A single line item within a media buy.
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `product_id` | `str` | -- | Product to book. |
+| `buyer_ref` | `str` or `None` | `None` | Package-level buyer reference. |
+| `budget` | `float` | -- | Package budget. |
+| `currency` | `str` | `"USD"` | ISO 4217 currency code. |
+| `targeting_overlay` | `dict` or `None` | `None` | Additional targeting on top of product defaults. |
+| `creative_ids` | `list[str]` or `None` | `None` | Creatives to assign. |
+{: .table .table-bordered .table-striped }
+
+### CreateMediaBuySuccess
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `media_buy_id` | `str` | Server-generated ID. |
+| `buyer_ref` | `str` or `None` | Echo of buyer reference. |
+| `status` | `str` | Initial status (`pending_activation`, `active`, `completed`). |
+| `packages` | `list[object]` | Created packages with server IDs. |
+| `total_budget` | `float` | Confirmed budget. |
+| `currency` | `str` | Currency code. |
+{: .table .table-bordered .table-striped }
+
+### UpdateMediaBuyRequest
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `paused` | `bool` or `None` | `None` | Pause (`True`) or resume (`False`) delivery. |
+| `budget` | `float` or `None` | `None` | New total budget. |
+| `start_time` | `str` (ISO 8601) or `None` | `None` | New flight start. |
+| `end_time` | `str` (ISO 8601) or `None` | `None` | New flight end. |
+| `packages` | `list[PackageUpdate]` or `None` | `None` | Package-level updates. |
+{: .table .table-bordered .table-striped }
+
+### GetMediaBuyDeliveryRequest
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `media_buy_ids` | `list[str]` or `None` | `None` | Filter by media buy IDs. |
+| `buyer_refs` | `list[str]` or `None` | `None` | Filter by buyer references. |
+| `start_date` | `str` (ISO 8601) or `None` | `None` | Reporting period start. |
+| `end_date` | `str` (ISO 8601) or `None` | `None` | Reporting period end. |
+| `account` | `str` or `None` | `None` | Account identifier. |
+| `reporting_dimensions` | `list[str]` or `None` | `None` | Breakout dimensions. |
+| `include_package_daily_breakdown` | `bool` | `False` | Include per-package daily data. |
+| `attribution_window` | `str` or `None` | `None` | Attribution window (e.g., `"7d"`, `"30d"`). |
+{: .table .table-bordered .table-striped }
+
+---
+
+## Delivery Domain
+
+### DeliveryTotals
+
+Aggregated delivery metrics.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `impressions` | `int` | Total impressions. |
+| `spend` | `float` | Total spend. |
+| `clicks` | `int` | Total clicks. |
+| `ctr` | `float` | Click-through rate. |
+| `video_completions` | `int` or `None` | Completed video views. |
+| `completion_rate` | `float` or `None` | Video completion rate. |
+| `conversions` | `int` or `None` | Attributed conversions. |
+| `viewability` | `float` or `None` | Viewability rate (0.0 - 1.0). |
+{: .table .table-bordered .table-striped }
+
+### PackageDelivery
+
+Per-package delivery breakdown.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `package_id` | `str` | Package identifier. |
+| `buyer_ref` | `str` or `None` | Package buyer reference. |
+| `impressions` | `int` | Package impressions. |
+| `spend` | `float` | Package spend. |
+| `pricing_model` | `str` | Pricing model in use. |
+| `rate` | `float` | Current rate. |
+| `currency` | `str` | Currency code. |
+| `pacing_index` | `float` or `None` | Pacing index (1.0 = on pace). |
+{: .table .table-bordered .table-striped }
+
+### DailyBreakdown
+
+Daily delivery data point.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `date` | `str` (ISO 8601 date) | The date. |
+| `impressions` | `int` | Impressions on this date. |
+| `spend` | `float` | Spend on this date. |
+{: .table .table-bordered .table-striped }
+
+---
+
+## Creative Domain
+
+### Creative
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `creative_id` | `str` | -- | Unique creative identifier. |
+| `format_id` | `FormatId` | -- | Format specification reference. |
+| `name` | `str` | -- | Human-readable name. |
+| `status` | `str` | -- | Current status. See `CreativeStatus` enum. |
+| `assets` | `dict` | -- | Asset payload (structure depends on format). |
+| `created_date` | `str` (ISO 8601) or `None` | `None` | Creation timestamp. |
+| `updated_date` | `str` (ISO 8601) or `None` | `None` | Last update timestamp. |
+| `provenance` | `Provenance` or `None` | `None` | Content provenance metadata. |
+{: .table .table-bordered .table-striped }
+
+### Provenance
+
+Content origin and AI involvement metadata for EU AI Act compliance.
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `digital_source_type` | `str` | -- | How the creative was produced. See `DigitalSourceType` enum. |
+| `ai_tool` | `str` or `None` | `None` | AI tool used in production. |
+| `human_oversight` | `str` or `None` | `None` | Description of human oversight. |
+| `declared_by` | `str` or `None` | `None` | Entity declaring provenance. |
+| `created_time` | `str` (ISO 8601) or `None` | `None` | Original production time. |
+| `c2pa` | `dict` or `None` | `None` | C2PA manifest data. |
+| `disclosure` | `str` or `None` | `None` | Human-readable AI disclosure. |
+| `verification` | `dict` or `None` | `None` | Third-party verification data. |
+{: .table .table-bordered .table-striped }
+
+### SyncCreativesResponse
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `created` | `int` | Number of creatives created. |
+| `updated` | `int` | Number of creatives updated. |
+| `deleted` | `int` | Number of creatives deleted. |
+| `results` | `list[object]` | Per-creative results with `creative_id`, `status`, `errors`. |
+{: .table .table-bordered .table-striped }
+
+### ListCreativesResponse
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `creatives` | `list[Creative]` | Matching creatives. |
+{: .table .table-bordered .table-striped }
+
+---
+
+## Common Types and Enums
+
+### PricingModel Enum
+
+| Value | Description |
+| --- | --- |
+| `cpm` | Cost per mille (thousand impressions). |
+| `cpc` | Cost per click. |
+| `cpcv` | Cost per completed view. |
+| `cpp` | Cost per point (GRP-based). |
+| `cpv` | Cost per view. |
+| `flat_rate` | Fixed price regardless of delivery volume. |
+| `vcpm` | Viewable cost per mille. |
+{: .table .table-bordered .table-striped }
+
+### MediaBuyStatus Enum
+
+| Value | Description |
+| --- | --- |
+| `pending_activation` | Awaiting approval, creative readiness, or future flight start. |
+| `active` | Currently delivering. |
+| `paused` | Delivery suspended by the buyer or operator. |
+| `completed` | Flight ended or budget exhausted. |
+{: .table .table-bordered .table-striped }
+
+### CreativeStatus Enum
+
+| Value | Description |
+| --- | --- |
+| `processing` | Upload received, validation in progress. |
+| `approved` | Cleared for serving. |
+| `rejected` | Failed validation or review. |
+| `pending_review` | Awaiting manual or automated review. |
+{: .table .table-bordered .table-striped }
+
+### ApprovalStatus Enum
+
+| Value | Description |
+| --- | --- |
+| `pending_review` | Awaiting review. |
+| `approved` | Approved. |
+| `rejected` | Rejected. |
+| `processing` | Currently being processed. |
+{: .table .table-bordered .table-striped }
+
+### DeliveryType Enum
+
+| Value | Description |
+| --- | --- |
+| `guaranteed` | Delivery volume is contractually guaranteed. |
+| `non_guaranteed` | Best-effort delivery. |
+{: .table .table-bordered .table-striped }
+
+### DeliveryStatus Enum
+
+| Value | Description |
+| --- | --- |
+| `delivering` | Actively serving impressions. |
+| `not_delivering` | Not currently serving (various reasons). |
+| `completed` | Delivery completed successfully. |
+| `budget_exhausted` | Budget fully spent. |
+| `flight_ended` | Flight end date reached. |
+| `goal_met` | Delivery goal achieved. |
+{: .table .table-bordered .table-striped }
+
+### DigitalSourceType Enum
+
+| Value | Description |
+| --- | --- |
+| `digital_capture` | Captured by a digital device. |
+| `digital_creation` | Created by a human using digital tools. |
+| `composite_capture` | Composite of captured sources. |
+| `composite_synthetic` | Composite with fully synthetic elements. |
+| `composite_with_trained_model` | Composite incorporating AI model output. |
+| `trained_algorithmic_model` | Entirely AI-generated. |
+| `algorithmic_media` | Generated by non-ML algorithm. |
+| `human_edits` | AI-generated with significant human edits. |
+| `minor_human_edits` | AI-generated with minor human edits. |
+{: .table .table-bordered .table-striped }
+
+### Channel Enum
+
+| Value | Description |
+| --- | --- |
+| `display` | Standard display advertising. |
+| `olv` | Online video. |
+| `video` | General video. |
+| `social` | Social media. |
+| `search` | Search advertising. |
+| `ctv` | Connected TV. |
+| `linear_tv` | Traditional linear television. |
+| `radio` | Terrestrial radio. |
+| `streaming_audio` | Streaming audio platforms. |
+| `audio` | General audio. |
+| `podcast` | Podcast advertising. |
+| `dooh` | Digital out-of-home. |
+| `ooh` | Traditional out-of-home. |
+| `print` | Print media. |
+| `cinema` | Cinema advertising. |
+| `email` | Email marketing. |
+| `gaming` | In-game advertising. |
+| `retail_media` | Retail media networks. |
+| `influencer` | Influencer marketing. |
+| `affiliate` | Affiliate marketing. |
+| `product_placement` | Product placement. |
+{: .table .table-bordered .table-striped }
+
+## Validation Rules
+
+Pydantic enforces the following validation rules across all models:
+
+1. **Required fields** -- Fields without a default value must be provided. Omitting them raises `AdCPValidationError`.
+2. **Type coercion** -- Pydantic performs strict type checking. Passing a string where a float is expected will raise a validation error.
+3. **Enum values** -- Enum fields accept only the documented values. Invalid values raise `AdCPValidationError` listing the allowed options.
+4. **Budget constraints** -- When a `PricingOption` specifies `min_budget` or `max_budget`, the package budget must fall within that range.
+5. **Date ordering** -- `start_time` must precede `end_time`. Reversed dates raise `AdCPValidationError`.
+6. **Currency consistency** -- All packages within a media buy should use the same currency. Mixed currencies raise `AdCPValidationError`.
+7. **Format existence** -- `format_id` references are validated against the creative agent. Non-existent formats raise `AdCPNotFoundError`.
+8. **Provenance completeness** -- When `provenance` is provided, `digital_source_type` is required. All other provenance fields are optional.
+
+## Related Pages
+
+- [Tool Reference](../tools/tool-reference.html) -- Overview of all tools
+- [Database Models](database-models.html) -- SQLAlchemy persistence layer
diff --git a/agents/salesagent/schemas/database-models.md b/agents/salesagent/schemas/database-models.md
new file mode 100644
index 0000000000..bdb402be92
--- /dev/null
+++ b/agents/salesagent/schemas/database-models.md
@@ -0,0 +1,442 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Database Models
+description: Complete reference for all SQLAlchemy database models, relationships, and tenant isolation patterns.
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Database Models
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+The Sales Agent persists all state in a PostgreSQL database using SQLAlchemy ORM models defined in `src/core/database/models.py`. This page documents every model, its columns, relationships, and the tenant-isolation pattern that governs data access.
+
+## Entity-Relationship Overview
+
+```text
+┌──────────┐ 1:N ┌───────────┐ 1:N ┌──────────────┐
+│ Tenant │──────────────►│ Principal │──────────────►│ MediaBuy │
+└──────────┘ └───────────┘ └──────────────┘
+ │ │ │
+ │ 1:N │ 1:N │ 1:N
+ ▼ ▼ ▼
+┌──────────┐ ┌───────────┐ ┌──────────────┐
+│ Product │ │ Creative │ │ WorkflowStep │
+└──────────┘ └───────────┘ └──────────────┘
+ │ │
+ │ 1:N │
+ ▼ │
+┌──────────────┐ │
+│PricingOption │ │
+└──────────────┘ │
+ │
+┌──────────────┐ ┌───────────────┐ ┌────────────┐ │
+│AdapterConfig │ │InventoryProfile│ │ AuditLog │◄──┘
+└──────────────┘ └───────────────┘ └────────────┘
+
+┌──────────────┐ ┌───────────────┐ ┌────────────┐
+│CreativeAgent │ │ SignalsAgent │ │CurrencyLimit│
+└──────────────┘ └───────────────┘ └────────────┘
+
+┌────────────────┐ ┌──────────┐
+│TenantAuthConfig │ │ User │
+└────────────────┘ └──────────┘
+```
+
+## Tenant Isolation
+
+All data is scoped to a tenant. Most models use `tenant_id` as part of a composite primary key or as a required foreign key. This ensures that queries never leak data across tenants.
+
+
+Every database query in the Sales Agent automatically filters by tenant_id. The resolved identity (extracted from the authentication token) provides the tenant context, and the data-access layer applies it as a mandatory WHERE clause.
+
+
+## Composite Key Pattern
+
+Several models use composite primary keys that include `tenant_id`:
+
+- **Product**: `(tenant_id, product_id)`
+- **PricingOption**: `(tenant_id, product_id, pricing_option_id)`
+- **Principal**: `(tenant_id, principal_id)`
+- **MediaBuy**: `(tenant_id, media_buy_id)`
+
+This pattern ensures that identifiers like `product_id` and `media_buy_id` only need to be unique within a single tenant, not globally.
+
+---
+
+## Core Models
+
+### Tenant
+
+The root entity representing a publisher or advertising operation.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `tenant_id` | `str` | PK | Unique tenant identifier. |
+| `name` | `str` | NOT NULL | Display name for the tenant. |
+| `subdomain` | `str` | UNIQUE, NOT NULL | URL subdomain (e.g., `publisher` in `publisher.adcp.example.com`). |
+| `virtual_host` | `str` or `None` | -- | Optional virtual host override. |
+| `auth_setup_mode` | `str` or `None` | -- | Authentication setup mode. |
+| `approval_mode` | `str` or `None` | -- | Media buy approval mode (e.g., `"manual"`, `"auto"`). |
+| `human_review_required` | `bool` | Default: `False` | Whether creatives require human review regardless of score. |
+| `creative_auto_approve_threshold` | `float` | Default: `0.9` | Content-standards score threshold for automatic creative approval. |
+| `creative_auto_reject_threshold` | `float` | Default: `0.1` | Content-standards score threshold for automatic creative rejection. |
+| `brand_manifest_policy` | `str` or `None` | -- | Policy for brand manifest validation. |
+| `ai_policy` | `JSONB` or `None` | -- | Tenant's AI usage policy (governs provenance requirements). |
+| `advertising_policy` | `JSONB` or `None` | -- | Advertising content policy. |
+| `ai_config` | `JSONB` or `None` | -- | AI service configuration (Gemini API key, model preferences). |
+| `order_name_template` | `str` or `None` | -- | Template for generating ad server order names. |
+| `line_item_name_template` | `str` or `None` | -- | Template for generating ad server line item names. |
+| `measurement_providers` | `JSONB` or `None` | -- | Configured measurement/verification providers. |
+| `slack_webhook_url` | `str` or `None` | -- | Slack webhook for notifications. |
+| `favicon_url` | `str` or `None` | -- | Favicon URL for the tenant portal. |
+| `product_ranking_prompt` | `str` or `None` | -- | Custom prompt used for AI-powered product ranking in `get_products`. |
+{: .table .table-bordered .table-striped }
+
+**Relationships**: One-to-many with Principal, Product, MediaBuy, AdapterConfig, InventoryProfile, CreativeAgent, SignalsAgent, CurrencyLimit, TenantAuthConfig, User.
+
+---
+
+### Principal
+
+An authenticated entity (buying agent, human buyer, etc.) that can create media buys and manage creatives within a tenant.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `tenant_id` | `str` | PK, FK → Tenant | Tenant scope. |
+| `principal_id` | `str` | PK | Unique identifier within the tenant. |
+| `name` | `str` | NOT NULL | Display name. |
+| `contact_email` | `str` or `None` | -- | Contact email address. |
+| `adapter_mappings` | `JSONB` or `None` | -- | Mappings to ad server entities (advertiser IDs, etc.). |
+| `brand_manifest` | `JSONB` or `None` | -- | Brand safety manifest for this principal. |
+| `auth_token` | `str` or `None` | -- | Hashed authentication token. |
+| `auth_token_expires_at` | `datetime` or `None` | -- | Token expiration time. |
+{: .table .table-bordered .table-striped }
+
+**Primary key**: Composite `(tenant_id, principal_id)`.
+
+**Relationships**: One-to-many with MediaBuy, Creative.
+
+---
+
+### Product
+
+An advertising product available for purchase.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `tenant_id` | `str` | PK, FK → Tenant | Tenant scope. |
+| `product_id` | `str` | PK | Unique identifier within the tenant. |
+| `name` | `str` | NOT NULL | Product name. |
+| `description` | `str` or `None` | -- | Product description. |
+| `format_ids` | `list` | -- | Accepted creative format identifiers (list of FormatId objects). |
+| `targeting_template` | `JSONB` or `None` | -- | Default targeting configuration for this product. |
+| `delivery_type` | `str` or `None` | -- | `"guaranteed"` or `"non_guaranteed"`. |
+| `delivery_measurement` | `JSONB` or `None` | -- | How delivery is measured. |
+| `creative_policy` | `JSONB` or `None` | -- | Product-specific creative requirements. |
+| `is_custom` | `bool` | Default: `False` | Custom product for a specific buyer. |
+| `countries` | `list[str]` or `None` | -- | Available countries (ISO 3166-1 alpha-2). |
+| `channels` | `list[str]` or `None` | -- | Advertising channels (Channel enum values). |
+| `implementation_config` | `JSONB` or `None` | -- | Adapter-specific configuration. |
+| `properties` | `list[str]` or `None` | -- | Publisher properties / domains. |
+| `property_ids` | `list[str]` or `None` | -- | Property identifiers. |
+| `property_tags` | `list[str]` or `None` | -- | Property classification tags. |
+| `inventory_profile_id` | `str` or `None` | FK → InventoryProfile | Link to inventory profile. |
+| `is_dynamic` | `bool` | Default: `False` | Whether this product is dynamically generated. |
+| `is_dynamic_variant` | `bool` | Default: `False` | Whether this is a variant of a dynamic product. |
+| `parent_product_id` | `str` or `None` | -- | Parent product ID for dynamic variants. |
+| `signals_agent_ids` | `list[str]` or `None` | -- | Signals agents configured for this product. |
+| `allowed_principal_ids` | `list[str]` or `None` | -- | Restrict access to specific principals. |
+| `expires_at` | `datetime` or `None` | -- | Product expiration date. |
+{: .table .table-bordered .table-striped }
+
+**Primary key**: Composite `(tenant_id, product_id)`.
+
+**Relationships**: One-to-many with PricingOption. Many-to-one with InventoryProfile.
+
+---
+
+### PricingOption
+
+A pricing model available for a product.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `tenant_id` | `str` | PK, FK → Tenant | Tenant scope. |
+| `product_id` | `str` | PK, FK → Product | Parent product. |
+| `pricing_option_id` | `str` | PK | Unique within the product. |
+| `pricing_model` | `str` | NOT NULL | Pricing model (`cpm`, `cpc`, `cpcv`, `cpp`, `cpv`, `flat_rate`, `vcpm`). |
+| `is_fixed` | `bool` | Default: `False` | Fixed rate vs. floor/guide. |
+| `rate` | `float` | NOT NULL | Price per unit. |
+| `currency` | `str` | Default: `"USD"` | ISO 4217 currency code. |
+| `min_budget` | `float` or `None` | -- | Minimum budget. |
+| `max_budget` | `float` or `None` | -- | Maximum budget. |
+| `metadata` | `JSONB` or `None` | -- | Additional pricing data. |
+{: .table .table-bordered .table-striped }
+
+**Primary key**: Composite `(tenant_id, product_id, pricing_option_id)`.
+
+---
+
+### MediaBuy
+
+A booked advertising order.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `tenant_id` | `str` | PK, FK → Tenant | Tenant scope. |
+| `media_buy_id` | `str` | PK | Server-generated unique identifier. |
+| `principal_id` | `str` | FK → Principal | The principal who created this buy. |
+| `buyer_ref` | `str` or `None` | -- | Buyer's external reference. |
+| `po_number` | `str` or `None` | -- | Purchase order number. |
+| `status` | `str` | NOT NULL | Current status (`pending_activation`, `active`, `paused`, `completed`). |
+| `flight_start_date` | `datetime` or `None` | -- | Flight start. |
+| `flight_end_date` | `datetime` or `None` | -- | Flight end. |
+| `budget` | `float` or `None` | -- | Total budget. |
+| `budget_spent` | `float` | Default: `0.0` | Amount spent to date. |
+| `budget_remaining` | `float` or `None` | -- | Remaining budget. |
+| `currency` | `str` | Default: `"USD"` | ISO 4217 currency code. |
+| `manual_approval_required` | `bool` | Default: `False` | Whether this buy requires manual approval. |
+| `creatives_approved` | `bool` | Default: `False` | Whether all assigned creatives are approved. |
+| `order_id` | `str` or `None` | -- | Ad server order ID (set by the adapter). |
+| `adapter_name` | `str` or `None` | -- | Name of the ad server adapter handling this buy. |
+| `paused` | `bool` | Default: `False` | Whether delivery is paused. |
+| `created_at` | `datetime` | Auto-set | Creation timestamp. |
+| `updated_at` | `datetime` | Auto-updated | Last modification timestamp. |
+{: .table .table-bordered .table-striped }
+
+**Primary key**: Composite `(tenant_id, media_buy_id)`.
+
+**Relationships**: Many-to-one with Principal and Tenant.
+
+---
+
+### Creative
+
+An ad creative with format validation and provenance tracking.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `creative_id` | `str` | PK | Caller-supplied unique identifier. |
+| `format_id` | Composite | NOT NULL | Format reference (stored as `agent_url` + `id` fields). |
+| `name` | `str` | NOT NULL | Human-readable name. |
+| `status` | `str` | NOT NULL | Current status (CreativeStatus enum). |
+| `assets` | `JSONB` | NOT NULL | Asset payload. |
+| `creative_type` | `str` or `None` | -- | Creative type classification. |
+| `provenance` | Composite | -- | Provenance fields stored as individual columns (see below). |
+| `principal_id` | `str` | FK → Principal | Owning principal. |
+| `created_date` | `datetime` | Auto-set | Creation timestamp. |
+| `updated_date` | `datetime` | Auto-updated | Last modification timestamp. |
+{: .table .table-bordered .table-striped }
+
+Provenance is stored as individual columns on the Creative model rather than a separate table:
+
+| Column | Type | Description |
+| --- | --- | --- |
+| `digital_source_type` | `str` or `None` | DigitalSourceType enum value. |
+| `ai_tool` | `str` or `None` | AI tool identifier. |
+| `human_oversight` | `str` or `None` | Oversight description. |
+| `declared_by` | `str` or `None` | Declaring entity. |
+| `provenance_created_time` | `datetime` or `None` | Original production time. |
+| `c2pa` | `JSONB` or `None` | C2PA manifest. |
+| `disclosure` | `str` or `None` | AI disclosure statement. |
+| `verification` | `JSONB` or `None` | Verification data. |
+{: .table .table-bordered .table-striped }
+
+---
+
+### WorkflowStep
+
+A single step in a human-in-the-loop workflow.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `step_id` | `str` | PK | Unique step identifier. |
+| `context_id` | `str` | FK | Parent workflow context. |
+| `status` | `str` | NOT NULL | Step status (`pending`, `in_progress`, `completed`, `failed`, `requires_approval`). |
+| `step_type` | `str` | NOT NULL | Type of step (e.g., `media_buy_approval`, `creative_review`). |
+| `tool_name` | `str` | NOT NULL | The tool that created this step. |
+| `owner` | `str` | NOT NULL | Principal who owns this step. |
+| `request_data` | `JSONB` or `None` | -- | Original request payload. |
+| `response_data` | `JSONB` or `None` | -- | Response after completion. |
+| `error_message` | `str` or `None` | -- | Error details if failed. |
+| `created_at` | `datetime` | Auto-set | Creation timestamp. |
+| `completed_at` | `datetime` or `None` | -- | Completion timestamp. |
+{: .table .table-bordered .table-striped }
+
+---
+
+### AuditLog
+
+Immutable audit trail for all operations.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `log_id` | `str` | PK | Unique log entry identifier. |
+| `tenant_id` | `str` | FK → Tenant | Tenant scope. |
+| `operation` | `str` | NOT NULL | Operation name (e.g., `create_media_buy`, `sync_creatives`). |
+| `principal_id` | `str` or `None` | -- | Acting principal. |
+| `principal_name` | `str` or `None` | -- | Principal display name at time of action. |
+| `adapter_id` | `str` or `None` | -- | Adapter involved (if any). |
+| `success` | `bool` | NOT NULL | Whether the operation succeeded. |
+| `details` | `JSONB` or `None` | -- | Structured operation details. |
+| `error` | `str` or `None` | -- | Error message if failed. |
+| `created_at` | `datetime` | Auto-set | Timestamp. |
+| `ip_address` | `str` or `None` | -- | Client IP address. |
+{: .table .table-bordered .table-striped }
+
+---
+
+## Configuration Models
+
+### AdapterConfig
+
+Configuration for the ad server adapter connected to a tenant.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `id` | `str` | PK | Unique config identifier. |
+| `tenant_id` | `str` | UNIQUE, FK → Tenant | One adapter config per tenant. |
+| `adapter_type` | `str` | NOT NULL | Adapter type: `"google_ad_manager"`, `"kevel"`, `"triton_digital"`, `"broadstreet"`, or `"mock"`. |
+| `config` | `JSONB` | NOT NULL | Adapter-specific configuration (credentials, network IDs, etc.). |
+{: .table .table-bordered .table-striped }
+
+---
+
+### InventoryProfile
+
+Reusable ad server inventory configuration templates. Products reference inventory profiles via `inventory_profile_id` FK instead of duplicating inventory configuration.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `id` | `str` | PK | Unique profile identifier. |
+| `tenant_id` | `str` | FK → Tenant | Tenant scope. |
+| `name` | `str` | NOT NULL | Profile name. |
+| `format_ids` | `JSONB` (list) | -- | List of FormatId objects (accepted creative formats). |
+| `publisher_properties` | `JSONB` (list) or `None` | -- | List of property configurations. |
+{: .table .table-bordered .table-striped }
+
+---
+
+### CurrencyLimit
+
+Per-currency budget constraints for a tenant.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `id` | `str` | PK | Unique limit identifier. |
+| `tenant_id` | `str` | FK → Tenant | Tenant scope. |
+| `currency_code` | `str` | NOT NULL | ISO 4217 currency code. |
+| `max_daily_budget` | `float` or `None` | -- | Maximum daily spend in this currency. |
+| `min_product_spend` | `float` or `None` | -- | Minimum spend per product in this currency. |
+{: .table .table-bordered .table-striped }
+
+---
+
+### Strategy
+
+Campaign simulation and testing configuration.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `strategy_id` | `str` | PK | Unique identifier. Prefix `sim_` for simulation strategies. |
+| `tenant_id` | `str` | FK → Tenant | Tenant scope. |
+| `name` | `str` | NOT NULL | Strategy name. |
+| `config` | `JSONB` | -- | Simulation parameters and configuration. |
+| `is_simulation` | `bool` | Default: `False` | Whether this is a simulation (vs. production) strategy. |
+| `created_at` | `datetime` | Auto-set | Creation timestamp. |
+{: .table .table-bordered .table-striped }
+
+---
+
+### CreativeAgent
+
+External creative format provider configuration. When `list_creative_formats` is called, the system queries all enabled creative agents and aggregates format specs.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `id` | `str` | PK | Unique agent identifier. |
+| `tenant_id` | `str` | FK → Tenant | Tenant scope. |
+| `agent_name` | `str` | NOT NULL | Identifier for the creative agent. |
+| `endpoint` | `str` | NOT NULL | URL for the creative agent API. |
+| `enabled` | `bool` | Default: `True` | Whether this agent is active. |
+{: .table .table-bordered .table-striped }
+
+---
+
+### SignalsAgent
+
+External audience signal provider configuration. When dynamic products request signal data, the system queries all enabled signals agents.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `id` | `str` | PK | Unique agent identifier. |
+| `tenant_id` | `str` | FK → Tenant | Tenant scope. |
+| `agent_name` | `str` | NOT NULL | Identifier for the signals agent. |
+| `endpoint` | `str` | NOT NULL | URL for the signals agent API. |
+| `enabled` | `bool` | Default: `True` | Whether this agent is active. |
+{: .table .table-bordered .table-striped }
+
+---
+
+### TenantAuthConfig
+
+OAuth / SAML authentication configuration for a tenant.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `id` | `str` | PK | Unique config identifier. |
+| `tenant_id` | `str` | UNIQUE, FK → Tenant | One auth config per tenant. |
+| `oauth_provider` | `str` or `None` | -- | OAuth provider name (e.g., `"google"`, `"azure_ad"`). |
+| `oauth_config` | `JSONB` or `None` | -- | OAuth configuration (client ID, secret, scopes). |
+| `saml_config` | `JSONB` or `None` | -- | SAML configuration (IdP URL, certificate). |
+{: .table .table-bordered .table-striped }
+
+---
+
+### User
+
+A human user of the tenant's management portal.
+
+| Column | Type | Constraints | Description |
+| --- | --- | --- | --- |
+| `id` | `str` | PK | Unique user identifier. |
+| `tenant_id` | `str` | FK → Tenant | Tenant scope. |
+| `email` | `str` | UNIQUE per tenant | User email address. |
+| `name` | `str` | NOT NULL | Display name. |
+| `role` | `str` | NOT NULL | Role: `admin`, `editor`, or `viewer`. |
+| `external_id` | `str` or `None` | -- | External identity provider ID. |
+| `last_login_at` | `datetime` or `None` | -- | Last login timestamp. |
+{: .table .table-bordered .table-striped }
+
+---
+
+## JSONB Column Patterns
+
+Several columns use PostgreSQL `JSONB` for flexible structured data:
+
+| Column | Model | Typical Structure |
+| --- | --- | --- |
+| `adapter_mappings` | Principal | `{"advertiser_id": "12345", "network_id": "67890"}` |
+| `brand_manifest` | Principal | `{"blocked_categories": ["IAB25"], "blocked_domains": ["competitor.com"]}` |
+| `ai_policy` | Tenant | `{"require_provenance": true, "max_ai_content_pct": 0.5}` |
+| `advertising_policy` | Tenant | `{"blocked_categories": [...], "required_disclosures": [...]}` |
+| `ai_config` | Tenant | `{"gemini_api_key": "...", "model": "gemini-pro", "ranking_enabled": true}` |
+| `targeting_template` | Product | `{"geo_countries": ["US"], "device_platform": ["desktop", "mobile"]}` |
+| `implementation_config` | Product | `{"order_type": "sponsorship", "priority": 12, "rate_type": "CPM"}` |
+| `config` | AdapterConfig | `{"network_code": "12345", "api_version": "v202402"}` |
+| `request_data` | WorkflowStep | Full request payload that triggered the workflow. |
+| `response_data` | WorkflowStep | Result payload after task completion. |
+| `details` | AuditLog | `{"media_buy_id": "mb_123", "action": "pause", "previous_status": "active"}` |
+| `measurement_providers` | Tenant | `[{"name": "moat", "config": {...}}, {"name": "ias", "config": {...}}]` |
+{: .table .table-bordered .table-striped }
+
+## Related Pages
+
+- [API Schema Reference](api-schemas.html) -- Pydantic models that map to these database models
+- [Tool Reference](../tools/tool-reference.html) -- Tools that read and write these models
+- [Architecture](../architecture.html) -- Workflow approval design
diff --git a/agents/salesagent/tools/create-media-buy.md b/agents/salesagent/tools/create-media-buy.md
new file mode 100644
index 0000000000..54f98d1e1d
--- /dev/null
+++ b/agents/salesagent/tools/create-media-buy.md
@@ -0,0 +1,150 @@
+---
+layout: page_v2
+title: create_media_buy
+description: Create a campaign with packages, targeting, and budgets
+sidebarType: 10
+---
+
+# create_media_buy
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+Creates a new media buy containing one or more packages. Each package references a product, specifies a budget, and may include targeting overlays and creative assignments.
+
+**Category:** Media Buy
+**Authentication:** Required
+**REST equivalent:** `POST /api/v1/media-buys`
+
+## Parameters
+
+{: .table .table-bordered .table-striped }
+| Parameter | Type | Required | Default | Description |
+|-----------|------|----------|---------|-------------|
+| `buyer_ref` | `str` | No | `None` | Buyer's external reference ID for this order. |
+| `brand` | `dict` or `None` | No | `None` | Brand context (`name`, `industry`, etc.) for audit and policy checks. |
+| `packages` | `list[Package]` | Yes | -- | One or more packages (line items). See below. |
+| `start_time` | `str` (ISO 8601) | No | `None` | Flight start date/time. |
+| `end_time` | `str` (ISO 8601) | No | `None` | Flight end date/time. |
+| `budget` | `float` or `None` | No | `None` | Total budget. Alias: `total_budget`. |
+| `total_budget` | `float` or `None` | No | `None` | Alias for `budget`. |
+| `po_number` | `str` or `None` | No | `None` | Purchase order number for billing. |
+
+### Package Fields
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `product_id` | `str` | Yes | -- | Product ID from [get_products](/agents/salesagent/tools/get-products.html). |
+| `buyer_ref` | `str` or `None` | No | `None` | Buyer's reference for this package. |
+| `budget` | `float` | Yes | -- | Budget for this package. |
+| `currency` | `str` | No | `"USD"` | ISO 4217 currency code. |
+| `targeting_overlay` | `dict` or `None` | No | `None` | Additional targeting on top of product defaults (geo, device, etc.). |
+| `creative_ids` | `list[str]` or `None` | No | `None` | Creative IDs to assign. Creatives must exist via [sync_creatives](/agents/salesagent/tools/sync-creatives.html). |
+
+## Response
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `media_buy_id` | `str` | Server-generated unique identifier. |
+| `buyer_ref` | `str` or `None` | Echo of the buyer's external reference. |
+| `status` | `str` | Initial status (see below). |
+| `packages` | `list[object]` | Created packages with server-generated IDs. |
+| `total_budget` | `float` | Confirmed total budget. |
+| `currency` | `str` | Currency of the media buy. |
+
+## Status Determination
+
+{: .table .table-bordered .table-striped }
+| Condition | Initial Status |
+|-----------|----------------|
+| Tenant requires manual approval | `pending_activation` |
+| Creatives missing or not yet approved | `pending_activation` |
+| Flight start date in the future | `pending_activation` |
+| Flight has ended | `completed` |
+| None of the above | `active` |
+
+## Media Buy Lifecycle
+
+```text
+create_media_buy
+ │
+ ▼
+ pending_activation ──► active ──► completed
+ │ ▲
+ pause │ │ unpause
+ ▼ │
+ paused
+```
+
+{: .alert.alert-info :}
+A media buy enters `pending_activation` when it requires manual approval, when its creatives are not yet approved, or when the flight start date is in the future. It transitions to `active` automatically once all conditions are met.
+
+## Errors
+
+{: .table .table-bordered .table-striped }
+| Error | Cause |
+|-------|-------|
+| `AdCPValidationError` (400) | Invalid product_id, missing required fields, budget below minimum. |
+| `AdCPNotFoundError` (404) | Referenced product or creative does not exist. |
+| `AdCPConflictError` (409) | Duplicate buyer_ref, overlapping flights. |
+| `AdCPAuthorizationError` (403) | Principal not authorized to book the referenced product. |
+
+## Example
+
+**Request:**
+
+```json
+{
+ "buyer_ref": "acme-sports-q1-2025",
+ "brand": {"name": "Acme Sports", "industry": "sporting goods"},
+ "packages": [
+ {
+ "product_id": "prod_ctv_sports_30s",
+ "budget": 50000,
+ "currency": "USD",
+ "targeting_overlay": {
+ "geo_countries": ["US"],
+ "device_platform": ["ctv"]
+ },
+ "creative_ids": ["cr_video_001"]
+ }
+ ],
+ "start_time": "2025-04-01T00:00:00Z",
+ "end_time": "2025-04-30T23:59:59Z",
+ "total_budget": 50000,
+ "po_number": "PO-12345"
+}
+```
+
+**Response:**
+
+```json
+{
+ "media_buy_id": "mb_a1b2c3d4",
+ "buyer_ref": "acme-sports-q1-2025",
+ "status": "pending_activation",
+ "packages": [
+ {
+ "package_id": "pkg_x1y2z3",
+ "product_id": "prod_ctv_sports_30s",
+ "buyer_ref": null,
+ "budget": 50000,
+ "currency": "USD"
+ }
+ ],
+ "total_budget": 50000,
+ "currency": "USD"
+}
+```
+
+## Further Reading
+
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools
+- [update_media_buy](/agents/salesagent/tools/update-media-buy.html) -- Modify an existing campaign
+- [get_media_buy_delivery](/agents/salesagent/tools/get-media-buy-delivery.html) -- Delivery metrics
+- [sync_creatives](/agents/salesagent/tools/sync-creatives.html) -- Upload creatives before creating a buy
diff --git a/agents/salesagent/tools/get-adcp-capabilities.md b/agents/salesagent/tools/get-adcp-capabilities.md
new file mode 100644
index 0000000000..dfd67d6de7
--- /dev/null
+++ b/agents/salesagent/tools/get-adcp-capabilities.md
@@ -0,0 +1,100 @@
+---
+layout: page_v2
+title: get_adcp_capabilities
+description: Returns publisher AdCP capabilities, protocols, and targeting dimensions
+sidebarType: 10
+---
+
+# get_adcp_capabilities
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+Returns the publisher's AdCP capabilities including supported protocol versions, portfolio description, feature flags, and available targeting dimensions. This is typically the first tool an agent calls to understand what a publisher offers.
+
+**Category:** Discovery
+**Authentication:** Optional
+**REST equivalent:** `GET /api/v1/capabilities`
+
+## Parameters
+
+{: .table .table-bordered .table-striped }
+| Parameter | Type | Required | Default | Description |
+|-----------|------|----------|---------|-------------|
+| `protocols` | `list[str]` or `None` | No | `None` | Filter to specific protocol names. When `None`, all protocols are returned. Currently the only supported protocol is `media_buy`. |
+
+## Response
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `adcp.major_versions` | `list[int]` | Supported major protocol versions. |
+| `supported_protocols` | `list[str]` | Protocol names supported (e.g., `media_buy`). |
+| `media_buy.portfolio.description` | `str` | Natural-language description of the publisher's offering. |
+| `media_buy.portfolio.primary_channels` | `list[str]` | Primary advertising channels (see Channel enum below). |
+| `media_buy.portfolio.publisher_domains` | `list[str]` | Domains where ads serve. |
+| `media_buy.portfolio.advertising_policies` | `str` | Summary of advertising policies. |
+| `media_buy.features.content_standards` | `bool` | Whether the publisher enforces content standards on creatives. |
+| `media_buy.features.inline_creative_management` | `bool` | Whether creatives can be managed inline with media buys. |
+| `media_buy.features.property_list_filtering` | `bool` | Whether products can be filtered by property. |
+| `media_buy.execution.targeting.geo_countries` | `list[str]` | ISO 3166-1 alpha-2 country codes for geo targeting. |
+| `media_buy.execution.targeting.geo_regions` | `list[str]` | Region codes for geo targeting. |
+| `media_buy.execution.targeting.geo_metros` | `list[str]` | Metro/DMA codes for geo targeting. |
+| `media_buy.execution.targeting.geo_postal_areas` | `list[str]` | Postal code prefixes for geo targeting. |
+| `media_buy.execution.targeting.device_platform` | `list[str]` | Device platforms (e.g., `desktop`, `mobile`, `tablet`, `ctv`). |
+
+### Channel Enum
+
+`display`, `olv`, `video`, `social`, `search`, `ctv`, `linear_tv`, `radio`, `streaming_audio`, `audio`, `podcast`, `dooh`, `ooh`, `print`, `cinema`, `email`, `gaming`, `retail_media`, `influencer`, `affiliate`, `product_placement`
+
+## Example
+
+**Request:**
+
+```json
+{
+ "protocols": ["media_buy"]
+}
+```
+
+**Response:**
+
+```json
+{
+ "adcp": {
+ "major_versions": [1]
+ },
+ "supported_protocols": ["media_buy"],
+ "media_buy": {
+ "portfolio": {
+ "description": "Premium sports and entertainment advertising across web and CTV.",
+ "primary_channels": ["display", "olv", "ctv"],
+ "publisher_domains": ["example.com", "sports.example.com"],
+ "advertising_policies": "No tobacco, gambling, or political advertising."
+ },
+ "features": {
+ "content_standards": true,
+ "inline_creative_management": true,
+ "property_list_filtering": true
+ },
+ "execution": {
+ "targeting": {
+ "geo_countries": ["US", "CA", "GB"],
+ "geo_regions": ["US-NY", "US-CA"],
+ "geo_metros": ["501", "803"],
+ "geo_postal_areas": ["100", "900"],
+ "device_platform": ["desktop", "mobile", "ctv"]
+ }
+ }
+ }
+}
+```
+
+## Further Reading
+
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools
+- [get_products](/agents/salesagent/tools/get-products.html) -- Search the product catalog
+- [Protocols](/agents/salesagent/protocols.html) -- MCP, A2A, and REST access
diff --git a/agents/salesagent/tools/get-media-buy-delivery.md b/agents/salesagent/tools/get-media-buy-delivery.md
new file mode 100644
index 0000000000..8b7398cefc
--- /dev/null
+++ b/agents/salesagent/tools/get-media-buy-delivery.md
@@ -0,0 +1,149 @@
+---
+layout: page_v2
+title: get_media_buy_delivery
+description: Delivery metrics, pacing, and performance data for media buys
+sidebarType: 10
+---
+
+# get_media_buy_delivery
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+Returns delivery metrics and performance data for one or more media buys. Supports filtering by date range and optional daily breakdowns at the package level.
+
+**Category:** Media Buy
+**Authentication:** Required
+**REST equivalent:** `GET /api/v1/media-buys/{id}/delivery`
+
+## Parameters
+
+{: .table .table-bordered .table-striped }
+| Parameter | Type | Required | Default | Description |
+|-----------|------|----------|---------|-------------|
+| `media_buy_ids` | `list[str]` or `None` | No | `None` | Filter by media buy IDs. |
+| `buyer_refs` | `list[str]` or `None` | No | `None` | Filter by buyer references. |
+| `start_date` | `str` (ISO 8601) or `None` | No | `None` | Reporting period start. |
+| `end_date` | `str` (ISO 8601) or `None` | No | `None` | Reporting period end. |
+| `account` | `str` or `None` | No | `None` | Account identifier for multi-account setups. |
+| `reporting_dimensions` | `list[str]` or `None` | No | `None` | Dimensions to break out in reporting. |
+| `include_package_daily_breakdown` | `bool` | No | `false` | Include per-package daily delivery data. |
+| `attribution_window` | `str` or `None` | No | `None` | Attribution window for conversions (e.g., `7d`, `30d`). |
+
+## Response
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `media_buy_deliveries` | `list[MediaBuyDelivery]` | Delivery data per media buy. |
+
+### MediaBuyDelivery Fields
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `media_buy_id` | `str` | The media buy ID. |
+| `buyer_ref` | `str` or `None` | Buyer's external reference. |
+| `status` | `str` | `ready`, `active`, `paused`, `completed`, `failed`, or `reporting_delayed`. |
+| `totals` | `DeliveryTotals` | Aggregated metrics. |
+| `by_package` | `list[PackageDelivery]` | Per-package breakdown. |
+| `daily_breakdown` | `list[DailyBreakdown]` or `None` | Daily data (only when `include_package_daily_breakdown` is `true`). |
+
+### DeliveryTotals
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `impressions` | `int` | Total impressions served. |
+| `spend` | `float` | Total spend in the buy's currency. |
+| `clicks` | `int` | Total clicks. |
+| `ctr` | `float` | Click-through rate. |
+| `video_completions` | `int` or `None` | Completed video views (video only). |
+| `completion_rate` | `float` or `None` | Video completion rate. |
+| `conversions` | `int` or `None` | Attributed conversions. |
+| `viewability` | `float` or `None` | Viewability rate (0.0 - 1.0). |
+
+### PackageDelivery
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `package_id` | `str` | The package ID. |
+| `buyer_ref` | `str` or `None` | Package-level buyer reference. |
+| `impressions` | `int` | Impressions for this package. |
+| `spend` | `float` | Spend for this package. |
+| `pricing_model` | `str` | Pricing model (e.g., `cpm`, `cpc`). |
+| `rate` | `float` | Rate being charged. |
+| `currency` | `str` | Currency code. |
+| `pacing_index` | `float` or `None` | `1.0` = on pace, `> 1.0` = ahead, `< 1.0` = behind. |
+
+### DailyBreakdown
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `date` | `str` (ISO 8601) | Date for this row. |
+| `impressions` | `int` | Impressions on this date. |
+| `spend` | `float` | Spend on this date. |
+
+## Example
+
+**Request:**
+
+```json
+{
+ "media_buy_ids": ["mb_a1b2c3d4"],
+ "start_date": "2025-04-01",
+ "end_date": "2025-04-15",
+ "include_package_daily_breakdown": true
+}
+```
+
+**Response:**
+
+```json
+{
+ "media_buy_deliveries": [
+ {
+ "media_buy_id": "mb_a1b2c3d4",
+ "buyer_ref": "acme-sports-q1-2025",
+ "status": "active",
+ "totals": {
+ "impressions": 1250000,
+ "spend": 12340.50,
+ "clicks": 3750,
+ "ctr": 0.003,
+ "video_completions": 875000,
+ "completion_rate": 0.70,
+ "conversions": null,
+ "viewability": 0.82
+ },
+ "by_package": [
+ {
+ "package_id": "pkg_x1y2z3",
+ "buyer_ref": null,
+ "impressions": 1250000,
+ "spend": 12340.50,
+ "pricing_model": "cpm",
+ "rate": 45.00,
+ "currency": "USD",
+ "pacing_index": 0.95
+ }
+ ],
+ "daily_breakdown": [
+ {"date": "2025-04-01", "impressions": 85000, "spend": 850.00},
+ {"date": "2025-04-02", "impressions": 88000, "spend": 880.00}
+ ]
+ }
+ ]
+}
+```
+
+## Further Reading
+
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools
+- [get_media_buys](/agents/salesagent/tools/get-media-buys.html) -- Query campaigns
+- [update_media_buy](/agents/salesagent/tools/update-media-buy.html) -- Adjust budget or pacing
diff --git a/agents/salesagent/tools/get-media-buys.md b/agents/salesagent/tools/get-media-buys.md
new file mode 100644
index 0000000000..3a37bdb550
--- /dev/null
+++ b/agents/salesagent/tools/get-media-buys.md
@@ -0,0 +1,80 @@
+---
+layout: page_v2
+title: get_media_buys
+description: Query media buys by ID, status, or date range
+sidebarType: 10
+---
+
+# get_media_buys
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+Retrieves one or more media buys matching the supplied filters. At least one filter should be provided.
+
+**Category:** Media Buy
+**Authentication:** Required
+**REST equivalent:** `GET /api/v1/media-buys`
+
+## Parameters
+
+{: .table .table-bordered .table-striped }
+| Parameter | Type | Required | Default | Description |
+|-----------|------|----------|---------|-------------|
+| `media_buy_ids` | `list[str]` or `None` | No | `None` | Filter by media buy IDs. |
+| `buyer_refs` | `list[str]` or `None` | No | `None` | Filter by buyer reference strings. |
+| `status` | `str` or `None` | No | `None` | Filter by status: `pending_activation`, `active`, `paused`, `completed`. |
+| `start_date` | `str` (ISO 8601) or `None` | No | `None` | Buys with flight start on or after this date. |
+| `end_date` | `str` (ISO 8601) or `None` | No | `None` | Buys with flight end on or before this date. |
+
+## Response
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `media_buys` | `list[MediaBuy]` | Matching media buy objects. |
+| `errors` | `list[str]` | Non-fatal warnings. |
+| `context` | `dict` or `None` | Pagination or query metadata. |
+
+## Example
+
+**Request:**
+
+```json
+{
+ "status": "active",
+ "start_date": "2025-04-01"
+}
+```
+
+**Response:**
+
+```json
+{
+ "media_buys": [
+ {
+ "media_buy_id": "mb_a1b2c3d4",
+ "buyer_ref": "acme-sports-q1-2025",
+ "status": "active",
+ "flight_start_date": "2025-04-01T00:00:00Z",
+ "flight_end_date": "2025-04-30T23:59:59Z",
+ "budget": 50000,
+ "budget_spent": 12340.50,
+ "budget_remaining": 37659.50,
+ "currency": "USD",
+ "packages": []
+ }
+ ],
+ "errors": [],
+ "context": null
+}
+```
+
+## Further Reading
+
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools
+- [get_media_buy_delivery](/agents/salesagent/tools/get-media-buy-delivery.html) -- Delivery metrics for campaigns
+- [update_media_buy](/agents/salesagent/tools/update-media-buy.html) -- Modify a campaign
diff --git a/agents/salesagent/tools/get-products.md b/agents/salesagent/tools/get-products.md
new file mode 100644
index 0000000000..734570aad1
--- /dev/null
+++ b/agents/salesagent/tools/get-products.md
@@ -0,0 +1,109 @@
+---
+layout: page_v2
+title: get_products
+description: Search the publisher product catalog with optional AI ranking
+sidebarType: 10
+---
+
+# get_products
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+Searches the publisher's product catalog. Supports natural-language search via the `brief` parameter, optional brand context, and structured filters. When the tenant has a `product_ranking_prompt` and a Gemini API key configured, products are ranked by relevance using Pydantic AI.
+
+**Category:** Discovery
+**Authentication:** Optional
+**REST equivalent:** `GET /api/v1/products`
+
+## Parameters
+
+{: .table .table-bordered .table-striped }
+| Parameter | Type | Required | Default | Description |
+|-----------|------|----------|---------|-------------|
+| `brief` | `str` | No | `""` | Natural-language description of what the buyer is looking for. Used for AI relevance ranking when available. |
+| `brand` | `dict` or `None` | No | `None` | Brand context (`name`, `industry`, `target_audience`) to inform product selection. |
+| `filters` | `dict` or `None` | No | `None` | Structured filters. Supported keys vary by tenant (e.g., `channels`, `countries`, `delivery_type`). |
+
+## Response
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `products` | `list[Product]` | Matching products. |
+| `portfolio_description` | `str` or `None` | Publisher's portfolio description. |
+| `errors` | `list[str]` | Non-fatal warnings (e.g., AI ranking unavailable). |
+| `context` | `dict` or `None` | Metadata about the search (e.g., ranking method used). |
+
+### Product Fields
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `product_id` | `str` | Unique product identifier within the tenant. |
+| `name` | `str` | Product name. |
+| `description` | `str` | Detailed product description. |
+| `format_ids` | `list[FormatId]` | Creative formats accepted. Each has `agent_url` (str) and `id` (str). |
+| `delivery_type` | `str` | `guaranteed` or `non_guaranteed`. |
+| `delivery_measurement` | `dict` | How delivery is measured (e.g., impressions, completed views). |
+| `pricing_options` | `list[PricingOption]` | Available pricing models with rates and budget ranges. |
+| `publisher_properties` | `list[str]` or `None` | Properties where this product runs. |
+
+## Example
+
+**Request:**
+
+```json
+{
+ "brief": "High-impact video ads targeting sports fans in the US",
+ "brand": {
+ "name": "Acme Sports",
+ "industry": "sporting goods"
+ },
+ "filters": {
+ "channels": ["olv", "ctv"],
+ "countries": ["US"]
+ }
+}
+```
+
+**Response:**
+
+```json
+{
+ "products": [
+ {
+ "product_id": "prod_ctv_sports_30s",
+ "name": "CTV Sports - 30s Pre-Roll",
+ "description": "30-second pre-roll on live sports streaming.",
+ "format_ids": [{"agent_url": "https://creative.example.com", "id": "video_30s"}],
+ "delivery_type": "guaranteed",
+ "delivery_measurement": {"type": "impressions"},
+ "pricing_options": [
+ {
+ "pricing_option_id": "po_1",
+ "pricing_model": "cpm",
+ "is_fixed": true,
+ "rate": 45.00,
+ "currency": "USD",
+ "min_budget": 5000,
+ "max_budget": 500000
+ }
+ ],
+ "publisher_properties": ["sports.example.com"]
+ }
+ ],
+ "portfolio_description": "Premium sports and entertainment advertising.",
+ "errors": [],
+ "context": {"ranking_method": "ai"}
+}
+```
+
+## Further Reading
+
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools
+- [create_media_buy](/agents/salesagent/tools/create-media-buy.html) -- Book products into a campaign
+- [list_creative_formats](/agents/salesagent/tools/list-creative-formats.html) -- Format specs for product creatives
diff --git a/agents/salesagent/tools/list-authorized-properties.md b/agents/salesagent/tools/list-authorized-properties.md
new file mode 100644
index 0000000000..b49627548c
--- /dev/null
+++ b/agents/salesagent/tools/list-authorized-properties.md
@@ -0,0 +1,56 @@
+---
+layout: page_v2
+title: list_authorized_properties
+description: Returns publisher domains, policies, and portfolio description
+sidebarType: 10
+---
+
+# list_authorized_properties
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+Returns the list of publisher domains, advertising policies, and portfolio description visible to the caller. Agents use this to understand which properties (sites/apps) they can target before building a media buy.
+
+**Category:** Discovery
+**Authentication:** Optional
+**REST equivalent:** `GET /api/v1/properties`
+
+## Parameters
+
+{: .table .table-bordered .table-striped }
+| Parameter | Type | Required | Default | Description |
+|-----------|------|----------|---------|-------------|
+| `req` | `ListAuthorizedPropertiesRequest` or `None` | No | `None` | Optional filtering. When `None`, all authorized properties are returned. |
+
+## Response
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `publisher_domains` | `list[str]` | Domains where the caller can serve ads. |
+| `advertising_policies` | `str` or `None` | Publisher's advertising policies. |
+| `portfolio_description` | `str` or `None` | Natural-language portfolio description. |
+| `context` | `dict` or `None` | Additional metadata. |
+
+## Example
+
+**Response:**
+
+```json
+{
+ "publisher_domains": ["example.com", "sports.example.com", "news.example.com"],
+ "advertising_policies": "No tobacco, gambling, or political advertising. All creatives must comply with IAB content taxonomy v3.",
+ "portfolio_description": "Premium sports and entertainment content across web, mobile, and CTV.",
+ "context": null
+}
+```
+
+## Further Reading
+
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools
+- [get_adcp_capabilities](/agents/salesagent/tools/get-adcp-capabilities.html) -- Publisher capabilities and targeting
+- [create_media_buy](/agents/salesagent/tools/create-media-buy.html) -- Create campaigns on these properties
diff --git a/agents/salesagent/tools/list-creative-formats.md b/agents/salesagent/tools/list-creative-formats.md
new file mode 100644
index 0000000000..5a2c12e4ef
--- /dev/null
+++ b/agents/salesagent/tools/list-creative-formats.md
@@ -0,0 +1,76 @@
+---
+layout: page_v2
+title: list_creative_formats
+description: Returns creative format specifications from registered creative agents
+sidebarType: 10
+---
+
+# list_creative_formats
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+Returns creative format specifications from all registered creative agents for the tenant. Each format describes the dimensions, type, and requirements for creative assets that can be submitted via [sync_creatives](/agents/salesagent/tools/sync-creatives.html).
+
+**Category:** Discovery
+**Authentication:** Optional
+**REST equivalent:** `GET /api/v1/creative-formats`
+
+## Parameters
+
+{: .table .table-bordered .table-striped }
+| Parameter | Type | Required | Default | Description |
+|-----------|------|----------|---------|-------------|
+| `req` | `ListCreativeFormatsRequest` or `None` | No | `None` | Optional filtering. When `None`, all formats are returned. |
+
+## Response
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `creative_formats` | `list[CreativeFormat]` | Available creative format specifications. |
+
+### CreativeFormat Fields
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `id` | `str` | Format identifier (referenced by `FormatId.id` in products). |
+| `name` | `str` | Human-readable format name. |
+| `type` | `str` | Format category (e.g., `display`, `video`, `native`). |
+| `dimensions` | `dict` or `None` | Size specs (e.g., `{"width": 300, "height": 250}`). |
+| `requirements` | `dict` or `None` | Asset requirements: file types, max file size, duration, aspect ratio. |
+
+## Example
+
+**Response:**
+
+```json
+{
+ "creative_formats": [
+ {
+ "id": "display_300x250",
+ "name": "Medium Rectangle",
+ "type": "display",
+ "dimensions": {"width": 300, "height": 250},
+ "requirements": {"file_types": ["jpg", "png", "gif", "html5"], "max_file_size_kb": 150}
+ },
+ {
+ "id": "video_30s",
+ "name": "Standard Video 30s",
+ "type": "video",
+ "dimensions": {"width": 1920, "height": 1080},
+ "requirements": {"file_types": ["mp4", "webm"], "max_duration_seconds": 30, "min_bitrate_kbps": 2000}
+ }
+ ]
+}
+```
+
+## Further Reading
+
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools
+- [sync_creatives](/agents/salesagent/tools/sync-creatives.html) -- Upload creatives matching these formats
+- [get_products](/agents/salesagent/tools/get-products.html) -- Products reference format IDs
diff --git a/agents/salesagent/tools/list-creatives.md b/agents/salesagent/tools/list-creatives.md
new file mode 100644
index 0000000000..3414b81d4e
--- /dev/null
+++ b/agents/salesagent/tools/list-creatives.md
@@ -0,0 +1,97 @@
+---
+layout: page_v2
+title: list_creatives
+description: List creatives by status, format, or media buy assignment
+sidebarType: 10
+---
+
+# list_creatives
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+Lists creatives visible to the authenticated principal, with optional filters by media buy, status, and format.
+
+**Category:** Creative
+**Authentication:** Required
+**REST equivalent:** `GET /api/v1/media-buys/{id}/creatives`
+
+## Parameters
+
+{: .table .table-bordered .table-striped }
+| Parameter | Type | Required | Default | Description |
+|-----------|------|----------|---------|-------------|
+| `media_buy_id` | `str` or `None` | No | `None` | Return only creatives assigned to this media buy. |
+| `media_buy_ids` | `list[str]` or `None` | No | `None` | Return creatives assigned to any of these media buys. |
+| `buyer_ref` | `str` or `None` | No | `None` | Filter by buyer reference. |
+| `status` | `str` or `None` | No | `None` | Filter by status: `processing`, `pending_review`, `approved`, `rejected`. |
+| `format` | `str` or `None` | No | `None` | Filter by format ID. |
+
+## Response
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `creatives` | `list[Creative]` | Matching creative objects. |
+
+### Creative Fields
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `creative_id` | `str` | The creative identifier. |
+| `format_id` | `FormatId` | Format reference (`agent_url`, `id`). |
+| `name` | `str` | Creative name. |
+| `status` | `str` | `processing`, `pending_review`, `approved`, or `rejected`. |
+| `assets` | `dict` | Asset payload. |
+| `created_date` | `str` (ISO 8601) | When the creative was created. |
+| `updated_date` | `str` (ISO 8601) | When the creative was last modified. |
+| `provenance` | `Provenance` or `None` | Content provenance metadata. |
+
+## Example
+
+**Request:**
+
+```json
+{
+ "media_buy_id": "mb_a1b2c3d4",
+ "status": "approved"
+}
+```
+
+**Response:**
+
+```json
+{
+ "creatives": [
+ {
+ "creative_id": "cr_video_001",
+ "format_id": {"agent_url": "https://creative.example.com", "id": "video_30s"},
+ "name": "Acme Sports CTV 30s",
+ "status": "approved",
+ "assets": {
+ "vast_url": "https://cdn.example.com/vast/acme_sports_30s.xml",
+ "duration_seconds": 30,
+ "resolution": "1920x1080"
+ },
+ "created_date": "2025-03-15T10:00:00Z",
+ "updated_date": "2025-03-15T14:30:00Z",
+ "provenance": {
+ "digital_source_type": "human_edits",
+ "ai_tool": "RunwayML Gen-3",
+ "human_oversight": "Creative director reviewed and approved final cut",
+ "disclosure": "This advertisement contains AI-generated visual effects."
+ }
+ }
+ ]
+}
+```
+
+## Further Reading
+
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools
+- [sync_creatives](/agents/salesagent/tools/sync-creatives.html) -- Upload and manage creatives
+- [list_creative_formats](/agents/salesagent/tools/list-creative-formats.html) -- Format specifications
diff --git a/agents/salesagent/tools/sync-creatives.md b/agents/salesagent/tools/sync-creatives.md
new file mode 100644
index 0000000000..d02a14b8e0
--- /dev/null
+++ b/agents/salesagent/tools/sync-creatives.md
@@ -0,0 +1,160 @@
+---
+layout: page_v2
+title: sync_creatives
+description: Upload, update, or delete creative assets with provenance metadata
+sidebarType: 10
+---
+
+# sync_creatives
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+Uploads new creatives, updates existing ones, or removes creatives no longer needed. Every creative is validated against the format specification returned by [list_creative_formats](/agents/salesagent/tools/list-creative-formats.html). Supports dry-run mode for pre-flight validation.
+
+**Category:** Creative
+**Authentication:** Required
+**REST equivalent:** `POST /api/v1/media-buys/{id}/creatives`
+
+## Parameters
+
+{: .table .table-bordered .table-striped }
+| Parameter | Type | Required | Default | Description |
+|-----------|------|----------|---------|-------------|
+| `creatives` | `list[dict]` or `None` | No | `None` | Creatives to create or update. See Creative fields below. |
+| `assignments` | `dict` or `None` | No | `None` | Map of media buy IDs to lists of creative IDs to assign. |
+| `creative_ids` | `list[str]` or `None` | No | `None` | When used with `delete_missing`, the canonical set of creative IDs. Any creative not in this list is deleted. |
+| `delete_missing` | `bool` | No | `false` | Delete creatives owned by the caller that are not in `creative_ids`. |
+| `dry_run` | `bool` | No | `false` | Validate inputs without persisting changes. |
+| `validation_mode` | `str` | No | `"strict"` | `"strict"` rejects any format mismatch; `"lenient"` allows minor deviations. |
+
+### Creative Fields
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `creative_id` | `str` | Yes | -- | Caller-supplied unique identifier. |
+| `format_id` | `FormatId` | Yes | -- | Format spec reference (`agent_url`, `id`). |
+| `name` | `str` | Yes | -- | Human-readable creative name. |
+| `status` | `str` or `None` | No | `None` | Initial status override (typically left as `None`). |
+| `assets` | `dict` | Yes | -- | Asset payload. Structure depends on format (e.g., `{"url": "...", "width": 300, "height": 250}` for display, `{"vast_url": "..."}` for video). |
+| `provenance` | `Provenance` or `None` | No | `None` | Content provenance metadata for EU AI Act compliance. See below. |
+
+### Provenance Fields
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `digital_source_type` | `str` | Yes | How the creative was produced. See DigitalSourceType enum below. |
+| `ai_tool` | `str` or `None` | No | AI tool used (e.g., `"DALL-E 3"`, `"Midjourney v6"`). |
+| `human_oversight` | `str` or `None` | No | Description of human oversight during production. |
+| `declared_by` | `str` or `None` | No | Entity declaring provenance. |
+| `created_time` | `str` (ISO 8601) or `None` | No | When the creative was originally produced. |
+| `c2pa` | `dict` or `None` | No | C2PA manifest data. |
+| `disclosure` | `str` or `None` | No | Human-readable disclosure about AI involvement. |
+| `verification` | `dict` or `None` | No | Third-party verification data. |
+
+### DigitalSourceType Enum
+
+{: .table .table-bordered .table-striped }
+| Value | Description |
+|-------|-------------|
+| `digital_capture` | Captured by a digital device. |
+| `digital_creation` | Created by a human using digital tools. |
+| `composite_capture` | Composite of captured sources. |
+| `composite_synthetic` | Composite with fully synthetic elements. |
+| `composite_with_trained_model` | Composite with AI model output. |
+| `trained_algorithmic_model` | Generated entirely by an AI model. |
+| `algorithmic_media` | Generated by a non-ML algorithm. |
+| `human_edits` | AI-generated with significant human modifications. |
+| `minor_human_edits` | AI-generated with minor human edits (cropping, colour correction). |
+
+## Creative Lifecycle
+
+```text
+sync_creatives → processing → pending_review → approved → live
+ │ │
+ ▼ ▼
+ rejected rejected
+```
+
+{: .alert.alert-info :}
+Tenants can configure automatic approval thresholds. When the content-standards score meets `creative_auto_approve_threshold` (default 0.9), the creative is approved without human review. Below `creative_auto_reject_threshold` (default 0.1), it is automatically rejected.
+
+## Response
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `created` | `int` | Number of creatives created. |
+| `updated` | `int` | Number of creatives updated. |
+| `deleted` | `int` | Number of creatives deleted. |
+| `results` | `list[object]` | Per-creative results with `creative_id`, `status`, and `errors`. |
+
+## Errors
+
+{: .table .table-bordered .table-striped }
+| Error | Cause |
+|-------|-------|
+| `AdCPValidationError` (400) | Format mismatch, missing required asset fields, or invalid format_id. |
+| `AdCPNotFoundError` (404) | Referenced format_id does not exist at the specified agent_url. |
+| `AdCPAuthorizationError` (403) | Attempting to modify creatives owned by another principal. |
+
+## Example
+
+**Request:**
+
+```json
+{
+ "creatives": [
+ {
+ "creative_id": "cr_video_001",
+ "format_id": {"agent_url": "https://creative.example.com", "id": "video_30s"},
+ "name": "Acme Sports CTV 30s",
+ "assets": {
+ "vast_url": "https://cdn.example.com/vast/acme_sports_30s.xml",
+ "duration_seconds": 30,
+ "resolution": "1920x1080"
+ },
+ "provenance": {
+ "digital_source_type": "human_edits",
+ "ai_tool": "RunwayML Gen-3",
+ "human_oversight": "Creative director reviewed and approved final cut",
+ "disclosure": "This advertisement contains AI-generated visual effects."
+ }
+ }
+ ],
+ "assignments": {
+ "mb_a1b2c3d4": ["cr_video_001"]
+ },
+ "dry_run": false,
+ "validation_mode": "strict"
+}
+```
+
+**Response:**
+
+```json
+{
+ "created": 1,
+ "updated": 0,
+ "deleted": 0,
+ "results": [
+ {
+ "creative_id": "cr_video_001",
+ "status": "processing",
+ "errors": []
+ }
+ ]
+}
+```
+
+## Further Reading
+
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools
+- [list_creatives](/agents/salesagent/tools/list-creatives.html) -- Query uploaded creatives
+- [list_creative_formats](/agents/salesagent/tools/list-creative-formats.html) -- Format specifications
+- [create_media_buy](/agents/salesagent/tools/create-media-buy.html) -- Assign creatives to packages
diff --git a/agents/salesagent/tools/tool-reference.md b/agents/salesagent/tools/tool-reference.md
new file mode 100644
index 0000000000..801003e92a
--- /dev/null
+++ b/agents/salesagent/tools/tool-reference.md
@@ -0,0 +1,108 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Tool Reference
+description: Index of all Sales Agent MCP tools
+sidebarType: 10
+---
+
+# Tool Reference
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+The Sales Agent exposes 11 tools via MCP, A2A, and REST. Each tool follows a three-layer pattern: MCP wrapper → `_raw` function → `_impl` function. The `_impl` layer is transport-agnostic, ensuring identical behavior across all protocols.
+
+## Tools by Category
+
+### Discovery (auth optional)
+
+{: .table .table-bordered .table-striped }
+| Tool | Description |
+|------|-------------|
+| [get_adcp_capabilities](/agents/salesagent/tools/get-adcp-capabilities.html) | Tenant capabilities, protocols, targeting dimensions |
+| [get_products](/agents/salesagent/tools/get-products.html) | Product catalog search with optional AI ranking |
+| [list_creative_formats](/agents/salesagent/tools/list-creative-formats.html) | Creative format specifications |
+| [list_authorized_properties](/agents/salesagent/tools/list-authorized-properties.html) | Publisher domains and policies |
+
+### Media Buy (auth required)
+
+{: .table .table-bordered .table-striped }
+| Tool | Description |
+|------|-------------|
+| [create_media_buy](/agents/salesagent/tools/create-media-buy.html) | Create a campaign with packages, targeting, budgets |
+| [update_media_buy](/agents/salesagent/tools/update-media-buy.html) | Modify an existing campaign |
+| [get_media_buys](/agents/salesagent/tools/get-media-buys.html) | Query campaigns by ID, status, date range |
+| [get_media_buy_delivery](/agents/salesagent/tools/get-media-buy-delivery.html) | Delivery metrics and pacing data |
+
+### Creative (auth required)
+
+{: .table .table-bordered .table-striped }
+| Tool | Description |
+|------|-------------|
+| [sync_creatives](/agents/salesagent/tools/sync-creatives.html) | Upload, update, or delete creative assets |
+| [list_creatives](/agents/salesagent/tools/list-creatives.html) | List creatives by status, format, media buy |
+
+### Performance (auth required)
+
+{: .table .table-bordered .table-striped }
+| Tool | Description |
+|------|-------------|
+| [update_performance_index](/agents/salesagent/tools/update-performance-index.html) | Submit AI performance feedback |
+
+## Authentication
+
+Requests carry identity via one of two headers:
+
+{: .table .table-bordered .table-striped }
+| Header | Format | Notes |
+|--------|--------|-------|
+| `x-adcp-auth` | Raw token | Primary AdCP header. Takes precedence. |
+| `Authorization` | `Bearer ` | Standard bearer token. |
+
+Tokens are hashed and matched against the `auth_token` column on the `Principal` table. Tokens may have an optional expiry.
+
+{: .alert.alert-warning :}
+Discovery tools return limited data without auth. All other tools return `AdCPAuthenticationError` (401) if the token is missing or invalid.
+
+## Error Handling
+
+Every error extends `AdCPError` with `message`, `recovery`, and `details` fields.
+
+{: .table .table-bordered .table-striped }
+| Error | Status | Recovery | Typical Cause |
+|-------|--------|----------|---------------|
+| `AdCPValidationError` | 400 | correctable | Invalid parameters or missing fields |
+| `AdCPAuthenticationError` | 401 | correctable | Missing or invalid token |
+| `AdCPAuthorizationError` | 403 | terminal | Insufficient permissions |
+| `AdCPNotFoundError` | 404 | correctable | Resource does not exist |
+| `AdCPConflictError` | 409 | correctable | State conflict (duplicate, overlapping) |
+| `AdCPGoneError` | 410 | terminal | Resource permanently removed |
+| `AdCPBudgetExhaustedError` | 422 | correctable | Budget limit reached |
+| `AdCPRateLimitError` | 429 | transient | Too many requests |
+| `AdCPAdapterError` | 502 | transient | Ad server error |
+| `AdCPServiceUnavailableError` | 503 | transient | Service temporarily unavailable |
+
+Recovery hints: `transient` (retry after delay), `correctable` (fix input and retry), `terminal` (cannot succeed).
+
+See [Error Codes](/agents/salesagent/reference/error-codes.html) for the complete catalog with MCP/A2A format examples.
+
+## Typical Workflow
+
+```text
+Discovery Execution Reporting
+───────── ───────── ─────────
+get_adcp_capabilities ──► create_media_buy ──► get_media_buys
+get_products ──► sync_creatives ──► get_media_buy_delivery
+list_creative_formats ──► update_media_buy list_creatives
+list_authorized_properties update_performance_index
+```
+
+## Further Reading
+
+- [Architecture](/agents/salesagent/architecture.html) -- System design and protocol comparison
+- [Error Codes](/agents/salesagent/reference/error-codes.html) -- Full error catalog
+- [API Schemas](/agents/salesagent/schemas/api-schemas.html) -- Pydantic request/response models
+- [Database Models](/agents/salesagent/schemas/database-models.html) -- SQLAlchemy models
diff --git a/agents/salesagent/tools/update-media-buy.md b/agents/salesagent/tools/update-media-buy.md
new file mode 100644
index 0000000000..94b26da48d
--- /dev/null
+++ b/agents/salesagent/tools/update-media-buy.md
@@ -0,0 +1,83 @@
+---
+layout: page_v2
+title: update_media_buy
+description: Modify an existing media buy's budget, dates, or pause state
+sidebarType: 10
+---
+
+# update_media_buy
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+Modifies an existing media buy. The authenticated principal must own the media buy. Supports pausing/unpausing, budget changes, flight date adjustments, and package updates.
+
+**Category:** Media Buy
+**Authentication:** Required
+**REST equivalent:** `PATCH /api/v1/media-buys/{id}`
+
+## Parameters
+
+{: .table .table-bordered .table-striped }
+| Parameter | Type | Required | Default | Description |
+|-----------|------|----------|---------|-------------|
+| `media_buy_id` | `str` | Yes | -- | ID of the media buy to update. |
+| `paused` | `bool` or `None` | No | `None` | `true` to pause delivery, `false` to resume. |
+| `budget` | `float` or `None` | No | `None` | New total budget. |
+| `start_time` | `str` (ISO 8601) or `None` | No | `None` | New flight start date. |
+| `end_time` | `str` (ISO 8601) or `None` | No | `None` | New flight end date. |
+| `packages` | `list[PackageUpdate]` or `None` | No | `None` | Package-level updates. Each entry must include a `package_id`. |
+
+## Response
+
+Returns the full updated media buy object with the same structure as [create_media_buy](/agents/salesagent/tools/create-media-buy.html) response, reflecting all changes.
+
+## Errors
+
+{: .table .table-bordered .table-striped }
+| Error | Cause |
+|-------|-------|
+| `AdCPNotFoundError` (404) | Media buy does not exist or is not owned by the caller. |
+| `AdCPAuthorizationError` (403) | Principal does not own this media buy. |
+| `AdCPValidationError` (400) | Invalid update (e.g., reducing budget below spend, past end date). |
+| `AdCPConflictError` (409) | State conflict (e.g., unpausing a completed buy). |
+
+## Example
+
+**Request:**
+
+```json
+{
+ "media_buy_id": "mb_a1b2c3d4",
+ "paused": true
+}
+```
+
+**Response:**
+
+```json
+{
+ "media_buy_id": "mb_a1b2c3d4",
+ "buyer_ref": "acme-sports-q1-2025",
+ "status": "paused",
+ "packages": [
+ {
+ "package_id": "pkg_x1y2z3",
+ "product_id": "prod_ctv_sports_30s",
+ "budget": 50000,
+ "currency": "USD"
+ }
+ ],
+ "total_budget": 50000,
+ "currency": "USD"
+}
+```
+
+## Further Reading
+
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools
+- [create_media_buy](/agents/salesagent/tools/create-media-buy.html) -- Create a new campaign
+- [get_media_buys](/agents/salesagent/tools/get-media-buys.html) -- Query existing campaigns
diff --git a/agents/salesagent/tools/update-performance-index.md b/agents/salesagent/tools/update-performance-index.md
new file mode 100644
index 0000000000..41e9871ce7
--- /dev/null
+++ b/agents/salesagent/tools/update-performance-index.md
@@ -0,0 +1,105 @@
+---
+layout: page_v2
+title: update_performance_index
+description: Submit AI performance feedback for media buy optimization
+sidebarType: 10
+---
+
+# update_performance_index
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Overview
+
+Submits AI performance feedback for a media buy. The performance data is stored and made available to the ad server adapter and any signals agents configured for the tenant. Signals inform optimization decisions like pacing adjustments, creative rotation, and budget reallocation.
+
+**Category:** Performance
+**Authentication:** Required
+
+## Parameters
+
+{: .table .table-bordered .table-striped }
+| Parameter | Type | Required | Default | Description |
+|-----------|------|----------|---------|-------------|
+| `media_buy_id` | `str` | Yes | -- | The media buy to associate performance data with. |
+| `performance_data` | `list[dict]` or `None` | No | `None` | List of performance signal objects. |
+
+### Performance Data Structure
+
+The schema is flexible to support different signals agents. Common fields:
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `signal_type` | `str` | Type of signal (e.g., `brand_lift`, `attention`, `sentiment`, `conversion_propensity`). |
+| `value` | `float` | Numeric signal value. |
+| `confidence` | `float` | Confidence score (0.0 - 1.0). |
+| `timestamp` | `str` (ISO 8601) | When the signal was measured. |
+| `dimensions` | `dict` or `None` | Breakdown dimensions (e.g., `{"creative_id": "cr_001", "geo": "US-NY"}`). |
+| `metadata` | `dict` or `None` | Additional context. |
+
+## Response
+
+{: .table .table-bordered .table-striped }
+| Field | Type | Description |
+|-------|------|-------------|
+| `media_buy_id` | `str` | Echo of the media buy ID. |
+| `signals_accepted` | `int` | Signals successfully ingested. |
+| `signals_rejected` | `int` | Signals rejected. |
+| `errors` | `list[str]` | Details for rejected signals. |
+
+## Errors
+
+{: .table .table-bordered .table-striped }
+| Error | Cause |
+|-------|-------|
+| `AdCPNotFoundError` (404) | Media buy does not exist or is not owned by the caller. |
+| `AdCPAuthorizationError` (403) | Principal not authorized for this media buy. |
+| `AdCPValidationError` (400) | Invalid performance_data entries. |
+
+## Example
+
+**Request:**
+
+```json
+{
+ "media_buy_id": "mb_a1b2c3d4",
+ "performance_data": [
+ {
+ "signal_type": "brand_lift",
+ "value": 0.12,
+ "confidence": 0.85,
+ "timestamp": "2025-04-10T00:00:00Z",
+ "dimensions": {"creative_id": "cr_video_001"},
+ "metadata": {"survey_sample_size": 500, "metric": "ad_recall"}
+ },
+ {
+ "signal_type": "attention",
+ "value": 7.3,
+ "confidence": 0.92,
+ "timestamp": "2025-04-10T14:00:00Z",
+ "dimensions": {"creative_id": "cr_video_001"},
+ "metadata": {"unit": "attention_seconds"}
+ }
+ ]
+}
+```
+
+**Response:**
+
+```json
+{
+ "media_buy_id": "mb_a1b2c3d4",
+ "signals_accepted": 2,
+ "signals_rejected": 0,
+ "errors": []
+}
+```
+
+## Further Reading
+
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- All tools
+- [get_media_buy_delivery](/agents/salesagent/tools/get-media-buy-delivery.html) -- Delivery metrics
+- [get_media_buys](/agents/salesagent/tools/get-media-buys.html) -- Query campaigns
diff --git a/agents/salesagent/tutorials/campaign-lifecycle.md b/agents/salesagent/tutorials/campaign-lifecycle.md
new file mode 100644
index 0000000000..94cb9e6908
--- /dev/null
+++ b/agents/salesagent/tutorials/campaign-lifecycle.md
@@ -0,0 +1,1187 @@
+---
+layout: page_v2
+title: Prebid Sales Agent - Campaign Lifecycle Tutorial
+description: End-to-end tutorial walking through a complete advertising campaign lifecycle with the Prebid Sales Agent, from discovery to delivery
+sidebarType: 10
+---
+
+# Prebid Sales Agent - Campaign Lifecycle Tutorial
+{: .no_toc}
+
+- TOC
+{:toc}
+
+## Prerequisites
+
+Before starting this tutorial, ensure you have:
+
+1. The Sales Agent running locally (see [Quick Start](/agents/salesagent/getting-started/quickstart.html) or [Development Setup](/agents/salesagent/developers/dev-setup.html)).
+2. The mock adapter configured (this is the default in the development environment).
+3. Test credentials available (`test-token` for API access).
+4. Python 3.12+ with the `fastmcp` package installed: `uv pip install fastmcp`
+
+All examples in this tutorial use the mock adapter, which simulates a complete ad server without requiring any external services. The mock adapter supports all campaign lifecycle operations and responds with realistic data.
+
+## The Scenario
+
+**TechCorp**, a technology company, wants to run a display advertising campaign targeting US tech enthusiasts. Here are the campaign details:
+
+{: .table .table-bordered .table-striped }
+| Parameter | Value |
+|-----------|-------|
+| Advertiser | TechCorp |
+| Goal | Drive awareness for a new developer tool |
+| Budget | $5,000 |
+| Flight Dates | April 1 -- April 30, 2026 |
+| Target Audience | US-based tech enthusiasts, developers |
+| Creative Format | Display banners (300x250, 728x90) |
+| Pricing Model | CPM (cost per thousand impressions) |
+
+We will walk through every step of the campaign lifecycle using the Sales Agent's MCP tools, showing the exact requests and responses at each stage.
+
+## Step 1: Discover Capabilities
+
+The first step is always to call `get_adcp_capabilities` to understand what the publisher supports. This tells you the available protocols, targeting options, creative formats, and business rules.
+
+### Request
+
+```python
+result = await client.call_tool("get_adcp_capabilities")
+```
+
+### Response
+
+```json
+{
+ "tenant": {
+ "name": "Demo Publisher",
+ "portfolio_description": "A premium digital media network reaching 50M monthly unique visitors across technology, business, and lifestyle verticals.",
+ "adapter": "mock",
+ "protocols": ["mcp", "a2a", "rest"]
+ },
+ "capabilities": {
+ "supported_channels": ["display", "video", "audio"],
+ "supported_pricing_models": ["cpm", "cpc", "cpd", "flat_rate"],
+ "supported_targeting": {
+ "geographic": ["country", "state", "city", "dma"],
+ "demographic": ["age_range", "gender", "income"],
+ "contextual": ["category", "keyword", "topic"],
+ "behavioral": ["interest", "intent"],
+ "device": ["device_type", "os", "browser"]
+ },
+ "currency": "USD",
+ "min_budget": 500,
+ "max_budget": 1000000,
+ "workflow_required": true,
+ "creative_review_required": true
+ },
+ "tools": [
+ "get_adcp_capabilities",
+ "get_products",
+ "list_creative_formats",
+ "list_authorized_properties",
+ "create_media_buy",
+ "update_media_buy",
+ "get_media_buys",
+ "get_media_buy_delivery",
+ "sync_creatives",
+ "list_creatives",
+ "update_performance_index"
+ ]
+}
+```
+
+### What This Tells Us
+
+- The publisher supports **display, video, and audio** channels -- display fits our needs.
+- **CPM pricing** is available.
+- **Geographic and contextual targeting** are supported, so we can target US tech audiences.
+- **Workflow approval is required** -- the publisher reviews campaigns before activation.
+- **Creative review is required** -- uploaded creatives need approval.
+- The minimum budget is $500 and our $5,000 budget is well within range.
+
+## Step 2: Search for Products
+
+Next, call `get_products` with a natural-language brief describing the campaign goal. The AI ranking agent scores products by relevance to the brief and returns them in ranked order.
+
+### Request
+
+```python
+result = await client.call_tool("get_products", {
+ "brief": "display ads targeting US tech enthusiasts, developers, $5000 budget"
+})
+```
+
+### Response
+
+```json
+{
+ "products": [
+ {
+ "id": "prod-tech-display-001",
+ "name": "Tech Audience - Premium Display",
+ "description": "Reach technology professionals and enthusiasts across our network of developer blogs, tech news sites, and software review portals. High-intent audience with strong engagement metrics.",
+ "channels": ["display"],
+ "pricing_options": [
+ {
+ "id": "price-001",
+ "model": "cpm",
+ "rate": 12.50,
+ "currency": "USD",
+ "minimum_spend": 1000
+ },
+ {
+ "id": "price-002",
+ "model": "cpc",
+ "rate": 2.75,
+ "currency": "USD",
+ "minimum_spend": 500
+ }
+ ],
+ "targeting": {
+ "contextual": ["technology", "software", "programming"],
+ "geographic": ["US"]
+ },
+ "estimated_reach": {
+ "daily_impressions": 150000,
+ "daily_uniques": 45000
+ },
+ "relevance_score": 0.95
+ },
+ {
+ "id": "prod-tech-video-001",
+ "name": "Tech Audience - Video Pre-Roll",
+ "description": "Video pre-roll ads served on tech tutorial and review video content. High completion rates with an engaged developer audience.",
+ "channels": ["video"],
+ "pricing_options": [
+ {
+ "id": "price-003",
+ "model": "cpm",
+ "rate": 25.00,
+ "currency": "USD",
+ "minimum_spend": 2500
+ }
+ ],
+ "targeting": {
+ "contextual": ["technology", "tutorials"],
+ "geographic": ["US"]
+ },
+ "estimated_reach": {
+ "daily_impressions": 50000,
+ "daily_uniques": 30000
+ },
+ "relevance_score": 0.78
+ },
+ {
+ "id": "prod-ron-display-001",
+ "name": "Run of Network - Display",
+ "description": "Broad reach display advertising across all network properties. Cost-effective for awareness campaigns.",
+ "channels": ["display"],
+ "pricing_options": [
+ {
+ "id": "price-004",
+ "model": "cpm",
+ "rate": 4.00,
+ "currency": "USD",
+ "minimum_spend": 500
+ }
+ ],
+ "targeting": {
+ "geographic": ["US"]
+ },
+ "estimated_reach": {
+ "daily_impressions": 500000,
+ "daily_uniques": 200000
+ },
+ "relevance_score": 0.52
+ }
+ ],
+ "total": 3,
+ "ranking_model": "gemini-2.0-flash"
+}
+```
+
+### What This Tells Us
+
+The AI ranked **"Tech Audience - Premium Display"** highest (0.95 relevance score). It offers CPM pricing at $12.50, targets technology and programming contexts in the US, and reaches 150K daily impressions. This is a strong match for our campaign.
+
+With a $5,000 budget at $12.50 CPM, we can expect approximately 400,000 impressions over the campaign flight.
+
+## Step 3: Check Creative Formats
+
+Before uploading creatives, call `list_creative_formats` to understand the exact specifications for display ads.
+
+### Request
+
+```python
+result = await client.call_tool("list_creative_formats")
+```
+
+### Response
+
+```json
+{
+ "formats": [
+ {
+ "id": "fmt-display-300x250",
+ "name": "Medium Rectangle",
+ "channel": "display",
+ "mime_types": ["image/png", "image/jpeg", "image/gif"],
+ "dimensions": {
+ "width": 300,
+ "height": 250
+ },
+ "max_file_size_bytes": 150000,
+ "max_animation_duration_seconds": 30,
+ "requirements": "Static or animated. Max 3 animation loops. Must include a visible border."
+ },
+ {
+ "id": "fmt-display-728x90",
+ "name": "Leaderboard",
+ "channel": "display",
+ "mime_types": ["image/png", "image/jpeg", "image/gif"],
+ "dimensions": {
+ "width": 728,
+ "height": 90
+ },
+ "max_file_size_bytes": 150000,
+ "max_animation_duration_seconds": 30,
+ "requirements": "Static or animated. Max 3 animation loops. Must include a visible border."
+ },
+ {
+ "id": "fmt-display-160x600",
+ "name": "Wide Skyscraper",
+ "channel": "display",
+ "mime_types": ["image/png", "image/jpeg", "image/gif"],
+ "dimensions": {
+ "width": 160,
+ "height": 600
+ },
+ "max_file_size_bytes": 150000,
+ "max_animation_duration_seconds": 30,
+ "requirements": "Static or animated. Max 3 animation loops."
+ },
+ {
+ "id": "fmt-video-preroll",
+ "name": "Video Pre-Roll",
+ "channel": "video",
+ "mime_types": ["video/mp4"],
+ "dimensions": {
+ "width": 1920,
+ "height": 1080
+ },
+ "max_file_size_bytes": 50000000,
+ "max_duration_seconds": 30,
+ "requirements": "MP4 H.264 codec. Audio required. Max 30 seconds."
+ }
+ ]
+}
+```
+
+### What This Tells Us
+
+For our display campaign, we need:
+
+- **Medium Rectangle (300x250)** and **Leaderboard (728x90)** -- both accept PNG, JPEG, or GIF.
+- Maximum file size is 150KB per creative.
+- Animated GIFs are allowed with up to 30 seconds of animation and 3 loops.
+
+## Step 4: View Publisher Properties
+
+Call `list_authorized_properties` to see the publisher's domains and sites where ads will appear.
+
+### Request
+
+```python
+result = await client.call_tool("list_authorized_properties")
+```
+
+### Response
+
+```json
+{
+ "properties": [
+ {
+ "id": "prop-001",
+ "name": "TechDaily.com",
+ "domain": "techdaily.com",
+ "channels": ["display", "video"],
+ "categories": ["technology", "software", "programming"],
+ "monthly_pageviews": 12000000,
+ "description": "Leading technology news and analysis"
+ },
+ {
+ "id": "prop-002",
+ "name": "DevHub Blog Network",
+ "domain": "devhub.io",
+ "channels": ["display"],
+ "categories": ["programming", "devops", "cloud"],
+ "monthly_pageviews": 8000000,
+ "description": "Developer-focused blog network covering modern software development"
+ },
+ {
+ "id": "prop-003",
+ "name": "GadgetReview",
+ "domain": "gadgetreview.com",
+ "channels": ["display", "video"],
+ "categories": ["technology", "consumer_electronics", "reviews"],
+ "monthly_pageviews": 5000000,
+ "description": "In-depth technology product reviews and comparisons"
+ }
+ ],
+ "portfolio_description": "A premium digital media network reaching 50M monthly unique visitors across technology, business, and lifestyle verticals.",
+ "advertising_policies": {
+ "prohibited_categories": ["gambling", "tobacco", "weapons", "adult"],
+ "creative_requirements": "All creatives must include brand name. No misleading claims. Landing page must match advertised product.",
+ "approval_sla_hours": 24
+ }
+}
+```
+
+### What This Tells Us
+
+The publisher operates three technology-focused properties with a combined 25M monthly pageviews. Our tech-focused campaign will appear across these sites. Note the advertising policies -- our creatives must include the TechCorp brand name and the landing page must match our product.
+
+## Step 5: Create the Media Buy
+
+Now we have all the information needed to create the media buy. We will use the "Tech Audience - Premium Display" product with CPM pricing.
+
+### Request
+
+```python
+result = await client.call_tool("create_media_buy", {
+ "name": "TechCorp Developer Tool Launch - Q2 2026",
+ "buyer_ref": "TC-2026-Q2-001",
+ "packages": [
+ {
+ "product_id": "prod-tech-display-001",
+ "pricing_option_id": "price-001",
+ "name": "Tech Display - Medium Rectangle",
+ "budget": 3000,
+ "start_date": "2026-04-01",
+ "end_date": "2026-04-30",
+ "targeting": {
+ "geographic": {"countries": ["US"]},
+ "contextual": {"categories": ["technology", "programming"]},
+ "device": {"device_types": ["desktop", "tablet"]}
+ },
+ "creative_format_ids": ["fmt-display-300x250"]
+ },
+ {
+ "product_id": "prod-tech-display-001",
+ "pricing_option_id": "price-001",
+ "name": "Tech Display - Leaderboard",
+ "budget": 2000,
+ "start_date": "2026-04-01",
+ "end_date": "2026-04-30",
+ "targeting": {
+ "geographic": {"countries": ["US"]},
+ "contextual": {"categories": ["technology", "software"]},
+ "device": {"device_types": ["desktop"]}
+ },
+ "creative_format_ids": ["fmt-display-728x90"]
+ }
+ ],
+ "total_budget": 5000,
+ "currency": "USD"
+})
+```
+
+### Response
+
+```json
+{
+ "media_buy": {
+ "id": "mb-a1b2c3d4-5678-9012-3456-789012345678",
+ "name": "TechCorp Developer Tool Launch - Q2 2026",
+ "buyer_ref": "TC-2026-Q2-001",
+ "status": "pending_activation",
+ "total_budget": 5000.00,
+ "currency": "USD",
+ "packages": [
+ {
+ "id": "pkg-001",
+ "name": "Tech Display - Medium Rectangle",
+ "product_id": "prod-tech-display-001",
+ "budget": 3000.00,
+ "start_date": "2026-04-01",
+ "end_date": "2026-04-30",
+ "pricing_model": "cpm",
+ "rate": 12.50,
+ "estimated_impressions": 240000,
+ "creative_format_ids": ["fmt-display-300x250"],
+ "targeting": {
+ "geographic": {"countries": ["US"]},
+ "contextual": {"categories": ["technology", "programming"]},
+ "device": {"device_types": ["desktop", "tablet"]}
+ }
+ },
+ {
+ "id": "pkg-002",
+ "name": "Tech Display - Leaderboard",
+ "product_id": "prod-tech-display-001",
+ "budget": 2000.00,
+ "start_date": "2026-04-01",
+ "end_date": "2026-04-30",
+ "pricing_model": "cpm",
+ "rate": 12.50,
+ "estimated_impressions": 160000,
+ "creative_format_ids": ["fmt-display-728x90"],
+ "targeting": {
+ "geographic": {"countries": ["US"]},
+ "contextual": {"categories": ["technology", "software"]},
+ "device": {"device_types": ["desktop"]}
+ }
+ }
+ ],
+ "created_at": "2026-03-09T14:30:00Z",
+ "workflow_tasks": [
+ {
+ "id": "task-review-001",
+ "type": "publisher_review",
+ "status": "pending",
+ "description": "Publisher review required before campaign activation"
+ }
+ ]
+ }
+}
+```
+
+### What This Tells Us
+
+The media buy was created successfully with ID `mb-a1b2c3d4-5678-9012-3456-789012345678`. Key observations:
+
+- **Status is `pending_activation`** -- the campaign needs publisher approval before it goes live.
+- **Two packages** were created, splitting the $5,000 budget between 300x250 ($3,000) and 728x90 ($2,000) formats.
+- **Estimated impressions**: 240,000 + 160,000 = 400,000 total at $12.50 CPM.
+- **A workflow task** (`task-review-001`) was created for publisher review. We will need to handle this in Step 7.
+
+## Step 6: Upload Creatives
+
+Upload the display ad creatives for both packages using `sync_creatives`. The creatives are submitted with their format, click-through URL, and base64-encoded asset data.
+
+### Request
+
+```python
+result = await client.call_tool("sync_creatives", {
+ "media_buy_id": "mb-a1b2c3d4-5678-9012-3456-789012345678",
+ "creatives": [
+ {
+ "name": "TechCorp DevTool - 300x250",
+ "format_id": "fmt-display-300x250",
+ "click_url": "https://techcorp.example.com/devtool?utm_source=salesagent&utm_medium=display&utm_campaign=q2-launch",
+ "assets": [
+ {
+ "role": "primary",
+ "mime_type": "image/png",
+ "width": 300,
+ "height": 250,
+ "file_size_bytes": 87500,
+ "url": "https://cdn.techcorp.example.com/ads/devtool-300x250.png"
+ }
+ ],
+ "alt_text": "TechCorp DevTool - Build faster with AI-powered development",
+ "provenance": {
+ "tool": "Adobe Creative Suite",
+ "created_by": "TechCorp Design Team"
+ }
+ },
+ {
+ "name": "TechCorp DevTool - 728x90",
+ "format_id": "fmt-display-728x90",
+ "click_url": "https://techcorp.example.com/devtool?utm_source=salesagent&utm_medium=display&utm_campaign=q2-launch",
+ "assets": [
+ {
+ "role": "primary",
+ "mime_type": "image/png",
+ "width": 728,
+ "height": 90,
+ "file_size_bytes": 62000,
+ "url": "https://cdn.techcorp.example.com/ads/devtool-728x90.png"
+ }
+ ],
+ "alt_text": "TechCorp DevTool - Ship code 10x faster",
+ "provenance": {
+ "tool": "Adobe Creative Suite",
+ "created_by": "TechCorp Design Team"
+ }
+ }
+ ]
+})
+```
+
+### Response
+
+```json
+{
+ "creatives": [
+ {
+ "id": "cr-001-300x250",
+ "name": "TechCorp DevTool - 300x250",
+ "format_id": "fmt-display-300x250",
+ "status": "pending_review",
+ "validation": {
+ "passed": true,
+ "checks": [
+ {"check": "dimensions", "status": "passed", "detail": "300x250 matches format spec"},
+ {"check": "file_size", "status": "passed", "detail": "87500 bytes < 150000 max"},
+ {"check": "mime_type", "status": "passed", "detail": "image/png is accepted"}
+ ]
+ },
+ "created_at": "2026-03-09T14:35:00Z"
+ },
+ {
+ "id": "cr-002-728x90",
+ "name": "TechCorp DevTool - 728x90",
+ "format_id": "fmt-display-728x90",
+ "status": "pending_review",
+ "validation": {
+ "passed": true,
+ "checks": [
+ {"check": "dimensions", "status": "passed", "detail": "728x90 matches format spec"},
+ {"check": "file_size", "status": "passed", "detail": "62000 bytes < 150000 max"},
+ {"check": "mime_type", "status": "passed", "detail": "image/png is accepted"}
+ ]
+ },
+ "created_at": "2026-03-09T14:35:00Z"
+ }
+ ],
+ "summary": {
+ "total": 2,
+ "pending_review": 2,
+ "approved": 0,
+ "rejected": 0
+ }
+}
+```
+
+### What This Tells Us
+
+Both creatives passed format validation (dimensions, file size, MIME type) and are now in `pending_review` status. The publisher's creative review process (which may involve an AI review agent) will evaluate them for policy compliance. The creative lifecycle is:
+
+```text
+processing --> pending_review --> approved
+ --> rejected
+```
+
+## Step 7: Handle Approval Workflows
+
+The media buy created a workflow task for publisher review, and the creatives are pending review. Workflow approvals are handled by the publisher through the Admin UI (or via Slack notifications if `hitl_webhook_url` is configured). The buying agent polls for status changes.
+
+### Waiting for Approval
+
+In a production environment, a publisher ad ops team member reviews the campaign in the Admin UI under **Workflow Tasks** and approves or rejects it. For testing, use the `X-Auto-Advance: true` header to bypass manual approval.
+
+```python
+# Poll until the media buy is approved
+import asyncio
+
+while True:
+ result = await client.call_tool("get_media_buys", {
+ "media_buy_ids": [media_buy_id]
+ })
+ status = result["media_buys"][0]["status"]
+ if status in ("approved", "active", "delivering"):
+ print(f"Media buy approved! Status: {status}")
+ break
+ elif status in ("rejected", "canceled"):
+ print(f"Media buy {status}.")
+ break
+ print(f"Current status: {status} — waiting for publisher review...")
+ await asyncio.sleep(30)
+```
+
+### Understanding HITL Workflows
+
+The human-in-the-loop workflow is a core feature of the Sales Agent. It ensures that publishers maintain control over what runs on their properties. The workflow system:
+
+- Automatically creates tasks based on tenant configuration (e.g., review required for all buys over $1,000).
+- Supports AI-assisted review -- the creative review agent can auto-approve creatives above a configurable confidence threshold.
+- Tracks the complete audit trail of who approved what and when.
+- Can be bypassed in testing using the `X-Auto-Advance: true` header.
+
+## Step 8: Monitor Campaign Status
+
+After approvals are complete, check the media buy status to confirm it has transitioned to `active`.
+
+### Request
+
+```python
+result = await client.call_tool("get_media_buys", {
+ "media_buy_ids": ["mb-a1b2c3d4-5678-9012-3456-789012345678"]
+})
+```
+
+### Response
+
+```json
+{
+ "media_buys": [
+ {
+ "id": "mb-a1b2c3d4-5678-9012-3456-789012345678",
+ "name": "TechCorp Developer Tool Launch - Q2 2026",
+ "buyer_ref": "TC-2026-Q2-001",
+ "status": "active",
+ "total_budget": 5000.00,
+ "currency": "USD",
+ "packages": [
+ {
+ "id": "pkg-001",
+ "name": "Tech Display - Medium Rectangle",
+ "status": "active",
+ "budget": 3000.00,
+ "start_date": "2026-04-01",
+ "end_date": "2026-04-30"
+ },
+ {
+ "id": "pkg-002",
+ "name": "Tech Display - Leaderboard",
+ "status": "active",
+ "budget": 2000.00,
+ "start_date": "2026-04-01",
+ "end_date": "2026-04-30"
+ }
+ ],
+ "creatives": [
+ {"id": "cr-001-300x250", "status": "approved"},
+ {"id": "cr-002-728x90", "status": "approved"}
+ ],
+ "created_at": "2026-03-09T14:30:00Z",
+ "activated_at": "2026-03-09T14:42:00Z"
+ }
+ ]
+}
+```
+
+### Status Lifecycle
+
+The media buy has transitioned through the status lifecycle:
+
+```text
+pending_activation --> active --> paused --> completed
+```
+
+- **pending_activation**: Campaign created, awaiting publisher approval.
+- **active**: Approved and delivering (or scheduled to deliver on start_date).
+- **paused**: Temporarily stopped (can be resumed).
+- **completed**: Flight ended or budget exhausted.
+
+## Step 9: Track Delivery
+
+Once the campaign is active and delivering, use `get_media_buy_delivery` to monitor performance. This returns impressions, spend, click-through rate, and pacing data.
+
+### Request
+
+```python
+result = await client.call_tool("get_media_buy_delivery", {
+ "media_buy_ids": ["mb-a1b2c3d4-5678-9012-3456-789012345678"]
+})
+```
+
+### Response
+
+```json
+{
+ "media_buy_id": "mb-a1b2c3d4-5678-9012-3456-789012345678",
+ "status": "active",
+ "delivery": {
+ "impressions": 125000,
+ "clicks": 1875,
+ "ctr": 0.015,
+ "spend": 1562.50,
+ "budget_remaining": 3437.50,
+ "budget_utilization": 0.3125
+ },
+ "pacing": {
+ "expected_spend_to_date": 1666.67,
+ "actual_spend_to_date": 1562.50,
+ "pacing_ratio": 0.9375,
+ "pacing_status": "slightly_behind",
+ "projected_end_spend": 4687.50,
+ "projected_end_date": "2026-04-30"
+ },
+ "by_package": [
+ {
+ "package_id": "pkg-001",
+ "name": "Tech Display - Medium Rectangle",
+ "impressions": 78000,
+ "clicks": 1248,
+ "ctr": 0.016,
+ "spend": 975.00,
+ "budget_remaining": 2025.00,
+ "pacing_ratio": 0.9500
+ },
+ {
+ "package_id": "pkg-002",
+ "name": "Tech Display - Leaderboard",
+ "impressions": 47000,
+ "clicks": 627,
+ "ctr": 0.0133,
+ "spend": 587.50,
+ "budget_remaining": 1412.50,
+ "pacing_ratio": 0.9167
+ }
+ ],
+ "period": {
+ "start": "2026-04-01",
+ "end": "2026-04-30",
+ "days_elapsed": 10,
+ "days_remaining": 20
+ }
+}
+```
+
+### Reading the Delivery Report
+
+Key metrics to understand:
+
+{: .table .table-bordered .table-striped }
+| Metric | Value | Interpretation |
+|--------|-------|----------------|
+| Impressions | 125,000 | 31.25% of estimated 400K total -- on track for day 10 of 30 |
+| CTR | 1.5% | Strong click-through rate for display ads |
+| Spend | $1,562.50 | 31.25% of $5,000 budget consumed |
+| Pacing Ratio | 0.9375 | Slightly behind pace (93.75% of expected) |
+| Projected End Spend | $4,687.50 | On current trajectory, will underspend by $312.50 |
+
+The **by_package** breakdown shows that the 300x250 Medium Rectangle is performing better (1.6% CTR) than the 728x90 Leaderboard (1.33% CTR). The 300x250 is also pacing better at 95% vs 91.67%.
+
+## Step 10: Optimize
+
+Based on the delivery data, we can optimize the campaign. The 300x250 format is performing better, so let us shift $500 from the leaderboard package to the medium rectangle and extend the flight by one week.
+
+### Update the Media Buy
+
+```python
+result = await client.call_tool("update_media_buy", {
+ "media_buy_id": "mb-a1b2c3d4-5678-9012-3456-789012345678",
+ "packages": [
+ {
+ "package_id": "pkg-001",
+ "budget": 3500,
+ "end_date": "2026-05-07"
+ },
+ {
+ "package_id": "pkg-002",
+ "budget": 1500,
+ "end_date": "2026-05-07"
+ }
+ ]
+})
+```
+
+```json
+{
+ "media_buy": {
+ "id": "mb-a1b2c3d4-5678-9012-3456-789012345678",
+ "status": "active",
+ "total_budget": 5000.00,
+ "packages": [
+ {
+ "id": "pkg-001",
+ "name": "Tech Display - Medium Rectangle",
+ "budget": 3500.00,
+ "start_date": "2026-04-01",
+ "end_date": "2026-05-07",
+ "previous_budget": 3000.00
+ },
+ {
+ "id": "pkg-002",
+ "name": "Tech Display - Leaderboard",
+ "budget": 1500.00,
+ "start_date": "2026-04-01",
+ "end_date": "2026-05-07",
+ "previous_budget": 2000.00
+ }
+ ],
+ "updated_at": "2026-04-11T10:15:00Z"
+ }
+}
+```
+
+### Submit Performance Feedback
+
+Use `update_performance_index` to feed back performance signals that help the Sales Agent optimize future product recommendations:
+
+```python
+result = await client.call_tool("update_performance_index", {
+ "media_buy_id": "mb-a1b2c3d4-5678-9012-3456-789012345678",
+ "feedback": {
+ "overall_satisfaction": 4,
+ "audience_quality": 5,
+ "delivery_consistency": 3,
+ "notes": "Strong CTR on 300x250 format. Leaderboard underperforming on pacing. Audience targeting is excellent for developer demographic."
+ }
+})
+```
+
+```json
+{
+ "performance_index": {
+ "media_buy_id": "mb-a1b2c3d4-5678-9012-3456-789012345678",
+ "overall_satisfaction": 4,
+ "audience_quality": 5,
+ "delivery_consistency": 3,
+ "updated_at": "2026-04-11T10:20:00Z"
+ }
+}
+```
+
+## Step 11: Campaign Completion
+
+When the flight ends (or the budget is exhausted), the campaign status transitions to `completed`. Check the final delivery report:
+
+### Final Status Check
+
+```python
+result = await client.call_tool("get_media_buys", {
+ "media_buy_ids": ["mb-a1b2c3d4-5678-9012-3456-789012345678"]
+})
+```
+
+```json
+{
+ "media_buys": [
+ {
+ "id": "mb-a1b2c3d4-5678-9012-3456-789012345678",
+ "name": "TechCorp Developer Tool Launch - Q2 2026",
+ "status": "completed",
+ "total_budget": 5000.00,
+ "completed_at": "2026-05-07T23:59:59Z"
+ }
+ ]
+}
+```
+
+### Final Delivery Report
+
+```python
+result = await client.call_tool("get_media_buy_delivery", {
+ "media_buy_ids": ["mb-a1b2c3d4-5678-9012-3456-789012345678"]
+})
+```
+
+```json
+{
+ "media_buy_id": "mb-a1b2c3d4-5678-9012-3456-789012345678",
+ "status": "completed",
+ "delivery": {
+ "impressions": 398500,
+ "clicks": 6176,
+ "ctr": 0.0155,
+ "spend": 4981.25,
+ "budget_remaining": 18.75,
+ "budget_utilization": 0.9963
+ },
+ "pacing": {
+ "pacing_status": "completed",
+ "final_delivery_ratio": 0.9963
+ },
+ "by_package": [
+ {
+ "package_id": "pkg-001",
+ "name": "Tech Display - Medium Rectangle",
+ "impressions": 278000,
+ "clicks": 4587,
+ "ctr": 0.0165,
+ "spend": 3475.00,
+ "budget_utilization": 0.9929
+ },
+ {
+ "package_id": "pkg-002",
+ "name": "Tech Display - Leaderboard",
+ "impressions": 120500,
+ "clicks": 1589,
+ "ctr": 0.0132,
+ "spend": 1506.25,
+ "budget_utilization": 1.0042
+ }
+ ],
+ "period": {
+ "start": "2026-04-01",
+ "end": "2026-05-07",
+ "total_days": 37
+ }
+}
+```
+
+### Campaign Summary
+
+{: .table .table-bordered .table-striped }
+| Metric | Target | Actual | Result |
+|--------|--------|--------|--------|
+| Budget | $5,000 | $4,981.25 | 99.6% utilized |
+| Impressions | 400,000 (est.) | 398,500 | 99.6% of estimate |
+| CTR | -- | 1.55% | Strong performance |
+| Flight | Apr 1 -- Apr 30 | Apr 1 -- May 7 | Extended by 1 week (optimization) |
+
+The campaign delivered 398,500 impressions at 99.6% budget utilization, with a strong 1.55% CTR. The mid-flight optimization (shifting budget to 300x250 and extending the flight) helped maximize delivery.
+
+## Complete Python Script
+
+Here is the complete working script that executes all steps in the campaign lifecycle. This can be run directly against a local Sales Agent instance using the mock adapter.
+
+```python
+"""
+Complete Campaign Lifecycle Example
+Runs a full advertising campaign through the Prebid Sales Agent.
+
+Requirements:
+ - Sales Agent running locally (docker compose up -d)
+ - Mock adapter configured (default in development)
+ - Python 3.12+ with fastmcp installed (uv pip install fastmcp)
+
+Usage:
+ uv run python campaign_lifecycle.py
+"""
+
+import asyncio
+import json
+from fastmcp import Client
+from fastmcp.client.transports import StreamableHttpTransport
+
+
+SALES_AGENT_URL = "http://localhost:8000/mcp/"
+AUTH_TOKEN = "test-token"
+
+
+def print_step(step_num: int, title: str) -> None:
+ print(f"\n{'='*60}")
+ print(f"Step {step_num}: {title}")
+ print(f"{'='*60}")
+
+
+def print_result(result: object) -> None:
+ if hasattr(result, "content"):
+ for block in result.content:
+ if hasattr(block, "text"):
+ parsed = json.loads(block.text)
+ print(json.dumps(parsed, indent=2))
+ else:
+ print(json.dumps(result, indent=2, default=str))
+
+
+async def run_campaign_lifecycle() -> None:
+ transport = StreamableHttpTransport(
+ SALES_AGENT_URL,
+ headers={"x-adcp-auth": AUTH_TOKEN},
+ )
+
+ async with Client(transport=transport) as client:
+
+ # Step 1: Discover capabilities
+ print_step(1, "Discover Capabilities")
+ result = await client.call_tool("get_adcp_capabilities")
+ print_result(result)
+
+ # Step 2: Search for products
+ print_step(2, "Search for Products")
+ result = await client.call_tool("get_products", {
+ "brief": "display ads targeting US tech enthusiasts, "
+ "developers, $5000 budget"
+ })
+ print_result(result)
+
+ # Step 3: Check creative formats
+ print_step(3, "Check Creative Formats")
+ result = await client.call_tool("list_creative_formats")
+ print_result(result)
+
+ # Step 4: View publisher properties
+ print_step(4, "View Publisher Properties")
+ result = await client.call_tool("list_authorized_properties")
+ print_result(result)
+
+ # Step 5: Create the media buy
+ print_step(5, "Create Media Buy")
+ result = await client.call_tool("create_media_buy", {
+ "name": "TechCorp Developer Tool Launch - Q2 2026",
+ "buyer_ref": "TC-2026-Q2-001",
+ "packages": [
+ {
+ "product_id": "prod-tech-display-001",
+ "pricing_option_id": "price-001",
+ "name": "Tech Display - Medium Rectangle",
+ "budget": 3000,
+ "start_date": "2026-04-01",
+ "end_date": "2026-04-30",
+ "targeting": {
+ "geographic": {"countries": ["US"]},
+ "contextual": {
+ "categories": ["technology", "programming"]
+ },
+ "device": {"device_types": ["desktop", "tablet"]},
+ },
+ "creative_format_ids": ["fmt-display-300x250"],
+ },
+ {
+ "product_id": "prod-tech-display-001",
+ "pricing_option_id": "price-001",
+ "name": "Tech Display - Leaderboard",
+ "budget": 2000,
+ "start_date": "2026-04-01",
+ "end_date": "2026-04-30",
+ "targeting": {
+ "geographic": {"countries": ["US"]},
+ "contextual": {
+ "categories": ["technology", "software"]
+ },
+ "device": {"device_types": ["desktop"]},
+ },
+ "creative_format_ids": ["fmt-display-728x90"],
+ },
+ ],
+ "total_budget": 5000,
+ "currency": "USD",
+ })
+ print_result(result)
+ # Extract media_buy_id from response for subsequent calls
+ media_buy_id = "mb-a1b2c3d4-5678-9012-3456-789012345678"
+
+ # Step 6: Upload creatives
+ print_step(6, "Upload Creatives")
+ result = await client.call_tool("sync_creatives", {
+ "media_buy_id": media_buy_id,
+ "creatives": [
+ {
+ "name": "TechCorp DevTool - 300x250",
+ "format_id": "fmt-display-300x250",
+ "click_url": "https://techcorp.example.com/devtool"
+ "?utm_source=salesagent"
+ "&utm_medium=display"
+ "&utm_campaign=q2-launch",
+ "assets": [
+ {
+ "role": "primary",
+ "mime_type": "image/png",
+ "width": 300,
+ "height": 250,
+ "file_size_bytes": 87500,
+ "url": "https://cdn.techcorp.example.com"
+ "/ads/devtool-300x250.png",
+ }
+ ],
+ "alt_text": "TechCorp DevTool - Build faster "
+ "with AI-powered development",
+ },
+ {
+ "name": "TechCorp DevTool - 728x90",
+ "format_id": "fmt-display-728x90",
+ "click_url": "https://techcorp.example.com/devtool"
+ "?utm_source=salesagent"
+ "&utm_medium=display"
+ "&utm_campaign=q2-launch",
+ "assets": [
+ {
+ "role": "primary",
+ "mime_type": "image/png",
+ "width": 728,
+ "height": 90,
+ "file_size_bytes": 62000,
+ "url": "https://cdn.techcorp.example.com"
+ "/ads/devtool-728x90.png",
+ }
+ ],
+ "alt_text": "TechCorp DevTool - Ship code 10x faster",
+ },
+ ],
+ })
+ print_result(result)
+
+ # Step 7: Wait for approval workflows
+ # In production, publisher approves via Admin UI.
+ # In testing, use X-Auto-Advance: true header to skip.
+ print_step(7, "Wait for Approval Workflows")
+
+ print("\nPolling media buy status...")
+ import asyncio
+ while True:
+ result = await client.call_tool("get_media_buys", {
+ "media_buy_ids": [media_buy_id],
+ })
+ status = result["media_buys"][0]["status"]
+ if status in ("approved", "active", "delivering"):
+ print(f"Media buy approved! Status: {status}")
+ break
+ elif status in ("rejected", "canceled"):
+ print(f"Media buy {status}.")
+ break
+ print(f"Status: {status} — waiting...")
+ await asyncio.sleep(10)
+
+ # Step 8: Monitor campaign status
+ print_step(8, "Monitor Campaign Status")
+ result = await client.call_tool("get_media_buys", {
+ "media_buy_ids": [media_buy_id],
+ })
+ print_result(result)
+
+ # Step 9: Track delivery
+ print_step(9, "Track Delivery")
+ result = await client.call_tool("get_media_buy_delivery", {
+ "media_buy_ids": [media_buy_id],
+ })
+ print_result(result)
+
+ # Step 10: Optimize
+ print_step(10, "Optimize Campaign")
+ result = await client.call_tool("update_media_buy", {
+ "media_buy_id": media_buy_id,
+ "packages": [
+ {
+ "package_id": "pkg-001",
+ "budget": 3500,
+ "end_date": "2026-05-07",
+ },
+ {
+ "package_id": "pkg-002",
+ "budget": 1500,
+ "end_date": "2026-05-07",
+ },
+ ],
+ })
+ print_result(result)
+
+ # Submit performance feedback
+ print("\nSubmitting performance feedback...")
+ result = await client.call_tool("update_performance_index", {
+ "media_buy_id": media_buy_id,
+ "feedback": {
+ "overall_satisfaction": 4,
+ "audience_quality": 5,
+ "delivery_consistency": 3,
+ "notes": "Strong CTR on 300x250. Leaderboard "
+ "underperforming on pacing.",
+ },
+ })
+ print_result(result)
+
+ # Step 11: Final delivery report
+ print_step(11, "Campaign Completion")
+ result = await client.call_tool("get_media_buy_delivery", {
+ "media_buy_ids": [media_buy_id],
+ })
+ print_result(result)
+
+ print("\n" + "=" * 60)
+ print("Campaign lifecycle complete!")
+ print("=" * 60)
+
+
+if __name__ == "__main__":
+ asyncio.run(run_campaign_lifecycle())
+```
+
+
+ In a real integration, you would parse the media_buy_id and task_id values from each response rather than using hardcoded IDs. The hardcoded IDs in this script correspond to the mock adapter's deterministic responses.
+
+
+## What's Next
+
+- [Buy-Side Integration](/agents/salesagent/getting-started/buy-side-integration.html) -- Build a production AI buying agent
+- [Tool Reference](/agents/salesagent/tools/tool-reference.html) -- Complete reference for all MCP tools
+- [get_products](/agents/salesagent/tools/get-products.html) -- Product catalog search
+- [create_media_buy](/agents/salesagent/tools/create-media-buy.html) -- Campaign creation parameters and response
+- [get_media_buy_delivery](/agents/salesagent/tools/get-media-buy-delivery.html) -- Delivery metrics and pacing
+- [Deployment Overview](/agents/salesagent/deployment/deployment-overview.html) -- Deploy to production on Fly.io or Google Cloud Run
+- [Development Environment Setup](/agents/salesagent/developers/dev-setup.html) -- Set up a local development environment