build-brand-rights-agent · git:20260502.522015d · 2026-05-02 · sha256 99af80ee9c057886

build-brand-rights-agent git:20260502.522015dA

Immutable. This exact content is served forever at /api/v1/blob/99af80ee9c057886.

---
name: build-brand-rights-agent
description: Use when building an AdCP brand rights agent — a platform that manages brand identity, licenses usage rights, and approves generated creatives.
---

# Build a Brand Rights Agent

## Overview

A brand rights agent represents a brand's identity and licensing. Buyers discover the brand, browse available rights (image usage, logo placement, AI generation), acquire licenses, and submit generated creatives for approval. The agent enforces brand guidelines.

## When to Use

- User wants to build an agent that manages brand identity and licensing
- User mentions brand rights, brand guidelines, creative approval, or licensing
- User references `get_brand_identity`, `get_rights`, `acquire_rights`, `update_rights`, or `creative_approval`

**Not this skill:**

- Selling ad inventory → `skills/build-seller-agent/`
- Managing creative formats/library → `skills/build-creative-agent/`
- Evaluating media buys → `skills/build-governance-agent/`

## Specialisms This Skill Covers

| Specialism     | Status | Delta                                                                                                                                                                                  | See                                        |
| -------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| `brand-rights` | stable | First-class tools: `get_brand_identity`, `get_rights`, `acquire_rights`, `update_rights`. `creative_approval` is webhook-only — wire your HTTP receiver at the `approval_webhook` URL. | [§ brand-rights](#specialism-brand-rights) |

Storyboard: `brand_rights`. The specialism tests identity discovery → rights search → acquisition → enforcement (including expired-campaign denial).

## Protocol-Wide Requirements

Full treatment in `skills/build-seller-agent/SKILL.md` §Protocol-Wide Requirements and §Composing. Minimum viable pointers:

- **`idempotency_key`** on every mutating request (`acquire_rights`, `update_rights`, and the `creative_approval` webhook payload). Wire `createIdempotencyStore` into `createAdcpServer({ idempotency })`. The framework auto-applies idempotency middleware to mutating tools; for the `creative_approval` webhook receiver, validate `idempotency_key` yourself and replay the cached verdict on resubmission.
- **Authentication** via `serve({ authenticate })` with `verifyApiKey`/`verifyBearer` from `@adcp/sdk/server`. Unauthenticated agents fail the universal `security_baseline` storyboard.
- **Signature-header transparency**: accept `Signature-Input`/`Signature` headers even if you don't claim `signed-requests`.

## Before Writing Code

### 1. What Brand?

Define the brand this agent represents:

- Brand name (locale-keyed for i18n), domain, logos
- House identity (parent organization)
- What languages/markets the brand operates in

### 2. What Rights Are Available?

Define licensable rights:

- **Image usage** — use brand images in digital ads
- **AI generation** — generate new creatives using brand assets
- **Logo placement** — use brand logo in ads
- **Talent likeness** — use a person's likeness in generated content

Each right needs pricing (flat_rate, cpm, etc.) and uses (likeness, voice, commercial, ai_generated_image, etc.).

### 3. Approval Criteria

How are generated creatives reviewed?

- **Auto-approve** — passes basic checks, instantly approved
- **Guidelines check** — validate against brand standards
- **Human review** — queue for manual review

## Protocol Status

Four MCP/A2A tools are first-class in the `brandRights` domain group. `creative_approval` is webhook-only — the spec models it as an HTTP POST from the buyer to the `approval_webhook` URL the seller returned in `acquire_rights`.

| Operation            | Status                                          | How to implement                                                                                       |
| -------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `get_brand_identity` | Published schema — `brand/get-brand-identity`   | `brandRights.getBrandIdentity` handler                                                                 |
| `get_rights`         | Published schema — `brand/get-rights`           | `brandRights.getRights` handler                                                                        |
| `acquire_rights`     | Published schema — `brand/acquire-rights`       | `brandRights.acquireRights` handler                                                                    |
| `update_rights`      | Published schema — `brand/update-rights`        | `brandRights.updateRights` handler (mutating)                                                          |
| `creative_approval`  | Published schema — `brand/creative-approval`    | Webhook receiver at the URL you returned in `acquire_rights.approval_webhook`; dispatch to `brandRights.reviewCreativeApproval` |

<a name="specialism-brand-rights"></a>

## Tools and Required Response Shapes

> **Before writing any handler's return statement, fetch [`docs/llms.txt`](../../docs/llms.txt) and grep for `#### \`<tool_name>\``(e.g.`#### \`acquire_rights\``) to read the exact required + optional field list.** The schema-derived contract lives there; this skill covers patterns, gotchas, and domain-specific examples. Strict response validation is on by default in dev — it will tell you the exact field path if you drift, so write the obvious thing and trust the contract.
>
> **Cross-cutting pitfalls matrix runs keep catching:**
>
> - **Declare `capabilities: { specialisms: ['brand-rights'] }` on `createAdcpServer`.** Value is `string[]` of enum ids (not `[{id, version}]`). Agents that don't declare their specialism fail the grader with "No applicable tracks found" even if every tool works — tracks are gated on the specialism claim.

**`get_brand_identity`** — returns brand identity matching `brand/get-brand-identity-response.json`

Required: `brand_id`, `house`, `names` (array of locale-keyed objects).

```typescript
{
  brand_id: 'acme_outdoor',
  house: {
    domain: 'acme.example',
    name: 'Acme Corporation',
  },
  names: [
    { en_US: 'Acme Outdoor' },   // locale-specific
    { en: 'Acme Outdoor' },      // language wildcard
  ],
  logos: [
    {
      url: 'https://cdn.acme.example/logo-primary.svg',
      orientation: 'horizontal',          // horizontal | vertical | square
      background: 'transparent-bg',       // dark-bg | light-bg | transparent-bg
      variant: 'primary',
      width: 512,
      height: 128,
    },
  ],
  tone: {                                 // brand voice lives under `tone`, not at the top level
    voice: 'Confident, outdoorsy, direct.',
  },
  // context echoed back by the framework when present
}
```

**`get_rights`** — returns matching rights with pricing

Each right requires `rights_id`, `brand_id`, `name`, `available_uses`, `pricing_options`.

The `right-use` enum at `/schemas/latest/enums/right-use.json` is: `likeness | voice | name | endorsement | motion_capture | signature | catchphrase | sync | background_music | editorial | commercial | ai_generated_image | image_generation`.

```typescript
{
  rights: [
    {
      rights_id: 'likeness_commercial_standard',
      brand_id: 'acme_outdoor',
      name: 'Likeness for commercial use — standard',
      available_uses: ['likeness', 'commercial'],
      pricing_options: [
        {
          pricing_option_id: 'monthly_standard',
          model: 'flat_rate',                 // from pricing-model enum
          price: 2500,
          currency: 'USD',
          uses: ['likeness', 'commercial'],
          period: 'monthly',
        },
      ],
    },
  ],
}
```

**`acquire_rights`** — returns a discriminated union on `status`

Three success variants plus an error variant. The most common is `acquired`. Three shapes need exact field names to satisfy the spec schemas — `terms` must match `rights-terms.json` (required: `pricing_option_id`, `amount`, `currency`, `uses`), `rights_constraint` must match `/schemas/latest/core/rights-constraint.json` (required: `rights_id`, `rights_agent`, `uses`), and `approval_webhook.authentication.credentials` requires `minLength: 32`.

```typescript
{
  rights_id: 'likeness_commercial_standard',   // echoed from request
  status: 'acquired',
  brand_id: 'acme_outdoor',
  terms: {
    pricing_option_id: 'monthly_standard',     // required
    amount: 2500,                              // required
    currency: 'USD',                           // required
    uses: ['likeness', 'commercial'],          // required
    period: 'monthly',
    start_date: '2026-04-01T00:00:00Z',
    end_date:   '2026-05-01T00:00:00Z',
    exclusivity: { scope: 'non_exclusive', countries: ['US', 'CA'] },    // object per rights-terms.json: { scope, countries }
  },
  generation_credentials: [ /* generation-credential refs */ ],
  rights_constraint: {
    rights_id: 'likeness_commercial_standard', // required — NOT brand_id
    rights_agent: {                            // required — {url, id} pointing at this agent
      url: 'https://brand.example/mcp',
      id: 'acme_outdoor',
    },
    uses: ['likeness', 'commercial'],          // required
  },
  approval_webhook: {
    url: 'https://brand.example/webhooks/creative-approval',
    authentication: {
      schemes: ['Bearer'],
      credentials: 'brand-approval-webhook-secret-32chars+',  // minLength: 32
    },
  },
}
// or
{ rights_id, status: 'pending_approval', brand_id, detail?, estimated_response_time? }
// or
{ rights_id, status: 'rejected', brand_id, reason, suggestions? }
```

**`update_rights`** — returns either a success arm (with re-issued credentials) or the error arm

Mutating; framework auto-applies idempotency middleware. Carry only the fields you're changing — omitted fields stay at their current value (parallels `update_media_buy` semantics). The framework hydrates the underlying grant from `rights_id`, so handlers read the resolved grant from `ctx.store`.

```typescript
// Success — change applied
{
  rights_id: 'likeness_commercial_standard',
  terms: { /* updated rights-terms shape */ },
  generation_credentials: [ /* re-issued with the new constraint */ ],
  rights_constraint: { /* updated for re-embedding in creative manifests */ },
  paused: false,
  implementation_date: '2026-05-02T19:00:00Z',  // string when live immediately
}
// Pending rights-holder approval
{
  rights_id: 'likeness_commercial_standard',
  terms: { /* updated terms */ },
  implementation_date: null,                     // null = follow-up via push_notification_config webhook
}
// Error — buyer-fixable rejection. Throw `adcpError('INVALID_REQUEST', ...)`
// for single-error cases; the multi-error arm is for batch failures.
{
  errors: [{ code: 'INVALID_REQUEST', message: 'impression_cap below delivered count' }],
}
```

Common rejections to surface as errors: `impression_cap` below already-delivered count, `end_date` earlier than current `end_date`, switching to a `pricing_option_id` from a different `get_rights` offering than the original.

**Creative approval (webhook).** The `approval_webhook` in your `acquire_rights` response is a URL **your agent hosts** — the buyer POSTs `CreativeApprovalRequest` there when a generated creative needs review. Schema is published in 3.0.x (`brand/creative-approval-{request,response}.json`); the SDK does NOT register this as an MCP/A2A tool because it's webhook-only.

The receiver pattern: **authenticate first**, then validate the request body with `CreativeApprovalRequestSchema`, dispatch to `brandRights.reviewCreativeApproval(req, ctx)`, and serialize the result with the typed builders.

> **Security order matters.** The auth check (Bearer token from `acquire_rights.approval_webhook.authentication.credentials`, or your RFC 9421 signature verifier) MUST run **before** the idempotency-store lookup. The cache is a verdict store keyed by `(principal, idempotency_key)`; an unauthenticated lookup turns the endpoint into a replay-existence oracle and lets anyone who can reach the URL pull back a previously-issued verdict. Same posture as `acquire_rights` — apply the same `verifyApiKey` / signature middleware to this route.

> **SSRF on `creative_url`.** The buyer's `creative_url` is a buyer-controlled URL the brand-rights agent typically fetches to render or scan for review. Treat it like any inbound URL: route through your standard SSRF-guarded HTTP client (https-only in prod, no RFC 1918 / loopback / link-local / metadata-service IPs). The SDK applies the same allowlist on `push_notification_config.url` via `extractPushConfig` — match that posture for `creative_url`.

```typescript
import express from 'express';
import {
  creativeApprovalApproved,
  creativeApprovalRejected,
  creativeApprovalPendingReview,
} from '@adcp/sdk/server';
import { CreativeApprovalRequestSchema } from '@adcp/sdk/types';

const app = express();
// Cap payload size to bound a hostile body.
app.use(express.json({ limit: '256kb' }));

// Auth middleware — verify Bearer token / RFC 9421 signature against the
// (principal, rights_id) you advertised on `acquire_rights`. Run BEFORE
// idempotency-store lookup; verdicts are tenant-scoped state.
app.use('/webhooks/creative-approval', verifyApprovalWebhookAuth);

app.post('/webhooks/creative-approval', async (req, res) => {
  const parsed = CreativeApprovalRequestSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ errors: [{ code: 'INVALID_REQUEST', message: 'malformed creative-approval request' }] });
  }

  // Idempotency — keyed by (authenticated principal, idempotency_key) so
  // verdicts can't leak across tenants. Replay the cached verdict if seen.
  const principal = req.adcpPrincipal; // set by verifyApprovalWebhookAuth
  const cached = await idempotencyStore.get(`${principal}:${parsed.data.idempotency_key}`);
  if (cached) return res.json(cached);

  // Dispatch to the platform — same `reviewCreativeApproval` method shape
  // as if it were a tool. The framework doesn't auto-wire HTTP for webhook-
  // only surfaces, so adopters host the route themselves.
  try {
    const verdict = await platform.brandRights.reviewCreativeApproval(parsed.data, /* ctx built per request */);
    await idempotencyStore.set(`${principal}:${parsed.data.idempotency_key}`, verdict);
    return res.json(verdict);
  } catch (err) {
    return res.status(500).json({ errors: [{ code: 'INTERNAL_ERROR', message: err.message }] });
  }
});
```

Three success arms (Approved / Rejected / PendingReview) plus an error arm. Arm choice depends on review pipeline — auto-approve immediately, route to human review, or pre-flight reject for hard violations. Use the typed builders so the discriminator (`status`) is injected for you:

```typescript
// Approved
creativeApprovalApproved({
  rights_id: 'likeness_commercial_standard',
  creative_id: 'cr_42',
  creative_url: 'https://buyer.example.com/creatives/42.mp4',
  approved_at: new Date().toISOString(),
  conditions: ['approved for NL only'],   // optional
});

// Rejected
creativeApprovalRejected({
  rights_id: 'likeness_commercial_standard',
  creative_id: 'cr_42',
  reason: 'logo not visible per brand standards',
  suggestions: ['enlarge the logo to 15% of the frame'],
});

// Pending — buyer polls `status_url` or waits up to `estimated_response_time`
creativeApprovalPendingReview({
  rights_id: 'likeness_commercial_standard',
  creative_id: 'cr_42',
  estimated_response_time: '24h',
  status_url: 'https://brand.example/approvals/cr_42',
});
```

**Revocation webhook.** The `acquire_rights` _request_ carries a required `revocation_webhook`. Persist it against the grant. When you need to revoke (credential rotation, terms violation, brand takedown), use `ctx.emitWebhook` — don't hand-roll `fetch`. See [`skills/build-seller-agent/SKILL.md`](../build-seller-agent/SKILL.md) § Webhooks for the full wiring; minimal call:

```typescript
await ctx.emitWebhook!({
  url: storedGrant.revocation_webhook.url,
  payload: { rights_id: storedGrant.rights_id, reason: 'credential_rotation', effective_at: new Date().toISOString() },
  operation_id: `revoke_rights.${storedGrant.rights_id}`, // stable across retries, NOT a fresh UUID
});
```

3.0 GA renamed `RevocationNotification.notification_id` → `idempotency_key` — the emitter populates it for you when `operation_id` is set.

### Context and Ext Passthrough

Every AdCP request may include a `context` field. The framework echoes it back on success and error responses automatically when you use `createAdcpServer`. Do not read, modify, or omit `context` in your handler — treat it as opaque.

## SDK Quick Reference

| SDK piece                                    | Usage                                                        |
| -------------------------------------------- | ------------------------------------------------------------ |
| `createAdcpServer({ brandRights: { ... } })` | Register brand rights handlers as a first-class domain group |
| `serve(() => createAdcpServer(...))`         | Start HTTP server on `:3001/mcp`                             |
| `adcpError(code, { message })`               | Structured error (BRAND_NOT_FOUND, RIGHTS_UNAVAILABLE, etc.) |

Import: `import { createAdcpServer, serve, adcpError } from '@adcp/sdk/server/legacy/v5';`

> **v6 specialism status.** `brandRights` is wired via the v5 `createAdcpServer` handler bag today. A v6 `BrandRightsPlatform` interface ships at `src/lib/server/decisioning/specialisms/brand-rights.ts` but the dispatcher path through `createAdcpServerFromPlatform` is not yet documented for adopters — pin to the legacy subpath until that lands.

## Setup

```bash
npm init -y
npm install @adcp/sdk
npm install -D typescript @types/node
```

Minimal `tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "skipLibCheck": true,
    "outDir": "dist"
  }
}
```

## Implementation

Single `.ts` file, one `createAdcpServer` call with a `brandRights` domain group. The framework:

- Auto-registers `get_adcp_capabilities` declaring `brand` as a supported protocol
- Echoes `context` on success and error responses
- Validates `get_brand_identity`, `get_rights`, `acquire_rights` against their Zod schemas

Creative-approval webhooks are implemented as a regular outbound HTTP call — outside the MCP server, after you accept `acquire_rights`.

```typescript
import {
  createAdcpServer,
  serve,
  adcpError,
  createIdempotencyStore,
  memoryBackend,
} from '@adcp/sdk/server/legacy/v5';

// Idempotency — required for v3. `acquire_rights` is mutating (issues
// credentials + may trigger billing); `get_brand_identity` and
// `get_rights` are read-only and exempt.
const idempotency = createIdempotencyStore({
  backend: memoryBackend(), // pgBackend(pool) for production
  ttlSeconds: 86400, // 24 hours (spec bounds: 1h–7d)
});

serve(() =>
  createAdcpServer({
    name: 'Acme Brand Rights Agent',
    version: '1.0.0',
    capabilities: { major_versions: [3] },
    idempotency,

    // Principal scoping for idempotency. MUST never return undefined — or
    // every mutating request rejects as SERVICE_UNAVAILABLE.
    resolveSessionKey: () => 'default-principal',

    // Accounts domain — required for the governance_denied scenario. Buyers
    // call sync_accounts to register their operator/billing relationship, then
    // sync_governance to point the brand agent at their governance agent.
    accounts: {
      async syncAccounts(params, ctx) {
        for (const account of params.accounts) {
          // `ctx.store.put` rejects keys outside `[A-Za-z0-9_.\-:]` — use `:`
          // as the composite-key separator (not `|`, which the store rejects).
          const key = `${account.brand.domain}:${account.operator}`;
          await ctx.store.put('accounts', key, {
            ...account,
            account_id: `acct_${key}`,
            status: 'active',
          });
        }
        return {
          accounts: params.accounts.map(account => ({
            brand: account.brand,
            operator: account.operator,
            account_id: `acct_${account.brand.domain}:${account.operator}`,
            status: 'active' as const,
            action: 'created' as const, // required — one of 'created'|'updated'|'unchanged'|'failed'
          })),
        };
      },

      async syncGovernance(params, ctx) {
        for (const acc of params.accounts) {
          const key = `${acc.account.brand.domain}:${acc.account.operator}`;
          await ctx.store.put('governance', key, {
            governance_agents: acc.governance_agents,
          });
        }
        return {
          status: 'synced' as const,
          governance_agents: params.accounts.flatMap(a => a.governance_agents),
        };
      },
    },

    brandRights: {
      async getBrandIdentity(params) {
        if (params.brand_id !== 'acme_outdoor') {
          return adcpError('BRAND_NOT_FOUND', {
            message: `Brand ${params.brand_id} is not managed by this agent`,
          });
        }
        return {
          brand_id: 'acme_outdoor',
          house: { domain: 'acme.example', name: 'Acme Corporation' },
          names: [{ en_US: 'Acme Outdoor' }, { en: 'Acme Outdoor' }],
          logos: [
            {
              url: 'https://cdn.acme.example/logo.svg',
              orientation: 'horizontal',
              background: 'transparent',
              variant: 'primary',
              width: 512,
              height: 128,
            },
          ],
        };
      },

      async getRights(params) {
        return {
          rights: [
            {
              rights_id: 'img_gen_standard',
              brand_id: 'acme_outdoor',
              name: 'AI image generation — standard',
              available_uses: ['ai_generated_image', 'commercial'],
              pricing_options: [
                {
                  pricing_option_id: 'monthly_standard',
                  model: 'flat_rate',
                  price: 2500,
                  currency: 'USD',
                  uses: ['ai_generated_image', 'commercial'],
                  period: 'monthly',
                },
              ],
            },
          ],
        };
      },

      async acquireRights(params, ctx) {
        const campaignEnd = new Date(params.campaign?.end_date ?? 0);
        if (campaignEnd < new Date()) {
          return adcpError('INVALID_REQUEST', {
            message: 'Campaign end_date is in the past',
            field: 'campaign.end_date',
          });
        }

        // Governance check — REQUIRED before issuing a rights license. Acquiring
        // rights is a spending event; the `brand_rights/governance_denied` scenario
        // expects GOVERNANCE_DENIED when the buyer's plan denies the spend.
        // 1. Look up the governance agent the buyer registered via sync_governance
        //    (accountKey = brand.domain + operator, stored by syncAccounts/syncGovernance).
        // 2. Call check_governance on it; propagate findings on denial.
        if (!params.account?.brand?.domain || !params.account?.operator) {
          return adcpError('INVALID_REQUEST', {
            message: 'acquire_rights requires account.brand.domain and account.operator',
            field: 'account',
          });
        }
        const accountKey = `${params.account.brand.domain}:${params.account.operator}`;
        const registration = await ctx.store.get('governance', accountKey);
        if (registration?.governance_agents?.length) {
          const { checkGovernance } = await import('@adcp/sdk'); // buyer-side helper
          const plan = await checkGovernance({
            agentUrl: registration.governance_agents[0].url,
            plan_id: params.plan_id ?? registration.plan_id,
            caller: { role: 'brand_agent', id: AGENT_URL },
            tool: 'acquire_rights',
            payload: {
              rights_id: params.rights_id,
              pricing_option_id: params.pricing_option_id,
              total_cost: { amount: 2500, currency: 'USD' },
            },
          });
          if (plan.status === 'denied') {
            return adcpError('GOVERNANCE_DENIED', {
              message: plan.explanation ?? 'Governance agent denied this rights acquisition.',
              findings: plan.findings ?? [], // propagate verbatim
            });
          }
          // status === 'conditions' → you may attach conditions, or deny in strict mode
          // status === 'approved'  → fall through to issue the grant
        }

        const grantId = `grant_${Date.now()}`;
        // Persist params.revocation_webhook against grantId so you can call it
        // if you later need to revoke (credential rotation, terms violation).
        return {
          rights_id: params.rights_id,
          status: 'acquired',
          brand_id: 'acme_outdoor',
          terms: {
            pricing_option_id: 'monthly_standard', // required per rights-terms.json
            amount: 2500, // required
            currency: 'USD', // required
            uses: params.campaign?.uses ?? [], // required
            countries: ['US', 'CA'],
            exclusivity: { scope: 'non_exclusive', countries: ['US', 'CA'] }, // object, not string
          },
          generation_credentials: [],
          rights_constraint: {
            rights_id: params.rights_id, // required — NOT brand_id
            rights_agent: { url: AGENT_URL, id: 'acme_outdoor' }, // required — {url, id}
            uses: params.campaign?.uses ?? [], // required
          },
          // URL you host — buyer POSTs creative-approval-request here for review.
          // `credentials` MUST be ≥32 chars (spec: push-notification-config.json
          // minLength: 32). `randomUUID().replace(/-/g, '')` produces 32 hex chars.
          approval_webhook: {
            url: `https://brand.example/webhooks/approval/${grantId}`,
            authentication: {
              schemes: ['Bearer'],
              credentials: randomUUID().replace(/-/g, ''), // 32-char high-entropy token
            },
          },
        };
      },
    },
  })
);
```

The skill contains everything you need. Do not read additional docs before writing code.

## Idempotency & Auth

For brand-rights only `acquire_rights` is mutating (`get_brand_identity` and `get_rights` are reads). Wire `createIdempotencyStore({ backend: memoryBackend(), ttlSeconds: 86400 })` into `createAdcpServer({ idempotency })` once — framework handles `INVALID_REQUEST` / `IDEMPOTENCY_CONFLICT` / `IDEMPOTENCY_EXPIRED` / replay-with-`replayed:true` / atomic-claim. See [`skills/build-seller-agent/SKILL.md`](../build-seller-agent/SKILL.md) § Idempotency for the full framework contract.

Authentication is mandatory (otherwise `security_baseline` fails). Minimum viable:

```typescript
import { serve } from '@adcp/sdk';
import { verifyApiKey } from '@adcp/sdk/server';

serve(createAgent, {
  authenticate: verifyApiKey({
    keys: { 'compliance-runner': { principal: 'compliance-runner' } }, // replace with db-backed lookup in prod
  }),
});
```

For OAuth, `anyOf(verifyApiKey, verifyBearer)` composition, or `publicUrl` + `protectedResource` see [seller skill § Protecting your agent](../build-seller-agent/SKILL.md#protecting-your-agent).

## Validate Locally

**Full validation checklist:** [docs/guides/VALIDATE-YOUR-AGENT.md](../../docs/guides/VALIDATE-YOUR-AGENT.md). Brand-rights-specific commands:

```bash
# Boot
npx tsx agent.ts &

# Happy path — brand_rights bundle (includes governance_denied sub-scenario)
npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp brand_rights --auth $TOKEN

# Cross-cutting obligations
npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp \
  --storyboards security_baseline,idempotency,schema_validation --auth $TOKEN

# Revocation webhook conformance (if you emit revocations)
npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp webhook_emission \
  --webhook-receiver --auth $TOKEN

# Rejection-surface fuzz
npx @adcp/sdk@latest fuzz http://localhost:3001/mcp --auth-token $TOKEN
```

Common failure decoder:

- `exclusivity: 'non_exclusive'` (string) → must be object `{ scope, countries }` — see § Concept model
- `available_uses` enum mismatch → `right-use.json` enum is the source of truth; includes `ai_generated_image` in AdCP 3.0+
- `acquire_rights` rejected with `Invalid input` → buyer omitted required `revocation_webhook: { url }`

**Keep iterating until all steps pass.** Can't bind ports? `npm run compliance:skill-matrix -- --filter brand-rights` runs an isolated end-to-end test.

## Common Mistakes

| Mistake                                                 | Fix                                                                                                                                                                                   |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `names: [{name, language}]`                             | `names` is an array of locale-keyed objects: `[{en_US: "Acme"}]`                                                                                                                      |
| `pricing_options` using `pricing_model`/`fixed_price`   | Schema uses `model` + `price` + `currency` + `uses`                                                                                                                                   |
| `uses` containing `digital_display` etc.                | Only values from `right-use` enum (likeness, voice, ai_generated_image, commercial, ...)                                                                                              |
| `logos` with `format: 'png'`                            | Use `orientation`, `background`, `variant`, plus optional `width`/`height` — derive format from the URL extension                                                                     |
| Acquire rights returning `status: 'active'`             | Valid values are `acquired`, `pending_approval`, `rejected`                                                                                                                           |
| Treating `approval_webhook` as a URL the buyer supplies | The seller _returns_ `approval_webhook` in `acquire_rights` response. The buyer POSTs `creative-approval-request` to that URL later — your agent hosts the endpoint.                  |
| Shipping a concrete `creative-approval-request` shape   | Spec names the payload but has not published the schema (see https://github.com/adcontextprotocol/adcp/issues/2253). Treat the body as TBD; don't lock buyers into an invented shape. |
| Dropping `context` from responses                       | Framework echoes it automatically — don't read or write it yourself                                                                                                                   |

## Storyboards

| Storyboard     | Tests                                                                                                                         |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `brand_rights` | Discover brand → browse rights → acquire license → enforce expired campaigns (update/approval covered once spec schemas land) |

## Reference

- `storyboards/brand_rights.yaml` — full brand rights storyboard
- `schemas/cache/latest/brand/` — JSON schemas (ground truth for request/response shapes)
- `docs/guides/BUILD-AN-AGENT.md` — SDK patterns
- `docs/TYPE-SUMMARY.md` — curated type signatures
- `docs/llms.txt` — full protocol reference