Schedule Ads
GitHub基于声明式配置管理多平台付费广告。支持默认调度投放现有广告组,或创建新Meta活动与广告组。具备暂停默认、每日预算熔断及干运行等安全护栏,通过API执行不可逆的支出操作。
Trigger Scenarios
Install
npx skills add aeonfun/aeon --skill Schedule Ads -g -y
SKILL.md
Frontmatter
{
"var": "Selects which flow runs (parse from ${var}):\n- empty \/ unset (default) → SCHEDULE branch: read config.yaml, pick schedule\n entries matching today, and launch those ads in-run via AdManage.ai.\n Launches PAUSED by default; dailySpendCap circuit-breaker; never auto-activates\n live spend.\n- \"create\" → CREATE branch: read config.create.yaml, diff against\n .admanage-state\/campaigns.json, and create the missing Meta campaigns + ad sets\n in-run. On-demand; creates entities PAUSED; returned IDs are written back into\n state so the schedule branch can launch into them.\n",
"name": "Schedule Ads",
"tags": [
"growth",
"ads"
],
"type": "Skill",
"commits": true,
"category": "productivity",
"requires": [
"ADMANAGE_API_KEY"
],
"schedule": "0 8 * * *",
"description": "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.",
"permissions": [
"contents:write"
]
}
${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).
Preamble (both branches)
- Read
memory/MEMORY.mdfor context. Read the last ~3 days ofmemory/logs/for recent launch / provisioning activity — don't re-report a signal already logged. - Parse
${var}:- empty / unset → run the Schedule branch below.
create→ run the Create branch below.- anything else → log
SCHEDULE_ADS_UNKNOWN_SELECTOR: <value>and exit cleanly (no notify).
- Both branches spend real money on ad platforms. The shared safety posture (see each branch) is: PAUSED by default, config-only (never invent campaigns/creative/targeting), dry-run available, and exit silently when there's nothing to do.
Schedule branch (default — empty ${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.
Safety defaults (schedule)
This branch spends real money on ad platforms. Guardrails, in priority order:
- PAUSED by default. Every launch request sets the entity to PAUSED. The operator has to resume manually in the AdManage dashboard before spend starts.
launchPaused: falsein config is the explicit opt-out. - Daily spend cap. Before launching, the branch checks
GET /v1/spend/dailyfor today. If spend ≥dailySpendCapin 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 mode. If
DRY_RUN=truein env ordryRun: truein config, the branch builds the payloads, writes them to.pending-admanage/dryrun/, notifies what would launch, and exits without calling the API. - Config-only. The branch does not invent campaigns, creative, or targeting. If there's no schedule for today, it exits cleanly with no API calls.
- Single source of truth. All ads/campaigns/targeting live in
config.yaml. The branch never generates new creative on the fly.
Network note (schedule)
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:
- Auth'd calls go through
./secretcurlwith the{ADMANAGE_API_KEY}placeholder — never a bare$ADMANAGE_API_KEY(the Bash permission layer refuses that).ADMANAGE_API_KEYis injected in-run viarequires:. - The branch checks the daily spend cap (
GET /v1/spend/daily), then per batch callsPOST /v1/launch, pollsGET /v1/batch-status/{id}to a terminal state, and reports via./notify. - If
ADMANAGE_API_KEYis unset, or the launch/spend call fails, skip the launch and notify — do not retry blindly. There is no deferred postprocess fallback.
Steps (schedule)
-
Load config. Read
skills/schedule-ads/config.yaml. If the file doesn't exist, logSCHEDULE_ADS_NOT_CONFIGUREDand exit cleanly (no notify, no error). The example template lives next to this file asconfig.example.yaml. -
Validate config shape. Required top-level keys:
defaults(withadAccountId,workspaceId,page), andschedules(array). If either is missing, file an issue inmemory/issues/per the CLAUDE.md issue tracker convention, notify once, and exit. -
Pick today's schedule entries. For each entry in
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_TODAYand exit cleanly (no notify). -
Build launch payloads. For each matching schedule entry, construct the AdManage
POST /v1/launchbody:{ "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: PAUSEDon every ad unlessdefaults.launchPausedis explicitlyfalse. Never strip it silently.Template substitutions inside string fields:
{date}→ today's ISO date (YYYY-MM-DD){dateHuman}→ "April 21, 2026" style
-
Pre-flight validation. For each payload:
media[*].urlmust be an absolutehttps://URL. Reject entries with local paths or obviously broken URLs.adSets[*].valuemust be a non-empty string. If missing, skip the entry with a warning in the log.- For Meta entries (
adAccountIdstarts withact_):pageandinstamust be set. TikTok/Snapchat/etc. have their own requirements — don't block on Meta-specific fields for other platforms. titleanddescriptionmust be non-empty.
Drop invalid entries, keep going. Log which ones were skipped and why.
-
Handle dry-run. If
DRY_RUN=trueorconfig.dryRun: true:- Write payloads to
.pending-admanage/dryrun/{schedule-name}-{timestamp}.json. - Notify a preview (see step 9) but with
[DRY RUN]prefix. - Skip step 7.
- This mode exists for the operator to sanity-check before arming real launches.
- Write payloads to
-
Launch in-run. This is the branch's final action — spends real money, so run the guardrails first. Only
./secretcurl,jq,date,echo,mkdir,grep,python3, and theWritetool are available.a. Config check.
[ -n "${ADMANAGE_API_KEY:+x}" ](the${VAR:+x}form — a bare$ADMANAGE_API_KEYtrips 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: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: [ ... ] }: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. -
Write artifact to
output/.chains/schedule-ads.mdso downstream chain consumers can read what was queued. Format:# Schedule Ads — ${today} Queued: N launches across M schedules. Dry-run: yes|no. ## Entries - <schedule name>: <ad count> ads, platform=<meta|tiktok|…>, paused=<bool> - <adName> — <title> -
Notify via
./notify. Keep it tight:*Ads queued — ${today}${dryRunSuffix}* <N> launches queued from <M> schedules. - <schedule name> → <ad count> ads <platform> <paused|LIVE> "<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.
-
Log — see the shared Log section below (discriminator:
schedule).
Config schema (schedule)
See skills/schedule-ads/config.example.yaml for a filled-in template. Minimum viable config:
defaults:
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
What the schedule branch does NOT do
- Does not create campaigns or ad sets. Those must pre-exist in AdManage — use the
createbranch (${var}=create), the dashboard, orPOST /v1/manage/create-campaignseparately. This branch only launches ads into existing ad sets. - Does not upload creative. Media URLs must be hosted somewhere accessible (AdManage CDN, your own CDN, Supabase, wherever). If you need upload, add a separate
upload-ad-mediaskill that callsPOST /v1/media/upload/url. - Does not generate copy. Titles/descriptions come from config. If the operator wants AI-written variants, a separate skill can write them into
config.yamland commit — keeps the launch path boring and auditable. - Does not manage budgets, bids, or targeting. Everything downstream of launch (scaling, pausing losers, budget shifts) lives in follow-up skills or the dashboard.
- Does not launch to Google Ads, Axon, or Taboola in v1. Config schema is deliberately Meta/TikTok/Snapchat/Pinterest/LinkedIn-shaped. Adding Google/Axon later is straightforward but their launch shapes differ enough to need their own validation.
Create branch (${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.
What this branch provisions
Two entity types only:
- Meta campaigns — name, objective, budget, bid strategy, promoted object.
- Meta ad sets — name, budget, optimization goal, targeting (geo/age/platforms), destination.
Everything else (TikTok/Snapchat/Pinterest/LinkedIn campaigns, advanced Meta fields like valueRuleSetId or Advantage+ catalog) is v2+. The shape below is intentionally minimal.
Safety defaults (create)
Same posture as the schedule branch:
- PAUSED by default. Every campaign + ad set is created with
status: PAUSED. No surprise spend. - Idempotent. The branch tracks created entities in
.admanage-state/campaigns.json. If a campaign name already exists in state, it's skipped. Run it twice → no duplicates. - Dry-run mode.
DRY_RUN=trueorconfig.dryRun: true→ payloads written to.pending-admanage/dryrun-create/, notified, no API calls. - Config-only. No config file → exit silently. No invented campaigns, no autonomous provisioning.
Network note (create)
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:
- Auth'd calls go through
./secretcurlwith the{ADMANAGE_API_KEY}placeholder — never a bare$ADMANAGE_API_KEY. The key is injected in-run viarequires:. - Order matters: create all campaigns first (
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.jsonas you go (the workflow's Commit step persists it). - If
ADMANAGE_API_KEYis 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.
Steps (create)
-
Load config. Read
skills/schedule-ads/config.create.yaml. If it doesn't exist, logCREATE_CAMPAIGN_NOT_CONFIGUREDand exit cleanly (no notify). The example template lives next to this file asconfig.create.example.yaml. -
Load state. Read
.admanage-state/campaigns.json. If it doesn't exist, treat as empty. Shape:{ "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" } ] } ] } -
Validate config shape. Required:
defaults.adAccountId,defaults.workspaceId,campaigns[]. Each campaign needsnameandobjective. Each ad set needsname, and eitheroptimizationGoal(explicit) or a compatible parent objective. If validation fails, file an issue inmemory/issues/and exit. -
Compute diff. For each campaign in config:
- Match against state by exact
name. If present, mark asexisting. - If missing, mark as
newand queue a campaign create. - For each ad set under the campaign, match against the parent's
adSets[]in state by name. If missing, mark it for creation (carrying aparentCampaignConfigNamereference you resolve to a real campaign ID in-run, once the parent campaign create returns).
If nothing is new, log
CREATE_CAMPAIGN_ALL_EXISTand exit without notify. - Match against state by exact
-
Build campaign create payloads. Per the AdManage
POST /v1/manage/create-campaignshape:{ "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 forcestatus: PAUSEDunlessdefaults.launchPaused: falseis set explicitly. -
Build ad-set create payloads. Per
POST /v1/manage/create-adset:{ "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 +parentCampaignConfigNamemarks an ad set whosecampaignIdyou 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. -
Pre-flight validation.
adAccountIdmust start withact_(this branch is Meta-only in v1).dailyBudgetmust be a positive number in dollars (not cents).objectivemust be one of the documented Meta objectives:OUTCOME_TRAFFIC,OUTCOME_ENGAGEMENT,OUTCOME_LEADS,OUTCOME_AWARENESS,OUTCOME_SALES,OUTCOME_APP_PROMOTION.- Targeting
geo_locations.countriesmust be a non-empty array. Drop invalid entries, keep going, log what was skipped and why.
-
Handle dry-run. If
DRY_RUN=trueorconfig.dryRun: true: write payloads to.pending-admanage/dryrun-create/instead, notify with a[DRY RUN]prefix, skip step 9. -
Create in-run. This is the branch's final action — provisions real entities, so run only after the diff + pre-flight pass. Only
./secretcurl,jq,date,echo,python3, and theWritetool are available (nomv). Seed.admanage-state/campaigns.jsonto{"campaigns":[]}if missing.a. Config check.
[ -n "${ADMANAGE_API_KEY:+x}" ](the${VAR:+x}form — a bare$ADMANAGE_API_KEYtrips 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: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 byparentCampaignConfigNamein the map from 9b or existing state — if the parent isn't found (its create failed), skip the ad set with a warning. ThenPOST /v1/manage/create-adset(same./secretcurlshape). On success: append{configName, adSetId, createdAt}under the parent campaign in.admanage-state/campaigns.json(viapython3/Write— nomv).Ordering is explicit here (campaigns loop fully before the ad-sets loop), so children always reference a resolved parent ID.
-
Write artifact to
output/.chains/create-campaign.mdso chain consumers can see what was queued:# Create Campaign — ${today} New campaigns: N. New ad sets: M. Dry-run: yes|no. ## Campaigns - <name> — <objective>, $<dailyBudget>/day - ad set: <name> — <optimizationGoal>, $<dailyBudget>/day, <countries> ## Skipped (already exist) - <name> -
Notify via
./notify. Tight format:*Campaigns queued — ${today}${dryRunSuffix}* <N> campaigns, <M> ad sets queued for creation. - <campaign name> - adset: <adset name> — <country>, $<budget>/day <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.
-
Log — see the shared Log section below (discriminator:
create).
Config schema (create)
See skills/schedule-ads/config.create.example.yaml for a filled-in template. Minimum viable config:
defaults:
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]
Interaction with the schedule branch
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:
- create branch provisions structure (container).
- schedule branch launches creative into that structure (contents).
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.
What the create branch does NOT do
- Doesn't touch existing campaigns. Once a campaign is in state, this branch leaves it alone. Budget changes, bid changes, status flips, renames — all handled elsewhere (dashboard or a separate skill).
- Doesn't delete or archive. No destructive paths.
- Doesn't provision media, pages, or pixels. Pixel IDs must already exist in AdManage. Use
GET /v1/conversions/pixelsto discover them. - Doesn't create TikTok / Snapchat / Pinterest / LinkedIn structures. Those have different payload shapes and live in v2.
- Doesn't resume paused campaigns. PAUSED is the end state; the operator unpauses manually when ready.
Log (both branches)
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/
Environment Variables
ADMANAGE_API_KEY— the AdManage.ai API key, injected in-run via this skill'srequires: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_KEYon the command line.DRY_RUN— optional. Iftrue, forces dry-run mode regardless of config, in whichever branch runs.- Notification channels configured via repo secrets (see CLAUDE.md).
Output
End with a ## Summary block naming the branch that ran:
- schedule: schedules matched today, payload count, dry-run yes/no, files written.
- create: new campaigns queued, new ad sets queued, skipped (already-exist) count, dry-run yes/no, files written.
Version History
-
d96f176
Current 2026-07-19 14:10
将广告管理从外部 postprocess 脚本迁移至技能内部通过 ./secretcurl 直接运行 API 调用,实现更严格的内联安全护栏(如支出熔断),并移除旧的脚本依赖。
- fb16753 2026-07-05 12:07


