Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Manage paid ads on AdManage.ai from declarative config - default schedules launches across Meta/TikTok/Snapchat/Pinterest/LinkedIn (always PAUSED); create provisions Meta campaigns and ad sets.
.claude/skills/aeonfun-schedule-ads/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-05 | ✗→✓ | ▲ Improved | 358% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 186% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 241% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 857% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 329% | 0% |
> ${var} selects the flow. Empty/unset = schedule (launch ads into existing ad sets). create = create-campaign (provision Meta campaigns + ad sets). Both are config-driven, PAUSED-by-default, and make the AdManage API calls in-run via ./secretcurl (the {ADMANAGE_API_KEY} placeholder keeps the key off the command line), behind fail-closed spend guardrails.
Reads a declarative config, computes what to do, and makes the AdManage.ai API calls in-run via ./secretcurl. The calls are an irreversible outbound side-effect (real ad spend), so they are each branch's final actions and run only behind the guardrails below (PAUSED-by-default, dailySpendCap circuit-breaker, dry-run). ADMANAGE_API_KEY is injected in-run via this skill's requires: — always write it as the {ADMANAGE_API_KEY} placeholder, never a bare $ADMANAGE_API_KEY (the Bash permission layer refuses that).
memory/MEMORY.md for context. Read the last ~3 days of memory/logs/ for recent launch / provisioning activity — don't re-report a signal already logged.${var}:create → run the Create branch below.SCHEDULE_ADS_UNKNOWN_SELECTOR: <value> and exit cleanly (no notify).${var})Reads skills/schedule-ads/config.yaml, picks schedule entries matching today, and launches those ads in-run via AdManage.ai (POST /v1/launch through ./secretcurl), behind the spend guardrails below.
This branch spends real money on ad platforms. Guardrails, in priority order:
launchPaused: false in config is the explicit opt-out.GET /v1/spend/daily for today. If spend ≥ dailySpendCap in the config, all launches are skipped and a warning is notified. If the spend figure can't be verified (malformed / empty response), fail closed — skip and notify, don't launch. This is a circuit breaker, not a budget enforcer — platform budgets still apply.DRY_RUN=true in env or dryRun: true in config, the branch builds the payloads, writes them to .pending-admanage/dryrun/, notifies what would launch, and exits without calling the API.config.yaml. The branch never generates new creative on the fly.Launching ads is an irreversible outbound side-effect (real ad spend), so it is the branch's final action and runs only after the guardrails above pass:
./secretcurl with the {ADMANAGE_API_KEY} placeholder — never a bare $ADMANAGE_API_KEY (the Bash permission layer refuses that). ADMANAGE_API_KEY is injected in-run via requires:.GET /v1/spend/daily), then per batch calls POST /v1/launch, polls GET /v1/batch-status/{id} to a terminal state, and reports via ./notify.ADMANAGE_API_KEY is unset, or the launch/spend call fails, skip the launch and notify — do not retry blindly. There is no deferred postprocess fallback.skills/schedule-ads/config.yaml. If the file doesn't exist, log SCHEDULE_ADS_NOT_CONFIGURED and exit cleanly (no notify, no error). The example template lives next to this file as config.example.yaml.defaults (with adAccountId, workspaceId, page), and schedules (array). If either is missing, file an issue in memory/issues/ per the CLAUDE.md issue tracker convention, notify once, and exit.schedules, match against today's date:when.everyDay: true → always matches.when.dayOfWeek: monday (or any weekday name, lowercase) → matches if today is that weekday (UTC).when.date: "2026-04-25" → matches only on that exact date.when.dates: ["2026-04-25", "2026-05-02"] → matches if today is in the list.when.cron: "0 8 * * 1" → (advanced) matches if today satisfies the cron. Optional — skip if it's too much parsing effort.If no entries match today, log SCHEDULE_ADS_NOTHING_TODAY and exit cleanly (no notify).
POST /v1/launch body:json { "ads": [ { "adName": "<templated from ad.adName, {date} replaced>", "adAccountId": "<from defaults or entry override>", "workspaceId": "<from defaults or entry override>", "title": "<from ad>", "description": "<from ad>", "cta": "<from ad or defaults.cta>", "link": "<from ad>", "page": "<from defaults>", "insta": "<from defaults, Meta only>", "adSets": [ { "value": "<id>", "label": "<name>" } ], "media": [ { "url": "<media url>" } ], "status": "PAUSED" } ] } Enforce status: PAUSED on every ad unless defaults.launchPaused is explicitly false. Never strip it silently.
Template substitutions inside string fields:
{date} → today's ISO date (YYYY-MM-DD){dateHuman} → "April 21, 2026" stylemedia[*].url must be an absolute https:// URL. Reject entries with local paths or obviously broken URLs.adSets[*].value must be a non-empty string. If missing, skip the entry with a warning in the log.adAccountId starts with act_): page and insta must be set. TikTok/Snapchat/etc. have their own requirements — don't block on Meta-specific fields for other platforms.title and description must be non-empty.Drop invalid entries, keep going. Log which ones were skipped and why.
DRY_RUN=true or config.dryRun: true:.pending-admanage/dryrun/{schedule-name}-{timestamp}.json.[DRY RUN] prefix../secretcurl, jq, date, echo, mkdir, grep, python3, and the Write tool are available.a. Config check. [ -n "${ADMANAGE_API_KEY:+x}" ] (the ${VAR:+x} form — a bare $ADMANAGE_API_KEY trips the secret-expansion analyzer and reads as unset). If unset → notify "ads computed but ADMANAGE_API_KEY missing — nothing launched" and stop.
b. Daily spend circuit-breaker (once). Take the strictest dailySpendCap (CAP) across today's payloads. If set, read today's spend and fail closed unless it's a clean number below the cap: bash SPEND=$(./secretcurl -sS --max-time 30 -H "Authorization: Bearer {ADMANAGE_API_KEY}" \ "https://api.admanage.ai/v1/spend/daily?startDate=$TODAY&endDate=$TODAY" | jq -r '.metadata.totalSpend // ""') echo "$SPEND" | grep -qE '^[0-9]+(\.[0-9]+)?$' || { echo "spend unverifiable — fail closed"; exit 0; } echo "$CAP" | grep -qE '^[0-9]+(\.[0-9]+)?$' || { echo "dailySpendCap not numeric — fail closed"; exit 0; } python3 -c "import sys; sys.exit(0 if float(sys.argv[1])>=float(sys.argv[2]) else 1)" "$SPEND" "$CAP" \ && { echo "daily spend cap tripped (today=$SPEND cap=$CAP) — launching nothing"; exit 0; } c. Per batch: launch, then poll. For each payload { ads: [ ... ] }: bash RESP=$(./secretcurl -sS --max-time 60 -w 'http=%{http_code}\n' -X POST "https://api.admanage.ai/v1/launch" \ -H "Authorization: Bearer {ADMANAGE_API_KEY}" -H "Content-Type: application/json" -d "$PAYLOAD") # success => .success==true and .adBatchId set; else record FAILED (.message/.error) and continue. # Poll GET /v1/batch-status/$BATCH_ID (~90s, 5s interval) until .summaryStatus is success|error. Record each batch's outcome (ok / error / still-running-after-timeout) for the notify. Ads launch PAUSED (the payload sets it) unless launchPaused: false.
output/.chains/schedule-ads.md so downstream chain consumers can read what was queued. Format:markdown # Schedule Ads — ${today}
Queued: N launches across M schedules. Dry-run: yes|no.
## Entries
./notify. Keep it tight: Ads queued — ${today}${dryRunSuffix}
<N> launches queued from <M> schedules.
"<first adName>"
<if dry-run> no API calls made — remove DRY_RUN to arm. <else> launched via AdManage (PAUSED) — resume in the dashboard to start delivery. If nothing matched today (no launches), don't notify at all.
schedule).See skills/schedule-ads/config.example.yaml for a filled-in template. Minimum viable config:
yamldefaults: adAccountId: act_XXXXXXXXXX workspaceId: XXXXXXXXXXXX page: XXXXXXXXXXXX # Meta Page ID insta: XXXXXXXXXXXX # Instagram user ID cta: LEARN_MORE launchPaused: true # NEVER change this without thought dailySpendCap: 50 # USD. Circuit breaker. dryRun: false schedules: - name: weekly-promo platform: meta when: { dayOfWeek: monday } adSets: - { value: "120xxxxxxxxxxxxx", label: "US Broad 25-55" } ads: - adName: "Weekly promo — {date}" title: "Headline copy here" description: "Supporting copy in a sentence or two." cta: LEARN_MORE link: https://example.com media: - url: https://media.admanage.ai/your-account/hero.mp4
create branch (${var}=create), the dashboard, or POST /v1/manage/create-campaign separately. This branch only launches ads into existing ad sets.upload-ad-media skill that calls POST /v1/media/upload/url.config.yaml and commit — keeps the launch path boring and auditable.${var}=create)Reads skills/schedule-ads/config.create.yaml, figures out which campaigns/ad sets don't exist yet, and creates them in-run via AdManage.ai (/v1/manage/create-* through ./secretcurl) — campaigns first, then ad sets referencing the returned campaign IDs — writing the new IDs back to .admanage-state/campaigns.json.
This branch is on-demand — invoke it manually when you want to provision new campaigns, then reference the returned IDs in skills/schedule-ads/config.yaml (schedule branch) to launch creatives into them.
Read .admanage-state/campaigns.json (if it exists) to see what's already created.
Two entity types only:
Everything else (TikTok/Snapchat/Pinterest/LinkedIn campaigns, advanced Meta fields like valueRuleSetId or Advantage+ catalog) is v2+. The shape below is intentionally minimal.
Same posture as the schedule branch:
status: PAUSED. No surprise spend..admanage-state/campaigns.json. If a campaign name already exists in state, it's skipped. Run it twice → no duplicates.DRY_RUN=true or config.dryRun: true → payloads written to .pending-admanage/dryrun-create/, notified, no API calls.Provisioning campaigns and ad sets is an irreversible outbound side-effect, so it is the branch's final action and runs in-run only after the diff + validation pass:
./secretcurl with the {ADMANAGE_API_KEY} placeholder — never a bare $ADMANAGE_API_KEY. The key is injected in-run via requires:.POST /v1/manage/create-campaign), keep a config-name → campaignId map, then create ad sets (POST /v1/manage/create-adset) substituting each parent's real campaign ID. Write every new ID back to .admanage-state/campaigns.json as you go (the workflow's Commit step persists it).ADMANAGE_API_KEY is unset, or a create call fails, record the failure and continue with the rest — never retry blindly, never invent IDs. An ad set whose parent campaign failed to create is skipped. There is no deferred postprocess fallback.skills/schedule-ads/config.create.yaml. If it doesn't exist, log CREATE_CAMPAIGN_NOT_CONFIGURED and exit cleanly (no notify). The example template lives next to this file as config.create.example.yaml..admanage-state/campaigns.json. If it doesn't exist, treat as empty. Shape:json { "campaigns": [ { "configName": "Prospecting — Q2 2026", "campaignId": "120251616228380456", "adAccountId": "act_xxx", "createdAt": "2026-04-21T08:00:00Z", "adSets": [ { "configName": "US Broad 25-54", "adSetId": "120251616242460456", "createdAt": "2026-04-21T08:00:04Z" } ] } ] }
defaults.adAccountId, defaults.workspaceId, campaigns[]. Each campaign needs name and objective. Each ad set needs name, and either optimizationGoal (explicit) or a compatible parent objective. If validation fails, file an issue in memory/issues/ and exit.name. If present, mark as existing.new and queue a campaign create.adSets[] in state by name. If missing, mark it for creation (carrying a parentCampaignConfigName reference you resolve to a real campaign ID in-run, once the parent campaign create returns).If nothing is new, log CREATE_CAMPAIGN_ALL_EXIST and exit without notify.
POST /v1/manage/create-campaign shape:json { "businessId": "<adAccountId>", "workspaceId": "<workspaceId>", "name": "<campaign.name>", "objective": "<campaign.objective>", "status": "PAUSED", "buyingType": "AUCTION", "specialAdCategories": [], "dailyBudget": <number>, "bidStrategy": "<LOWEST_COST_WITHOUT_CAP | LOWEST_COST_WITH_BID_CAP | COST_CAP | ...>", "promotedObject": { ... } } Skip keys that are null/absent in config — don't send empty strings. Always force status: PAUSED unless defaults.launchPaused: false is set explicitly.
POST /v1/manage/create-adset:json { "businessId": "<adAccountId>", "workspaceId": "<workspaceId>", "campaignId": "__RESOLVE_FROM_PARENT__", "parentCampaignConfigName": "<campaign.name>", "name": "<adSet.name>", "status": "PAUSED", "dailyBudget": <number>, "billingEvent": "IMPRESSIONS", "optimizationGoal": "<LANDING_PAGE_VIEWS | OFFSITE_CONVERSIONS | ...>", "destinationType": "<WEBSITE | PHONE_CALL | MESSAGING_... | ...>", "targeting": { ... }, "promotedObject": { ... } }
The __RESOLVE_FROM_PARENT__ sentinel + parentCampaignConfigName marks an ad set whose campaignId you fill in-run, from the map built as each campaign create returns (step 9b). If the parent campaign was existing (already in state), write the real campaign ID directly and drop the sentinel.
adAccountId must start with act_ (this branch is Meta-only in v1).dailyBudget must be a positive number in dollars (not cents).objective must be one of the documented Meta objectives: OUTCOME_TRAFFIC, OUTCOME_ENGAGEMENT, OUTCOME_LEADS, OUTCOME_AWARENESS, OUTCOME_SALES, OUTCOME_APP_PROMOTION.geo_locations.countries must be a non-empty array.Drop invalid entries, keep going, log what was skipped and why.
DRY_RUN=true or config.dryRun: true: write payloads to .pending-admanage/dryrun-create/ instead, notify with a [DRY RUN] prefix, skip step 9../secretcurl, jq, date, echo, python3, and the Write tool are available (no mv). Seed .admanage-state/campaigns.json to {"campaigns":[]} if missing.a. Config check. [ -n "${ADMANAGE_API_KEY:+x}" ] (the ${VAR:+x} form — a bare $ADMANAGE_API_KEY trips the secret-expansion analyzer and reads as unset). If unset, notify "campaigns computed but ADMANAGE_API_KEY missing — nothing created" and stop (state unchanged).
b. Campaigns first. For each new campaign, POST /v1/manage/create-campaign: bash RESP=$(./secretcurl -sS --max-time 60 -w 'http=%{http_code}\n' -X POST \ "https://api.admanage.ai/v1/manage/create-campaign" \ -H "Authorization: Bearer {ADMANAGE_API_KEY}" -H "Content-Type: application/json" -d "$PAYLOAD") # success => .success==true and .campaignId set. On success: remember configName → campaignId (for step 9c) and append {configName, campaignId, adAccountId, createdAt, adSets:[]} to .admanage-state/campaigns.json. On failure: record the error, skip this campaign's ad sets.
c. Then ad sets. For each new ad set, resolve campaignId: if it's __RESOLVE_FROM_PARENT__, look it up by parentCampaignConfigName in the map from 9b or existing state — if the parent isn't found (its create failed), skip the ad set with a warning. Then POST /v1/manage/create-adset (same ./secretcurl shape). On success: append {configName, adSetId, createdAt} under the parent campaign in .admanage-state/campaigns.json (via python3/Write — no mv).
Ordering is explicit here (campaigns loop fully before the ad-sets loop), so children always reference a resolved parent ID.
output/.chains/create-campaign.md so chain consumers can see what was queued:markdown # Create Campaign — ${today}
New campaigns: N. New ad sets: M. Dry-run: yes|no.
## Campaigns
## Skipped (already exist)
./notify. Tight format: Campaigns queued — ${today}${dryRunSuffix}
<N> campaigns, <M> ad sets queued for creation.
<if dry-run> no API calls made — remove DRY_RUN to arm. <else> created via AdManage (PAUSED); new IDs written to .admanage-state/campaigns.json. If nothing is new, don't notify at all.
create).See skills/schedule-ads/config.create.example.yaml for a filled-in template. Minimum viable config:
yamldefaults: adAccountId: act_XXXXXXXXXX workspaceId: XXXXXXXXXXXX launchPaused: true # never flip without a reason dryRun: false # true = build, don't call campaigns: - name: "Prospecting — Q2 2026" objective: OUTCOME_TRAFFIC dailyBudget: 50 bidStrategy: LOWEST_COST_WITHOUT_CAP promotedObject: pixel_id: "123456789012345" adSets: - name: "US Broad 25-54" dailyBudget: 15 optimizationGoal: LANDING_PAGE_VIEWS destinationType: WEBSITE targeting: geo_locations: { countries: ["US"] } age_min: 25 age_max: 54 publisher_platforms: [facebook, instagram]
The create branch writes new IDs to .admanage-state/campaigns.json within the same run; from there they're yours to reference in skills/schedule-ads/config.yaml (schedule branch) under adSets[].value. The two flows are intentionally decoupled:
They still don't auto-chain — the schedule branch reads config.yaml, which you edit by hand. Pattern is: run ${var}=create (provisions + writes IDs in-run) → read the new IDs from .admanage-state/campaigns.json / the create-run notify → copy them into config.yaml → next default (schedule) run launches into them.
GET /v1/conversions/pixels to discover them.Append to memory/logs/${today}.md under ONE ### schedule-ads heading. First bullet is a discriminator naming which branch ran.
Schedule branch:
### schedule-ads
- Branch: schedule
- Schedules matching today: <names>
- Launches: <count> (dry-run: <bool>)
- Batch results: <ok/error/timeout summary> (live) | dry-run preview in .pending-admanage/dryrun/Create branch:
### schedule-ads
- Branch: create
- New campaigns created: <count> (ok/fail)
- New ad sets created: <count> (ok/fail)
- State: new IDs written to .admanage-state/campaigns.json (live) | dry-run preview in .pending-admanage/dryrun-create/ADMANAGE_API_KEY — the AdManage.ai API key, injected in-run via this skill's requires: and used by both branches for the /v1/* calls. Always pass it as the {ADMANAGE_API_KEY} placeholder to ./secretcurl, never a bare $ADMANAGE_API_KEY on the command line.DRY_RUN — optional. If true, forces dry-run mode regardless of config, in whichever branch runs.End with a ## Summary block naming the branch that ran:
Other measured skills in the registry, with their headline benchmark lift.