ad-upload

star 258

Prepare and upload Meta ad copy/images with official Ads CLI-first workflows where supported. Builds payloads, enforces dry-run/PAUSED-only guardrails, and creates or refreshes ads only after approval. Downstream of ad-copy-generator.

TheMattBerman By TheMattBerman schedule Updated 4/30/2026

name: ad-upload description: "Prepare and upload Meta ad copy/images with official Ads CLI-first workflows where supported. Builds payloads, enforces dry-run/PAUSED-only guardrails, and creates or refreshes ads only after approval. Downstream of ad-copy-generator." metadata: openclaw: emoji: "๐Ÿš€" user-invocable: true homepage: https://github.com/TheMattBerman/meta-ads-kit requires: env: - ACCESS_TOKEN - AD_ACCOUNT_ID


Ad Upload

Take the copy and images from ad-copy-generator, validate them locally, generate a dry-run payload, and only then prepare an official Ads CLI-backed upload/create flow. No copy-paste into Ads Manager. No live change without approval.

This skill handles the guarded chain: validate โ†’ dry-run artifact โ†’ creative/ad command preview โ†’ approved PAUSED create.

Read workspace/brand/ per the _vibe-system protocol if available.


Brand Memory Integration

Reads: stack.md, assets.md, learnings.md (all optional)

File What it provides
workspace/brand/stack.md Stored ad account ID, default ad set IDs, target CPA
workspace/brand/assets.md Index of existing creatives and their IDs
workspace/brand/learnings.md Past upload patterns, what's worked

Writes:

File What it contains
workspace/brand/assets.md Appends uploaded creative IDs and ad IDs
workspace/campaigns/{name}/ads/{creative}.upload.json Full API response โ€” creative ID, ad ID, status

Setup

Official Ads CLI auth

# System user token + ad account for official Ads CLI
export ACCESS_TOKEN="your_system_user_token"
export AD_ACCOUNT_ID="act_123456789"

# Verify CLI/auth posture
./scripts/meta-kit.sh doctor
meta ads adaccount list

Use direct Graph API only as a fallback for fields/features not yet exposed by official Ads CLI.

Store in workspace/brand/stack.md so you don't set these every time:

ad_account: act_123456789
default_adset_id: 23847293847

The Upload Chain

Every ad goes through four steps:

1. Validate copy + image
       |
2. Write dry-run artifact with exact command/payload preview
       |
3. Create/upload creative via official CLI where supported
       |
4. Create PAUSED ad only after explicit approval

Step 1: Validate Before Uploading

Run validation before touching the API. Catch errors locally.

Copy Validation

Element Rule Error
Headline Max 40 chars (hard stop at 50) "Headline '[text]' is [N] chars โ€” truncates on mobile"
Body 50-500 chars per variant "Body V1 too short (<50 chars)"
Description Max 30 chars "Description too long โ€” will be cut off"
Titles array At least 1, max 5 "Need at least 1 title"
Bodies array At least 1, max 5 "Need at least 1 body"
Call to action Must be valid Meta CTA type "LEARN_MORE is valid; 'click here' is not"

Valid Meta CTA Types

LEARN_MORE, SHOP_NOW, SIGN_UP, BOOK_TRAVEL, DOWNLOAD,
GET_OFFER, GET_QUOTE, SUBSCRIBE, WATCH_MORE, APPLY_NOW,
CONTACT_US, GET_DIRECTIONS, ORDER_NOW, REQUEST_TIME,
SEE_MENU, SEND_MESSAGE, BUY_TICKETS, CALL_NOW

Image Validation

Check Rule
File exists Error if path not found
Format JPG or PNG only (no WebP for carousel/DPA)
File size Under 30MB
Dimensions Min 600x600px; recommended 1080x1080 (square) or 1080x1350 (4:5)
Aspect ratio 1:1, 4:5, 16:9, or 9:16 โ€” no odd ratios
Text overlay Warn if >20% text (Meta's rule, not enforced but affects delivery)
# Check image dimensions
identify -format "%wx%h" image.jpg   # requires ImageMagick
# Or:
python3 -c "from PIL import Image; img=Image.open('image.jpg'); print(img.size)"

Dry-Run Mode

Add --dry-run to any operation to see exactly what would be sent without hitting the API:

"Upload these ads -- dry run first"
"Dry run: push creative for summer-sale campaign"

Dry-run output shows:

  • Validation results
  • Exact JSON payload that would be sent to each endpoint
  • Estimated creative/ad names
  • No API calls made, no IDs returned

Step 2: Upload Images

Single Image Upload

TOKEN="$ACCESS_TOKEN"
ACCOUNT="$META_AD_ACCOUNT"  # e.g. act_123456789
IMAGE_PATH="/path/to/image.jpg"
IMAGE_NAME="summer-sale-hero.jpg"

curl -s \
  -F "filename=$IMAGE_NAME" \
  -F "source=@$IMAGE_PATH" \
  "https://graph.facebook.com/v22.0/$ACCOUNT/adimages?access_token=$TOKEN" \
  | jq .

Response:

{
  "images": {
    "summer-sale-hero.jpg": {
      "hash": "a1b2c3d4e5f6789abc...",
      "url": "https://www.facebook.com/ads/image/?d=...",
      "width": 1080,
      "height": 1080
    }
  }
}

Extract the hash:

HASH=$(curl -s \
  -F "filename=$IMAGE_NAME" \
  -F "source=@$IMAGE_PATH" \
  "https://graph.facebook.com/v22.0/$ACCOUNT/adimages?access_token=$TOKEN" \
  | jq -r ".images[\"$IMAGE_NAME\"].hash")

echo "Image hash: $HASH"

Batch Image Upload (multiple files)

# Upload multiple images in one call
curl -s \
  -F "filename[0]=hero-v1.jpg" \
  -F "source[0]=@/path/to/hero-v1.jpg" \
  -F "filename[1]=hero-v2.jpg" \
  -F "source[1]=@/path/to/hero-v2.jpg" \
  "https://graph.facebook.com/v22.0/$ACCOUNT/adimages?access_token=$TOKEN" \
  | jq .

Check Existing Image by Hash

If you think an image is already uploaded (check workspace/brand/assets.md):

curl -s \
  "https://graph.facebook.com/v22.0/$ACCOUNT/adimages?hashes=['HASH_HERE']&access_token=$TOKEN" \
  | jq .

Step 3: Create Ad Creative (asset_feed_spec)

This is Meta's Degrees of Freedom format. You provide multiple headlines, bodies, descriptions, and images โ€” Meta automatically tests combinations across placements.

Full asset_feed_spec Creative

ACCOUNT="$META_AD_ACCOUNT"
TOKEN="$ACCESS_TOKEN"
PAGE_ID="YOUR_FACEBOOK_PAGE_ID"
IMAGE_HASH="a1b2c3d4..."  # from Step 2
PIXEL_ID="YOUR_PIXEL_ID"  # optional but recommended

curl -s \
  -X POST \
  "https://graph.facebook.com/v22.0/$ACCOUNT/adcreatives" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Summer Sale - Multi-Copy v1",
    "object_story_spec": {
      "page_id": "'"$PAGE_ID"'"
    },
    "asset_feed_spec": {
      "bodies": [
        {"text": "Your V1 body copy here. Pain point opener, specific stat, soft CTA."},
        {"text": "Your V2 body copy here. Different angle, different psychology."},
        {"text": "Your V3 body copy here. Social proof heavy. Numbers up front."}
      ],
      "titles": [
        {"text": "Headline One - 35 Chars"},
        {"text": "Question Headline?"},
        {"text": "Stat-Driven: 3X Results"}
      ],
      "descriptions": [
        {"text": "Supporting benefit statement."},
        {"text": "Secondary proof point."}
      ],
      "images": [
        {"hash": "'"$IMAGE_HASH"'"}
      ],
      "call_to_actions": [
        {
          "type": "LEARN_MORE",
          "value": {
            "link": "https://yoursite.com/landing-page",
            "link_caption": "yoursite.com"
          }
        }
      ],
      "optimization_type": "DEGREES_OF_FREEDOM"
    },
    "degrees_of_freedom_spec": {
      "creative_features_spec": {
        "standard_enhancements": {"enroll_status": "OPT_IN"}
      }
    },
    "access_token": "'"$TOKEN"'"
  }' \
  | jq .

Response:

{
  "id": "23847293847293847"
}

That id is your CREATIVE_ID for Step 4.

Multiple Images in asset_feed_spec

"images": [
  {"hash": "hash_for_image_1"},
  {"hash": "hash_for_image_2"},
  {"hash": "hash_for_image_3"}
]

Meta will test copy + image combinations. 3 images ร— 3 headlines ร— 3 bodies = 27 combinations. Meta's algorithm finds the winners.

Verify Creative Was Created

CREATIVE_ID="23847293847293847"

curl -s \
  "https://graph.facebook.com/v22.0/$CREATIVE_ID?fields=id,name,status,asset_feed_spec&access_token=$TOKEN" \
  | jq .

Step 4: Create Ad

Create New Ad

ACCOUNT="$META_AD_ACCOUNT"
TOKEN="$ACCESS_TOKEN"
ADSET_ID="23847000000001"  # existing ad set ID
CREATIVE_ID="23847293847293847"  # from Step 3

curl -s \
  -X POST \
  "https://graph.facebook.com/v22.0/$ACCOUNT/ads" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Summer Sale - Multi-Copy v1",
    "adset_id": "'"$ADSET_ID"'",
    "creative": {
      "creative_id": "'"$CREATIVE_ID"'"
    },
    "status": "PAUSED",
    "access_token": "'"$TOKEN"'"
  }' \
  | jq .

Always create as PAUSED first. Review in Ads Manager before activating.

Response:

{
  "id": "23847111111111111"
}

Create Ad with Tracking Spec

curl -s \
  -X POST \
  "https://graph.facebook.com/v22.0/$ACCOUNT/ads" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Summer Sale - Multi-Copy v1",
    "adset_id": "'"$ADSET_ID"'",
    "creative": {
      "creative_id": "'"$CREATIVE_ID"'"
    },
    "status": "PAUSED",
    "tracking_specs": [
      {
        "action.type": ["offsite_conversion"],
        "fb_pixel": ["'"$PIXEL_ID"'"]
      }
    ],
    "access_token": "'"$TOKEN"'"
  }' \
  | jq .

Activate an Ad (after review)

AD_ID="23847111111111111"

curl -s \
  -X POST \
  "https://graph.facebook.com/v22.0/$AD_ID" \
  -d "status=ACTIVE&access_token=$TOKEN" \
  | jq .

Update Existing Ad (Copy Refresh)

When a creative is fatigued, don't rebuild from scratch โ€” swap in fresh copy.

Step 1: Get Current Creative

AD_ID="23847111111111111"

curl -s \
  "https://graph.facebook.com/v22.0/$AD_ID?fields=creative{id,name,asset_feed_spec}&access_token=$TOKEN" \
  | jq .

Step 2: Create New Creative with Fresh Copy

Run Step 3 above with updated bodies/titles/descriptions.

Step 3: Attach New Creative to Existing Ad

NEW_CREATIVE_ID="23847999999999999"

curl -s \
  -X POST \
  "https://graph.facebook.com/v22.0/$AD_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "creative": {
      "creative_id": "'"$NEW_CREATIVE_ID"'"
    },
    "access_token": "'"$TOKEN"'"
  }' \
  | jq .

Note: You're replacing the creative on the existing ad. The ad ID stays the same (metrics history preserved). The old creative still exists โ€” it's just detached from this ad.


Batch Mode

Upload copy for multiple ads in one session.

Batch Input Format

Reads from workspace/campaigns/{name}/ads/batch-upload.json:

{
  "campaign": "summer-sale-2026",
  "adset_id": "23847000000001",
  "page_id": "123456789",
  "destination_url": "https://yoursite.com/lp",
  "ads": [
    {
      "name": "hero-notes-app",
      "image_path": "/path/to/notes-app.jpg",
      "asset_feed_spec_path": "workspace/campaigns/summer-sale-2026/ads/notes-app.json"
    },
    {
      "name": "hero-receipt",
      "image_path": "/path/to/receipt.jpg",
      "asset_feed_spec_path": "workspace/campaigns/summer-sale-2026/ads/receipt.json"
    }
  ]
}

Batch Upload Process

"Upload all ads in summer-sale-2026 campaign"
"Batch upload ads from batch-upload.json"

For each ad in the batch:

  1. Validate copy + image
  2. Upload image โ†’ get hash
  3. Create creative
  4. Create ad (PAUSED)
  5. Log result

Show progress as each completes:

[1/3] notes-app โ€” image uploaded (hash: a1b2c...) โ€” creative created (ID: 238472...) โ€” ad created (ID: 238471...) PAUSED
[2/3] receipt โ€” image uploaded (hash: d4e5f...) โ€” creative created (ID: 238473...) โ€” ad created (ID: 238474...) PAUSED
[3/3] hero-split โ€” image uploaded (hash: g7h8i...) โ€” creative created (ID: 238475...) โ€” ad created (ID: 238476...) PAUSED

Batch complete: 3 ads created, all PAUSED
Review at: https://www.facebook.com/adsmanager/manage/ads

Error Handling

Common Graph API Errors

Error Code Meaning Fix
190 Token expired or invalid Regenerate system user token and update ACCESS_TOKEN
200 Permission missing Add ads_management scope to token
294 Managing ads over rate limit Back off 60s, retry
100 Invalid parameter Check field names and values
2615 Creative rejected by policy Check text overlay %, prohibited content
1487395 Image hash invalid Re-upload image โ€” hash may have expired
368 Temporarily blocked Account flagged, review in Ads Manager

Token Expiry (Error 190)

# Check token validity
curl -s "https://graph.facebook.com/v22.0/debug_token?\
input_token=$ACCESS_TOKEN\
&access_token=$ACCESS_TOKEN" | jq '.data | {valid, expires_at, scopes}'

# If expired, re-auth
# Generate a system user token in Meta Business Suite
# Then re-export
export ACCESS_TOKEN="your_system_user_token"

Rate Limits (Error 294)

Meta's Marketing API uses a score-based rate limit, not a simple per-minute cap. Back off with exponential retry:

# Simple retry wrapper
upload_with_retry() {
  local max_retries=3
  local wait=10
  for i in $(seq 1 $max_retries); do
    result=$(eval "$@")
    if echo "$result" | jq -e '.error.code == 294' > /dev/null 2>&1; then
      echo "Rate limited. Waiting ${wait}s (attempt $i/$max_retries)..."
      sleep $wait
      wait=$((wait * 2))
    else
      echo "$result"
      return
    fi
  done
  echo "Max retries hit. Last response: $result"
}

Permission Issues (Error 200)

Token needs ads_management scope:

Generate a system user token with `ads_read`, `ads_management`, and page scopes as needed

Image Rejected

If the creative API returns a policy rejection on the image:

  1. Check text overlay percentage (>20% often rejected)
  2. Remove any prohibited content (alcohol with people <25, misleading imagery)
  3. Try a cropped or reframed version of the image

Invocation Patterns

"Upload the summer-sale ads to Meta"
"Push this creative to ad set 238470000001"
"Refresh copy on ad ID 238471111111"
"Batch upload all ads in the Q2 campaign"
"Dry run: what would happen if I uploaded these ads?"
"Upload ad but don't activate it yet"

Decision Tree

User asks to upload โ†’
  Have asset_feed_spec JSON? (from ad-copy-generator)
    NO  โ†’ Run ad-copy-generator first, then come back
    YES โ†’ Check for images
  Have images?
    NO  โ†’ Ask: "What images should these ads use?"
    YES โ†’ Validate copy + images
  Validation pass?
    NO  โ†’ Show errors, stop until fixed
    YES โ†’ Dry-run mode?
      YES โ†’ Show what would happen, no API calls
      NO  โ†’ Run upload chain: image โ†’ creative โ†’ ad (PAUSED)
  Ad created?
    YES โ†’ Save IDs to campaign files, show review link
    NO  โ†’ Show error with specific fix

Campaign File Output

After each upload, save results:

workspace/campaigns/{campaign-name}/ads/
  {creative-name}.json             <- asset_feed_spec (from ad-copy-generator)
  {creative-name}.upload.json      <- API response (creative ID, ad ID, status)
  {creative-name}.md               <- Human-readable copy document

upload.json format

{
  "uploaded_at": "2026-02-26T00:00:00Z",
  "campaign": "summer-sale-2026",
  "ad_name": "hero-notes-app",
  "image_hash": "a1b2c3d4e5f6789abc",
  "creative_id": "23847293847293847",
  "ad_id": "23847111111111111",
  "adset_id": "23847000000001",
  "status": "PAUSED",
  "review_url": "https://www.facebook.com/adsmanager/manage/ads?act=123456789"
}

Append to workspace/brand/assets.md:

| summer-sale-2026 hero-notes-app | ad | 2026-02-26 | creative: 238472... ad: 238471... | PAUSED |

Integration

This skill is the downstream piece of the Meta Ads Copilot ecosystem:

  • Upstream (required): ad-copy-generator โ€” produces the asset_feed_spec JSON and copy variants that this skill uploads
  • Upstream (trigger): ad-creative-monitor โ€” detects creative fatigue and flags ads that need copy refresh; hand off to this skill to push fresh copy
  • Downstream: meta-ads โ€” monitor performance of the ads this skill just created; check if new copy is outperforming old

Typical Full Workflow

1. meta-ads          โ†’ "Ad #238471 has frequency 4.2, CTR dropped 40% โ€” refresh needed"
2. ad-copy-generator โ†’ Write 3 new body variants + 3 headlines matched to the image
3. ad-upload         โ†’ Push fresh creative, attach to existing ad (preserves ad ID and history)
4. meta-ads          โ†’ Monitor new creative performance next 7 days

File Handoff

ad-copy-generator saves to:

workspace/campaigns/{name}/ads/{creative}.json  โ† asset_feed_spec ready for API

This skill reads from the same path. Zero copy-paste.


Anti-Patterns

  • Activating ads immediately โ€” Always create as PAUSED, review in Ads Manager first
  • Uploading without dry-run on first use โ€” Use dry-run until you've confirmed the chain works end-to-end
  • Ignoring validation errors โ€” Headline truncation kills CTR; fix it before uploading
  • Uploading duplicate images โ€” Check workspace/brand/assets.md for existing hashes first
  • One-size copy across all placements โ€” asset_feed_spec exists so Meta can optimize per placement; give it options (3+ bodies, 3+ titles)
  • Using WebP images โ€” Meta accepts them sometimes but rejects them in specific placements; stick to JPG/PNG
  • Hard-coding token in scripts โ€” Always read from ACCESS_TOKEN / .env.*.local; never commit tokens
  • Skipping the page ID โ€” Creative will fail without a valid Facebook Page ID in object_story_spec
  • Not saving upload responses โ€” If you don't save the creative ID and ad ID, you can't update or monitor later
  • Creating new ad for copy refresh โ€” Update the creative on the existing ad to preserve metric history and delivery algorithm learning
Install via CLI
npx skills add https://github.com/TheMattBerman/meta-ads-kit --skill ad-upload
Repository Details
star Stars 258
call_split Forks 50
navigation Branch main
article Path SKILL.md
More from Creator
TheMattBerman
TheMattBerman Explore all skills →