build-brand-rights-agent · git:20260430.6066a7a · 2026-04-30 · sha256 998208a38ec61b80

build-brand-rights-agent git:20260430.6066a7aA

Immutable. This exact content is served forever at /api/v1/blob/998208a38ec61b80.

---
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`, or `acquire_rights`

**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                                                                                                                                                                                |
| -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ |
| `brand-rights` | stable | First-class tools: `get_brand_identity`, `get_rights`, `acquire_rights`. `update_rights` and `creative_approval` are spec-tracked but not schema-backed (see Protocol Status below). | [§ 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` — and `update_rights` / `creative_approval` once their schemas land). Wire `createIdempotencyStore` into `createAdcpServer({ idempotency })`.
- **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

Three tools are first-class in the `brandRights` domain group. Two additional operations are spec-tracked but not yet schema-backed.

| 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`      | Spec prose only — no JSON schema              | HTTP endpoint outside MCP surface                                 |
| `creative_approval`  | Webhook contract, no JSON schema              | HTTP endpoint your agent hosts (URL returned in `acquire_rights`) |

Upstream tracking for the two schema gaps: https://github.com/adcontextprotocol/adcp/issues/2253. The SDK will register handlers for both once schemas land.

<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? }
```

**Creative approval.** The `approval_webhook` in your `acquire_rights` response is a URL **your agent hosts** — the buyer POSTs `creative-approval-request` there when a generated creative needs review. Payload shapes (`creative-approval-request`/`creative-approval-response`) are spec-tracked but not yet schema-published (adcontextprotocol/adcp#2253); accept at minimum the creative reference and a `rights_grant_id`, return a decision.

**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