Build holdco agent
Skill adcontextprotocol/adcp-client/skills/build-holdco-agent
Use when building an AdCP agency / holdco hub — one server hosting multiple specialism agents (governance + brand-rights + property-lists, etc.) with per-tenant data isolation. Distinct from `skills/build-seller-agent/` (single-specialism) and `decisioning-platform-multi-tenant.ts` (host-routed tenancy).From its SKILL.md
npx -y skills add adcontextprotocol/adcp-client --skill build-holdco-agentAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 18 stars18 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
19.1 KB, as published. Nobody here has run it
Build an Agency / Holdco Hub Agent
What you're building
One AdCP server, multiple specialism interfaces, multiple tenants whose data never crosses. The agency holdco shape: a parent company hosts governance, brand-rights, property-lists, and sometimes signals, all on one endpoint, with per-operator tenant routing so each operating company sees only its own data.
Distinct from two adjacent patterns — pick by deployment shape:
| Pattern | Surface | When |
|---|---|---|
| Single-specialism, single-tenant | skills/build-seller-agent/, skills/build-signals-agent/, etc. | One agent, one role, one tenant |
| Multi-specialism, single-tenant | This skill | One process, multiple specialism interfaces, no tenant partitioning |
| Single-specialism, multi-tenant (host-routed) | examples/decisioning-platform-multi-tenant.ts + TenantRegistry | One specialism, vhost-per-tenant (different agentUrls) |
| Multi-specialism, multi-tenant (account-routed) | This skill (full pattern) | Holdco hub: one URL, account.operator routes to tenant |
The reference adapter is examples/hello_seller_adapter_multi_tenant.ts — fork it.
When to use
- User wants one server hosting governance + brand-rights together (or any combination of specialisms)
- User describes an "agency hub", "holdco", "shared services platform", or "agency operating multiple brands"
- User mentions "tenant isolation" + "shared catalog" / "shared codebase" / "single deployment"
Not this skill:
- One specialism, multi-tenant via vhosts →
examples/decisioning-platform-multi-tenant.ts - Multiple specialisms but no tenant partitioning (single-tenant SaaS) → drop the tenant routing and follow the per-specialism skill for each interface
- One agent acting on behalf of multiple brands without specialism overlap → that's a multi-account single-specialism agent; use the per-specialism skill
Cross-cutting rules
Every holdco hub hits the cross-cutting rules in ../cross-cutting.md. The most relevant for a holdco (deep-linked to the rule):
- Account resolution —
createTenantStoreis the right answer for any multi-tenant adopter (covered in detail below) - Resolve-then-authorize — byte-equivalent errors on cross-tenant id lookups; the multi-tenant attack surface makes this load-bearing
ctx_metadatais not for credentials — particularly important for holdcos because per-tenant credentials must NOT travel throughctx_metadata; re-derive fromctx.authInfoper request
The shape: one DecisioningPlatform, three specialism interfaces
class HoldcoAdapter implements DecisioningPlatform<Config, TenantMeta> {
capabilities = {
specialisms: ['governance-spend-authority', 'property-lists', 'brand-rights'] as const,
config: {},
brand: { /* RequiredCapabilitiesFor<'brand-rights'> */ },
};
agentRegistry = /* BuyerAgentRegistry */;
accounts: AccountStore<TenantMeta> = createTenantStore({ /* see below */ });
campaignGovernance = defineCampaignGovernancePlatform({ /* ... */ });
propertyLists = definePropertyListsPlatform({ /* ... */ });
brandRights = defineBrandRightsPlatform({ /* ... */ });
private async enforceGovernance(tenant, ctx, offering, req): Promise<AcquireRightsRejected | null> {
/* cross-specialism dispatch — see below */
}
}
Each specialism interface is the same shape it would have in a single-specialism agent (reuse skills/build-governance-agent/, skills/build-brand-rights-agent/ for per-handler details). The hub-specific work is everything around them: tenancy, isolation, cross-specialism dispatch.
Account resolution + tenant-isolation gate (use createTenantStore)
The framework calls accounts.resolve(ref, ctx) once per request, and hub adapters historically had to hand-write three pieces:
- Path 1 — operator-routed resolution for tools that carry
accounton the wire (sync_accounts,sync_governance, governance, property-lists) - Path 2 — auth-derived resolution for no-account tools (
get_brand_identity,get_rights) - Per-entry tenant-isolation gate on
sync_accounts/sync_governance— verify each entry'soperatormaps to the buyer's authenticated home tenant before persisting, fail-closed when the auth principal isn't registered - A resolve posture — decide whether a buyer-supplied ref naming another tenant resolves or fails closed. This is the
refAccessfield and it is required with no default, because both answers are correct for some hubs. It is a separate decision from (3): the gate in (3) covers the sync tools only, whileresolveis the account path forcreate_media_buyandupdate_media_buy
All three live in createTenantStore<TTenant, TCtxMeta> (@adcp/sdk/server). Callbacks the adopter provides; gating logic the SDK owns:
import { createTenantStore, narrowAccountRef } from '@adcp/sdk/server';
accounts: AccountStore<TenantMeta> = createTenantStore<TenantState, TenantMeta>({
// REQUIRED, no default. 'auth-scoped' = a ref naming another tenant fails
// closed. Use it unless one credential is *supposed* to span tenants.
refAccess: 'auth-scoped',
resolveByRef: ref => { // Path 1 — operator (or account_id) on the wire
const r = narrowAccountRef(ref);
const tenantId = OPERATOR_TO_TENANT.get(r.operator ?? '');
return tenantId ? TENANTS.get(tenantId) ?? null : null;
},
resolveFromAuth: ctx => { // Path 2 — derive from authenticated buyer agent
const tenantId = ctx?.agent ? BUYER_HOME_TENANT.get(ctx.agent.agent_url) : undefined;
return tenantId ? TENANTS.get(tenantId) ?? null : null;
},
tenantId: tenant => tenant.id, // stable equality for the gate (NOT reference)
tenantToAccount: (tenant, ref, ctx) => { // project tenant + ref → Account
const r = narrowAccountRef(ref);
return {
id: tenant.id,
name: `${tenant.display_name} (${r?.operator ?? ctx?.agent?.agent_url})`,
status: 'active',
operator: r?.operator ?? ctx?.agent?.agent_url ?? 'derived',
ctx_metadata: { tenant_id: tenant.id, display_name: tenant.display_name },
sandbox: r?.sandbox ?? false, // SWAP: route to your sandbox backend on this flag
};
},
upsertRow: (tenant, ref, ctx) => { // sync_accounts — only in-tenant entries reach here
/* row-level upsert under tenant transaction; gate already filtered */
return { /* SyncAccountsResultRow */ };
},
syncGovernanceRow: (tenant, entry, ctx) => { // sync_governance — same gating
/* persist entry.governance_agents on tenant; helper strips credentials on echo */
return { /* SyncGovernanceResponseRow */ };
},
});
Pick your refAccess deliberately — it is the one field with no safe default:
resolve with a ref naming another tenant | Use when | |
|---|---|---|
'auth-scoped' | returns null → ACCOUNT_NOT_FOUND | Tenants are unrelated clients / competing agencies. Most hubs. |
'ref-routed' | returns that tenant's Account | One credential is supposed to span tenants (a single agency operating all of them) |
Getting this wrong in the 'ref-routed' direction means any authenticated tenant can name another tenant's account_id or operator and have create_media_buy run scoped to that account — cross-tenant spend, not just cross-tenant reads. If you genuinely need 'ref-routed', layer a resolve-presets guard (requireAccountMatch / requireAdvertiserMatch / requireOrgScope) on top via composeMethod.
What the helper does NOT guarantee:
refAccessgovernsresolveonly. Verifying the sync-tool gate tells you nothing aboutresolve. Under'ref-routed'there is no isolation onresolveat all — that is the mode's purpose, not a bug.- It does not scope your data layer.
tenantToAccountreturns whatever your callbacks hand it; ifresolveByRefreads a shared table without a tenant predicate, the helper cannot see that.
What the helper guarantees:
-
Cross-tenant entries never reach
upsertRow/syncGovernanceRow. The helper resolvesauthTenant = resolveFromAuth(ctx)once per request, then for each entry resolvesentryTenant = resolveByRef(ref)and emits a'failed'row withcode: 'PERMISSION_DENIED'whentenantId(authTenant) !== tenantId(entryTenant). Adopter callbacks see only in-tenant entries. -
Fail-closed when
resolveFromAuthreturns null (unknown principal, noagentRegistry, agent_url not in your home-tenant map): every entry failsPERMISSION_DENIED. Don't fork around this — the prior fail-OPEN shape (if (homeTenantId && entryTenant !== homeTenantId)) silently disabled isolation for credentials lacking a home-tenant binding. -
Construction refuses a missing or unrecognized
refAccess. TypeScript flags it, and the helper also throws at runtime so a JS adopter or anas anycast can't fall through to the permissive branch silently. -
accounts.upsertandaccounts.syncGovernanceare non-writable on the returned store. An adopter who writesaccounts.upsert = customHandlerafter construction gets aTypeError(in strict mode) instead of silently bypassing the gate. To extend withlist/reportUsage/getAccountFinancials, useObject.assign:accounts = Object.assign( createTenantStore<TenantState, TenantMeta>({...}), { list: async (filter, ctx) => ... } );
Sandbox lives in tenantToAccount. AccountReference.sandbox?: boolean flows through the projector. Two patterns: (1) flag the resolved Account so per-handler code routes reads/writes to a sandbox backend; (2) resolve to a separate sandbox tenant via resolveByRef reading ref.sandbox. Both are adopter-side decisions — the helper API doesn't take a sandbox parameter.
The pre-helper version of this skill documented the inlined gate pattern (with the fail-OPEN anti-pattern flagged in examples/CONTRIBUTING.md). That pattern is now superseded — adopters who genuinely need a custom gate write a plain AccountStore and own the security surface; everyone else uses createTenantStore.
Cross-specialism dispatch
When one specialism's handler needs another's logic (canonical case: brandRights.acquireRights consulting campaignGovernance.checkGovernance before granting rights), there is no ctx.platform.<specialism> accessor — the framework does not thread a separate platform handle on RequestContext. Two idiomatic patterns; pick by authoring style:
Pattern A — class instance + this (canonical for holdco hubs)
The reference adapter (examples/hello_seller_adapter_multi_tenant.ts) takes this path. Author the adapter as a class implementing DecisioningPlatform<TConfig, TCtxMeta>. Each specialism is a class field; cross-specialism calls go via this:
class HoldcoAdapter implements DecisioningPlatform<Config, TenantMeta> {
campaignGovernance = defineCampaignGovernancePlatform<TenantMeta>({ /* ... */ });
brandRights = defineBrandRightsPlatform<TenantMeta>({
acquireRights: async (req, ctx) => {
/* validation seams up front */
const denial = await this.enforceGovernance(tenant, ctx, offering, req);
if (denial) return denial;
/* rest of acquire flow */
},
});
private async enforceGovernance(tenant, ctx, offering, req) {
// Reads tenant.governanceBindings (registered via sync_governance).
// Calls this.campaignGovernance.checkGovernance internally.
// Returns AcquireRightsRejected on denial, null otherwise.
}
}
Extracting a private method (rather than inlining the 30-line block) gives you:
- Visible data flow at the call site
- A clean copy target for single-specialism adopters
- A place to document the same-tenant invariant:
getTenant(ctx)resolves once per request; both specialisms share it. If a future split lets brand-rights and governance live in different tenants, this in-process call no longer applies.
Pattern B — closure capture (functional authoring)
If you'd rather build specialisms as standalone factory results (no class), capture the sibling in the closure passed to the second factory. No runnable example ships for Pattern B — Pattern A is the canonical hub shape, and the multi-tenant adapter exercises the full surface; if you go functional, this snippet is the contract:
const campaignGovernance = defineCampaignGovernancePlatform<TenantMeta>({ /* ... */ });
const brandRights = defineBrandRightsPlatform<TenantMeta>({
acquireRights: async (req, ctx) => {
const govResp = await campaignGovernance.checkGovernance!(checkReq, ctx);
/* ... */
},
});
const platform: DecisioningPlatform<Config, TenantMeta> = {
capabilities: { /* ... */ },
accounts: { /* ... */ },
campaignGovernance,
brandRights,
};
The ! after checkGovernance is needed because the spec marks it optional on the interface; you know it's defined here because you defined it three lines up.
What both patterns share
- The
ctxyou forward to the sibling is the sameRequestContextyou received. Resolved account, agent, and authInfo carry through, so tenant invariants hold transitively. - In-process calls bypass wire-side validation, idempotency dedup, and the framework's mutating-tool annotations. That's correct — you're inside the seller's code, not handling a buyer request — but it means an in-process
checkGovernancewon't be re-deduped if the originating tool is already idempotency-protected. If you want buyer-side semantics for the call (e.g., the sibling specialism is hosted on a different tenant or a different process), don't reach forthis— dial out via@adcp/sdk's client to the registered agent URL. - ⚠️ Always document the "DO NOT copy this short-circuit into a single-specialism agent" warning on the helper's JSDoc. Single-specialism adopters who don't have a co-resident governance handler need to dial out via the @adcp/sdk client to the registered governance agent's URL — supplying credentials that this hello pattern (intentionally) drops.
What sync_governance ACTUALLY persists
The wire payload supports up to 10 governance agents per account, with category scoping and write-only authentication.credentials. Hello-adapter convention is to record only the first agent's URL + plan binding. Production adopters MUST persist credentials and present them on outbound calls — silently dropping them ships unauthenticated cross-agent requests if real dial-out is added later.
Spec-correct denial
When governance denies an acquire_rights, return AcquireRightsRejected (the spec's first-class denial arm), NOT a thrown GOVERNANCE_DENIED error code:
return {
rights_id: offering.rights_id,
status: 'rejected',
brand_id: offering.brand_id,
reason: `Denied by governance plan ${planId}: ${govResp.explanation}`,
...(govResp.findings?.length && {
suggestions: govResp.findings.map(f => `[${f.severity}] ${f.category_id}: ${f.explanation}`),
}),
};
GOVERNANCE_DENIED as a thrown error code is for buy-side flows where the governance agent itself is unreachable or returned a system error — not for adopter-controlled policy decisions.
Validation seams (request boundary)
Spec-correct validation MUSTs sit at the request boundary, not nested under enforcement helpers. Example for acquire_rights under a registered governance binding:
acquireRights: async (req, ctx) => {
/* ... pricing + offering validation ... */
// Validation seam: governance enforcement will project CPM spend, which
// requires estimated_impressions per spec. Throw INVALID_REQUEST here.
const hasBinding = tenant.governanceBindings.has(req.buyer.domain);
if (hasBinding && (req.campaign.estimated_impressions == null || req.campaign.estimated_impressions <= 0)) {
throw new AdcpError('INVALID_REQUEST', {
message:
'campaign.estimated_impressions is required when acquiring CPM-priced rights under a registered governance plan.',
field: 'campaign.estimated_impressions',
});
}
const denial = await this.enforceGovernance(tenant, ctx, offering, req);
if (denial) return denial;
/* rest of flow */
};
The spec MUSTs estimated_impressions only under intent-phase governance_context + CPM. The broader gate above is conservative — when the agent holds a registered binding, projecting spend without impressions silently grants under-priced rights. Production adopters with mixed pricing or governance_context-driven flows can tighten.
Common gotchas
AcquireRightsRequesthas noaccountfield on the wire. Bind byreq.buyer.domain(a brand reference, not the buyer's account). Track adcontextprotocol/adcp#3918 for the request-shape extension that would let agents bind on (operator, brand) — until it lands, in-tenant collision case is a known limitation.sync_governancerequest has no top-levelaccountfield. Framework auth-derivesctx.accountviaresolveAccountFromAuth; per-entry tenant gate runs againstentry.account.operatorcross-checked againstctx.account.ctx_metadata.tenant_id.v6doesn't modelsync_governanceonAccountStoreyet. Wire viaopts.accounts.syncGovernancev5 escape-hatch; promotion tracked at adcontextprotocol/adcp-client#1387.- Schema requires
^https://forgovernance_agent_url. Local-dev adopters can't register their own server's URL viasync_governancewithout a TLS terminator. Loopback-pattern relaxation tracked at adcontextprotocol/adcp#3918.
See also
examples/hello_seller_adapter_multi_tenant.ts— the working referenceexamples/CONTRIBUTING.md— SWAP-marker convention, fail-closed gate pattern, cross-specialism helper extractionskills/build-governance-agent/,skills/build-brand-rights-agent/,skills/build-seller-agent/— per-specialism handler detailsexamples/decisioning-platform-multi-tenant.ts+skills/build-decisioning-platform/— host-routed multi-tenant pattern (different shape; use when each tenant has its own subdomain)skills/triage-storyboard-failure/— when storyboards fail on your forkskills/run-by-experts/— convergence-of-reviewers pattern for non-trivial PRs (multi-tenant + auth surfaces are exactly the kind of change this skill calls for)