This is the canonical instruction file for agents working in this repository. Codex and Claude Code (2.1.277 and later) both read AGENTS.md directly.
A Codex, Claude Code, and Antigravity CLI plugin package for the Spotify Ads API v3. Plugin source is markdown and shell; there is no compiled code, package manager, or build step. Lightweight shell regression tests live under tests/. The plugin translates natural language into REST API calls for managing campaigns, ad sets, ads, assets, audiences, and reporting.
The plugin follows the agent plugin structure with four component types:
- Skills (
skills/) — User-invokable slash commands and reference documentation, each in its own directory with aSKILL.mdfile:skills/configure/— OAuth 2.0 Authorization Code with PKCE setup, direct-token fallback, and helper scripts inscripts/skills/campaigns/— Campaign CRUD operationsskills/ads/— Ad set and ad managementskills/build-campaign/— Full campaign builder from natural language descriptions (prefers draft flow)skills/media-plan-to-draft/— Source-traceable media-plan ingestion, Ads API enrichment and forecasting, review gating, and approved draft handoffskills/drafts/— Draft campaign lifecycle: create, edit, validate, and publish draft campaigns, ad sets, and ads. Preferred flow for creating new campaigns — builds the full hierarchy as drafts, validates everything at once, then publishes only after review.skills/report/— Aggregate, insight, and async CSV reportingskills/assets/— Upload, list, and manage creative assets (audio, video, images)skills/audiences/— Customer-list uploads and custom, engagement, event, and lookalike audiencesskills/measurement-setup/— Pixel, CAPI, datasets, event planning, advanced matching, mobile apps, and sharingskills/measurement-debug/— Read-only-first Pixel, CAPI, dataset, deduplication, and attribution troubleshootingskills/account-admin/— Business/ad-account discovery and account membership, roles, invitations, and detailsskills/dashboard/— Quick performance overview with pacing for active campaignsskills/monitor/— Campaign health diagnostics for pacing, delivery, stalled entities, and underdeliveryskills/export/— Denormalized CSV exports of campaigns, ad sets, ads, targeting, budgets, and optional metricsskills/bulk/— Batch pause, resume, budget, delivery, archive, and creative-swap workflowsskills/clone/— Clone campaigns or ad sets by reading the source hierarchy and recreating entities with modificationsskills/change-history/— View a timeline of changes made to campaigns, ad sets, creatives, and other entities. Supports filtering by entity type, actor, change category, and date range (180-day retention).skills/api-reference/— Comprehensive API v3 reference documentation withreferences/(live OpenAPI workflow, runtime API behaviors, ad product validation, create retry safety) andexamples/(full flows). Activates automatically when the Spotify Ads API is mentioned.
- Agent (
agents/spotify-ads-request-builder.md) — A natural language agent that triggers automatically when users describe advertising tasks conversationally. Handles multi-step operations (campaign -> ad set -> ad) by chaining API calls and passing IDs between steps. - Scripts (
scripts/) — Shared shell scripts used by skills and agents:scripts/api-request.sh— Request wrapper that handles settings discovery, authentication, SDK/skill tracking headers, and curl execution. Skills define a localapi()function that delegates to this script:api() { "$PLUGIN_ROOT/scripts/api-request.sh" <skill-name> "$@"; }. Usage:api <METHOD> <path> [json_body]orapi --envfor settings. Paths use{ad_account_id}as a placeholder (auto-substituted from settings).--envprintsTOKEN,AD_ACCOUNT_ID,AUTO_EXECUTE,BASE_URL,SDK_HEADER,PLUGIN_VERSION, and (when a skill name is passed)SKILL_HEADER. Every value is single-quoted soeval $(api --env)assigns them intact — unquoted, the space insideSDK_HEADERsplits the assignment andevalsilently sets nothing at all.scripts/fetch-openapi-schema.sh— Downloads the current public Ads API v3 OpenAPI document to a caller-provided temporary path and rejects failed or structurally invalid responses. It also writes a pointer file ($TMPDIR/spotify-ads-api-openapi-<uid>.current) recording that path, because each agent Bash call is a fresh shell that does not inheritOPENAPI_FILE. The pointer is honoured for 6 hours;OPENAPI_FILE, when set, takes precedence.scripts/openapi_tool.pyandscripts/openapi-op.sh— Standard-library-only OpenAPI tooling, built in-house after evaluatingopenapi-to-cli(ocli). Its runtime would have bypassed the token hook, attribution, per-workflow spec freshness, and the no-dependency rule, so only its ideas were adopted.openapi-op.sh describe <METHOD> <path>prints a compact,$ref-resolved view of one operation (about 8 KB for ad set create, against a 400 KB spec).openapi-op.sh search <words>ranks operations with BM25. The YAML loader covers the block-style subset the published spec uses and rejects anchors/aliases rather than misreading them. Parsed output is cached as<spec>.parsed.jsonnext to the spec. When changing the loader, runOPENAPI_TOOL_LIVE_SPEC=<downloaded api.yaml> bash tests/test-openapi-tool.sh, which diffs against Ruby's YAML parser when Ruby is installed.- Pre-send validation (in
api-request.sh) — When a workflow spec has been fetched, every request is validated locally byopenapi_tool.py validatebefore curl runs. Problems that are never valid block. These are missing or null required fields at any depth, missing required query parameters, bodies, and discriminators, wrong types, bounds, read-only fields, and invalid JSON. They exit 3 withERROR: Request not sent.and never touch the network, so they are not a rejected POST/PATCH under the retry rules: fix the request and resend. Fields, query parameters, and enum or discriminator values the spec does not define only warn (WARNING: Sent anyway) and are sent, because a local block could reject a request the user explicitly asked for. Don't add fields the spec doesn't define on your own initiative. Treat a warning as a likely mistake unless the user explicitly asked for that field or value. It fails open (sends as before) when no spec has been fetched, Python is missing, or the operation is absent from the public spec, with aWARNINGin that last case. There is no opt-out: what still blocks is never valid, and a wrong rule in the spec is fixed upstream. skills/configure/scripts/settings_file.py— Secure settings writer used during OAuth and direct-token setup. It creates replacement files as mode 0600 before atomically renaming them and never prints token values.
- Hooks — Per-platform hook configs invoking
hooks/check-token.shbefore Spotify API calls, to refresh expired OAuth tokens and to enforce telemetry attribution (see the next bullet).hooks.jsonat the plugin root contains the AntigravityPreToolUseevent, auto-discovered by both Antigravity CLI (agy plugin install) and Antigravity 2.0 (.agents/plugins/workspace discovery)..claude-plugin/hooks.jsonand.codex-plugin/hooks.jsoncontain the Claude/CodexPreToolUseevent, declared via thehooksfield in each platform'splugin.json. The Codex hook command uses Codex's${PLUGIN_ROOT}manifest substitution; do not replace it with a cwd fallback or assumeCODEX_PLUGIN_ROOTis exported to the hook process. Note: the hook payload and response formats differ across platforms — Claude/Codex use.tool_input.commandand support command rewriting viaupdatedInput, while Antigravity uses.toolCall.args.CommandLineand only supports allow/deny withdecision/reason(the hook refreshes the token in the settings file and tells the agent to re-read it). - Hook attribution enforcement (
hooks/lib/attribution.sh, sourced byhooks/check-token.sh) —scripts/api-request.shsetsX-Spotify-Ads-SkillandX-Spotify-Ads-Sdkdeterministically, but hand-writtencurlbypasses it, leaving those requests unattributable in per-skill usage and error-rate reporting. The hook closes that gap:- Never register attribution as a second hook. Matching
PreToolUsehooks run in parallel, each receiving the original input, and the lastupdatedInputto finish wins in non-deterministic order. A second rewriting hook would race the token refresh and silently drop one of the two edits. There are also open upstream bugs whereupdatedInputis dropped once multiplePreToolUsehooks are involved. One hook owns the single rewrite; attribution is a sourced library, not a separate registration. For the same reason a user's own unrelatedPreToolUseBash hook can cause our rewrite to be dropped, in which case behaviour degrades to thesystemMessagenudge, so keep that path working. - Interface —
apply_attribution <original> <current> <platform> <transcript>setsATTRIBUTION_COMMANDandATTRIBUTION_NOTE. It readsPLUGIN_ROOTand never fails the caller. If the library is missing, the hook falls back to token refresh only. - Detection — claims a command naming
api-partner.spotify.com, or referencing$BASE_URLtogether with a Spotify marker (X-Spotify-Ads-,SDK_HEADER,SKILL_HEADER, orad_accounts). The$BASE_URLform matters because the assets and audiences upload flows never spell out the host. A command already counts as attributed if it names the header, references$SKILL_HEADER/$SDK_HEADER, or delegates toapi-request.sh. - SDK header — deterministic, so it is injected verbatim when missing. The value comes from
api-request.sh --envrather than a second copy of the platform/version logic, so it cannot drift from what skills send. - Skill header — can only be inferred. The hook scans the last
SPOTIFY_ADS_SKILL_LOOKBACK_LINES(default 300) transcript lines newest-first, so recency beats pattern strength, and accepts the first candidate matching a realskills/<name>/SKILL.mdor the request-builder agent. Matching is deliberately plain-text so the same patterns work across Claude, Codex, and Antigravity transcripts without encoding any one platform's JSON schema. - Inferred values carry an
-inferredsuffix (campaigns-inferred). Reporting can then treat exact matches as deterministic and*-inferredas best-effort. Never strip this suffix: a stale inference labelled as deterministic silently corrupts per-skill error rates. - When inference fails no skill header is injected and the agent gets a nudge toward
api()instead. A sentinel would add nothing, since a null skill already reads as unattributed downstream. Note that the SDK header is still backfilled, so a call omitting both headers becomes visible with a null skill rather than staying invisible. - Antigravity cannot rewrite commands on any of its hook events, so there the hook allows the call and attaches the nudge as
reason. - Injection inserts
-Harguments after eachcurlword, with word-boundary checks socurl-configandmycurlare left alone, and a single-quote parity check so acurlinside a-d '{...}'body is treated as data. It is idempotent, and skipped when the command has nocurlto rewrite.
- Never register attribution as a second hook. Matching
- Commands (
commands/configure.toml) — An Antigravity CLI custom command exposing/configureas an explicit entry point to the configure skill. Other skills auto-activate on Antigravity via its native Agent Skills support. - Settings (
.codex/spotify-ads-api.local.md,.claude/spotify-ads-api.local.md, or.agents/spotify-ads-api.local.md, with each platform preferring its own file and falling back to the other two) — Per-user local config with YAML frontmatter storing OAuth credentials (access_token,refresh_token,client_id,token_expires_at, andauth_flow),ad_account_id, andauto_execute. OAuth uses Authorization Code with PKCE (S256) and team-owned client IDs; no application secret or platform credential store is used. Template lives intemplates/settings-template.md. These files are gitignored.
Keep both marketplace files intentional and in sync:
-
.agents/plugins/marketplace.jsonis the Codex-facing catalog used bycodex plugin marketplace add spotify/ads-agentic-tools. It must include Codex'sinterface.displayName,policy.installation,policy.authentication, andcategorymetadata. Because this plugin lives at the repository root, use a Git-backed root plugin source ("source": "url"with the repository URL) instead of a localsource.pathof"./"; Codex treats that local root path as empty and skips the plugin. -
.claude-plugin/marketplace.jsonis the Claude Code-compatible catalog. Claude Code requires marketplace metadata at that path and requires a top-levelowner. To keep the file portable, use the stricter Claude-valid schema: local plugin sources should use the relative string form ("source": "./"for this repo-root plugin), and Codex-only marketplace fields such as top-levelinterfaceor pluginpolicyshould not be added to this file. The Claude plugin manifest.claude-plugin/plugin.jsonmust also avoid Codex-only manifest fields such asinterface; keep display metadata in.codex-plugin/plugin.jsonand the Codex marketplace. -
Antigravity has no marketplace file. The root
plugin.jsonis the Antigravity manifest; Antigravity CLI installs viaagy plugin install <repo-url>and Antigravity 2.0 discovers plugins placed in.agents/plugins/(workspace) or~/.gemini/config/plugins/(global). Both versions auto-discoverskills/,commands/, andhooks.jsonat the plugin root. Keepname,version, anddescriptionin sync with the other two manifests, and do not add Antigravity-only fields (such ascontextFileName) to the Claude or Codex manifests.
When updating marketplace metadata, keep the plugin name, source path, category, description, and user-facing display name aligned wherever each schema supports those fields. The plugin version must be bumped in all three manifests together: .claude-plugin/plugin.json, .codex-plugin/plugin.json, and plugin.json.
These non-obvious API quirks were discovered through real testing and are critical when modifying any command or agent:
- Micro-amounts: Budget and bid values in entity payloads (
budget.micro_amount,bid_micro_amount) are in micro-units (1 unit of the ad account's billing currency = 1,000,000 micro-units; e.g., $1 USD = 1,000,000, ¥1 JPY = 1,000,000). However, SPEND values returned byaggregate_reportsare already in the billing currency — do NOT divide those by 1,000,000. bid_strategyis a plain string enum (MAX_BID,COST_PER_RESULT,AUTOBID,UNSET), not an object. Default toMAX_BIDwith a requiredbid_micro_amount; useAUTOBIDfor automatic bidding withoutbid_micro_amounton the ad set. This AUTOBID exception applies only to ad set payloads. Do not chooseUNSETfor new ad sets.geo_targetsis a flat object (not an array) with a requiredcountry_codeand optional refinement arrays (region_ids,city_ids,postal_code_ids). UseGET /targets/geos?country_code=<code>&q=<query>to look up geo IDs. Geo types returned by lookup:REGION(states/provinces),DMA_REGION(media markets),CITY,POSTAL_CODE— note thatdma_idsis no longer a valid targeting field; DMA-level targeting is not supported. Example:{"country_code": "US", "region_ids": ["4831725"]}targets Connecticut. NEVER fall back to country-only without looking up the user's requested location first.platformsvalid values areANDROID,DESKTOP,IOS— not "MOBILE" or "CONNECTED_DEVICE".categoryis required on ad sets — must be a validADV_X_Ycode fromGET /ad_categories.call_to_actionuses fieldkey(nottype) andclickthrough_url(noturl).companion_asset_idis required for AUDIO format ads.- Array query params use repeated parameter names (
&fields=X&fields=Y), not comma-separated. - Report field name is
fields, notreport_fields. - No DELETE on campaigns/ad sets/ads — use status changes (ARCHIVED, PAUSED).
- Base URL:
https://api-partner.spotify.com/ads/v3. - Tracking headers: The request wrapper (
scripts/api-request.sh) injectsX-Spotify-Ads-Sdk: <sdk-product>/<version>andX-Spotify-Ads-Skill: <skill-name>on every API call. Skills never construct these headers manually — the wrapper reads the plugin version from the platform manifest and takes the skill name as its first argument. This eliminates malformed-header errors seen in production logs (e.g., doubledX-Spotify-Ads-Sdk: X-Spotify-Ads-Sdk: ...). entity_status_typemust matchentity_typeinaggregate_reportsqueries. For example, useentity_status_type=AD_SETwhenentity_type=AD_SET— usingentity_status_type=CAMPAIGNwithentity_type=AD_SETcauses a filter validation error.- Audience estimates: The build-campaign and ads skills run
POST /estimates/audiencebefore creating ad sets to validate targeting. This catches "min audience threshold" errors before they happen. Required fields come from the live spec (see OpenAPI Spec below); the AUTOBID no-bid exception applies only to ad set payloads. A "Min audience threshold was not met" 400 means the targeting is too narrow: never resend the same request or widen targeting unilaterally; ask the user how to broaden it, then re-estimate. - Ad product catalog validation: Before every campaign, ad set, or ad POST/PATCH, follow
skills/api-reference/references/ad-product-validation.md. FetchGET /ad_product_catalogonce per workflow; do not maintain a timed cross-workflow cache because the endpoint returnsCache-Control: no-cache, no-store. Validate final creates and deep-merged effective updates, including required parent/runtime context. Never send a known violation, but do not add per-field success checklists or validation-only confirmation gates. Campaign responses do not consistently exposead_product, so use known workflow context and do not silently infer a reserved CONTENT/FPMNG product.
https://developer.spotify.com/reference/ads-api/v3/api.yamlis the authoritative OpenAPI v3 document. Before the first Ads API call in every workflow, followskills/api-reference/references/live-openapi.md: fetch it once, inspect every planned operation's parameters and request body, reuse it throughout that workflow, and remove the temporary file afterward.- Every payload MUST include every field the spec marks as required, including required fields in nested objects and array items, for every endpoint. Never omit one because a doc, default, or strategy suggests it is optional.
- Never name fields the public spec does not define in skills, agents, or docs, not even to warn against using them. A name that appears only in spec description text may be internal, and mentioning it exposes it.
When creating new campaigns, prefer the draft workflow over direct entity creation:
- Create drafts —
POST /ad_accounts/{id}/drafts/campaigns, thenPOST /ad_accounts/{id}/drafts/ad_sets(withcampaign_id= draft campaign ID), thenPOST /ad_accounts/{id}/drafts/ads(withad_set_id= draft ad set ID) - Validate —
POST /ad_accounts/{id}/drafts/campaigns/{draft_id}with{"action": "VALIDATE", "draft_hierarchy_version": N}. This checks the entire hierarchy at once. VALIDATE and PUBLISH share one handler. HTTP 200 means success (validation_errorsis null). Hierarchy validation errors return HTTP 400 with aPublishCampaignResultwhosevalidation_errorsarray is filled in (each item hasvalidation_entity_type,validation_entity_id,message, anderror_codes). On a 400, check forvalidation_errorsfirst: if present, show the errors (step 3). Otherwise handle it like any other error; never retry automatically. - Fix and re-validate — use PATCH on draft entities to fix errors, then validate again
- Publish —
POST /ad_accounts/{id}/drafts/campaigns/{draft_id}with{"action": "PUBLISH", "draft_hierarchy_version": N}. This publishes the drafts as live entities, retaining the same entity IDs.
The draft flow is preferred because validation is batched — all errors across campaigns, ad sets, and ads surface before anything goes live. The direct flow validates per-entity, so errors in ads are only discovered after the campaign and ad sets are already live.
Key draft conventions:
draft_hierarchy_versionis a read-only field on the draft campaign entity only (ad set and ad drafts return it asnull). It increments when any entity in the hierarchy is created or edited. Always fetch the current version from the draft campaign immediately before publishing or validating; do not reuse a version captured before creating child draft ad sets/ads or applying edits.- Publishing requires explicit confirmation —
PUBLISHcreates live entities, so ask the user to confirm immediately before the publish request even whenauto_executeis true. - Draft entities can be deleted —
DELETEon/drafts/campaigns/{id},/drafts/ad_sets/{id}, or/drafts/ads/{id}. Draft DELETE returns 204 and is safe to retry. - Create-from-published —
POST /ad_accounts/{id}/campaigns/{campaign_id}/drafts(also for ad sets and ads) creates a draft copy of a live entity for editing. The draft reuses the same entityid(not a new UUID) and its status becomesACTIVE_RESTRICTED.
All skills use the request wrapper script (scripts/api-request.sh) to make API calls. Each skill defines a local api() function:
PLUGIN_ROOT="${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.}}"
api() { "$PLUGIN_ROOT/scripts/api-request.sh" <skill-name> "$@"; }
api GET "ad_accounts/{ad_account_id}/campaigns?limit=50"
api POST "ad_accounts/{ad_account_id}/campaigns" '{"name":"...","objective":"..."}'Before using the wrapper, each workflow fetches the current public OpenAPI document once and uses it to check the complete operation contract as described in skills/api-reference/references/live-openapi.md. The wrapper then handles settings discovery (platform-ordered fallback), authentication, SDK/skill tracking headers, and \nHTTP_STATUS:<code> capture without downloading the document again for each request. If auto_execute is false, the skill presents the command and asks for confirmation before executing. Exceptions (asset file uploads, OAuth token exchange) use raw curl — see the relevant skill for details.
In skills, agents, documentation, and test scenarios, express standard Ads API v3 requests with the shared api() helper, never as expanded raw HTTP-client commands. This keeps authentication, tracking headers, settings lookup, and status capture centralized. Only the documented asset-upload and OAuth implementations may use their required raw transport commands; do not use those exceptions as patterns for ordinary API examples.
After execution, check the HTTP_STATUS: line first:
- 2xx: Request succeeded — parse and display the response body.
- 4xx/5xx: Request failed — display the error from the response body. Never retry a POST or PATCH that returned a non-timeout 4xx — the request was received and rejected, not lost.
- Retry safety: Only retry on network errors or 5xx responses, and only for idempotent methods (GET). Never automatically retry POST or PATCH — these are non-idempotent and may have already been applied server-side (e.g., a 500 after the entity was created). On POST/PATCH failure, check for the created/modified resource first before suggesting a retry.
There is no GET /ad_accounts list endpoint. To discover a user's ad accounts during onboarding:
GET /businesses— lists businesses the authenticated user belongs toGET /businesses/{business_id}/ad_accounts— lists ad accounts under a specific business
This two-step flow is used by the configure skill during setup.