git:20260502.522015d to git:20260503.91c1ff8

57 added, 599 removed. Audit A to B.

---
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.
+ description: Use when building an AdCP brand rights agent — a platform that represents brand identity, licenses rights (image usage, logo placement, AI generation), and approves 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
+ ## Pick your fork target
- Single `.ts` file, one `createAdcpServer` call with a `brandRights` domain group. The framework:
+ | Specialism | Status | Fork this | Storyboard |
+ | --- | --- | --- | --- |
+ | `brand-rights` | stable | [`hello_seller_adapter_multi_tenant.ts`](../../examples/hello_seller_adapter_multi_tenant.ts) — `brandRights` block | `brand_rights` |
- - 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
+ The multi-tenant adapter is the canonical fork target. Its `brandRights` block implements all five operations (`getBrandIdentity`, `getRights`, `acquireRights`, `updateRights`, `reviewCreativeApproval`) plus the cross-specialism governance check (`enforceGovernance`) called from `acquireRights`.
- Creative-approval webhooks are implemented as a regular outbound HTTP call — outside the MCP server, after you accept `acquire_rights`.
+ ### What to delete if you're single-specialism brand-rights
- ```typescript
- import {
- createAdcpServer,
- serve,
- adcpError,
- createIdempotencyStore,
- memoryBackend,
- } from '@adcp/sdk/server/legacy/v5';
+ **Forking the multi-tenant adapter for a single specialism? Delete these blocks first** — leaning on stable symbol names rather than line numbers (the adapter evolves; greppable identifiers don't):
- // 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)
- });
+ - The `campaignGovernance = defineCampaignGovernancePlatform({ ... })` block (the entire `sync_plans` / `check_governance` / `report_plan_outcome` / `get_plan_audit_logs` surface)
+ - The `propertyLists = definePropertyListsPlatform({ ... })` block (property-lists CRUD)
+ - The `private async enforceGovernance(...)` helper method and its **call site inside `acquireRights`** — the two lines `const denial = await this.enforceGovernance(tenant, ctx, offering, req); if (denial) return denial;`. Without `campaignGovernance` co-resident, the in-process dispatch has nothing to call. Single-specialism adopters who need governance dial out to a registered governance agent's URL via the `@adcp/sdk` client.
+ - The `governanceBindings: Map<string, GovernanceBinding>` field on `TenantState`, the `interface GovernanceBinding`, and the `syncGovernanceRow` callback on `createTenantStore` (governance bindings have nothing to register against).
+ - The validation seam block in `acquireRights` that requires `campaign.estimated_impressions` when a governance binding exists — without bindings this constraint never fires.
- serve(() =>
- createAdcpServer({
- name: 'Acme Brand Rights Agent',
- version: '1.0.0',
- capabilities: { major_versions: [3] },
- idempotency,
+ **Keep**: the `accounts` / `createTenantStore` block (translates to single-tenant by passing one tenant entry — needed for tenant isolation), `agentRegistry`, the `brandRights` block, `getTenant(ctx)` resolution.
- // Principal scoping for idempotency. MUST never return undefined — or
- // every mutating request rejects as SERVICE_UNAVAILABLE.
- resolveSessionKey: () => 'default-principal',
+ The storyboard tests identity discovery → rights search → acquisition → enforcement (including expired-campaign denial).
- // 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'
- })),
- };
- },
+ For exact response shapes, error codes, and optional fields, `docs/llms.txt` is the canonical reference.
- 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),
- };
- },
- },
+ ## When to use this skill
- 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,
- },
- ],
- };
- },
+ - User wants to build an agent that manages brand identity and licensing
+ - User mentions brand rights, brand guidelines, creative approval, or licensing (Warner Bros Discovery, Disney rights pipelines)
+ - User references `get_brand_identity`, `get_rights`, `acquire_rights`, `update_rights`, or `creative_approval`
- 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',
- },
- ],
- },
- ],
- };
- },
+ **Not this skill:**
- 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',
- });
- }
+ - Selling ad inventory → `skills/build-seller-agent/`
+ - Managing creative formats/library → `skills/build-creative-agent/`
+ - Evaluating media buys → `skills/build-governance-agent/`
- // 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
- }
+ ## Cross-cutting rules
- 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
- },
- },
- };
- },
- },
- })
- );
- ```
+ Every brand-rights agent hits the cross-cutting rules in [`../cross-cutting.md`](../cross-cutting.md). One brand-rights-specific note:
- The skill contains everything you need. Do not read additional docs before writing code.
+ ### `creative_approval` is webhook-only
- ## Idempotency & Auth
+ The spec models creative approval as an HTTP POST from the buyer to the `approval_webhook` URL the seller returned in `acquire_rights`. There is no inbound MCP/A2A tool for `creative_approval` — wire an HTTP receiver and dispatch to `brandRights.reviewCreativeApproval`.
- 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.
+ The receiver must validate `idempotency_key` itself (the framework's auto-idempotency middleware applies to MCP/A2A tools, not arbitrary HTTP receivers) and replay the cached verdict on resubmission.
- Authentication is mandatory (otherwise `security_baseline` fails). Minimum viable:
+ ## Tool surface
- ```typescript
- import { serve } from '@adcp/sdk';
- import { verifyApiKey } from '@adcp/sdk/server';
+ | Operation | How to implement |
+ | --- | --- |
+ | `get_brand_identity` | `brandRights.getBrandIdentity` handler — locale-keyed brand name, domain, logos, house identity |
+ | `get_rights` | `brandRights.getRights` handler — list licensable rights with pricing + use cases |
+ | `acquire_rights` | `brandRights.acquireRights` handler (mutating) — returns `approval_webhook` URL |
+ | `update_rights` | `brandRights.updateRights` handler (mutating) — for campaign-end revocation, scope changes |
+ | `creative_approval` | HTTP receiver at the `approval_webhook` URL; dispatch to `brandRights.reviewCreativeApproval` |
- serve(createAgent, {
- authenticate: verifyApiKey({
- keys: { 'compliance-runner': { principal: 'compliance-runner' } }, // replace with db-backed lookup in prod
- }),
- });
- ```
+ ## Specialism deltas
- For OAuth, `anyOf(verifyApiKey, verifyBearer)` composition, or `publicUrl` + `protectedResource` see [seller skill § Protecting your agent](../build-seller-agent/SKILL.md#protecting-your-agent).
+ **`brand-rights`** —
- ## Validate Locally
+ - **Brand definition**: name (locale-keyed for i18n), domain, logos, house identity, languages/markets
+ - **Rights catalog**: image usage, AI generation, logo placement, talent likeness; each with pricing (flat_rate, cpm) and uses (likeness, voice, commercial, ai_generated_image)
+ - **Approval criteria**: auto-approve (basic checks), guidelines check (brand standards), human review (queue for manual)
+ - **Revocation webhook**: emit when a campaign ends or rights are revoked mid-campaign — same `operation_id` stability rules as other webhooks (see `../cross-cutting.md`)
+ - **Governance denial**: the storyboard exercises `GOVERNANCE_DENIED` on `acquire_rights` when the requested use exceeds the licensed scope; map this error code, don't substitute a generic `INVALID_REQUEST`
- **Full validation checklist:** [docs/guides/VALIDATE-YOUR-AGENT.md](../../docs/guides/VALIDATE-YOUR-AGENT.md). Brand-rights-specific commands:
+ ## Validate locally
```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
+ # Run the fork-matrix gate (tsc strict)
+ npm run compliance:fork-matrix -- --test-name-pattern="hello-seller-adapter-multi-tenant"
- # Rejection-surface fuzz
- npx @adcp/sdk@latest fuzz http://localhost:3001/mcp --auth-token $TOKEN
+ # Run your forked agent against the brand_rights storyboard
+ adcp storyboard run http://127.0.0.1:3003/mcp brand_rights \
+ --bearer "$ADCP_AUTH_TOKEN" --include-bundles --json
```
- 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
+ The fork-matrix gate is the three-gate contract from [`docs/guides/EXAMPLE-TEST-CONTRACT.md`](../../docs/guides/EXAMPLE-TEST-CONTRACT.md). The multi-tenant adapter currently runs the strict-tsc gate only (no brand-rights mock-server today); storyboard-grader gates land alongside the next mock-server family.
- | 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 |
+ For deeper validation: [`docs/guides/VALIDATE-YOUR-AGENT.md`](../../docs/guides/VALIDATE-YOUR-AGENT.md).
- ## Storyboards
+ ## Common shape gotchas
- | Storyboard | Tests |
- | -------------- | ----------------------------------------------------------------------------------------------------------------------------- |
- | `brand_rights` | Discover brand → browse rights → acquire license → enforce expired campaigns (update/approval covered once spec schemas land) |
+ `acquire_rights` response includes `approval_webhook` URL — buyer POSTs to this to submit creatives, you don't pull. `sync_accounts` rows require `action: 'created' | 'updated' | 'unchanged' | 'failed'`. Brand name is locale-keyed (`{ "en-US": "...", "es-MX": "..." }`), not a bare string. See [`../SHAPE-GOTCHAS.md`](../SHAPE-GOTCHAS.md).
- ## Reference
+ ## Migration notes
- - `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
+ - 6.6 → 6.7: `update_rights` wired as a first-class tool + `creative_approval` webhook builders shipped in #1349. See [`docs/migration-6.6-to-6.7.md`](../../docs/migration-6.6-to-6.7.md).
+ - 4.x → 5.x: [`docs/migration-4.x-to-5.x.md`](../../docs/migration-4.x-to-5.x.md)