Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/sync-skills-from-tarball.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@adcp/client": minor
---

feat(sync): pull canonical agent skills from the protocol tarball

`scripts/sync-schemas.ts` now extracts protocol-managed skills (`call-adcp-agent`, `adcp-media-buy`, `adcp-creative`, `adcp-signals`, `adcp-governance`, `adcp-si`, `adcp-brand`) from the published `/protocol/<version>.tgz` bundle alongside schemas and compliance, into `@adcp/client/skills/<name>/`. The sync is **manifest-driven and per-name** — only directories enumerated in `manifest.contents.skills` are overwritten, so SDK-local skills (`build-seller-agent`, `build-creative-agent`, etc.) stay untouched.

The buyer-side `call-adcp-agent` skill is now sourced from the spec repo (adcontextprotocol/adcp#3097) rather than maintained as a local copy — version-pinned to `ADCP_VERSION`, Sigstore-verified via the same cosign path as schemas, no manual sync.

Adds an `ADCP_BASE_URL` env override (defaults to `https://adcontextprotocol.org`) so CI / local-dev can point sync at a fake CDN for testing.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,6 @@ IMPLEMENTATION_PLAN.md

# Claude Code runtime artifacts (.claude/skills/ is tracked on purpose)
.claude/scheduled_tasks.lock

# Snapshot artifacts created by sync-schemas replaceTree
skills/*.previous/
89 changes: 88 additions & 1 deletion scripts/sync-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ import { spawnSync } from 'child_process';
import path from 'path';
import * as tar from 'tar';

const ADCP_BASE_URL = 'https://adcontextprotocol.org';
const ADCP_BASE_URL = process.env.ADCP_BASE_URL || 'https://adcontextprotocol.org';
const REPO_ROOT = path.join(__dirname, '..');
const SCHEMA_CACHE_DIR = path.join(REPO_ROOT, 'schemas/cache');
const COMPLIANCE_CACHE_DIR = path.join(REPO_ROOT, 'compliance/cache');
const SKILLS_DIR = path.join(REPO_ROOT, 'skills');
const REGISTRY_SPEC_PATH = path.join(REPO_ROOT, 'schemas/registry/registry.yaml');

// Sigstore keyless identity used by the upstream release workflow (adcontextprotocol/adcp#2273).
Expand Down Expand Up @@ -210,6 +211,85 @@ async function verifyCosignSignature(tgzPath: string, version: string): Promise<
console.log(`✅ cosign signature verified (identity: adcontextprotocol/adcp release workflow).`);
}

/**
* Copy a skill directory tree into the SDK, replacing the destination but
* skipping nested `schemas/` subdirs (duplicates of `schemas/cache/<version>/`).
*/
function copySkillTree(srcDir: string, destDir: string): void {
if (existsSync(destDir)) {
// Snapshot the outgoing tree the same way replaceTree does so the
// schema-diff helper can pick up changes between syncs.
const previous = `${destDir}.previous`;
if (existsSync(previous)) rmSync(previous, { recursive: true, force: true });
renameSync(destDir, previous);
}
mkdirSync(destDir, { recursive: true });
copyTreeFiltered(srcDir, destDir);
}

function copyTreeFiltered(srcDir: string, destDir: string): void {
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
const src = path.join(srcDir, entry.name);
const dst = path.join(destDir, entry.name);
if (entry.isDirectory()) {
// The spec repo's per-protocol skills bundle a copy of the AdCP schemas
// for self-contained agent consumption; the SDK has them in
// `schemas/cache/` already, so this would just duplicate ~1.4MB per
// protocol. Skip.
if (entry.name === 'schemas') continue;
mkdirSync(dst, { recursive: true });
copyTreeFiltered(src, dst);
} else {
copyFileSync(src, dst);
}
}
}

/**
* Sync protocol-managed skills from the extracted bundle into the SDK's
* top-level `skills/` tree. Driven by `manifest.contents.skills` (a list of
* skill directory names) so we only overwrite the entries the spec repo
* publishes — leaves SDK-local skills (`build-seller-agent/`, etc.) alone.
*/
function syncSkillsFromBundle(extractRoot: string): void {
const skillsInBundle = path.join(extractRoot, 'skills');
const manifestPath = path.join(extractRoot, 'manifest.json');
if (!existsSync(skillsInBundle) || !existsSync(manifestPath)) {
return;
}
let manifest: unknown;
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
} catch (err) {
console.warn(`⚠️ Skill sync skipped: manifest unparseable (${err instanceof Error ? err.message : err}).`);
return;
}
const skillNames = (manifest as { contents?: { skills?: unknown } }).contents?.skills;
if (!Array.isArray(skillNames)) {
// Older tarballs predate manifest.contents.skills enumeration. Skip silently.
return;
}
let synced = 0;
for (const name of skillNames) {
if (typeof name !== 'string' || name.includes('/') || name.includes('..')) {
continue;
}
const src = path.join(skillsInBundle, name);
const dst = path.join(SKILLS_DIR, name);
if (!existsSync(src) || !statSync(src).isDirectory()) continue;
// Skip nested `schemas/` subdirs — those duplicate `schemas/cache/<version>/`
// already extracted from the same tarball. Per-protocol skills in the spec
// repo bundle them for self-contained agent consumption; the SDK has them
// in `schemas/cache/` already, so re-copying inflates the package by ~1.4MB
// per protocol with no functional gain.
copySkillTree(src, dst);
synced++;
}
if (synced > 0) {
console.log(`📁 Skills: ${SKILLS_DIR} (${synced} protocol-managed)`);
}
}

/**
* Fetch /protocol/{version}.tgz, verify sha256, and extract schemas + compliance
* into their cache directories. Returns true on success.
Expand Down Expand Up @@ -258,6 +338,13 @@ async function syncFromTarball(version: string): Promise<boolean> {
replaceTree(path.join(extractRoot, 'schemas'), path.join(SCHEMA_CACHE_DIR, version));
replaceTree(path.join(extractRoot, 'compliance'), path.join(COMPLIANCE_CACHE_DIR, version));

// Skills sync is manifest-driven and per-name. SDK-local skills like
// build-seller-agent/ stay untouched; protocol-canonical ones (the
// call-adcp-agent buyer skill plus per-protocol skills) are kept aligned
// with the pinned spec version. Older tarballs (no manifest.contents.skills
// array) are silently skipped — the SDK-local copies stay as-is.
syncSkillsFromBundle(extractRoot);

// Refs inside the tarball point to /schemas/latest/; rewrite for pinned versions.
const schemaDest = path.join(SCHEMA_CACHE_DIR, version);
const indexJson = JSON.parse(readFileSync(path.join(schemaDest, 'index.json'), 'utf8'));
Expand Down
202 changes: 202 additions & 0 deletions skills/adcp-brand/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
---
name: adcp-brand
description: Execute AdCP Brand Protocol operations with brand agents - get brand identity data, search for licensable rights, acquire rights for campaigns, and manage existing grants. Use when users want to look up brand identities, find talent or IP for licensing, or manage rights grants.
---

# AdCP Brand Protocol

This skill enables you to execute the AdCP Brand Protocol with brand agents. The Brand Protocol provides access to brand identity, creative guidelines, and licensable rights (talent, IP, content).

> **Buyer-side basics** — idempotency replay, `oneOf` variants, async `status:'submitted'` polling, error recovery from `adcp_error.issues[]` — live in `skills/call-adcp-agent/SKILL.md`. This skill covers per-task semantics only.

## Overview

The Brand Protocol provides 4 standardized tasks:

| Task | Purpose | Response Time |
|------|---------|---------------|
| `get_brand_identity` | Get brand identity and guidelines | ~1-3s |
| `get_rights` | Search licensable rights | ~1-5s |
| `acquire_rights` | Acquire rights for a campaign | ~1-10s |
| `update_rights` | Modify an existing grant | ~1-5s |

## Typical Workflow

### Brand Identity Lookup
1. **Get identity**: `get_brand_identity` with brand domain and optional field filter
2. **Use data**: Apply colors, logos, tone, guidelines to creative generation

### Rights Licensing
1. **Search rights**: `get_rights` with natural language query and use types
2. **Review options**: Evaluate matches by pricing, availability, compatibility
3. **Acquire**: `acquire_rights` with selected pricing option and campaign details
4. **Manage**: `update_rights` to extend, adjust caps, or pause/resume

---

## Task Reference

### get_brand_identity

Get brand identity data from a brand agent.

**Request:**
```json
{
"brand_id": "athlete-jane-doe",
"fields": ["description", "logos", "colors", "tone"],
"use_case": "creative_production",
"authorized": true
}
```

**Key fields:**
- `brand_id` (string, required): Brand identifier within the agent's roster
- `fields` (array, optional): Sections to include — `description`, `industry`, `keller_type`, `logos`, `colors`, `fonts`, `visual_guidelines`, `tone`, `tagline`, `voice_synthesis`, `assets`, `rights`. Omit for all.
- `use_case` (string, optional): Intended use — `endorsement`, `voice_synthesis`, `likeness`, `creative_production`, `media_planning`
- `authorized` (boolean, optional): Sandbox only — simulate authorized access to see protected fields. Real agents use OAuth. Default false.

**Response contains:**
- `brand`: Brand identity object with requested fields
- Public fields (always available): `description`, `industry`, `logos` (public subset)
- Protected fields (require authorization): `colors`, `fonts`, `tone`, `voice_synthesis`, `visual_guidelines`, full `assets`

---

### get_rights

Search for licensable rights (talent, IP, content) from a brand agent.

**Request:**
```json
{
"query": "Dutch athlete for restaurant brand in Amsterdam, budget 400 EUR/month",
"uses": ["likeness", "endorsement"],
"buyer_brand": {
"domain": "restaurant.nl"
},
"countries": ["NL"],
"include_excluded": false
}
```

**Key fields:**
- `query` (string, required): Natural language description of desired rights
- `uses` (array, required): Rights uses — `likeness`, `voice`, `name`, `endorsement`
- `buyer_brand` (object, optional): Buyer brand for compatibility filtering — `{ domain, brand_id }`
- `countries` (array, optional): Countries where rights are needed (ISO 3166-1 alpha-2)
- `brand_id` (string, optional): Search within a specific brand only
- `include_excluded` (boolean, optional): Include filtered-out results with reasons. Default false.

**Response contains:**
- `rights`: Array of matching rights offerings with:
- `rights_id`: Use in `acquire_rights`
- `brand_id`, `name`, `description`: Who/what the rights cover
- `uses`: Available use types
- `pricing_options`: Array with `pricing_option_id`, `price`, `currency`, `period`
- `availability`: Geographic and temporal restrictions
- `exclusions`: Any brand/category conflicts

---

### acquire_rights

Acquire rights from a brand agent for a campaign.

**Request:**
```json
{
"rights_id": "rights_jane_doe_endorsement",
"pricing_option_id": "monthly_standard",
"buyer": {
"domain": "restaurant.nl"
},
"campaign": {
"description": "Social media campaign featuring athlete endorsement for Amsterdam restaurant launch",
"uses": ["likeness", "endorsement"],
"countries": ["NL"],
"estimated_impressions": 500000,
"start_date": "2025-03-01",
"end_date": "2025-06-30"
}
}
```

**Key fields:**
- `rights_id` (string, required): From `get_rights` response
- `pricing_option_id` (string, required): Selected pricing option
- `buyer` (object, required): Buyer brand identity — `{ domain, brand_id }`
- `campaign` (object, required): Campaign details for rights clearance
- `description` (string, required): How the rights will be used
- `uses` (array, required): Rights uses for this campaign
- `countries` (array, optional): Campaign countries
- `estimated_impressions` (integer, optional): Estimated total impressions
- `start_date`, `end_date` (string, optional): Campaign dates (YYYY-MM-DD)

**Response contains:**
- `status`: `acquired`, `pending_approval`, or `rejected`
- `rights_grant_id`: Grant identifier (if acquired)
- `generation_credentials`: Credentials for AI generation (voice synthesis, likeness, etc.)
- `rejection_reason`: Why the request was rejected (category conflict, exclusivity, etc.)

---

### update_rights

Update an existing rights grant — extend dates, adjust impression caps, or pause/resume.

**Request:**
```json
{
"rights_id": "grant_abc123",
"end_date": "2025-09-30",
"impression_cap": 1000000,
"paused": false
}
```

**Key fields:**
- `rights_id` (string, required): Rights grant identifier from `acquire_rights`
- `end_date` (string, optional): New end date (must be >= current end date)
- `impression_cap` (number, optional): New impression cap (must be >= current)
- `paused` (boolean, optional): Pause or resume the grant

---

## Key Concepts

### Public vs Protected Fields

Brand agents distinguish between public and protected data:
- **Public**: Available without authorization — basic description, industry, public logos
- **Protected**: Requires OAuth or authorized flag — colors, fonts, tone, voice synthesis credentials, full asset library

### Rights Use Types

- `likeness`: Use of a person's visual likeness (photos, AI-generated images)
- `voice`: Voice synthesis or audio recording rights
- `name`: Use of a person's name in advertising
- `endorsement`: Endorsement/testimonial rights

### Rights Clearance

`acquire_rights` checks:
1. Brand/category compatibility (no competitor conflicts)
2. Geographic availability
3. Temporal availability
4. Existing exclusivity agreements

Results: `acquired` (immediate), `pending_approval` (human review), or `rejected` (with reason).

---

## Error Handling

Common error codes:

- `BRAND_NOT_FOUND`: Invalid brand_id
- `RIGHTS_NOT_FOUND`: Invalid rights_id
- `PRICING_OPTION_NOT_FOUND`: Invalid pricing_option_id
- `CATEGORY_CONFLICT`: Buyer brand conflicts with existing agreements
- `GEOGRAPHIC_RESTRICTION`: Rights not available in requested countries
- `AUTHORIZATION_REQUIRED`: Protected fields require OAuth
Loading
Loading