npx @adcp/sdk@latest storyboard run.”
The three surfaces the runner needs
The runner drives your agent through the same public tools a buyer would call, plus one sandbox-only tool for fixture setup. Three surfaces must be in place:
You ship these three surfaces. The runner owns storyboard selection, fixture ordering, and response comparison.
Step 1 — Declare capabilities honestly
get_adcp_capabilities is how the runner picks which storyboards apply to you. It is also the conformance contract: you are promising to pass every storyboard that matches what you declare.
The example below is for a full-service guaranteed seller (proposal lifecycle enabled). A direct-buy guaranteed seller would set media_buy.supports_proposals: false (or omit it). A broadcast-TV seller would claim sales-broadcast-tv; a creative-only agent would claim the creative protocol with creative-ad-server or creative-generative specialisms; a signals provider would claim signals. The pattern is the same: declare only what you actually implement.
supported_protocols— Pulls in the matching protocol storyboards from/compliance/{version}/protocols/.specialisms— Pulls in opt-in specialism storyboards (see the Compliance Catalog for the full enumeration).account.sandbox: true— Signals that you honor sandbox semantics (no real spend, no production side effects).account.require_operator_auth— Determines your sandbox bootstrap path (step 2).
Step 2 — Pick your sandbox bootstrap path
The runner must obtain a sandbox account before it can do anything. Yourrequire_operator_auth flag chooses the path:
Buyer-declared accounts (require_operator_auth: false). Your agent accepts sync_accounts from any authenticated buyer. The runner calls sync_accounts with sandbox: true to mint a test account on demand. Most new sales agents start here.
Account-id namespaces (require_operator_auth: true). Accounts must be pre-provisioned by a human on your side or by an upstream platform. The runner calls list_accounts with a sandbox filter to discover pre-existing test accounts. If your credential is bound to exactly one sandbox account, return that singleton. Publish a short note telling operators how to request one — include the contact, the expected turnaround, and what credentials they’ll receive.
When your response includes the
account capability block, its require_operator_auth flag — not the presence of list_accounts — selects the account model. Keep the flag false (or omit the flag, since false is its schema default) for buyer-declared natural-key accounts even if your agent also exposes list_accounts. Do not omit the entire account block: without account capability data, runners may retain a legacy list_accounts discovery heuristic. Set the flag to true only when callers must use seller-assigned account IDs. In that mode, the sandbox credential must expose one unambiguous account for the storyboard brand; if it exposes a roster, make sure exactly one row matches the test kit’s brand.house.domain. Do not advertise false merely to bypass an ambiguous roster — that tells buyers the wrong account model.Step 3 — Implement the compliance test controller
Without a compliance test controller, the runner tests only buyer-initiated flows (observational mode) — schema conformance, auth rejection, happy-path buyer calls. That is enough for early integration work and production-path sandbox smoke tests, but it produces partial coverage whenever controller-seeded or controller-forced scenarios are in scope. Conformance treats deterministic mode — full lifecycle walks enabled by the controller — as the bar for complete specialism coverage. In practice, sellers usually run both:
The first run answers “does the production buyer-visible path tolerate sandbox traffic?” The second answers “can the runner prove every lifecycle path for the declared specialisms?” Do not collapse those into one signal. A zero-failure partial run is useful, but buyers need the skip list to know exactly which lifecycle assertions were not graded.
comply_test_controller is a single sandbox-only tool with a scenario parameter covering three families:
See the Compliance test controller reference for scenario-by-scenario parameters and response shapes, and the Compliance Catalog for which scenarios each specialism requires.
If your products come from a publisher-owned catalog and use seller-issued or
derived IDs, do not add a test-only alias layer merely to accept storyboard
literals. For storyboards that publish
fixture_resolution rules, provision
representative inventory through your normal platform path and let the runner
discover and bind it. The run will report any requirement your sandbox catalog
cannot satisfy as fixture_unsatisfied. Storyboards that have not yet migrated
remain seed-only, so inspect the coverage report rather than assuming discovery
applies suite-wide.
Wiring the SDK scaffold
@adcp/sdk (6.x is the production GA on AdCP 3.0) ships createComplyController so you wire your data layer to the controller without reimplementing tool registration, param validation, error envelopes, or re-seed idempotency.
The scaffold is TypeScript/JavaScript. Python, Go, and Java sellers implement the tool directly against the schema — the contract below (adapters, error codes, idempotency semantics) applies the same way. SDKs for other languages are tracked in Choose your SDK.
- Tool registration and schema.
controller.toolDefinitionstays in sync with the published spec version. - Dispatch and
UNKNOWN_SCENARIO. Scenarios you do not register returnUNKNOWN_SCENARIOautomatically — never a schema error. - Param validation. Invalid params produce
INVALID_PARAMSwith a readableerror_detailwithout reaching your adapter. - Seed idempotency. Calling
seed_producttwice with the sameproduct_idand an equivalentfixturereturnsprevious_state: "existing"; a divergentfixturereturnsINVALID_PARAMS. Your adapter is only invoked on the first seed. - Typed error envelopes. Throw
TestControllerError(code, message, currentState?)withcodefrom the controller error-code table. Common adapter codes are'INVALID_TRANSITION' | 'NOT_FOUND' | 'FORBIDDEN' | 'INVALID_PARAMS'; digest-modequery_upstream_trafficimplementations may also returnJCS_NON_FINITE_NUMBERwhen RFC 8785/JCS canonicalization encounters a non-finite numeric value.
Two layers of sandbox gating
The scaffold supports two gates. Ship both in any deployment that serves both sandbox and production traffic from the same process:- Registration gate (primary). Wrap
controller.register(server)in an environment check. This is what keepscomply_test_controllerout of productiontools/listentirely. Without it, a leaked sandbox credential on a production endpoint exposes seller-side state-forcing. - Per-request gate (defense-in-depth). Pass a
sandboxGate: (input) => booleantocreateComplyController. The scaffold calls it on every request and returnsFORBIDDENwhen it returnsfalse. Use this on shared-process deployments where the tool IS registered but some requests might still reference a production account.
sandboxGate receives the raw tool input (Record<string, unknown>). The SDK does not plumb auth context onto it — you decide what to inspect. A typical pattern is to pull the referenced entity ID out of params and verify it belongs to a sandbox account in your own data layer:
For custom MCP wrappers — AsyncLocalStorage for per-request auth, transport-level sandbox gating, session-backed stores — compose the lower-level
handleTestControllerRequest, toMcpResponse, and TOOL_INPUT_SHAPE from @adcp/sdk/server directly.Step 4 — Run the storyboard runner
Once the three surfaces are in place, the runner takes over:Avoiding the teach-to-test trap
Storyboards hardcode fixture IDs —"test-product", "campaign_hero_video", "acmeoutdoor.example". A controller that special-cases those strings passes the suite while silently failing on every real buyer. That is the exact industry cost conformance is trying to prevent: every post-conformance integration failure burns seller reputation, inflates buyer agent skepticism, and slows protocol adoption.
The SDK scaffold already points you in the right direction: adapters receive product_id, creative_id, etc. as values, not as conditions. If your adapter contains a switch on product_id === "test-product", you have regressed.
Two rules of thumb:
- Implement seed scenarios generically.
seed_productaccepts anyproduct_idand persists a product with that ID in your sandbox data layer. Your adapter is a thin wrapper over a real upsert against your sandbox store. - The
fixtureobject is the contract, the ID is not. Storyboard authors setfixtureto the minimum shape the test needs. Everything beyond that — discovery, filtering, authorization — is your normal code path, exercised on fixture-seeded data the same way it runs on production data.
Readiness checklist
Before your first full storyboard sweep:-
get_adcp_capabilitiesreturns only protocols and specialisms you actually implement -
account.sandbox: trueis declared and honored — sandbox requests produce no real spend, no production platform calls, no persisted production state -
sync_accounts(implicit) orlist_accounts(explicit) handles sandbox requests per step 2 -
comply_test_controlleris absent fromtools/liston any production endpoint - Requests that reference a non-sandbox account are rejected with
FORBIDDEN - Every seed scenario your claimed storyboards depend on persists fixtures generically, with no ID special-cases
- Every force scenario uses the same state-transition rules as production, returning typed errors on invalid transitions
- A full storyboard sweep still passes when fixture IDs are swapped for random UUIDs
What’s next
- Validate Your Agent — CLI, Addie workflows, and multi-instance verification
- Compliance test controller reference — Full scenario-by-scenario spec
- Sandbox mode — The two account model paths in depth
- Conformance — What “conformant” and “verified” mean once your runs pass