ops-marketing
Marketing command center. Email campaigns (Klaviyo), paid ads (Meta/Google), analytics (GA4), SEO, and social media metrics. One dashboard for all marketing channels.
Install
npx skills add https://github.com/davepoon/buildwithclaude/tree/main/plugins/claude-ops/skills/ops-marketing
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install davepoon-buildwithclaude@llmmart
git clone https://github.com/davepoon/buildwithclaude.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole davepoon/buildwithclaude collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
OPS ► MARKETING COMMAND CENTER
Runtime Context
Before executing, load available context:
Preferences: Read
${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.jsontimezone— display all timestamps correctlyklaviyo_private_key,meta_ads_token,meta_ad_account_id,ga4_property_id,google_search_console_site— check userConfig keys before env varsgoogle_ads_developer_token,google_ads_client_id,google_ads_client_secret,google_ads_refresh_token,google_ads_customer_id,google_ads_login_customer_id— Google Ads credentials
Daemon health: Read
${CLAUDE_PLUGIN_DATA_DIR}/daemon-health.json- If
action_neededis not null → surface it before running any channel queries
- If
Secrets: Resolve API keys via userConfig → env vars → Doppler MCP (
mcp__doppler__*) → Doppler CLI fallback (see Credential Resolution section below)
CLI/API Reference
Klaviyo REST API
| Endpoint | Method | Description |
|---|---|---|
https://a.klaviyo.com/api/lists/?fields[list]=name,id,profile_count |
GET | All lists + subscriber counts |
https://a.klaviyo.com/api/campaigns/?filter=equals(messages.channel,'email')&sort=-created_at |
GET | Recent campaigns |
https://a.klaviyo.com/api/flows/?filter=equals(status,'live') |
GET | Active flows |
https://a.klaviyo.com/api/metrics/ |
GET | Available metrics |
Auth header: Authorization: Klaviyo-API-Key ${KLAVIYO_KEY} | Revision header: revision: 2024-10-15
Meta Graph API
| Endpoint | Method | Description |
|---|---|---|
https://graph.facebook.com/v18.0/${META_ACCOUNT}/insights?fields=spend,...&date_preset=last_7d |
GET | Account-level ad spend |
https://graph.facebook.com/v18.0/${META_ACCOUNT}/campaigns?fields=name,status,insights{...} |
GET | Campaign breakdown |
https://graph.facebook.com/v18.0/me/accounts?fields=instagram_business_account |
GET | Linked Instagram account |
Auth header: Authorization: Bearer ${META_TOKEN}
Google Analytics 4 (Data API)
| Endpoint | Method | Description |
|---|---|---|
https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runReport |
POST | Run custom report |
Auth: gcloud ADC — GA4_TOKEN=$(gcloud auth application-default print-access-token)
Google Search Console
| Endpoint | Method | Description |
|---|---|---|
https://searchconsole.googleapis.com/webmasters/v3/sites/${GSC_SITE_ENCODED}/searchAnalytics/query |
POST | Search performance data |
Auth: Same gcloud ADC token as GA4
Agent Teams support
If CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 is set, use Agent Teams when gathering channel data in parallel. This enables:
- Agents share context and can coordinate mid-flight
- You can steer priorities in real-time
- Agents report progress as they complete
Team setup (only when flag is enabled):
TeamCreate("marketing-team")
Agent(team_name="marketing-team", name="email-metrics", prompt="Pull Klaviyo subscriber counts, campaign stats, and flow metrics")
Agent(team_name="marketing-team", name="ads-metrics", prompt="Pull Meta Ads spend, ROAS, and campaign breakdown")
Agent(team_name="marketing-team", name="analytics-metrics", prompt="Pull GA4 sessions, conversions, and traffic sources")
Agent(team_name="marketing-team", name="seo-metrics", prompt="Pull Search Console clicks, impressions, and top queries")
If the flag is NOT set, use standard fire-and-forget subagents.
Credential Resolution
Resolve credentials in this order for each service:
Klaviyo
KLAVIYO_KEY="${KLAVIYO_PRIVATE_KEY:-$(claude plugin config get klaviyo_private_key 2>/dev/null)}"
if [ -z "$KLAVIYO_KEY" ]; then
KLAVIYO_KEY="$(doppler secrets get KLAVIYO_PRIVATE_KEY --plain 2>/dev/null)"
fi
Meta Ads
META_TOKEN="${META_ADS_TOKEN:-$(claude plugin config get meta_ads_token 2>/dev/null)}"
META_ACCOUNT="${META_AD_ACCOUNT_ID:-$(claude plugin config get meta_ad_account_id 2>/dev/null)}"
if [ -z "$META_TOKEN" ]; then
META_TOKEN="$(doppler secrets get META_ADS_TOKEN --plain 2>/dev/null)"
fi
GA4
GA4_PROPERTY="${GA4_PROPERTY_ID:-$(claude plugin config get ga4_property_id 2>/dev/null)}"
# GA4 uses gcloud application default credentials — check if configured:
gcloud auth application-default print-access-token 2>/dev/null
Google Search Console
GSC_SITE="${GOOGLE_SEARCH_CONSOLE_SITE:-$(claude plugin config get google_search_console_site 2>/dev/null)}"
# Uses same gcloud ADC as GA4
Google Ads
GADS_API_VERSION="v23"
GADS_DEV_TOKEN="${GOOGLE_ADS_DEVELOPER_TOKEN:-$(claude plugin config get google_ads_developer_token 2>/dev/null)}"
GADS_CLIENT_ID="${GOOGLE_ADS_CLIENT_ID:-$(claude plugin config get google_ads_client_id 2>/dev/null)}"
GADS_CLIENT_SECRET="${GOOGLE_ADS_CLIENT_SECRET:-$(claude plugin config get google_ads_client_secret 2>/dev/null)}"
GADS_REFRESH_TOKEN="${GOOGLE_ADS_REFRESH_TOKEN:-$(claude plugin config get google_ads_refresh_token 2>/dev/null)}"
GADS_CUSTOMER_ID="${GOOGLE_ADS_CUSTOMER_ID:-$(claude plugin config get google_ads_customer_id 2>/dev/null)}"
GADS_LOGIN_CUSTOMER_ID="${GOOGLE_ADS_LOGIN_CUSTOMER_ID:-$(claude plugin config get google_ads_login_customer_id 2>/dev/null)}"
# Doppler fallback
if [ -z "$GADS_REFRESH_TOKEN" ]; then
GADS_REFRESH_TOKEN="$(doppler secrets get GOOGLE_ADS_REFRESH_TOKEN --plain 2>/dev/null)"
fi
if [ -z "$GADS_DEV_TOKEN" ]; then
GADS_DEV_TOKEN="$(doppler secrets get GOOGLE_ADS_DEVELOPER_TOKEN --plain 2>/dev/null)"
fi
# Strip dashes from customer ID (API requires no dashes)
GADS_CUSTOMER_ID="${GADS_CUSTOMER_ID//-/}"
# Refresh access token (expires in ~1 hour — always refresh before API calls)
GADS_ACCESS_TOKEN=$(curl -s -X POST https://oauth2.googleapis.com/token \
--data "client_id=${GADS_CLIENT_ID}" \
--data "client_secret=${GADS_CLIENT_SECRET}" \
--data "refresh_token=${GADS_REFRESH_TOKEN}" \
--data "grant_type=refresh_token" | jq -r '.access_token')
# Common headers for all Google Ads API calls
GADS_HEADERS=(-H "Content-Type: application/json" -H "Authorization: Bearer ${GADS_ACCESS_TOKEN}" -H "developer-token: ${GADS_DEV_TOKEN}")
if [ -n "$GADS_LOGIN_CUSTOMER_ID" ]; then
GADS_HEADERS+=(-H "login-customer-id: ${GADS_LOGIN_CUSTOMER_ID}")
fi
Sub-command Routing
Route $ARGUMENTS to the correct section below:
| Input | Action |
|---|---|
| (empty), dashboard | Run full marketing dashboard |
| email, klaviyo | Klaviyo email metrics |
| ads, meta | Meta Ads performance (read-only overview) |
| meta-manage, meta create-campaign, meta target, meta creative, meta rules, meta audiences, meta advantage | Meta Ads campaign management (see ## meta-manage section) |
| google-ads, gads | Google Ads dashboard + campaign management (see ## google-ads section) |
| analytics, ga4 | GA4 sessions + conversions |
| ga4 realtime, ga4 funnel, ga4 cohort, ga4 audience, ga4 pivot | GA4 advanced analytics (see ## ga4-advanced section) |
| seo, gsc | Search Console metrics |
| social | Social media aggregator |
| instagram, instagram post, instagram reel, instagram story, instagram insights, instagram demographics | Instagram publishing + insights (see ## instagram section) |
| campaigns | Cross-channel campaign overview (all platforms) |
| optimize | Cross-platform ad optimization agent |
| attribution | Unified attribution table (Meta + Google + Klaviyo + GA4) |
| setup | Configure API keys |
email / klaviyo
Pull Klaviyo metrics for last 30 days.
Subscriber count
curl -s "https://a.klaviyo.com/api/lists/?fields[list]=name,id,profile_count" \
-H "Authorization: Klaviyo-API-Key ${KLAVIYO_KEY}" \
-H "revision: 2024-10-15" | jq '.data[] | {name: .attributes.name, id: .id, count: .attributes.profile_count}'
Recent campaigns (last 10)
curl -s "https://a.klaviyo.com/api/campaigns/?filter=equals(messages.channel,'email')&sort=-created_at&page[size]=10&fields[campaign]=name,status,created_at,send_time" \
-H "Authorization: Klaviyo-API-Key ${KLAVIYO_KEY}" \
-H "revision: 2024-10-15" | jq '.data[] | {name: .attributes.name, status: .attributes.status, sent: .attributes.send_time}'
Flow metrics (active flows)
curl -s "https://a.klaviyo.com/api/flows/?filter=equals(status,'live')&fields[flow]=name,status,created,trigger_type" \
-H "Authorization: Klaviyo-API-Key ${KLAVIYO_KEY}" \
-H "revision: 2024-10-15" | jq '.data[] | {name: .attributes.name, trigger: .attributes.trigger_type}'
Key email metrics (opens, clicks, revenue via metric aggregates)
# Get metric IDs first
curl -s "https://a.klaviyo.com/api/metrics/" \
-H "Authorization: Klaviyo-API-Key ${KLAVIYO_KEY}" \
-H "revision: 2024-10-15" | jq '.data[] | select(.attributes.name | test("Opened Email|Clicked Email|Placed Order")) | {name: .attributes.name, id: .id}'
Output format
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
EMAIL (KLAVIYO) — last 30d
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Lists: [list_name] — [N] subscribers
Campaigns: [N sent] | [N drafts]
Active Flows: [N]
RECENT CAMPAIGNS
[name] [status] sent [date]
...
ads / meta
Pull Meta Ads insights for the configured ad account.
Account-level spend (last 7 days)
curl -s "https://graph.facebook.com/v18.0/${META_ACCOUNT}/insights?fields=spend,impressions,clicks,ctr,cpc,actions,action_values&date_preset=last_7d&level=account" \
-H "Authorization: Bearer ${META_TOKEN}" | jq '{spend: .data[0].spend, impressions: .data[0].impressions, clicks: .data[0].clicks, ctr: .data[0].ctr, cpc: .data[0].cpc}'
Campaign breakdown (last 7 days)
curl -s "https://graph.facebook.com/v18.0/${META_ACCOUNT}/campaigns?fields=name,status,daily_budget,lifetime_budget,insights{spend,impressions,clicks,actions,action_values}&date_preset=last_7d" \
-H "Authorization: Bearer ${META_TOKEN}" | jq '.data[] | {name: .name, status: .status, spend: .insights.data[0].spend}'
ROAS calculation
From action_values array: extract action_type == "purchase" value, divide by spend.
Top performing ads (last 7d)
curl -s "https://graph.facebook.com/v18.0/${META_ACCOUNT}/ads?fields=name,adset_id,insights{spend,impressions,clicks,actions,action_values,ctr,cpc}&date_preset=last_7d&limit=10" \
-H "Authorization: Bearer ${META_TOKEN}" | jq '.data | sort_by(.insights.data[0].spend | tonumber) | reverse | .[0:5] | .[] | {name: .name, spend: .insights.data[0].spend, ctr: .insights.data[0].ctr}'
Output format
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
META ADS — last 7d
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Spend: $[X]
ROAS: [X]x
Purchases: [N] ($[X] revenue)
Impressions: [N] CTR: [X]%
CPC: $[X]
CAMPAIGNS
[name] [status] $[spend] [roas]x ROAS
...
TOP ADS (by spend)
[name] $[spend] [ctr]% CTR
meta-manage
Full Meta Ads campaign management. Uses same META_TOKEN and META_ACCOUNT credentials as read-only ads section.
Credential check: If META_TOKEN is empty, print Meta Ads not configured. Run /ops:marketing setup. and stop.
Route $ARGUMENTS within meta-manage:
| Input | Action |
|---|---|
| create-campaign | Create a new campaign (always PAUSED) |
| target <ADSET_ID> | Configure ad set targeting |
| creative <CAMPAIGN_ID> | Upload image + create ad with copy |
| rules | List / create automation rules |
| audiences | Create custom or lookalike audiences |
| advantage | Create Advantage+ AI-optimized campaign |
create-campaign
Collect via AskUserQuestion (max 4 options each call):
- Campaign objective —
[OUTCOME_TRAFFIC, OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_AWARENESS] - Daily budget in dollars (free text)
- Campaign name (free text)
Then confirm via AskUserQuestion: "Create Meta campaign '<NAME>' with $<BUDGET>/day budget?" options [Create, Cancel]
BUDGET_CENTS=$(awk "BEGIN {printf \"%d\", ${BUDGET_DOLLARS} * 100}")
curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/campaigns" \
-H "Authorization: Bearer ${META_TOKEN}" \
-F "name=${CAMPAIGN_NAME}" \
-F "objective=${OBJECTIVE}" \
-F "status=PAUSED" \
-F "special_ad_categories=[]" \
-F "daily_budget=${BUDGET_CENTS}" | jq '{id: .id, error: .error.message}'
Print: Campaign "${CAMPAIGN_NAME}" created (ID: <ID>, status: PAUSED, budget: $<BUDGET>/day). Enable via Meta Ads Manager or add ad sets first.
If error, print the error message.
target <ADSET_ID>
Configure targeting for an existing ad set. Collect via AskUserQuestion:
- Target countries (comma-separated ISO codes, e.g.
US,CA,GB) — free text - Age range:
[18-34, 25-54, 35-65, 18-65] - Gender:
[All, Men only, Women only, Skip]
# Build geo_locations JSON
GEO_JSON=$(echo "$COUNTRIES" | tr ',' '\n' | jq -Rc '.' | jq -sc '{"countries": .}')
# Build targeting spec
TARGETING_JSON=$(jq -n \
--argjson geo "$GEO_JSON" \
--arg age_min "$AGE_MIN" \
--arg age_max "$AGE_MAX" \
'{
geo_locations: $geo,
age_min: ($age_min | tonumber),
age_max: ($age_max | tonumber)
}')
# Add gender filter if requested
if [ "$GENDER" = "Men only" ]; then
TARGETING_JSON=$(echo "$TARGETING_JSON" | jq '. + {"genders": [1]}')
elif [ "$GENDER" = "Women only" ]; then
TARGETING_JSON=$(echo "$TARGETING_JSON" | jq '. + {"genders": [2]}')
fi
curl -s -X POST "https://graph.facebook.com/v20.0/${ADSET_ID}" \
-H "Authorization: Bearer ${META_TOKEN}" \
-F "targeting=${TARGETING_JSON}" | jq '{success: .success, error: .error.message}'
Print: Ad set ${ADSET_ID} targeting updated: ${COUNTRIES}, ages ${AGE_MIN}-${AGE_MAX}${GENDER_LABEL}.
creative <CAMPAIGN_ID>
Upload an image and create an ad. Collect via AskUserQuestion:
- Image file path or URL (free text)
- Ad set ID to attach the ad to (free text)
- Primary text (ad copy, free text — up to 125 characters recommended)
Then collect headline (free text, up to 40 characters) via a second AskUserQuestion.
# Step 1: Upload image
if [[ "$IMAGE_INPUT" == http* ]]; then
# Upload by URL
UPLOAD_RESP=$(curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/adimages" \
-H "Authorization: Bearer ${META_TOKEN}" \
-F "url=${IMAGE_INPUT}")
else
# Upload by file (multipart)
UPLOAD_RESP=$(curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/adimages" \
-H "Authorization: Bearer ${META_TOKEN}" \
-F "filename=@${IMAGE_INPUT}")
fi
IMAGE_HASH=$(echo "$UPLOAD_RESP" | jq -r '.images | to_entries[0].value.hash // empty')
if [ -z "$IMAGE_HASH" ]; then
echo "Image upload failed: $(echo "$UPLOAD_RESP" | jq -r '.error.message // "unknown error"')"
exit 0
fi
# Resolve the Facebook Page ID. Meta's `object_story_spec.page_id` requires a
# real Page ID — the ad account ID (with `act_` stripped) is NOT a Page ID and
# the API call will fail. Require META_PAGE_ID in env or plugin prefs.
META_PAGE_ID="${META_PAGE_ID:-$(claude plugin config get meta_page_id 2>/dev/null || echo "")}"
if [ -z "$META_PAGE_ID" ]; then
echo "META_PAGE_ID is required to create an ad creative. Set it via:"
echo " claude plugin config set meta_page_id <your_fb_page_id>"
echo "Find your Page ID at https://www.facebook.com/<your-page>/about_profile_transparency"
exit 0
fi
# Step 2: Create ad creative
CREATIVE_RESP=$(curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/adcreatives" \
-H "Authorization: Bearer ${META_TOKEN}" \
-F "name=Creative for ${AD_NAME}" \
-F "object_story_spec={\"page_id\": \"${META_PAGE_ID}\", \"link_data\": {\"image_hash\": \"${IMAGE_HASH}\", \"message\": \"${PRIMARY_TEXT}\", \"name\": \"${HEADLINE}\"}}")
CREATIVE_ID=$(echo "$CREATIVE_RESP" | jq -r '.id // empty')
if [ -z "$CREATIVE_ID" ]; then
echo "Creative creation failed: $(echo "$CREATIVE_RESP" | jq -r '.error.message // "unknown error"')"
exit 0
fi
# Step 3: Create ad (status PAUSED — Rule 5)
curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/ads" \
-H "Authorization: Bearer ${META_TOKEN}" \
-F "name=${AD_NAME}" \
-F "adset_id=${ADSET_ID}" \
-F "creative={\"creative_id\": \"${CREATIVE_ID}\"}" \
-F "status=PAUSED" | jq '{id: .id, error: .error.message}'
Print: Ad created (ID: <ID>, creative: <CREATIVE_ID>, status: PAUSED). Enable via Meta Ads Manager when ready.
rules
List existing rules or create a new automation rule.
List rules:
curl -s "https://graph.facebook.com/v20.0/${META_ACCOUNT}/adrules_library?fields=name,status,evaluation_spec,execution_spec" \
-H "Authorization: Bearer ${META_TOKEN}" | jq '.data[] | {id: .id, name: .name, status: .status}'
Create rule (prompt via AskUserQuestion):
- Rule type:
[Pause low performers, Scale winners, Increase budget, Decrease budget]
For "Pause low performers":
# Pause ads where CPA > $50 and spend > $20 in last 7 days
curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/adrules_library" \
-H "Authorization: Bearer ${META_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"name": "Pause high CPA ads",
"schedule_spec": {"schedule_type": "SEMI_HOURLY"},
"evaluation_spec": {
"evaluation_type": "SCHEDULE",
"filters": [
{"field": "cost_per_result", "value": [50], "operator": "GREATER_THAN"},
{"field": "spent", "value": [20], "operator": "GREATER_THAN"},
{"field": "entity_type", "value": ["AD"], "operator": "EQUAL"},
{"field": "time_preset", "value": ["LAST_7_DAYS"], "operator": "EQUAL"}
]
},
"execution_spec": {
"execution_type": "PAUSE"
},
"status": "ENABLED"
}' | jq '{id: .id, error: .error.message}'
For "Scale winners":
⚠️ Scope this to prospecting ad sets. A bare
purchase_roas > 3filter auto-scales retargeting ad sets too — whose ROAS is inflated by warm-audience demand capture (conversions that would have happened anyway), not incremental growth. Blanket-scaling them pours budget into demand you already own while starving prospecting, and the funnel contracts a month later. Add an ad-set-name/audience filter that excludes retargeting/remarketing (or restrict the rule to your prospecting ad sets), and confirm a winner's lift with a holdout before scaling on ROAS alone.
# Increase budget 20% for ad sets with ROAS > 3x in last 7 days
# NOTE: restrict to prospecting ad sets — see caveat above
curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/adrules_library" \
-H "Authorization: Bearer ${META_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"name": "Scale winning ad sets",
"schedule_spec": {"schedule_type": "DAILY"},
"evaluation_spec": {
"evaluation_type": "SCHEDULE",
"filters": [
{"field": "purchase_roas", "value": [3], "operator": "GREATER_THAN"},
{"field": "entity_type", "value": ["ADSET"], "operator": "EQUAL"},
{"field": "time_preset", "value": ["LAST_7_DAYS"], "operator": "EQUAL"}
]
},
"execution_spec": {
"execution_type": "INCREASE_BUDGET",
"execution_options": [{"field": "budget_value", "value": "20", "operator": "PERCENTAGE"}]
},
"status": "ENABLED"
}' | jq '{id: .id, error: .error.message}'
Print: Rule created (ID: <ID>). Runs semi-hourly and will auto-pause ads with CPA > $50.
audiences
Create Custom Audience or Lookalike Audience.
Prompt via AskUserQuestion:
- Audience type:
[Custom — website, Custom — customer list, Lookalike, Skip]
Custom — website (Pixel-based):
curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/customaudiences" \
-H "Authorization: Bearer ${META_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"name": "Website visitors — last 30 days",
"subtype": "WEBSITE",
"retention_days": 30,
"rule": {"inclusions": {"operator": "or", "rules": [{"event_sources": [{"id": "<PIXEL_ID>", "type": "pixel"}], "retention_seconds": 2592000, "filter": {"operator": "and", "filters": [{"field": "event", "operator": "eq", "value": "PageView"}]}}]}}
}' | jq '{id: .id, name: .name, error: .error.message}'
Note: Replace <PIXEL_ID> with actual pixel ID from Meta Events Manager.
Lookalike Audience (requires origin audience with min 100 matched profiles):
# Prompt for origin audience ID via AskUserQuestion (free text)
curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/customaudiences" \
-H "Authorization: Bearer ${META_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Lookalike — ${ORIGIN_AUDIENCE_NAME} 1%\",
\"subtype\": \"LOOKALIKE\",
\"origin_audience_id\": \"${ORIGIN_AUDIENCE_ID}\",
\"lookalike_spec\": {
\"country\": \"US\",
\"ratio\": 0.01,
\"type\": \"similarity\"
}
}" | jq '{id: .id, name: .name, error: .error.message}'
Print: Lookalike audience created (ID: <ID>). Typically takes 1-6 hours to populate.
advantage
Create an Advantage+ Shopping Campaign (AI-optimized).
Collect via AskUserQuestion:
- Daily budget in dollars (free text)
- Campaign name (free text)
Then confirm: "Create Advantage+ campaign '<NAME>' with $<BUDGET>/day?" options [Create, Cancel]
BUDGET_CENTS=$(awk "BEGIN {printf \"%d\", ${BUDGET_DOLLARS} * 100}")
curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/campaigns" \
-H "Authorization: Bearer ${META_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"${CAMPAIGN_NAME}\",
\"objective\": \"OUTCOME_SALES\",
\"status\": \"PAUSED\",
\"special_ad_categories\": [],
\"daily_budget\": ${BUDGET_CENTS},
\"smart_promotion_type\": \"AUTOMATED_SHOPPING_ADS\"
}" | jq '{id: .id, error: .error.message}'
Print: Advantage+ campaign "${CAMPAIGN_NAME}" created (ID: <ID>, status: PAUSED). Meta AI will optimize targeting and creative delivery once enabled.
analytics / ga4
Pull GA4 data via the Data API using gcloud ADC.
Get access token
GA4_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null)
Sessions + conversions (last 7d)
curl -s -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runReport" \
-H "Authorization: Bearer ${GA4_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"dateRanges": [{"startDate": "7daysAgo", "endDate": "today"}],
"metrics": [
{"name": "sessions"},
{"name": "totalUsers"},
{"name": "conversions"},
{"name": "totalRevenue"},
{"name": "bounceRate"},
{"name": "averageSessionDuration"}
]
}' | jq '.rows[0].metricValues | {sessions: .[0].value, users: .[1].value, conversions: .[2].value, revenue: .[3].value, bounce_rate: .[4].value}'
Traffic sources (last 7d)
curl -s -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runReport" \
-H "Authorization: Bearer ${GA4_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"dateRanges": [{"startDate": "7daysAgo", "endDate": "today"}],
"dimensions": [{"name": "sessionDefaultChannelGrouping"}],
"metrics": [{"name": "sessions"}, {"name": "conversions"}],
"orderBys": [{"metric": {"metricName": "sessions"}, "desc": true}],
"limit": 8
}' | jq '.rows[] | {channel: .dimensionValues[0].value, sessions: .metricValues[0].value, conversions: .metricValues[1].value}'
Top pages (last 7d)
curl -s -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runReport" \
-H "Authorization: Bearer ${GA4_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"dateRanges": [{"startDate": "7daysAgo", "endDate": "today"}],
"dimensions": [{"name": "pagePath"}],
"metrics": [{"name": "screenPageViews"}, {"name": "averageSessionDuration"}],
"orderBys": [{"metric": {"metricName": "screenPageViews"}, "desc": true}],
"limit": 10
}' | jq '.rows[] | {page: .dimensionValues[0].value, views: .metricValues[0].value}'
If GA4_TOKEN is empty or gcloud not available, output: GA4 not configured — run /ops:marketing setup or configure gcloud ADC.
Output format
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ANALYTICS (GA4) — last 7d
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Sessions: [N] Users: [N]
Conversions: [N] CVR: [X]%
Revenue: $[X]
Bounce Rate: [X]% Avg Session: [Xm Xs]
TRAFFIC SOURCES
[channel] [N sessions] [N conversions]
...
TOP PAGES
[path] [N views]
ga4-advanced
Advanced GA4 analytics: realtime, funnel, cohort, audience export, and pivot reports.
Credential check: If GA4_TOKEN is empty or GA4_PROPERTY is missing, print GA4 not configured — run /ops:marketing setup or configure gcloud ADC and stop.
Route $ARGUMENTS within ga4-advanced (matches ga4 <sub> pattern):
| Input | Action |
|---|---|
| realtime | Active users right now (last 30 min) |
| funnel | Conversion funnel with step visualization |
| cohort | Cohort retention analysis by device |
| audience | Async audience segment export |
| pivot | Multi-dimensional pivot report |
realtime
GA4_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null)
RESULT=$(curl -s -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runRealtimeReport" \
-H "Authorization: Bearer ${GA4_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"minuteRanges": [{"startMinutesAgo": 29, "endMinutesAgo": 0}],
"dimensions": [
{"name": "unifiedScreenName"},
{"name": "deviceCategory"}
],
"metrics": [{"name": "activeUsers"}],
"orderBys": [{"metric": {"metricName": "activeUsers"}, "desc": true}],
"limit": 10
}')
TOTAL=$(echo "$RESULT" | jq '[.rows[]?.metricValues[0].value | tonumber] | add // 0')
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " GA4 REALTIME — Last 30 Minutes"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Active Users Right Now: ${TOTAL}"
echo ""
echo "Top Pages:"
printf "| %-40s | %-8s | %-7s |\n" "Page" "Device" "Users"
printf "|%s|%s|%s|\n" "------------------------------------------" "----------" "---------"
echo "$RESULT" | jq -r '.rows[]? | [.dimensionValues[0].value, .dimensionValues[1].value, .metricValues[0].value] | @tsv' 2>/dev/null | \
while IFS=$'\t' read -r page device users; do
printf "| %-40s | %-8s | %-7s |\n" "${page:0:40}" "$device" "$users"
done
funnel
Ask user for funnel steps via AskUserQuestion (free text). Default template uses session_start → page_view → purchase.
# NOTE: Uses v1alpha — breaking changes possible per Google's versioning policy
GA4_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null)
# Prompt for funnel type first
# AskUserQuestion: "Funnel mode?" options [Closed funnel, Open funnel]
IS_OPEN=$([ "$FUNNEL_MODE" = "Open funnel" ] && echo "true" || echo "false")
RESULT=$(curl -s -X POST "https://analyticsdata.googleapis.com/v1alpha/properties/${GA4_PROPERTY}:runFunnelReport" \
-H "Authorization: Bearer ${GA4_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"dateRanges\": [{\"startDate\": \"30daysAgo\", \"endDate\": \"today\"}],
\"funnel\": {
\"isOpenFunnel\": ${IS_OPEN},
\"steps\": [
{
\"name\": \"Session Start\",
\"filterExpression\": {\"funnelEventFilter\": {\"eventName\": \"session_start\"}}
},
{
\"name\": \"Page View\",
\"filterExpression\": {\"funnelEventFilter\": {\"eventName\": \"page_view\"}}
},
{
\"name\": \"Purchase\",
\"filterExpression\": {\"funnelEventFilter\": {\"eventName\": \"purchase\"}}
}
]
},
\"funnelBreakdown\": {
\"breakdownDimension\": {\"name\": \"deviceCategory\"},
\"limit\": 4
}
}")
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " GA4 FUNNEL — Last 30 Days (${FUNNEL_MODE})"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "$RESULT" | jq -r '
.funnelTable.rows[]? |
"Step: \(.dimensionValues[0].value) Users: \(.metricValues[0].value) Completion: \(.metricValues[1].value)% Abandoned: \(.metricValues[2].value)"
' 2>/dev/null || echo "No funnel data — ensure purchase events are firing in GA4."
cohort
Weekly cohort retention for users acquired in the past month, broken down by device.
GA4_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null)
START_DATE=$(date -v-30d +%Y-%m-%d 2>/dev/null || date -d '30 days ago' +%Y-%m-%d)
END_DATE=$(date +%Y-%m-%d)
RESULT=$(curl -s -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runReport" \
-H "Authorization: Bearer ${GA4_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"dimensions\": [
{\"name\": \"cohort\"},
{\"name\": \"cohortNthWeek\"},
{\"name\": \"deviceCategory\"}
],
\"metrics\": [
{\"name\": \"cohortActiveUsers\"},
{\"name\": \"cohortRetentionFraction\"}
],
\"cohortSpec\": {
\"cohorts\": [{
\"dimension\": \"firstSessionDate\",
\"dateRange\": {\"startDate\": \"${START_DATE}\", \"endDate\": \"${END_DATE}\"}
}],
\"cohortsRange\": {
\"granularity\": \"WEEKLY\",
\"startOffset\": 0,
\"endOffset\": 5
}
}
}")
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " GA4 COHORT RETENTION — Last 30 Days"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
printf "| %-12s | %-6s | %-8s | %-10s | %-10s |\n" "Cohort" "Week" "Device" "Users" "Retention%"
printf "|%s|%s|%s|%s|%s|\n" "--------------" "--------" "----------" "------------" "------------"
echo "$RESULT" | jq -r '.rows[]? | [
.dimensionValues[0].value,
.dimensionValues[1].value,
.dimensionValues[2].value,
.metricValues[0].value,
(.metricValues[1].value | tonumber * 100 | tostring | split(".")[0])
] | @tsv' 2>/dev/null | \
while IFS=$'\t' read -r cohort week device users retention; do
printf "| %-12s | %-6s | %-8s | %-10s | %-10s |\n" "$cohort" "$week" "$device" "$users" "${retention}%"
done
audience
Async audience export: create → poll until ACTIVE → show user count.
GA4_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null)
# Step 1: List available audiences so user can pick one
AUDIENCES=$(curl -s "https://analyticsadmin.googleapis.com/v1alpha/properties/${GA4_PROPERTY}/audiences" \
-H "Authorization: Bearer ${GA4_TOKEN}")
echo "Available audiences:"
echo "$AUDIENCES" | jq -r '.audiences[]? | "\(.name | split("/") | last): \(.displayName)"' 2>/dev/null
# AskUserQuestion: "Enter audience ID from list above:" (free text)
# Step 2: Create export
EXPORT_RESP=$(curl -s -X POST \
"https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}/audienceExports" \
-H "Authorization: Bearer ${GA4_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"audience\": \"properties/${GA4_PROPERTY}/audiences/${AUDIENCE_ID}\",
\"dimensions\": [
{\"dimensionName\": \"deviceId\"},
{\"dimensionName\": \"isAdsPersonalizationAllowed\"}
]
}")
EXPORT_NAME=$(echo "$EXPORT_RESP" | jq -r '.name // empty')
if [ -z "$EXPORT_NAME" ]; then
echo "Failed to create export: $(echo "$EXPORT_RESP" | jq -r '.error.message // "unknown error"')"
exit 0
fi
echo "Export created: ${EXPORT_NAME}"
echo "Polling for completion (small audiences: ~30s, large: up to 15 min)..."
# Step 3: Poll until ACTIVE or FAILED
ATTEMPTS=0
while [ $ATTEMPTS -lt 60 ]; do
STATUS_RESP=$(curl -s "https://analyticsdata.googleapis.com/v1beta/${EXPORT_NAME}" \
-H "Authorization: Bearer ${GA4_TOKEN}")
STATE=$(echo "$STATUS_RESP" | jq -r '.state // "UNKNOWN"')
PCT=$(echo "$STATUS_RESP" | jq -r '.percentageCompleted // 0')
if [ "$STATE" = "ACTIVE" ]; then break; fi
if [ "$STATE" = "FAILED" ]; then
echo "Export failed. Try again or check GA4 audience configuration."
exit 0
fi
echo " State: ${STATE} (${PCT}% complete)..."
sleep 10
ATTEMPTS=$((ATTEMPTS + 1))
done
# Step 4: Query results
QUERY_RESP=$(curl -s -X POST \
"https://analyticsdata.googleapis.com/v1beta/${EXPORT_NAME}:query" \
-H "Authorization: Bearer ${GA4_TOKEN}")
ROW_COUNT=$(echo "$QUERY_RESP" | jq '.rowCount // 0')
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " GA4 AUDIENCE EXPORT"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Audience ID: ${AUDIENCE_ID}"
echo " Users exported: ${ROW_COUNT}"
echo " Ads-eligible: $(echo "$QUERY_RESP" | jq '[.audienceRows[]? | select(.dimensionValues[1].value == "true")] | length') users"
echo ""
echo "Export ready. Use this audience for retargeting in Meta or Google Ads."
pivot
Multi-dimensional pivot: channel group × device category × conversions.
GA4_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null)
RESULT=$(curl -s -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runPivotReport" \
-H "Authorization: Bearer ${GA4_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"dateRanges": [{"startDate": "30daysAgo", "endDate": "today"}],
"dimensions": [
{"name": "sessionDefaultChannelGrouping"},
{"name": "deviceCategory"}
],
"metrics": [
{"name": "sessions"},
{"name": "conversions"},
{"name": "totalRevenue"}
],
"pivots": [
{
"fieldNames": ["sessionDefaultChannelGrouping"],
"limit": 6
},
{
"fieldNames": ["deviceCategory"],
"limit": 3
}
]
}')
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " GA4 PIVOT — Channel × Device (Last 30 Days)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
printf "| %-20s | %-8s | %-10s | %-11s | %-10s |\n" "Channel" "Device" "Sessions" "Conversions" "Revenue"
printf "|%s|%s|%s|%s|%s|\n" "----------------------" "----------" "------------" "-------------" "------------"
echo "$RESULT" | jq -r '.rows[]? | [
.dimensionValues[0].value,
.dimensionValues[1].value,
.metricValues[0].value,
.metricValues[1].value,
(.metricValues[2].value | tonumber | . * 100 | round / 100 | tostring)
] | @tsv' 2>/dev/null | \
while IFS=$'\t' read -r channel device sessions convs revenue; do
printf "| %-20s | %-8s | %-10s | %-11s | \$%-9s |\n" "${channel:0:20}" "$device" "$sessions" "$convs" "$revenue"
done
seo / gsc
Pull Google Search Console data.
Get access token (same gcloud ADC)
GSC_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null)
GSC_SITE_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${GSC_SITE}', safe=''))" 2>/dev/null || echo "${GSC_SITE}" | sed 's|:|%3A|g; s|/|%2F|g')
Search performance (last 28 days)
curl -s -X POST "https://searchconsole.googleapis.com/webmasters/v3/sites/${GSC_SITE_ENCODED}/searchAnalytics/query" \
-H "Authorization: Bearer ${GSC_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"startDate": "'$(date -v-28d +%Y-%m-%d 2>/dev/null || date -d '28 days ago' +%Y-%m-%d)'",
"endDate": "'$(date +%Y-%m-%d)'",
"dimensions": [],
"rowLimit": 1
}' | jq '{clicks: .rows[0].clicks, impressions: .rows[0].impressions, ctr: .rows[0].ctr, position: .rows[0].position}'
Top queries (last 28 days)
curl -s -X POST "https://searchconsole.googleapis.com/webmasters/v3/sites/${GSC_SITE_ENCODED}/searchAnalytics/query" \
-H "Authorization: Bearer ${GSC_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"startDate": "'$(date -v-28d +%Y-%m-%d 2>/dev/null || date -d '28 days ago' +%Y-%m-%d)'",
"endDate": "'$(date +%Y-%m-%d)'",
"dimensions": ["query"],
"rowLimit": 20,
"dimensionFilterGroups": []
}' | jq '.rows[] | {query: .keys[0], clicks: .clicks, impressions: .impressions, position: (.position | floor)}'
Top pages by clicks
curl -s -X POST "https://searchconsole.googleapis.com/webmasters/v3/sites/${GSC_SITE_ENCODED}/searchAnalytics/query" \
-H "Authorization: Bearer ${GSC_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"startDate": "'$(date -v-28d +%Y-%m-%d 2>/dev/null || date -d '28 days ago' +%Y-%m-%d)'",
"endDate": "'$(date +%Y-%m-%d)'",
"dimensions": ["page"],
"rowLimit": 10
}' | jq '.rows[] | {page: .keys[0], clicks: .clicks, impressions: .impressions, position: (.position | floor)}'
If GSC not configured, output: Search Console not configured — run /ops:marketing setup.
Output format
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SEO (SEARCH CONSOLE) — last 28d
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Clicks: [N]
Impressions: [N]
CTR: [X]%
Avg Position: [X]
TOP QUERIES
[query] [clicks] clicks pos [N]
...
TOP PAGES
[url] [clicks] clicks [impressions] impr
social
Aggregate available social media metrics. Check which are configured.
Instagram (via Meta Graph API — same token as Meta Ads)
# Get Instagram Business Account ID linked to the ad account
curl -s "https://graph.facebook.com/v18.0/me/accounts?fields=instagram_business_account" \
-H "Authorization: Bearer ${META_TOKEN}" | jq '.data[].instagram_business_account.id' 2>/dev/null
# Then pull media insights
curl -s "https://graph.facebook.com/v18.0/${IG_ACCOUNT_ID}?fields=followers_count,media_count,profile_views" \
-H "Authorization: Bearer ${META_TOKEN}" | jq '{followers: .followers_count, posts: .media_count, profile_views: .profile_views}'
YouTube (if configured via gcloud)
YT_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null)
curl -s "https://www.googleapis.com/youtube/v3/channels?part=statistics&mine=true" \
-H "Authorization: Bearer ${YT_TOKEN}" | jq '.items[0].statistics | {subscribers: .subscriberCount, views: .viewCount, videos: .videoCount}'
Show [not configured] for any unconfigured channels rather than failing.
Output format
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SOCIAL MEDIA
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Instagram: [N followers] [N posts] [N profile views]
YouTube: [N subscribers] [N total views]
TikTok: [not configured] — set TIKTOK_ACCESS_TOKEN
Instagram publishing and insights via Instagram Graph API (same META_TOKEN as Meta Ads).
Prerequisites:
META_TOKENconfigured (same as Meta Ads)- Instagram Business account linked to a Facebook Page
IG_ACCOUNT_IDresolved via:curl "https://graph.facebook.com/v21.0/me/accounts?fields=instagram_business_account" -H "Authorization: Bearer ${META_TOKEN}"→data[0].instagram_business_account.id
Rate limit: 200 API calls/hour per app. Demographics require 48h reporting delay. Media insights require account with >1,000 followers.
Resolve IG account ID at the start of every instagram invocation:
IG_ACCOUNT_ID=$(claude plugin config get instagram_account_id 2>/dev/null)
if [ -z "$IG_ACCOUNT_ID" ]; then
IG_ACCOUNT_ID=$(curl -s "https://graph.facebook.com/v21.0/me/accounts?fields=instagram_business_account" \
-H "Authorization: Bearer ${META_TOKEN}" | jq -r '.data[0].instagram_business_account.id // empty')
# Cache it
[ -n "$IG_ACCOUNT_ID" ] && claude plugin config set instagram_account_id "$IG_ACCOUNT_ID" 2>/dev/null
fi
if [ -z "$IG_ACCOUNT_ID" ]; then
echo "Instagram Business account not linked to your Meta token. Ensure your Facebook Page has an Instagram Business account connected."
exit 0
fi
Route $ARGUMENTS within instagram:
| Input | Action |
|---|---|
| post <IMAGE_URL> | Publish image post to feed |
| reel <VIDEO_URL> | Publish a Reel |
| story <IMAGE_URL|VIDEO_URL> | Publish a Story |
| insights <MEDIA_ID> | Per-post metrics |
| account-insights [days] | Account-level reach + impressions |
| demographics | Audience age/gender/location |
post
Publish an image post (two-step: create container → publish).
Collect via AskUserQuestion:
- Image URL (publicly accessible HTTPS URL) — free text
- Caption — free text
# Step 1: Create media container
CONTAINER=$(curl -s -X POST "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/media" \
-H "Authorization: Bearer ${META_TOKEN}" \
-F "image_url=${IMAGE_URL}" \
-F "caption=${CAPTION}" \
-F "media_type=IMAGE")
CONTAINER_ID=$(echo "$CONTAINER" | jq -r '.id // empty')
if [ -z "$CONTAINER_ID" ]; then
echo "Failed to create media container: $(echo "$CONTAINER" | jq -r '.error.message // "unknown error"')"
exit 0
fi
# Step 2: Publish
PUBLISH=$(curl -s -X POST "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/media_publish" \
-H "Authorization: Bearer ${META_TOKEN}" \
-F "creation_id=${CONTAINER_ID}")
MEDIA_ID=$(echo "$PUBLISH" | jq -r '.id // empty')
if [ -n "$MEDIA_ID" ]; then
echo "Post published (Media ID: ${MEDIA_ID}). View at https://www.instagram.com/ — may take 1-2 min to appear."
else
echo "Publish failed: $(echo "$PUBLISH" | jq -r '.error.message // "unknown error"')"
fi
reel
Publish a Reel. Video must be an HTTPS URL (MP4, H.264, max 15 min, min 500px width).
Collect via AskUserQuestion:
- Video URL (HTTPS) — free text
- Caption — free text
# Step 1: Create video container (async — must poll for status)
CONTAINER=$(curl -s -X POST "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/media" \
-H "Authorization: Bearer ${META_TOKEN}" \
-F "media_type=REELS" \
-F "video_url=${VIDEO_URL}" \
-F "caption=${CAPTION}" \
-F "share_to_feed=true")
CONTAINER_ID=$(echo "$CONTAINER" | jq -r '.id // empty')
if [ -z "$CONTAINER_ID" ]; then
echo "Failed to create Reel container: $(echo "$CONTAINER" | jq -r '.error.message // "unknown error"')"
exit 0
fi
echo "Reel uploading... polling for ready status."
# Poll until status is FINISHED
ATTEMPTS=0
while [ $ATTEMPTS -lt 30 ]; do
STATUS=$(curl -s "https://graph.facebook.com/v21.0/${CONTAINER_ID}?fields=status_code" \
-H "Authorization: Bearer ${META_TOKEN}" | jq -r '.status_code // "UNKNOWN"')
[ "$STATUS" = "FINISHED" ] && break
[ "$STATUS" = "ERROR" ] && echo "Reel processing failed." && exit 0
sleep 10
ATTEMPTS=$((ATTEMPTS + 1))
done
# Step 2: Publish
PUBLISH=$(curl -s -X POST "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/media_publish" \
-H "Authorization: Bearer ${META_TOKEN}" \
-F "creation_id=${CONTAINER_ID}")
MEDIA_ID=$(echo "$PUBLISH" | jq -r '.id // empty')
if [ -n "$MEDIA_ID" ]; then
echo "Reel published (Media ID: ${MEDIA_ID}). Reach and plays metrics available after 24-48h."
else
echo "Reel publish failed: $(echo "$PUBLISH" | jq -r '.error.message // "unknown error"')"
fi
story
Publish a Story (image or video, 24h expiry).
Collect via AskUserQuestion:
- Content URL (HTTPS image or video) — free text
- Content type:
[Image story, Video story]
if [ "$CONTENT_TYPE" = "Video story" ]; then
MEDIA_TYPE="VIDEO"
URL_FIELD="video_url"
else
MEDIA_TYPE="IMAGE"
URL_FIELD="image_url"
fi
CONTAINER=$(curl -s -X POST "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/media" \
-H "Authorization: Bearer ${META_TOKEN}" \
-F "media_type=STORIES" \
-F "${URL_FIELD}=${CONTENT_URL}")
CONTAINER_ID=$(echo "$CONTAINER" | jq -r '.id // empty')
if [ -z "$CONTAINER_ID" ]; then
echo "Failed to create Story container: $(echo "$CONTAINER" | jq -r '.error.message // "unknown error"')"
exit 0
fi
PUBLISH=$(curl -s -X POST "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/media_publish" \
-H "Authorization: Bearer ${META_TOKEN}" \
-F "creation_id=${CONTAINER_ID}")
MEDIA_ID=$(echo "$PUBLISH" | jq -r '.id // empty')
if [ -n "$MEDIA_ID" ]; then
echo "Story published (Media ID: ${MEDIA_ID}). Expires after 24 hours."
else
echo "Story publish failed: $(echo "$PUBLISH" | jq -r '.error.message // "unknown error"')"
fi
insights <MEDIA_ID>
Per-post metrics. Note: reach, saves, shares deprecated for non-Reels video; plays only for Reels/video.
METRICS="reach,saved,shares,comments_count,like_count,impressions"
# For Reels, add plays: detect via media_type field
MEDIA_TYPE=$(curl -s "https://graph.facebook.com/v21.0/${MEDIA_ID}?fields=media_type" \
-H "Authorization: Bearer ${META_TOKEN}" | jq -r '.media_type // "IMAGE"')
[ "$MEDIA_TYPE" = "VIDEO" ] || [ "$MEDIA_TYPE" = "REEL" ] && METRICS="${METRICS},plays"
RESULT=$(curl -s "https://graph.facebook.com/v21.0/${MEDIA_ID}/insights?metric=${METRICS}&period=lifetime" \
-H "Authorization: Bearer ${META_TOKEN}")
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " INSTAGRAM POST INSIGHTS — ${MEDIA_ID}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "$RESULT" | jq -r '.data[]? | " \(.name): \(.values[0].value)"' 2>/dev/null || \
echo "No insights data — account must have >1,000 followers for insights access."
account-insights [days]
Account-level reach and impressions. Default: last 7 days.
DAYS="${DAYS:-7}"
END_DATE=$(date +%Y-%m-%d)
START_DATE=$(date -v-${DAYS}d +%Y-%m-%d 2>/dev/null || date -d "${DAYS} days ago" +%Y-%m-%d)
RESULT=$(curl -s "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/insights?metric=reach,impressions,profile_views&period=day&since=${START_DATE}&until=${END_DATE}" \
-H "Authorization: Bearer ${META_TOKEN}")
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " INSTAGRAM ACCOUNT INSIGHTS — Last ${DAYS} Days"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Note: Data reflects 24-48h reporting delay"
echo ""
REACH=$(echo "$RESULT" | jq '[.data[]? | select(.name == "reach") | .values[]?.value | tonumber] | add // 0')
IMPRESSIONS=$(echo "$RESULT" | jq '[.data[]? | select(.name == "impressions") | .values[]?.value | tonumber] | add // 0')
PROFILE_VIEWS=$(echo "$RESULT" | jq '[.data[]? | select(.name == "profile_views") | .values[]?.value | tonumber] | add // 0')
echo " Reach: ${REACH}"
echo " Impressions: ${IMPRESSIONS}"
echo " Profile Views: ${PROFILE_VIEWS}"
demographics
Audience breakdown by age/gender and top locations. Requires lifetime period (48h delay).
RESULT=$(curl -s "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/insights?metric=audience_gender_age,audience_city,audience_country&period=lifetime" \
-H "Authorization: Bearer ${META_TOKEN}")
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " INSTAGRAM DEMOGRAPHICS"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Note: Top 45 segments shown. Data has 48h delay."
echo ""
echo "AGE / GENDER BREAKDOWN:"
echo "$RESULT" | jq -r '.data[]? | select(.name == "audience_gender_age") | .values[0].value | to_entries[] | " \(.key): \(.value)%"' 2>/dev/null | head -20
echo ""
echo "TOP CITIES:"
echo "$RESULT" | jq -r '.data[]? | select(.name == "audience_city") | .values[0].value | to_entries | sort_by(-.value) | .[0:10][] | " \(.key): \(.value)%"' 2>/dev/null
echo ""
echo "TOP COUNTRIES:"
echo "$RESULT" | jq -r '.data[]? | select(.name == "audience_country") | .values[0].value | to_entries | sort_by(-.value) | .[0:10][] | " \(.key): \(.value)%"' 2>/dev/null
google-ads
Credential check: If GADS_DEV_TOKEN or GADS_REFRESH_TOKEN is empty after resolution, print:
Warning: Google Ads not configured. Run /ops:setup marketing to set up credentials.
and stop.
Token refresh: Run the access token refresh curl (from Credential Resolution above) at the start of every google-ads invocation. If GADS_ACCESS_TOKEN is null or "null", print:
Warning: Google Ads token refresh failed. Check client_id/client_secret/refresh_token in /ops:setup.
and stop.
Route $ARGUMENTS within the google-ads section:
| Input | Action |
|---|---|
| (empty), dashboard, overview | Campaign performance dashboard (last 7 days) |
| search-terms, terms | Search Terms Report with negative keyword candidates (last 30 days) |
| budget-recs, recommendations, recs | Budget optimization recommendations from Google |
| campaigns, manage | Campaign management — list, create, pause, enable, adjust budget |
| keywords, kw, keyword-planner | Keyword Planner — discover keywords with volume and bid data |
| ad-groups, ag | Ad group management — list, create, add/remove keywords, adjust bids |
Dashboard (default — no args, dashboard, overview)
# Campaign performance — last 7 days
CAMPAIGNS=$(curl -s -X POST \
"https://googleads.googleapis.com/${GADS_API_VERSION}/customers/${GADS_CUSTOMER_ID}/googleAds:searchStream" \
"${GADS_HEADERS[@]}" \
--data-binary '{
"query": "SELECT campaign.id, campaign.name, campaign.status, campaign_budget.amount_micros, metrics.cost_micros, metrics.impressions, metrics.clicks, metrics.conversions, metrics.conversions_value FROM campaign WHERE segments.date DURING LAST_7_DAYS AND campaign.status != REMOVED ORDER BY metrics.cost_micros DESC LIMIT 20"
}')
# Check for API error
GADS_ERROR=$(echo "$CAMPAIGNS" | jq -r '.[0].error.message // empty' 2>/dev/null)
if [ -n "$GADS_ERROR" ]; then
echo "Google Ads API error: ${GADS_ERROR}"
echo "Check credentials with /ops:marketing setup."
exit 0
fi
# Check for empty results
CAMPAIGN_COUNT=$(echo "$CAMPAIGNS" | jq '[.[].results[]?] | length' 2>/dev/null || echo "0")
if [ "$CAMPAIGN_COUNT" -eq 0 ]; then
echo "No active campaigns found in the last 7 days."
exit 0
fi
# Compute totals
TOTAL_SPEND=$(echo "$CAMPAIGNS" | jq '[.[].results[]?.metrics.costMicros // "0" | tonumber] | add / 1000000' 2>/dev/null)
TOTAL_CONVERSIONS=$(echo "$CAMPAIGNS" | jq '[.[].results[]?.metrics.conversions // "0" | tonumber] | add' 2>/dev/null)
TOTAL_VALUE=$(echo "$CAMPAIGNS" | jq '[.[].results[]?.metrics.conversionsValue // "0" | tonumber] | add' 2>/dev/null)
OVERALL_ROAS=$(awk "BEGIN { if (${TOTAL_SPEND:-0} > 0) printf \"%.2f\", ${TOTAL_VALUE:-0} / ${TOTAL_SPEND:-1}; else print \"—\" }")
TOTAL_SPEND_FMT=$(awk "BEGIN { printf \"%.2f\", ${TOTAL_SPEND:-0} }")
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " GOOGLE ADS — Last 7 Days"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Total Spend: \$${TOTAL_SPEND_FMT}"
echo "Total Conversions: ${TOTAL_CONVERSIONS}"
echo "Overall ROAS: ${OVERALL_ROAS}"
echo ""
# Print table header
printf "| %-30s | %-8s | %-10s | %-10s | %-8s | %-8s | %-6s | %-6s | %-6s |\n" \
"Campaign" "Status" "Budget/day" "Spend" "Impr" "Clicks" "CTR" "Conv" "ROAS"
printf "|%s|%s|%s|%s|%s|%s|%s|%s|%s|\n" \
"--------------------------------" "----------" "------------" "------------" "----------" "----------" "--------" "--------" "--------"
# Print each campaign row
echo "$CAMPAIGNS" | jq -r '.[].results[]? | [
.campaign.name,
.campaign.status,
(.campaignBudget.amountMicros // "0" | tonumber / 1000000),
(.metrics.costMicros // "0" | tonumber / 1000000),
(.metrics.impressions // "0" | tonumber),
(.metrics.clicks // "0" | tonumber),
(.metrics.conversions // "0" | tonumber),
(.metrics.conversionsValue // "0" | tonumber),
(.metrics.costMicros // "0" | tonumber)
] | @tsv' 2>/dev/null | while IFS=$'\t' read -r name status budget_raw spend_raw impr_raw clicks_raw conv_raw value_raw cost_raw; do
BUDGET=$(awk "BEGIN { printf \"%.2f\", ${budget_raw:-0} }")
SPEND=$(awk "BEGIN { printf \"%.2f\", ${spend_raw:-0} }")
CTR=$(awk "BEGIN { if (${impr_raw:-0} > 0) printf \"%.2f\", ${clicks_raw:-0} / ${impr_raw:-0} * 100; else print \"0.00\" }")
ROAS=$(awk "BEGIN { if (${spend_raw:-0} > 0) printf \"%.2f\", ${value_raw:-0} / ${spend_raw:-1}; else print \"—\" }")
printf "| %-30s | %-8s | \$%-9s | \$%-9s | %-8s | %-8s | %-5s%% | %-6s | %-6s |\n" \
"${name:0:30}" "$status" "$BUDGET" "$SPEND" "$impr_raw" "$clicks_raw" "$CTR" "$conv_raw" "$ROAS"
done
Search Terms (search-terms, terms)
SEARCH_TERMS=$(curl -s -X POST \
"https://googleads.googleapis.com/${GADS_API_VERSION}/customers/${GADS_CUSTOMER_ID}/googleAds:searchStream" \
"${GADS_HEADERS[@]}" \
--data-binary '{
"query": "SELECT search_term_view.search_term, search_term_view.status, campaign.name, ad_group.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM search_term_view WHERE segments.date DURING LAST_30_DAYS AND metrics.impressions > 0 ORDER BY metrics.impressions DESC LIMIT 100"
}')
# Check for API error
GADS_ST_ERROR=$(echo "$SEARCH_TERMS" | jq -r '.[0].error.message // empty' 2>/dev/null)
if [ -n "$GADS_ST_ERROR" ]; then
echo "Google Ads API error: ${GADS_ST_ERROR}"
echo "Check credentials with /ops:marketing setup."
exit 0
fi
TERM_COUNT=$(echo "$SEARCH_TERMS" | jq '[.[].results[]?] | length' 2>/dev/null || echo "0")
if [ "$TERM_COUNT" -eq 0 ]; then
echo "No search term data found for the last 30 days."
exit 0
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " SEARCH TERMS REPORT — Last 30 Days"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
printf "| %-30s | %-10s | %-20s | %-15s | %-6s | %-6s | %-7s | %-6s |\n" \
"Search Term" "Status" "Campaign" "Ad Group" "Impr" "Clicks" "Cost" "Conv"
printf "|%s|%s|%s|%s|%s|%s|%s|%s|\n" \
"--------------------------------" "------------" "----------------------" "-----------------" "--------" "--------" "---------" "--------"
# Status mapping and table rows
echo "$SEARCH_TERMS" | jq -r '.[].results[]? | [
.searchTermView.searchTerm,
.searchTermView.status,
.campaign.name,
.adGroup.name,
(.metrics.impressions // "0" | tostring),
(.metrics.clicks // "0" | tostring),
(.metrics.costMicros // "0" | tonumber / 1000000 | tostring),
(.metrics.conversions // "0" | tostring)
] | @tsv' 2>/dev/null | while IFS=$'\t' read -r term status campaign adgroup impr clicks cost_raw conv; do
case "$status" in
ADDED) STATUS_LABEL="✓ Added" ;;
EXCLUDED) STATUS_LABEL="✗ Excluded" ;;
*) STATUS_LABEL="○ New" ;;
esac
COST=$(awk "BEGIN { printf \"%.2f\", ${cost_raw:-0} }")
printf "| %-30s | %-10s | %-20s | %-15s | %-6s | %-6s | \$%-6s | %-6s |\n" \
"${term:0:30}" "$STATUS_LABEL" "${campaign:0:20}" "${adgroup:0:15}" "$impr" "$clicks" "$COST" "$conv"
done
echo ""
echo "Negative keyword candidates (high spend, zero conversions):"
echo "$SEARCH_TERMS" | jq -r '.[].results[]? | select(
(.metrics.conversions // "0" | tonumber) == 0 and
(.metrics.costMicros // "0" | tonumber) > 1000000
) | " • \(.searchTermView.searchTerm) — $\(.metrics.costMicros | tonumber / 1000000 | tostring | split(".") | .[0] + "." + (.[1] // "00")[0:2])"' 2>/dev/null || echo " (none found)"
Budget Recommendations (budget-recs, recommendations, recs)
RECS=$(curl -s -X POST \
"https://googleads.googleapis.com/${GADS_API_VERSION}/customers/${GADS_CUSTOMER_ID}/googleAds:searchStream" \
"${GADS_HEADERS[@]}" \
--data-binary '{
"query": "SELECT recommendation.resource_name, recommendation.type, recommendation.campaign, recommendation.impact, recommendation.campaign_budget_recommendation FROM recommendation WHERE recommendation.type IN (CAMPAIGN_BUDGET, MOVE_UNUSED_BUDGET, MARGINAL_ROI_CAMPAIGN_BUDGET, FORECASTING_CAMPAIGN_BUDGET)"
}')
# Check for API error
GADS_REC_ERROR=$(echo "$RECS" | jq -r '.[0].error.message // empty' 2>/dev/null)
if [ -n "$GADS_REC_ERROR" ]; then
echo "Google Ads API error: ${GADS_REC_ERROR}"
echo "Check credentials with /ops:marketing setup."
exit 0
fi
REC_COUNT=$(echo "$RECS" | jq '[.[].results[]?] | length' 2>/dev/null || echo "0")
if [ "$REC_COUNT" -eq 0 ]; then
echo "No budget recommendations available. Google needs campaign data to generate recommendations."
exit 0
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " BUDGET RECOMMENDATIONS"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
printf "| %-22s | %-25s | %-15s | %-13s | %-20s |\n" \
"Type" "Campaign" "Current Budget" "Recommended" "Impact"
printf "|%s|%s|%s|%s|%s|\n" \
"------------------------" "---------------------------" "-----------------" "---------------" "----------------------"
echo "$RECS" | jq -r '.[].results[]? | [
.recommendation.type,
.recommendation.campaign,
(.recommendation.campaignBudgetRecommendation.currentBudgetAmountMicros // "0" | tonumber / 1000000 | tostring),
(.recommendation.campaignBudgetRecommendation.recommendedBudgetAmountMicros // "0" | tonumber / 1000000 | tostring),
(.recommendation.impact.baseMetrics.impressions // "0" | tonumber | tostring),
(.recommendation.impact.potentialMetrics.impressions // "0" | tonumber | tostring)
] | @tsv' 2>/dev/null | while IFS=$'\t' read -r rec_type campaign current_raw recommended_raw base_impr_raw pot_impr_raw; do
case "$rec_type" in
CAMPAIGN_BUDGET) TYPE_LABEL="Increase Budget" ;;
MOVE_UNUSED_BUDGET) TYPE_LABEL="Move Unused Budget" ;;
MARGINAL_ROI_CAMPAI
Files (buildwithclaude)
-
SKILL.md 84.2 KB
--- name: ops-marketing description: Marketing command center. Email campaigns (Klaviyo), paid ads (Meta/Google), analytics (GA4), SEO, and social media metrics. One dashboard for all marketing channels. argument-hint: "[email|ads|analytics|seo|social|campaigns|setup]" allowed-tools: - Bash - Read - Write - Grep - Glob - Agent - TeamCreate - SendMessage - AskUserQuestion - WebFetch - WebSearch effort: medium maxTurns: 40 --- # OPS ► MARKETING COMMAND CENTER ## Runtime Context Before executing, load available context: 1. **Preferences**: Read `${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json` - `timezone` — display all timestamps correctly - `klaviyo_private_key`, `meta_ads_token`, `meta_ad_account_id`, `ga4_property_id`, `google_search_console_site` — check userConfig keys before env vars - `google_ads_developer_token`, `google_ads_client_id`, `google_ads_client_secret`, `google_ads_refresh_token`, `google_ads_customer_id`, `google_ads_login_customer_id` — Google Ads credentials 2. **Daemon health**: Read `${CLAUDE_PLUGIN_DATA_DIR}/daemon-health.json` - If `action_needed` is not null → surface it before running any channel queries 3. **Secrets**: Resolve API keys via userConfig → env vars → Doppler MCP (`mcp__doppler__*`) → Doppler CLI fallback (see Credential Resolution section below) ## CLI/API Reference ### Klaviyo REST API | Endpoint | Method | Description | |----------|--------|-------------| | `https://a.klaviyo.com/api/lists/?fields[list]=name,id,profile_count` | GET | All lists + subscriber counts | | `https://a.klaviyo.com/api/campaigns/?filter=equals(messages.channel,'email')&sort=-created_at` | GET | Recent campaigns | | `https://a.klaviyo.com/api/flows/?filter=equals(status,'live')` | GET | Active flows | | `https://a.klaviyo.com/api/metrics/` | GET | Available metrics | **Auth header**: `Authorization: Klaviyo-API-Key ${KLAVIYO_KEY}` | **Revision header**: `revision: 2024-10-15` ### Meta Graph API | Endpoint | Method | Description | |----------|--------|-------------| | `https://graph.facebook.com/v18.0/${META_ACCOUNT}/insights?fields=spend,...&date_preset=last_7d` | GET | Account-level ad spend | | `https://graph.facebook.com/v18.0/${META_ACCOUNT}/campaigns?fields=name,status,insights{...}` | GET | Campaign breakdown | | `https://graph.facebook.com/v18.0/me/accounts?fields=instagram_business_account` | GET | Linked Instagram account | **Auth header**: `Authorization: Bearer ${META_TOKEN}` ### Google Analytics 4 (Data API) | Endpoint | Method | Description | |----------|--------|-------------| | `https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runReport` | POST | Run custom report | **Auth**: gcloud ADC — `GA4_TOKEN=$(gcloud auth application-default print-access-token)` ### Google Search Console | Endpoint | Method | Description | |----------|--------|-------------| | `https://searchconsole.googleapis.com/webmasters/v3/sites/${GSC_SITE_ENCODED}/searchAnalytics/query` | POST | Search performance data | **Auth**: Same gcloud ADC token as GA4 ## Agent Teams support If `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` is set, use **Agent Teams** when gathering channel data in parallel. This enables: - Agents share context and can coordinate mid-flight - You can steer priorities in real-time - Agents report progress as they complete **Team setup** (only when flag is enabled): ``` TeamCreate("marketing-team") Agent(team_name="marketing-team", name="email-metrics", prompt="Pull Klaviyo subscriber counts, campaign stats, and flow metrics") Agent(team_name="marketing-team", name="ads-metrics", prompt="Pull Meta Ads spend, ROAS, and campaign breakdown") Agent(team_name="marketing-team", name="analytics-metrics", prompt="Pull GA4 sessions, conversions, and traffic sources") Agent(team_name="marketing-team", name="seo-metrics", prompt="Pull Search Console clicks, impressions, and top queries") ``` If the flag is NOT set, use standard fire-and-forget subagents. ## Credential Resolution Resolve credentials in this order for each service: ### Klaviyo ```bash KLAVIYO_KEY="${KLAVIYO_PRIVATE_KEY:-$(claude plugin config get klaviyo_private_key 2>/dev/null)}" if [ -z "$KLAVIYO_KEY" ]; then KLAVIYO_KEY="$(doppler secrets get KLAVIYO_PRIVATE_KEY --plain 2>/dev/null)" fi ``` ### Meta Ads ```bash META_TOKEN="${META_ADS_TOKEN:-$(claude plugin config get meta_ads_token 2>/dev/null)}" META_ACCOUNT="${META_AD_ACCOUNT_ID:-$(claude plugin config get meta_ad_account_id 2>/dev/null)}" if [ -z "$META_TOKEN" ]; then META_TOKEN="$(doppler secrets get META_ADS_TOKEN --plain 2>/dev/null)" fi ``` ### GA4 ```bash GA4_PROPERTY="${GA4_PROPERTY_ID:-$(claude plugin config get ga4_property_id 2>/dev/null)}" # GA4 uses gcloud application default credentials — check if configured: gcloud auth application-default print-access-token 2>/dev/null ``` ### Google Search Console ```bash GSC_SITE="${GOOGLE_SEARCH_CONSOLE_SITE:-$(claude plugin config get google_search_console_site 2>/dev/null)}" # Uses same gcloud ADC as GA4 ``` ### Google Ads ```bash GADS_API_VERSION="v23" GADS_DEV_TOKEN="${GOOGLE_ADS_DEVELOPER_TOKEN:-$(claude plugin config get google_ads_developer_token 2>/dev/null)}" GADS_CLIENT_ID="${GOOGLE_ADS_CLIENT_ID:-$(claude plugin config get google_ads_client_id 2>/dev/null)}" GADS_CLIENT_SECRET="${GOOGLE_ADS_CLIENT_SECRET:-$(claude plugin config get google_ads_client_secret 2>/dev/null)}" GADS_REFRESH_TOKEN="${GOOGLE_ADS_REFRESH_TOKEN:-$(claude plugin config get google_ads_refresh_token 2>/dev/null)}" GADS_CUSTOMER_ID="${GOOGLE_ADS_CUSTOMER_ID:-$(claude plugin config get google_ads_customer_id 2>/dev/null)}" GADS_LOGIN_CUSTOMER_ID="${GOOGLE_ADS_LOGIN_CUSTOMER_ID:-$(claude plugin config get google_ads_login_customer_id 2>/dev/null)}" # Doppler fallback if [ -z "$GADS_REFRESH_TOKEN" ]; then GADS_REFRESH_TOKEN="$(doppler secrets get GOOGLE_ADS_REFRESH_TOKEN --plain 2>/dev/null)" fi if [ -z "$GADS_DEV_TOKEN" ]; then GADS_DEV_TOKEN="$(doppler secrets get GOOGLE_ADS_DEVELOPER_TOKEN --plain 2>/dev/null)" fi # Strip dashes from customer ID (API requires no dashes) GADS_CUSTOMER_ID="${GADS_CUSTOMER_ID//-/}" # Refresh access token (expires in ~1 hour — always refresh before API calls) GADS_ACCESS_TOKEN=$(curl -s -X POST https://oauth2.googleapis.com/token \ --data "client_id=${GADS_CLIENT_ID}" \ --data "client_secret=${GADS_CLIENT_SECRET}" \ --data "refresh_token=${GADS_REFRESH_TOKEN}" \ --data "grant_type=refresh_token" | jq -r '.access_token') # Common headers for all Google Ads API calls GADS_HEADERS=(-H "Content-Type: application/json" -H "Authorization: Bearer ${GADS_ACCESS_TOKEN}" -H "developer-token: ${GADS_DEV_TOKEN}") if [ -n "$GADS_LOGIN_CUSTOMER_ID" ]; then GADS_HEADERS+=(-H "login-customer-id: ${GADS_LOGIN_CUSTOMER_ID}") fi ``` --- ## Sub-command Routing Route `$ARGUMENTS` to the correct section below: | Input | Action | |---|---| | (empty), dashboard | Run full marketing dashboard | | email, klaviyo | Klaviyo email metrics | | ads, meta | Meta Ads performance (read-only overview) | | meta-manage, meta create-campaign, meta target, meta creative, meta rules, meta audiences, meta advantage | Meta Ads campaign management (see ## meta-manage section) | | google-ads, gads | Google Ads dashboard + campaign management (see ## google-ads section) | | analytics, ga4 | GA4 sessions + conversions | | ga4 realtime, ga4 funnel, ga4 cohort, ga4 audience, ga4 pivot | GA4 advanced analytics (see ## ga4-advanced section) | | seo, gsc | Search Console metrics | | social | Social media aggregator | | instagram, instagram post, instagram reel, instagram story, instagram insights, instagram demographics | Instagram publishing + insights (see ## instagram section) | | campaigns | Cross-channel campaign overview (all platforms) | | optimize | Cross-platform ad optimization agent | | attribution | Unified attribution table (Meta + Google + Klaviyo + GA4) | | setup | Configure API keys | --- ## email / klaviyo Pull Klaviyo metrics for last 30 days. ### Subscriber count ```bash curl -s "https://a.klaviyo.com/api/lists/?fields[list]=name,id,profile_count" \ -H "Authorization: Klaviyo-API-Key ${KLAVIYO_KEY}" \ -H "revision: 2024-10-15" | jq '.data[] | {name: .attributes.name, id: .id, count: .attributes.profile_count}' ``` ### Recent campaigns (last 10) ```bash curl -s "https://a.klaviyo.com/api/campaigns/?filter=equals(messages.channel,'email')&sort=-created_at&page[size]=10&fields[campaign]=name,status,created_at,send_time" \ -H "Authorization: Klaviyo-API-Key ${KLAVIYO_KEY}" \ -H "revision: 2024-10-15" | jq '.data[] | {name: .attributes.name, status: .attributes.status, sent: .attributes.send_time}' ``` ### Flow metrics (active flows) ```bash curl -s "https://a.klaviyo.com/api/flows/?filter=equals(status,'live')&fields[flow]=name,status,created,trigger_type" \ -H "Authorization: Klaviyo-API-Key ${KLAVIYO_KEY}" \ -H "revision: 2024-10-15" | jq '.data[] | {name: .attributes.name, trigger: .attributes.trigger_type}' ``` ### Key email metrics (opens, clicks, revenue via metric aggregates) ```bash # Get metric IDs first curl -s "https://a.klaviyo.com/api/metrics/" \ -H "Authorization: Klaviyo-API-Key ${KLAVIYO_KEY}" \ -H "revision: 2024-10-15" | jq '.data[] | select(.attributes.name | test("Opened Email|Clicked Email|Placed Order")) | {name: .attributes.name, id: .id}' ``` ### Output format ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ EMAIL (KLAVIYO) — last 30d ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Lists: [list_name] — [N] subscribers Campaigns: [N sent] | [N drafts] Active Flows: [N] RECENT CAMPAIGNS [name] [status] sent [date] ... ``` --- ## ads / meta Pull Meta Ads insights for the configured ad account. ### Account-level spend (last 7 days) ```bash curl -s "https://graph.facebook.com/v18.0/${META_ACCOUNT}/insights?fields=spend,impressions,clicks,ctr,cpc,actions,action_values&date_preset=last_7d&level=account" \ -H "Authorization: Bearer ${META_TOKEN}" | jq '{spend: .data[0].spend, impressions: .data[0].impressions, clicks: .data[0].clicks, ctr: .data[0].ctr, cpc: .data[0].cpc}' ``` ### Campaign breakdown (last 7 days) ```bash curl -s "https://graph.facebook.com/v18.0/${META_ACCOUNT}/campaigns?fields=name,status,daily_budget,lifetime_budget,insights{spend,impressions,clicks,actions,action_values}&date_preset=last_7d" \ -H "Authorization: Bearer ${META_TOKEN}" | jq '.data[] | {name: .name, status: .status, spend: .insights.data[0].spend}' ``` ### ROAS calculation From `action_values` array: extract `action_type == "purchase"` value, divide by spend. ### Top performing ads (last 7d) ```bash curl -s "https://graph.facebook.com/v18.0/${META_ACCOUNT}/ads?fields=name,adset_id,insights{spend,impressions,clicks,actions,action_values,ctr,cpc}&date_preset=last_7d&limit=10" \ -H "Authorization: Bearer ${META_TOKEN}" | jq '.data | sort_by(.insights.data[0].spend | tonumber) | reverse | .[0:5] | .[] | {name: .name, spend: .insights.data[0].spend, ctr: .insights.data[0].ctr}' ``` ### Output format ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ META ADS — last 7d ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Spend: $[X] ROAS: [X]x Purchases: [N] ($[X] revenue) Impressions: [N] CTR: [X]% CPC: $[X] CAMPAIGNS [name] [status] $[spend] [roas]x ROAS ... TOP ADS (by spend) [name] $[spend] [ctr]% CTR ``` --- ## meta-manage Full Meta Ads campaign management. Uses same `META_TOKEN` and `META_ACCOUNT` credentials as read-only `ads` section. **Credential check**: If `META_TOKEN` is empty, print `Meta Ads not configured. Run /ops:marketing setup.` and stop. Route `$ARGUMENTS` within meta-manage: | Input | Action | |---|---| | create-campaign | Create a new campaign (always PAUSED) | | target \<ADSET_ID\> | Configure ad set targeting | | creative \<CAMPAIGN_ID\> | Upload image + create ad with copy | | rules | List / create automation rules | | audiences | Create custom or lookalike audiences | | advantage | Create Advantage+ AI-optimized campaign | ### create-campaign Collect via AskUserQuestion (max 4 options each call): 1. Campaign objective — `[OUTCOME_TRAFFIC, OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_AWARENESS]` 2. Daily budget in dollars (free text) 3. Campaign name (free text) Then confirm via AskUserQuestion: `"Create Meta campaign '<NAME>' with $<BUDGET>/day budget?"` options `[Create, Cancel]` ```bash BUDGET_CENTS=$(awk "BEGIN {printf \"%d\", ${BUDGET_DOLLARS} * 100}") curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/campaigns" \ -H "Authorization: Bearer ${META_TOKEN}" \ -F "name=${CAMPAIGN_NAME}" \ -F "objective=${OBJECTIVE}" \ -F "status=PAUSED" \ -F "special_ad_categories=[]" \ -F "daily_budget=${BUDGET_CENTS}" | jq '{id: .id, error: .error.message}' ``` Print: `Campaign "${CAMPAIGN_NAME}" created (ID: <ID>, status: PAUSED, budget: $<BUDGET>/day). Enable via Meta Ads Manager or add ad sets first.` If error, print the error message. ### target \<ADSET_ID\> Configure targeting for an existing ad set. Collect via AskUserQuestion: 1. Target countries (comma-separated ISO codes, e.g. `US,CA,GB`) — free text 2. Age range: `[18-34, 25-54, 35-65, 18-65]` 3. Gender: `[All, Men only, Women only, Skip]` ```bash # Build geo_locations JSON GEO_JSON=$(echo "$COUNTRIES" | tr ',' '\n' | jq -Rc '.' | jq -sc '{"countries": .}') # Build targeting spec TARGETING_JSON=$(jq -n \ --argjson geo "$GEO_JSON" \ --arg age_min "$AGE_MIN" \ --arg age_max "$AGE_MAX" \ '{ geo_locations: $geo, age_min: ($age_min | tonumber), age_max: ($age_max | tonumber) }') # Add gender filter if requested if [ "$GENDER" = "Men only" ]; then TARGETING_JSON=$(echo "$TARGETING_JSON" | jq '. + {"genders": [1]}') elif [ "$GENDER" = "Women only" ]; then TARGETING_JSON=$(echo "$TARGETING_JSON" | jq '. + {"genders": [2]}') fi curl -s -X POST "https://graph.facebook.com/v20.0/${ADSET_ID}" \ -H "Authorization: Bearer ${META_TOKEN}" \ -F "targeting=${TARGETING_JSON}" | jq '{success: .success, error: .error.message}' ``` Print: `Ad set ${ADSET_ID} targeting updated: ${COUNTRIES}, ages ${AGE_MIN}-${AGE_MAX}${GENDER_LABEL}.` ### creative \<CAMPAIGN_ID\> Upload an image and create an ad. Collect via AskUserQuestion: 1. Image file path or URL (free text) 2. Ad set ID to attach the ad to (free text) 3. Primary text (ad copy, free text — up to 125 characters recommended) Then collect headline (free text, up to 40 characters) via a second AskUserQuestion. ```bash # Step 1: Upload image if [[ "$IMAGE_INPUT" == http* ]]; then # Upload by URL UPLOAD_RESP=$(curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/adimages" \ -H "Authorization: Bearer ${META_TOKEN}" \ -F "url=${IMAGE_INPUT}") else # Upload by file (multipart) UPLOAD_RESP=$(curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/adimages" \ -H "Authorization: Bearer ${META_TOKEN}" \ -F "filename=@${IMAGE_INPUT}") fi IMAGE_HASH=$(echo "$UPLOAD_RESP" | jq -r '.images | to_entries[0].value.hash // empty') if [ -z "$IMAGE_HASH" ]; then echo "Image upload failed: $(echo "$UPLOAD_RESP" | jq -r '.error.message // "unknown error"')" exit 0 fi # Resolve the Facebook Page ID. Meta's `object_story_spec.page_id` requires a # real Page ID — the ad account ID (with `act_` stripped) is NOT a Page ID and # the API call will fail. Require META_PAGE_ID in env or plugin prefs. META_PAGE_ID="${META_PAGE_ID:-$(claude plugin config get meta_page_id 2>/dev/null || echo "")}" if [ -z "$META_PAGE_ID" ]; then echo "META_PAGE_ID is required to create an ad creative. Set it via:" echo " claude plugin config set meta_page_id <your_fb_page_id>" echo "Find your Page ID at https://www.facebook.com/<your-page>/about_profile_transparency" exit 0 fi # Step 2: Create ad creative CREATIVE_RESP=$(curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/adcreatives" \ -H "Authorization: Bearer ${META_TOKEN}" \ -F "name=Creative for ${AD_NAME}" \ -F "object_story_spec={\"page_id\": \"${META_PAGE_ID}\", \"link_data\": {\"image_hash\": \"${IMAGE_HASH}\", \"message\": \"${PRIMARY_TEXT}\", \"name\": \"${HEADLINE}\"}}") CREATIVE_ID=$(echo "$CREATIVE_RESP" | jq -r '.id // empty') if [ -z "$CREATIVE_ID" ]; then echo "Creative creation failed: $(echo "$CREATIVE_RESP" | jq -r '.error.message // "unknown error"')" exit 0 fi # Step 3: Create ad (status PAUSED — Rule 5) curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/ads" \ -H "Authorization: Bearer ${META_TOKEN}" \ -F "name=${AD_NAME}" \ -F "adset_id=${ADSET_ID}" \ -F "creative={\"creative_id\": \"${CREATIVE_ID}\"}" \ -F "status=PAUSED" | jq '{id: .id, error: .error.message}' ``` Print: `Ad created (ID: <ID>, creative: <CREATIVE_ID>, status: PAUSED). Enable via Meta Ads Manager when ready.` ### rules List existing rules or create a new automation rule. **List rules:** ```bash curl -s "https://graph.facebook.com/v20.0/${META_ACCOUNT}/adrules_library?fields=name,status,evaluation_spec,execution_spec" \ -H "Authorization: Bearer ${META_TOKEN}" | jq '.data[] | {id: .id, name: .name, status: .status}' ``` **Create rule** (prompt via AskUserQuestion): 1. Rule type: `[Pause low performers, Scale winners, Increase budget, Decrease budget]` For "Pause low performers": ```bash # Pause ads where CPA > $50 and spend > $20 in last 7 days curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/adrules_library" \ -H "Authorization: Bearer ${META_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "name": "Pause high CPA ads", "schedule_spec": {"schedule_type": "SEMI_HOURLY"}, "evaluation_spec": { "evaluation_type": "SCHEDULE", "filters": [ {"field": "cost_per_result", "value": [50], "operator": "GREATER_THAN"}, {"field": "spent", "value": [20], "operator": "GREATER_THAN"}, {"field": "entity_type", "value": ["AD"], "operator": "EQUAL"}, {"field": "time_preset", "value": ["LAST_7_DAYS"], "operator": "EQUAL"} ] }, "execution_spec": { "execution_type": "PAUSE" }, "status": "ENABLED" }' | jq '{id: .id, error: .error.message}' ``` For "Scale winners": > ⚠️ **Scope this to prospecting ad sets.** A bare `purchase_roas > 3` filter auto-scales *retargeting* ad sets too — whose ROAS is inflated by warm-audience demand capture (conversions that would have happened anyway), not incremental growth. Blanket-scaling them pours budget into demand you already own while starving prospecting, and the funnel contracts a month later. Add an ad-set-name/audience filter that excludes retargeting/remarketing (or restrict the rule to your prospecting ad sets), and confirm a winner's lift with a holdout before scaling on ROAS alone. ```bash # Increase budget 20% for ad sets with ROAS > 3x in last 7 days # NOTE: restrict to prospecting ad sets — see caveat above curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/adrules_library" \ -H "Authorization: Bearer ${META_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "name": "Scale winning ad sets", "schedule_spec": {"schedule_type": "DAILY"}, "evaluation_spec": { "evaluation_type": "SCHEDULE", "filters": [ {"field": "purchase_roas", "value": [3], "operator": "GREATER_THAN"}, {"field": "entity_type", "value": ["ADSET"], "operator": "EQUAL"}, {"field": "time_preset", "value": ["LAST_7_DAYS"], "operator": "EQUAL"} ] }, "execution_spec": { "execution_type": "INCREASE_BUDGET", "execution_options": [{"field": "budget_value", "value": "20", "operator": "PERCENTAGE"}] }, "status": "ENABLED" }' | jq '{id: .id, error: .error.message}' ``` Print: `Rule created (ID: <ID>). Runs semi-hourly and will auto-pause ads with CPA > $50.` ### audiences Create Custom Audience or Lookalike Audience. **Prompt via AskUserQuestion:** 1. Audience type: `[Custom — website, Custom — customer list, Lookalike, Skip]` **Custom — website (Pixel-based):** ```bash curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/customaudiences" \ -H "Authorization: Bearer ${META_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "name": "Website visitors — last 30 days", "subtype": "WEBSITE", "retention_days": 30, "rule": {"inclusions": {"operator": "or", "rules": [{"event_sources": [{"id": "<PIXEL_ID>", "type": "pixel"}], "retention_seconds": 2592000, "filter": {"operator": "and", "filters": [{"field": "event", "operator": "eq", "value": "PageView"}]}}]}} }' | jq '{id: .id, name: .name, error: .error.message}' ``` Note: Replace `<PIXEL_ID>` with actual pixel ID from Meta Events Manager. **Lookalike Audience** (requires origin audience with min 100 matched profiles): ```bash # Prompt for origin audience ID via AskUserQuestion (free text) curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/customaudiences" \ -H "Authorization: Bearer ${META_TOKEN}" \ -H "Content-Type: application/json" \ -d "{ \"name\": \"Lookalike — ${ORIGIN_AUDIENCE_NAME} 1%\", \"subtype\": \"LOOKALIKE\", \"origin_audience_id\": \"${ORIGIN_AUDIENCE_ID}\", \"lookalike_spec\": { \"country\": \"US\", \"ratio\": 0.01, \"type\": \"similarity\" } }" | jq '{id: .id, name: .name, error: .error.message}' ``` Print: `Lookalike audience created (ID: <ID>). Typically takes 1-6 hours to populate.` ### advantage Create an Advantage+ Shopping Campaign (AI-optimized). Collect via AskUserQuestion: 1. Daily budget in dollars (free text) 2. Campaign name (free text) Then confirm: `"Create Advantage+ campaign '<NAME>' with $<BUDGET>/day?"` options `[Create, Cancel]` ```bash BUDGET_CENTS=$(awk "BEGIN {printf \"%d\", ${BUDGET_DOLLARS} * 100}") curl -s -X POST "https://graph.facebook.com/v20.0/${META_ACCOUNT}/campaigns" \ -H "Authorization: Bearer ${META_TOKEN}" \ -H "Content-Type: application/json" \ -d "{ \"name\": \"${CAMPAIGN_NAME}\", \"objective\": \"OUTCOME_SALES\", \"status\": \"PAUSED\", \"special_ad_categories\": [], \"daily_budget\": ${BUDGET_CENTS}, \"smart_promotion_type\": \"AUTOMATED_SHOPPING_ADS\" }" | jq '{id: .id, error: .error.message}' ``` Print: `Advantage+ campaign "${CAMPAIGN_NAME}" created (ID: <ID>, status: PAUSED). Meta AI will optimize targeting and creative delivery once enabled.` --- ## analytics / ga4 Pull GA4 data via the Data API using gcloud ADC. ### Get access token ```bash GA4_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null) ``` ### Sessions + conversions (last 7d) ```bash curl -s -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runReport" \ -H "Authorization: Bearer ${GA4_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "dateRanges": [{"startDate": "7daysAgo", "endDate": "today"}], "metrics": [ {"name": "sessions"}, {"name": "totalUsers"}, {"name": "conversions"}, {"name": "totalRevenue"}, {"name": "bounceRate"}, {"name": "averageSessionDuration"} ] }' | jq '.rows[0].metricValues | {sessions: .[0].value, users: .[1].value, conversions: .[2].value, revenue: .[3].value, bounce_rate: .[4].value}' ``` ### Traffic sources (last 7d) ```bash curl -s -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runReport" \ -H "Authorization: Bearer ${GA4_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "dateRanges": [{"startDate": "7daysAgo", "endDate": "today"}], "dimensions": [{"name": "sessionDefaultChannelGrouping"}], "metrics": [{"name": "sessions"}, {"name": "conversions"}], "orderBys": [{"metric": {"metricName": "sessions"}, "desc": true}], "limit": 8 }' | jq '.rows[] | {channel: .dimensionValues[0].value, sessions: .metricValues[0].value, conversions: .metricValues[1].value}' ``` ### Top pages (last 7d) ```bash curl -s -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runReport" \ -H "Authorization: Bearer ${GA4_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "dateRanges": [{"startDate": "7daysAgo", "endDate": "today"}], "dimensions": [{"name": "pagePath"}], "metrics": [{"name": "screenPageViews"}, {"name": "averageSessionDuration"}], "orderBys": [{"metric": {"metricName": "screenPageViews"}, "desc": true}], "limit": 10 }' | jq '.rows[] | {page: .dimensionValues[0].value, views: .metricValues[0].value}' ``` If `GA4_TOKEN` is empty or gcloud not available, output: `GA4 not configured — run /ops:marketing setup or configure gcloud ADC`. ### Output format ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ANALYTICS (GA4) — last 7d ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Sessions: [N] Users: [N] Conversions: [N] CVR: [X]% Revenue: $[X] Bounce Rate: [X]% Avg Session: [Xm Xs] TRAFFIC SOURCES [channel] [N sessions] [N conversions] ... TOP PAGES [path] [N views] ``` --- ## ga4-advanced Advanced GA4 analytics: realtime, funnel, cohort, audience export, and pivot reports. **Credential check**: If `GA4_TOKEN` is empty or `GA4_PROPERTY` is missing, print `GA4 not configured — run /ops:marketing setup or configure gcloud ADC` and stop. Route `$ARGUMENTS` within ga4-advanced (matches `ga4 <sub>` pattern): | Input | Action | |---|---| | realtime | Active users right now (last 30 min) | | funnel | Conversion funnel with step visualization | | cohort | Cohort retention analysis by device | | audience | Async audience segment export | | pivot | Multi-dimensional pivot report | ### realtime ```bash GA4_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null) RESULT=$(curl -s -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runRealtimeReport" \ -H "Authorization: Bearer ${GA4_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "minuteRanges": [{"startMinutesAgo": 29, "endMinutesAgo": 0}], "dimensions": [ {"name": "unifiedScreenName"}, {"name": "deviceCategory"} ], "metrics": [{"name": "activeUsers"}], "orderBys": [{"metric": {"metricName": "activeUsers"}, "desc": true}], "limit": 10 }') TOTAL=$(echo "$RESULT" | jq '[.rows[]?.metricValues[0].value | tonumber] | add // 0') echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " GA4 REALTIME — Last 30 Minutes" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "Active Users Right Now: ${TOTAL}" echo "" echo "Top Pages:" printf "| %-40s | %-8s | %-7s |\n" "Page" "Device" "Users" printf "|%s|%s|%s|\n" "------------------------------------------" "----------" "---------" echo "$RESULT" | jq -r '.rows[]? | [.dimensionValues[0].value, .dimensionValues[1].value, .metricValues[0].value] | @tsv' 2>/dev/null | \ while IFS=$'\t' read -r page device users; do printf "| %-40s | %-8s | %-7s |\n" "${page:0:40}" "$device" "$users" done ``` ### funnel Ask user for funnel steps via AskUserQuestion (free text). Default template uses session_start → page_view → purchase. ```bash # NOTE: Uses v1alpha — breaking changes possible per Google's versioning policy GA4_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null) # Prompt for funnel type first # AskUserQuestion: "Funnel mode?" options [Closed funnel, Open funnel] IS_OPEN=$([ "$FUNNEL_MODE" = "Open funnel" ] && echo "true" || echo "false") RESULT=$(curl -s -X POST "https://analyticsdata.googleapis.com/v1alpha/properties/${GA4_PROPERTY}:runFunnelReport" \ -H "Authorization: Bearer ${GA4_TOKEN}" \ -H "Content-Type: application/json" \ -d "{ \"dateRanges\": [{\"startDate\": \"30daysAgo\", \"endDate\": \"today\"}], \"funnel\": { \"isOpenFunnel\": ${IS_OPEN}, \"steps\": [ { \"name\": \"Session Start\", \"filterExpression\": {\"funnelEventFilter\": {\"eventName\": \"session_start\"}} }, { \"name\": \"Page View\", \"filterExpression\": {\"funnelEventFilter\": {\"eventName\": \"page_view\"}} }, { \"name\": \"Purchase\", \"filterExpression\": {\"funnelEventFilter\": {\"eventName\": \"purchase\"}} } ] }, \"funnelBreakdown\": { \"breakdownDimension\": {\"name\": \"deviceCategory\"}, \"limit\": 4 } }") echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " GA4 FUNNEL — Last 30 Days (${FUNNEL_MODE})" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "$RESULT" | jq -r ' .funnelTable.rows[]? | "Step: \(.dimensionValues[0].value) Users: \(.metricValues[0].value) Completion: \(.metricValues[1].value)% Abandoned: \(.metricValues[2].value)" ' 2>/dev/null || echo "No funnel data — ensure purchase events are firing in GA4." ``` ### cohort Weekly cohort retention for users acquired in the past month, broken down by device. ```bash GA4_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null) START_DATE=$(date -v-30d +%Y-%m-%d 2>/dev/null || date -d '30 days ago' +%Y-%m-%d) END_DATE=$(date +%Y-%m-%d) RESULT=$(curl -s -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runReport" \ -H "Authorization: Bearer ${GA4_TOKEN}" \ -H "Content-Type: application/json" \ -d "{ \"dimensions\": [ {\"name\": \"cohort\"}, {\"name\": \"cohortNthWeek\"}, {\"name\": \"deviceCategory\"} ], \"metrics\": [ {\"name\": \"cohortActiveUsers\"}, {\"name\": \"cohortRetentionFraction\"} ], \"cohortSpec\": { \"cohorts\": [{ \"dimension\": \"firstSessionDate\", \"dateRange\": {\"startDate\": \"${START_DATE}\", \"endDate\": \"${END_DATE}\"} }], \"cohortsRange\": { \"granularity\": \"WEEKLY\", \"startOffset\": 0, \"endOffset\": 5 } } }") echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " GA4 COHORT RETENTION — Last 30 Days" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" printf "| %-12s | %-6s | %-8s | %-10s | %-10s |\n" "Cohort" "Week" "Device" "Users" "Retention%" printf "|%s|%s|%s|%s|%s|\n" "--------------" "--------" "----------" "------------" "------------" echo "$RESULT" | jq -r '.rows[]? | [ .dimensionValues[0].value, .dimensionValues[1].value, .dimensionValues[2].value, .metricValues[0].value, (.metricValues[1].value | tonumber * 100 | tostring | split(".")[0]) ] | @tsv' 2>/dev/null | \ while IFS=$'\t' read -r cohort week device users retention; do printf "| %-12s | %-6s | %-8s | %-10s | %-10s |\n" "$cohort" "$week" "$device" "$users" "${retention}%" done ``` ### audience Async audience export: create → poll until ACTIVE → show user count. ```bash GA4_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null) # Step 1: List available audiences so user can pick one AUDIENCES=$(curl -s "https://analyticsadmin.googleapis.com/v1alpha/properties/${GA4_PROPERTY}/audiences" \ -H "Authorization: Bearer ${GA4_TOKEN}") echo "Available audiences:" echo "$AUDIENCES" | jq -r '.audiences[]? | "\(.name | split("/") | last): \(.displayName)"' 2>/dev/null # AskUserQuestion: "Enter audience ID from list above:" (free text) # Step 2: Create export EXPORT_RESP=$(curl -s -X POST \ "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}/audienceExports" \ -H "Authorization: Bearer ${GA4_TOKEN}" \ -H "Content-Type: application/json" \ -d "{ \"audience\": \"properties/${GA4_PROPERTY}/audiences/${AUDIENCE_ID}\", \"dimensions\": [ {\"dimensionName\": \"deviceId\"}, {\"dimensionName\": \"isAdsPersonalizationAllowed\"} ] }") EXPORT_NAME=$(echo "$EXPORT_RESP" | jq -r '.name // empty') if [ -z "$EXPORT_NAME" ]; then echo "Failed to create export: $(echo "$EXPORT_RESP" | jq -r '.error.message // "unknown error"')" exit 0 fi echo "Export created: ${EXPORT_NAME}" echo "Polling for completion (small audiences: ~30s, large: up to 15 min)..." # Step 3: Poll until ACTIVE or FAILED ATTEMPTS=0 while [ $ATTEMPTS -lt 60 ]; do STATUS_RESP=$(curl -s "https://analyticsdata.googleapis.com/v1beta/${EXPORT_NAME}" \ -H "Authorization: Bearer ${GA4_TOKEN}") STATE=$(echo "$STATUS_RESP" | jq -r '.state // "UNKNOWN"') PCT=$(echo "$STATUS_RESP" | jq -r '.percentageCompleted // 0') if [ "$STATE" = "ACTIVE" ]; then break; fi if [ "$STATE" = "FAILED" ]; then echo "Export failed. Try again or check GA4 audience configuration." exit 0 fi echo " State: ${STATE} (${PCT}% complete)..." sleep 10 ATTEMPTS=$((ATTEMPTS + 1)) done # Step 4: Query results QUERY_RESP=$(curl -s -X POST \ "https://analyticsdata.googleapis.com/v1beta/${EXPORT_NAME}:query" \ -H "Authorization: Bearer ${GA4_TOKEN}") ROW_COUNT=$(echo "$QUERY_RESP" | jq '.rowCount // 0') echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " GA4 AUDIENCE EXPORT" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " Audience ID: ${AUDIENCE_ID}" echo " Users exported: ${ROW_COUNT}" echo " Ads-eligible: $(echo "$QUERY_RESP" | jq '[.audienceRows[]? | select(.dimensionValues[1].value == "true")] | length') users" echo "" echo "Export ready. Use this audience for retargeting in Meta or Google Ads." ``` ### pivot Multi-dimensional pivot: channel group × device category × conversions. ```bash GA4_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null) RESULT=$(curl -s -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA4_PROPERTY}:runPivotReport" \ -H "Authorization: Bearer ${GA4_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "dateRanges": [{"startDate": "30daysAgo", "endDate": "today"}], "dimensions": [ {"name": "sessionDefaultChannelGrouping"}, {"name": "deviceCategory"} ], "metrics": [ {"name": "sessions"}, {"name": "conversions"}, {"name": "totalRevenue"} ], "pivots": [ { "fieldNames": ["sessionDefaultChannelGrouping"], "limit": 6 }, { "fieldNames": ["deviceCategory"], "limit": 3 } ] }') echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " GA4 PIVOT — Channel × Device (Last 30 Days)" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" printf "| %-20s | %-8s | %-10s | %-11s | %-10s |\n" "Channel" "Device" "Sessions" "Conversions" "Revenue" printf "|%s|%s|%s|%s|%s|\n" "----------------------" "----------" "------------" "-------------" "------------" echo "$RESULT" | jq -r '.rows[]? | [ .dimensionValues[0].value, .dimensionValues[1].value, .metricValues[0].value, .metricValues[1].value, (.metricValues[2].value | tonumber | . * 100 | round / 100 | tostring) ] | @tsv' 2>/dev/null | \ while IFS=$'\t' read -r channel device sessions convs revenue; do printf "| %-20s | %-8s | %-10s | %-11s | \$%-9s |\n" "${channel:0:20}" "$device" "$sessions" "$convs" "$revenue" done ``` --- ## seo / gsc Pull Google Search Console data. ### Get access token (same gcloud ADC) ```bash GSC_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null) GSC_SITE_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${GSC_SITE}', safe=''))" 2>/dev/null || echo "${GSC_SITE}" | sed 's|:|%3A|g; s|/|%2F|g') ``` ### Search performance (last 28 days) ```bash curl -s -X POST "https://searchconsole.googleapis.com/webmasters/v3/sites/${GSC_SITE_ENCODED}/searchAnalytics/query" \ -H "Authorization: Bearer ${GSC_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "startDate": "'$(date -v-28d +%Y-%m-%d 2>/dev/null || date -d '28 days ago' +%Y-%m-%d)'", "endDate": "'$(date +%Y-%m-%d)'", "dimensions": [], "rowLimit": 1 }' | jq '{clicks: .rows[0].clicks, impressions: .rows[0].impressions, ctr: .rows[0].ctr, position: .rows[0].position}' ``` ### Top queries (last 28 days) ```bash curl -s -X POST "https://searchconsole.googleapis.com/webmasters/v3/sites/${GSC_SITE_ENCODED}/searchAnalytics/query" \ -H "Authorization: Bearer ${GSC_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "startDate": "'$(date -v-28d +%Y-%m-%d 2>/dev/null || date -d '28 days ago' +%Y-%m-%d)'", "endDate": "'$(date +%Y-%m-%d)'", "dimensions": ["query"], "rowLimit": 20, "dimensionFilterGroups": [] }' | jq '.rows[] | {query: .keys[0], clicks: .clicks, impressions: .impressions, position: (.position | floor)}' ``` ### Top pages by clicks ```bash curl -s -X POST "https://searchconsole.googleapis.com/webmasters/v3/sites/${GSC_SITE_ENCODED}/searchAnalytics/query" \ -H "Authorization: Bearer ${GSC_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "startDate": "'$(date -v-28d +%Y-%m-%d 2>/dev/null || date -d '28 days ago' +%Y-%m-%d)'", "endDate": "'$(date +%Y-%m-%d)'", "dimensions": ["page"], "rowLimit": 10 }' | jq '.rows[] | {page: .keys[0], clicks: .clicks, impressions: .impressions, position: (.position | floor)}' ``` If GSC not configured, output: `Search Console not configured — run /ops:marketing setup`. ### Output format ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ SEO (SEARCH CONSOLE) — last 28d ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Clicks: [N] Impressions: [N] CTR: [X]% Avg Position: [X] TOP QUERIES [query] [clicks] clicks pos [N] ... TOP PAGES [url] [clicks] clicks [impressions] impr ``` --- ## social Aggregate available social media metrics. Check which are configured. ### Instagram (via Meta Graph API — same token as Meta Ads) ```bash # Get Instagram Business Account ID linked to the ad account curl -s "https://graph.facebook.com/v18.0/me/accounts?fields=instagram_business_account" \ -H "Authorization: Bearer ${META_TOKEN}" | jq '.data[].instagram_business_account.id' 2>/dev/null # Then pull media insights curl -s "https://graph.facebook.com/v18.0/${IG_ACCOUNT_ID}?fields=followers_count,media_count,profile_views" \ -H "Authorization: Bearer ${META_TOKEN}" | jq '{followers: .followers_count, posts: .media_count, profile_views: .profile_views}' ``` ### YouTube (if configured via gcloud) ```bash YT_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null) curl -s "https://www.googleapis.com/youtube/v3/channels?part=statistics&mine=true" \ -H "Authorization: Bearer ${YT_TOKEN}" | jq '.items[0].statistics | {subscribers: .subscriberCount, views: .viewCount, videos: .videoCount}' ``` Show `[not configured]` for any unconfigured channels rather than failing. ### Output format ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ SOCIAL MEDIA ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Instagram: [N followers] [N posts] [N profile views] YouTube: [N subscribers] [N total views] TikTok: [not configured] — set TIKTOK_ACCESS_TOKEN ``` --- ## instagram Instagram publishing and insights via Instagram Graph API (same `META_TOKEN` as Meta Ads). **Prerequisites:** - `META_TOKEN` configured (same as Meta Ads) - Instagram Business account linked to a Facebook Page - `IG_ACCOUNT_ID` resolved via: `curl "https://graph.facebook.com/v21.0/me/accounts?fields=instagram_business_account" -H "Authorization: Bearer ${META_TOKEN}"` → `data[0].instagram_business_account.id` **Rate limit:** 200 API calls/hour per app. Demographics require 48h reporting delay. Media insights require account with >1,000 followers. **Resolve IG account ID at the start of every instagram invocation:** ```bash IG_ACCOUNT_ID=$(claude plugin config get instagram_account_id 2>/dev/null) if [ -z "$IG_ACCOUNT_ID" ]; then IG_ACCOUNT_ID=$(curl -s "https://graph.facebook.com/v21.0/me/accounts?fields=instagram_business_account" \ -H "Authorization: Bearer ${META_TOKEN}" | jq -r '.data[0].instagram_business_account.id // empty') # Cache it [ -n "$IG_ACCOUNT_ID" ] && claude plugin config set instagram_account_id "$IG_ACCOUNT_ID" 2>/dev/null fi if [ -z "$IG_ACCOUNT_ID" ]; then echo "Instagram Business account not linked to your Meta token. Ensure your Facebook Page has an Instagram Business account connected." exit 0 fi ``` Route `$ARGUMENTS` within instagram: | Input | Action | |---|---| | post \<IMAGE_URL\> | Publish image post to feed | | reel \<VIDEO_URL\> | Publish a Reel | | story \<IMAGE_URL\|VIDEO_URL\> | Publish a Story | | insights \<MEDIA_ID\> | Per-post metrics | | account-insights [days] | Account-level reach + impressions | | demographics | Audience age/gender/location | ### post Publish an image post (two-step: create container → publish). Collect via AskUserQuestion: 1. Image URL (publicly accessible HTTPS URL) — free text 2. Caption — free text ```bash # Step 1: Create media container CONTAINER=$(curl -s -X POST "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/media" \ -H "Authorization: Bearer ${META_TOKEN}" \ -F "image_url=${IMAGE_URL}" \ -F "caption=${CAPTION}" \ -F "media_type=IMAGE") CONTAINER_ID=$(echo "$CONTAINER" | jq -r '.id // empty') if [ -z "$CONTAINER_ID" ]; then echo "Failed to create media container: $(echo "$CONTAINER" | jq -r '.error.message // "unknown error"')" exit 0 fi # Step 2: Publish PUBLISH=$(curl -s -X POST "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/media_publish" \ -H "Authorization: Bearer ${META_TOKEN}" \ -F "creation_id=${CONTAINER_ID}") MEDIA_ID=$(echo "$PUBLISH" | jq -r '.id // empty') if [ -n "$MEDIA_ID" ]; then echo "Post published (Media ID: ${MEDIA_ID}). View at https://www.instagram.com/ — may take 1-2 min to appear." else echo "Publish failed: $(echo "$PUBLISH" | jq -r '.error.message // "unknown error"')" fi ``` ### reel Publish a Reel. Video must be an HTTPS URL (MP4, H.264, max 15 min, min 500px width). Collect via AskUserQuestion: 1. Video URL (HTTPS) — free text 2. Caption — free text ```bash # Step 1: Create video container (async — must poll for status) CONTAINER=$(curl -s -X POST "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/media" \ -H "Authorization: Bearer ${META_TOKEN}" \ -F "media_type=REELS" \ -F "video_url=${VIDEO_URL}" \ -F "caption=${CAPTION}" \ -F "share_to_feed=true") CONTAINER_ID=$(echo "$CONTAINER" | jq -r '.id // empty') if [ -z "$CONTAINER_ID" ]; then echo "Failed to create Reel container: $(echo "$CONTAINER" | jq -r '.error.message // "unknown error"')" exit 0 fi echo "Reel uploading... polling for ready status." # Poll until status is FINISHED ATTEMPTS=0 while [ $ATTEMPTS -lt 30 ]; do STATUS=$(curl -s "https://graph.facebook.com/v21.0/${CONTAINER_ID}?fields=status_code" \ -H "Authorization: Bearer ${META_TOKEN}" | jq -r '.status_code // "UNKNOWN"') [ "$STATUS" = "FINISHED" ] && break [ "$STATUS" = "ERROR" ] && echo "Reel processing failed." && exit 0 sleep 10 ATTEMPTS=$((ATTEMPTS + 1)) done # Step 2: Publish PUBLISH=$(curl -s -X POST "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/media_publish" \ -H "Authorization: Bearer ${META_TOKEN}" \ -F "creation_id=${CONTAINER_ID}") MEDIA_ID=$(echo "$PUBLISH" | jq -r '.id // empty') if [ -n "$MEDIA_ID" ]; then echo "Reel published (Media ID: ${MEDIA_ID}). Reach and plays metrics available after 24-48h." else echo "Reel publish failed: $(echo "$PUBLISH" | jq -r '.error.message // "unknown error"')" fi ``` ### story Publish a Story (image or video, 24h expiry). Collect via AskUserQuestion: 1. Content URL (HTTPS image or video) — free text 2. Content type: `[Image story, Video story]` ```bash if [ "$CONTENT_TYPE" = "Video story" ]; then MEDIA_TYPE="VIDEO" URL_FIELD="video_url" else MEDIA_TYPE="IMAGE" URL_FIELD="image_url" fi CONTAINER=$(curl -s -X POST "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/media" \ -H "Authorization: Bearer ${META_TOKEN}" \ -F "media_type=STORIES" \ -F "${URL_FIELD}=${CONTENT_URL}") CONTAINER_ID=$(echo "$CONTAINER" | jq -r '.id // empty') if [ -z "$CONTAINER_ID" ]; then echo "Failed to create Story container: $(echo "$CONTAINER" | jq -r '.error.message // "unknown error"')" exit 0 fi PUBLISH=$(curl -s -X POST "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/media_publish" \ -H "Authorization: Bearer ${META_TOKEN}" \ -F "creation_id=${CONTAINER_ID}") MEDIA_ID=$(echo "$PUBLISH" | jq -r '.id // empty') if [ -n "$MEDIA_ID" ]; then echo "Story published (Media ID: ${MEDIA_ID}). Expires after 24 hours." else echo "Story publish failed: $(echo "$PUBLISH" | jq -r '.error.message // "unknown error"')" fi ``` ### insights \<MEDIA_ID\> Per-post metrics. Note: reach, saves, shares deprecated for non-Reels video; plays only for Reels/video. ```bash METRICS="reach,saved,shares,comments_count,like_count,impressions" # For Reels, add plays: detect via media_type field MEDIA_TYPE=$(curl -s "https://graph.facebook.com/v21.0/${MEDIA_ID}?fields=media_type" \ -H "Authorization: Bearer ${META_TOKEN}" | jq -r '.media_type // "IMAGE"') [ "$MEDIA_TYPE" = "VIDEO" ] || [ "$MEDIA_TYPE" = "REEL" ] && METRICS="${METRICS},plays" RESULT=$(curl -s "https://graph.facebook.com/v21.0/${MEDIA_ID}/insights?metric=${METRICS}&period=lifetime" \ -H "Authorization: Bearer ${META_TOKEN}") echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " INSTAGRAM POST INSIGHTS — ${MEDIA_ID}" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "$RESULT" | jq -r '.data[]? | " \(.name): \(.values[0].value)"' 2>/dev/null || \ echo "No insights data — account must have >1,000 followers for insights access." ``` ### account-insights [days] Account-level reach and impressions. Default: last 7 days. ```bash DAYS="${DAYS:-7}" END_DATE=$(date +%Y-%m-%d) START_DATE=$(date -v-${DAYS}d +%Y-%m-%d 2>/dev/null || date -d "${DAYS} days ago" +%Y-%m-%d) RESULT=$(curl -s "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/insights?metric=reach,impressions,profile_views&period=day&since=${START_DATE}&until=${END_DATE}" \ -H "Authorization: Bearer ${META_TOKEN}") echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " INSTAGRAM ACCOUNT INSIGHTS — Last ${DAYS} Days" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " Note: Data reflects 24-48h reporting delay" echo "" REACH=$(echo "$RESULT" | jq '[.data[]? | select(.name == "reach") | .values[]?.value | tonumber] | add // 0') IMPRESSIONS=$(echo "$RESULT" | jq '[.data[]? | select(.name == "impressions") | .values[]?.value | tonumber] | add // 0') PROFILE_VIEWS=$(echo "$RESULT" | jq '[.data[]? | select(.name == "profile_views") | .values[]?.value | tonumber] | add // 0') echo " Reach: ${REACH}" echo " Impressions: ${IMPRESSIONS}" echo " Profile Views: ${PROFILE_VIEWS}" ``` ### demographics Audience breakdown by age/gender and top locations. Requires lifetime period (48h delay). ```bash RESULT=$(curl -s "https://graph.facebook.com/v21.0/${IG_ACCOUNT_ID}/insights?metric=audience_gender_age,audience_city,audience_country&period=lifetime" \ -H "Authorization: Bearer ${META_TOKEN}") echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " INSTAGRAM DEMOGRAPHICS" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " Note: Top 45 segments shown. Data has 48h delay." echo "" echo "AGE / GENDER BREAKDOWN:" echo "$RESULT" | jq -r '.data[]? | select(.name == "audience_gender_age") | .values[0].value | to_entries[] | " \(.key): \(.value)%"' 2>/dev/null | head -20 echo "" echo "TOP CITIES:" echo "$RESULT" | jq -r '.data[]? | select(.name == "audience_city") | .values[0].value | to_entries | sort_by(-.value) | .[0:10][] | " \(.key): \(.value)%"' 2>/dev/null echo "" echo "TOP COUNTRIES:" echo "$RESULT" | jq -r '.data[]? | select(.name == "audience_country") | .values[0].value | to_entries | sort_by(-.value) | .[0:10][] | " \(.key): \(.value)%"' 2>/dev/null ``` --- ## google-ads **Credential check**: If `GADS_DEV_TOKEN` or `GADS_REFRESH_TOKEN` is empty after resolution, print: `Warning: Google Ads not configured. Run /ops:setup marketing to set up credentials.` and stop. **Token refresh**: Run the access token refresh curl (from Credential Resolution above) at the start of every google-ads invocation. If `GADS_ACCESS_TOKEN` is null or "null", print: `Warning: Google Ads token refresh failed. Check client_id/client_secret/refresh_token in /ops:setup.` and stop. Route `$ARGUMENTS` within the google-ads section: | Input | Action | |---|---| | (empty), dashboard, overview | Campaign performance dashboard (last 7 days) | | search-terms, terms | Search Terms Report with negative keyword candidates (last 30 days) | | budget-recs, recommendations, recs | Budget optimization recommendations from Google | | campaigns, manage | Campaign management — list, create, pause, enable, adjust budget | | keywords, kw, keyword-planner | Keyword Planner — discover keywords with volume and bid data | | ad-groups, ag | Ad group management — list, create, add/remove keywords, adjust bids | ### Dashboard (default — no args, `dashboard`, `overview`) ```bash # Campaign performance — last 7 days CAMPAIGNS=$(curl -s -X POST \ "https://googleads.googleapis.com/${GADS_API_VERSION}/customers/${GADS_CUSTOMER_ID}/googleAds:searchStream" \ "${GADS_HEADERS[@]}" \ --data-binary '{ "query": "SELECT campaign.id, campaign.name, campaign.status, campaign_budget.amount_micros, metrics.cost_micros, metrics.impressions, metrics.clicks, metrics.conversions, metrics.conversions_value FROM campaign WHERE segments.date DURING LAST_7_DAYS AND campaign.status != REMOVED ORDER BY metrics.cost_micros DESC LIMIT 20" }') # Check for API error GADS_ERROR=$(echo "$CAMPAIGNS" | jq -r '.[0].error.message // empty' 2>/dev/null) if [ -n "$GADS_ERROR" ]; then echo "Google Ads API error: ${GADS_ERROR}" echo "Check credentials with /ops:marketing setup." exit 0 fi # Check for empty results CAMPAIGN_COUNT=$(echo "$CAMPAIGNS" | jq '[.[].results[]?] | length' 2>/dev/null || echo "0") if [ "$CAMPAIGN_COUNT" -eq 0 ]; then echo "No active campaigns found in the last 7 days." exit 0 fi # Compute totals TOTAL_SPEND=$(echo "$CAMPAIGNS" | jq '[.[].results[]?.metrics.costMicros // "0" | tonumber] | add / 1000000' 2>/dev/null) TOTAL_CONVERSIONS=$(echo "$CAMPAIGNS" | jq '[.[].results[]?.metrics.conversions // "0" | tonumber] | add' 2>/dev/null) TOTAL_VALUE=$(echo "$CAMPAIGNS" | jq '[.[].results[]?.metrics.conversionsValue // "0" | tonumber] | add' 2>/dev/null) OVERALL_ROAS=$(awk "BEGIN { if (${TOTAL_SPEND:-0} > 0) printf \"%.2f\", ${TOTAL_VALUE:-0} / ${TOTAL_SPEND:-1}; else print \"—\" }") TOTAL_SPEND_FMT=$(awk "BEGIN { printf \"%.2f\", ${TOTAL_SPEND:-0} }") echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " GOOGLE ADS — Last 7 Days" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "Total Spend: \$${TOTAL_SPEND_FMT}" echo "Total Conversions: ${TOTAL_CONVERSIONS}" echo "Overall ROAS: ${OVERALL_ROAS}" echo "" # Print table header printf "| %-30s | %-8s | %-10s | %-10s | %-8s | %-8s | %-6s | %-6s | %-6s |\n" \ "Campaign" "Status" "Budget/day" "Spend" "Impr" "Clicks" "CTR" "Conv" "ROAS" printf "|%s|%s|%s|%s|%s|%s|%s|%s|%s|\n" \ "--------------------------------" "----------" "------------" "------------" "----------" "----------" "--------" "--------" "--------" # Print each campaign row echo "$CAMPAIGNS" | jq -r '.[].results[]? | [ .campaign.name, .campaign.status, (.campaignBudget.amountMicros // "0" | tonumber / 1000000), (.metrics.costMicros // "0" | tonumber / 1000000), (.metrics.impressions // "0" | tonumber), (.metrics.clicks // "0" | tonumber), (.metrics.conversions // "0" | tonumber), (.metrics.conversionsValue // "0" | tonumber), (.metrics.costMicros // "0" | tonumber) ] | @tsv' 2>/dev/null | while IFS=$'\t' read -r name status budget_raw spend_raw impr_raw clicks_raw conv_raw value_raw cost_raw; do BUDGET=$(awk "BEGIN { printf \"%.2f\", ${budget_raw:-0} }") SPEND=$(awk "BEGIN { printf \"%.2f\", ${spend_raw:-0} }") CTR=$(awk "BEGIN { if (${impr_raw:-0} > 0) printf \"%.2f\", ${clicks_raw:-0} / ${impr_raw:-0} * 100; else print \"0.00\" }") ROAS=$(awk "BEGIN { if (${spend_raw:-0} > 0) printf \"%.2f\", ${value_raw:-0} / ${spend_raw:-1}; else print \"—\" }") printf "| %-30s | %-8s | \$%-9s | \$%-9s | %-8s | %-8s | %-5s%% | %-6s | %-6s |\n" \ "${name:0:30}" "$status" "$BUDGET" "$SPEND" "$impr_raw" "$clicks_raw" "$CTR" "$conv_raw" "$ROAS" done ``` ### Search Terms (`search-terms`, `terms`) ```bash SEARCH_TERMS=$(curl -s -X POST \ "https://googleads.googleapis.com/${GADS_API_VERSION}/customers/${GADS_CUSTOMER_ID}/googleAds:searchStream" \ "${GADS_HEADERS[@]}" \ --data-binary '{ "query": "SELECT search_term_view.search_term, search_term_view.status, campaign.name, ad_group.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM search_term_view WHERE segments.date DURING LAST_30_DAYS AND metrics.impressions > 0 ORDER BY metrics.impressions DESC LIMIT 100" }') # Check for API error GADS_ST_ERROR=$(echo "$SEARCH_TERMS" | jq -r '.[0].error.message // empty' 2>/dev/null) if [ -n "$GADS_ST_ERROR" ]; then echo "Google Ads API error: ${GADS_ST_ERROR}" echo "Check credentials with /ops:marketing setup." exit 0 fi TERM_COUNT=$(echo "$SEARCH_TERMS" | jq '[.[].results[]?] | length' 2>/dev/null || echo "0") if [ "$TERM_COUNT" -eq 0 ]; then echo "No search term data found for the last 30 days." exit 0 fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " SEARCH TERMS REPORT — Last 30 Days" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" printf "| %-30s | %-10s | %-20s | %-15s | %-6s | %-6s | %-7s | %-6s |\n" \ "Search Term" "Status" "Campaign" "Ad Group" "Impr" "Clicks" "Cost" "Conv" printf "|%s|%s|%s|%s|%s|%s|%s|%s|\n" \ "--------------------------------" "------------" "----------------------" "-----------------" "--------" "--------" "---------" "--------" # Status mapping and table rows echo "$SEARCH_TERMS" | jq -r '.[].results[]? | [ .searchTermView.searchTerm, .searchTermView.status, .campaign.name, .adGroup.name, (.metrics.impressions // "0" | tostring), (.metrics.clicks // "0" | tostring), (.metrics.costMicros // "0" | tonumber / 1000000 | tostring), (.metrics.conversions // "0" | tostring) ] | @tsv' 2>/dev/null | while IFS=$'\t' read -r term status campaign adgroup impr clicks cost_raw conv; do case "$status" in ADDED) STATUS_LABEL="✓ Added" ;; EXCLUDED) STATUS_LABEL="✗ Excluded" ;; *) STATUS_LABEL="○ New" ;; esac COST=$(awk "BEGIN { printf \"%.2f\", ${cost_raw:-0} }") printf "| %-30s | %-10s | %-20s | %-15s | %-6s | %-6s | \$%-6s | %-6s |\n" \ "${term:0:30}" "$STATUS_LABEL" "${campaign:0:20}" "${adgroup:0:15}" "$impr" "$clicks" "$COST" "$conv" done echo "" echo "Negative keyword candidates (high spend, zero conversions):" echo "$SEARCH_TERMS" | jq -r '.[].results[]? | select( (.metrics.conversions // "0" | tonumber) == 0 and (.metrics.costMicros // "0" | tonumber) > 1000000 ) | " • \(.searchTermView.searchTerm) — $\(.metrics.costMicros | tonumber / 1000000 | tostring | split(".") | .[0] + "." + (.[1] // "00")[0:2])"' 2>/dev/null || echo " (none found)" ``` ### Budget Recommendations (`budget-recs`, `recommendations`, `recs`) ```bash RECS=$(curl -s -X POST \ "https://googleads.googleapis.com/${GADS_API_VERSION}/customers/${GADS_CUSTOMER_ID}/googleAds:searchStream" \ "${GADS_HEADERS[@]}" \ --data-binary '{ "query": "SELECT recommendation.resource_name, recommendation.type, recommendation.campaign, recommendation.impact, recommendation.campaign_budget_recommendation FROM recommendation WHERE recommendation.type IN (CAMPAIGN_BUDGET, MOVE_UNUSED_BUDGET, MARGINAL_ROI_CAMPAIGN_BUDGET, FORECASTING_CAMPAIGN_BUDGET)" }') # Check for API error GADS_REC_ERROR=$(echo "$RECS" | jq -r '.[0].error.message // empty' 2>/dev/null) if [ -n "$GADS_REC_ERROR" ]; then echo "Google Ads API error: ${GADS_REC_ERROR}" echo "Check credentials with /ops:marketing setup." exit 0 fi REC_COUNT=$(echo "$RECS" | jq '[.[].results[]?] | length' 2>/dev/null || echo "0") if [ "$REC_COUNT" -eq 0 ]; then echo "No budget recommendations available. Google needs campaign data to generate recommendations." exit 0 fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " BUDGET RECOMMENDATIONS" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" printf "| %-22s | %-25s | %-15s | %-13s | %-20s |\n" \ "Type" "Campaign" "Current Budget" "Recommended" "Impact" printf "|%s|%s|%s|%s|%s|\n" \ "------------------------" "---------------------------" "-----------------" "---------------" "----------------------" echo "$RECS" | jq -r '.[].results[]? | [ .recommendation.type, .recommendation.campaign, (.recommendation.campaignBudgetRecommendation.currentBudgetAmountMicros // "0" | tonumber / 1000000 | tostring), (.recommendation.campaignBudgetRecommendation.recommendedBudgetAmountMicros // "0" | tonumber / 1000000 | tostring), (.recommendation.impact.baseMetrics.impressions // "0" | tonumber | tostring), (.recommendation.impact.potentialMetrics.impressions // "0" | tonumber | tostring) ] | @tsv' 2>/dev/null | while IFS=$'\t' read -r rec_type campaign current_raw recommended_raw base_impr_raw pot_impr_raw; do case "$rec_type" in CAMPAIGN_BUDGET) TYPE_LABEL="Increase Budget" ;; MOVE_UNUSED_BUDGET) TYPE_LABEL="Move Unused Budget" ;; MARGINAL_ROI_CAMPAI
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.