build-brand-rights-agent · git:20260428.88e3b02 · 2026-04-28 · sha256 cf827daf338cc929
build-brand-rights-agent git:20260428.88e3b02A
Immutable. This exact content is served forever at /api/v1/blob/cf827daf338cc929.
---
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';`
## 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 } from '@adcp/sdk';
import { createIdempotencyStore, memoryBackend } from '@adcp/sdk/server';
// 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