OPS ► WHATSAPP BUSINESS skill
OPS on-demand: This skill should be used when the user asks to "whatsapp business", "template message"…
by Lifecycle-Innovations-Limited·MIT license·★ 543 Stars on the repo·GitHub ↗
npx degit Lifecycle-Innovations-Limited/claude-ops/claude-ops/skills/ops-whatsapp-biz#main ~/.claude/skills/ops-whatsapp-bizChecked ·commit main
Inside this skill
SKILL.md · the author's own words# OPS ► WHATSAPP BUSINESS Load `ops-rules` before acting. Public repo (no personal data). Outbound: one draft → one approval → one send. If `AskUserQuestion` / `Work… WhatsApp Business Cloud API — distinct from wacli personal WhatsApp. **Key difference:** This skill uses the WhatsApp Business Cloud API (Meta Graph API) for business-to-customer messaging at scale. `wacli` i… ## Credential Resolution ```bash PREFS_PATH="${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json" # WhatsApp Business credentials (separate from wacli personal) WABA_TOKEN="${WHATSAPP_BUSINESS_TOKEN:-$(jq -r '.user_config.whatsapp_business_token // empty' "$PREFS_PATH" 2>/dev/null)}" WABA_PHONE_ID="${WHATSAPP_PHONE_NUMBER_ID:-$(jq -r '.user_config.whatsapp_phone_number_id // empty' "$PREFS_PATH" 2>/dev/null)}"
Word-for-word excerpt, not a result. Read the full SKILL.md ↓
Files of OPS ► WHATSAPP BUSINESS
Show the full text398 lines
OPS ► WHATSAPP BUSINESS
Load ops-rules before acting. Public repo (no personal data). Outbound: one draft → one approval → one send. If AskUserQuestion / Workflow are missing, follow Rule 10 in ops-rules (Hermes: numbered options / two-turn Telegram card; delegate_task).
WhatsApp Business Cloud API — distinct from wacli personal WhatsApp.
Key difference: This skill uses the WhatsApp Business Cloud API (Meta Graph API) for business-to-customer messaging at scale. wacli is for personal WhatsApp messaging. Different credentials, different phone numbers, different use cases.
Credential Resolution
PREFS_PATH="${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json"
# WhatsApp Business credentials (separate from wacli personal)
WABA_TOKEN="${WHATSAPP_BUSINESS_TOKEN:-$(jq -r '.user_config.whatsapp_business_token // empty' "$PREFS_PATH" 2>/dev/null)}"
WABA_PHONE_ID="${WHATSAPP_PHONE_NUMBER_ID:-$(jq -r '.user_config.whatsapp_phone_number_id // empty' "$PREFS_PATH" 2>/dev/null)}"
WABA_ACCOUNT_ID="${WHATSAPP_BUSINESS_ACCOUNT_ID:-$(jq -r '.user_config.whatsapp_business_account_id // empty' "$PREFS_PATH" 2>/dev/null)}"
# Doppler fallback
if [ -z "$WABA_TOKEN" ]; then
WABA_TOKEN="$(doppler secrets get WHATSAPP_BUSINESS_TOKEN --plain 2>/dev/null)"
fi
if [ -z "$WABA_PHONE_ID" ]; then
WABA_PHONE_ID="$(doppler secrets get WHATSAPP_PHONE_NUMBER_ID --plain 2>/dev/null)"
fi
if [ -z "$WABA_ACCOUNT_ID" ]; then
WABA_ACCOUNT_ID="$(doppler secrets get WHATSAPP_BUSINESS_ACCOUNT_ID --plain 2>/dev/null)"
fi
Credential check: If WABA_TOKEN or WABA_PHONE_ID is empty, print:
WhatsApp Business not configured. Run /ops:whatsapp-biz setup to configure credentials.
and stop.
Sub-command Routing
Route $ARGUMENTS:
| Input | Action |
|---|---|
| (empty), list-templates | List all templates with approval status |
| send-template | Send an approved template message to one or more recipients |
| create-template | Guided template creation wizard |
| check-template <NAME> | Poll approval status for a specific template |
| catalog | View and manage linked product catalog |
| setup | Configure WhatsApp Business API credentials |
list-templates
List all message templates for the WhatsApp Business Account.
RESULT=$(curl -s "https://graph.facebook.com/v20.0/${WABA_ACCOUNT_ID}/message_templates?fields=name,status,category,language,components&limit=50" \
-H "Authorization: Bearer ${WABA_TOKEN}")
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " WHATSAPP BUSINESS TEMPLATES"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
printf "| %-30s | %-10s | %-12s | %-8s |\n" "Template Name" "Status" "Category" "Language"
printf "|%s|%s|%s|%s|\n" "--------------------------------" "------------" "--------------" "----------"
echo "$RESULT" | jq -r '.data[]? | [.name, .status, .category, .language] | @tsv' 2>/dev/null | \
while IFS=$'\t' read -r name status category language; do
STATUS_ICON="○"
[ "$status" = "APPROVED" ] && STATUS_ICON="✓"
[ "$status" = "REJECTED" ] && STATUS_ICON="✗"
[ "$status" = "PENDING" ] && STATUS_ICON="…"
printf "| %-30s | %s %-9s | %-12s | %-8s |\n" "${name:0:30}" "$STATUS_ICON" "$status" "${category:0:12}" "$language"
done
TOTAL=$(echo "$RESULT" | jq '.data | length // 0')
APPROVED=$(echo "$RESULT" | jq '[.data[]? | select(.status == "APPROVED")] | length')
echo ""
echo "Total: ${TOTAL} templates | Approved: ${APPROVED}"
echo ""
echo "Note: Per-template pricing applies since July 2025. Check Meta pricing page for current rates."
send-template
Send an approved template message to one or more recipients.
Before sending, collect via AskUserQuestion:
- Template name (free text — use
list-templatesfirst to see available) - Recipient phone number(s) — free text (single number or comma-separated list, include country code, e.g. +14155552671)
If template has variables, ask for each parameter value via AskUserQuestion (free text).
Cost note: Always display approximate cost before sending. Utility templates: ~$0.005/msg; Marketing: ~$0.025/msg (US rates — varies by country). Show AskUserQuestion to confirm before bulk sends > 10 recipients.
# Parse recipients
IFS=',' read -ra RECIPIENTS <<< "$PHONE_NUMBERS"
RECIPIENT_COUNT=${#RECIPIENTS[@]}
# For bulk sends > 10, confirm
if [ $RECIPIENT_COUNT -gt 10 ]; then
# AskUserQuestion: "Send to ${RECIPIENT_COUNT} recipients? Estimated cost: ~$$(awk "BEGIN {printf \"%.2f\", $RECIPIENT_COUNT * 0.025}") for marketing templates." options [Send, Cancel]
[ "$CONFIRM" = "Cancel" ] && echo "Cancelled." && exit 0
fi
# Send to each recipient
SUCCESS=0; FAILED=0
for PHONE in "${RECIPIENTS[@]}"; do
PHONE=$(echo "$PHONE" | tr -d ' ')
RESP=$(curl -s -X POST "https://graph.facebook.com/v20.0/${WABA_PHONE_ID}/messages" \
-H "Authorization: Bearer ${WABA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"messaging_product\": \"whatsapp\",
\"to\": \"${PHONE}\",
\"type\": \"template\",
\"template\": {
\"name\": \"${TEMPLATE_NAME}\",
\"language\": {\"code\": \"en_US\"},
\"components\": ${TEMPLATE_COMPONENTS_JSON:-[]}
}
}")
MSG_ID=$(echo "$RESP" | jq -r '.messages[0].id // empty')
if [ -n "$MSG_ID" ]; then
SUCCESS=$((SUCCESS + 1))
else
FAILED=$((FAILED + 1))
ERR=$(echo "$RESP" | jq -r '.error.message // "unknown error"')
echo " Failed for ${PHONE}: ${ERR}"
fi
done
echo ""
echo "Sent: ${SUCCESS}/${RECIPIENT_COUNT} messages delivered"
[ $FAILED -gt 0 ] && echo "Failed: ${FAILED} — check phone number format (+country_code + number)"
Template components format (build TEMPLATE_COMPONENTS_JSON from user input):
For templates with header + body variables:
[
{
"type": "header",
"parameters": [{ "type": "text", "text": "{{header_value}}" }]
},
{
"type": "body",
"parameters": [
{ "type": "text", "text": "{{var1}}" },
{ "type": "text", "text": "{{var2}}" }
]
}
]
For templates with no variables: TEMPLATE_COMPONENTS_JSON=[]
create-template
Guided template creation wizard.
Step 1 — Collect basic info:
AskUserQuestion: "Template category?" options [MARKETING, UTILITY, AUTHENTICATION, More...]
If More: AskUserQuestion: [AUTHENTICATION, UTILITY, Skip]
Step 2 — AskUserQuestion: "Template name (lowercase, underscores only, e.g. order_confirmation):" (free text)
Step 3 — AskUserQuestion: "Language?" options [en_US, en_GB, es, fr]
Step 4 — Collect body text (free text). Instruct user: "Use {{1}}, {{2}} etc for variables."
Step 5 — AskUserQuestion: "Add a header?" options [Text header, Image header, No header, Video header]
Step 6 — AskUserQuestion: "Add call-to-action button?" options [URL button, Phone button, No button, Quick reply]
# Build components array
COMPONENTS='[{"type":"BODY","text":"'"${BODY_TEXT}"'"}]'
# Add header if selected
if [ "$HEADER_TYPE" = "Text header" ]; then
HEADER_TEXT_INPUT="..." # collected via AskUserQuestion
COMPONENTS=$(echo "$COMPONENTS" | jq '. + [{"type":"HEADER","format":"TEXT","text":"'"${HEADER_TEXT_INPUT}"'"}]')
elif [ "$HEADER_TYPE" = "Image header" ]; then
COMPONENTS=$(echo "$COMPONENTS" | jq '. + [{"type":"HEADER","format":"IMAGE","example":{"header_handle":["<upload_handle>"]}}]')
fi
# Add button if selected
if [ "$BUTTON_TYPE" = "URL button" ]; then
BUTTON_URL="..." # collected via AskUserQuestion: "Button URL:"
BUTTON_TEXT="..." # collected via AskUserQuestion: "Button text:"
COMPONENTS=$(echo "$COMPONENTS" | jq '. + [{"type":"BUTTONS","buttons":[{"type":"URL","text":"'"${BUTTON_TEXT}"'","url":"'"${BUTTON_URL}"'"}]}]')
elif [ "$BUTTON_TYPE" = "Phone button" ]; then
BUTTON_PHONE="..." # collected via AskUserQuestion: "Phone number for button:"
BUTTON_TEXT="..."
COMPONENTS=$(echo "$COMPONENTS" | jq '. + [{"type":"BUTTONS","buttons":[{"type":"PHONE_NUMBER","text":"'"${BUTTON_TEXT}"'","phone_number":"'"${BUTTON_PHONE}"'"}]}]')
fi
RESP=$(curl -s -X POST "https://graph.facebook.com/v20.0/${WABA_ACCOUNT_ID}/message_templates" \
-H "Authorization: Bearer ${WABA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"${TEMPLATE_NAME}\",
\"category\": \"${CATEGORY}\",
\"language\": \"${LANGUAGE}\",
\"components\": ${COMPONENTS}
}")
TEMPLATE_ID=$(echo "$RESP" | jq -r '.id // empty')
STATUS=$(echo "$RESP" | jq -r '.status // empty')
if [ -n "$TEMPLATE_ID" ]; then
echo "Template submitted (ID: ${TEMPLATE_ID}, status: ${STATUS})."
echo "Approval typically takes 24-48 hours. Check status with: /ops:whatsapp-biz check-template ${TEMPLATE_NAME}"
else
echo "Template creation failed: $(echo "$RESP" | jq -r '.error.message // "unknown error"')"
fi
check-template <NAME>
Poll approval status for a specific template by name.
TEMPLATE_NAME_ARG=$(echo "$ARGUMENTS" | awk '{print $2}')
if [ -z "$TEMPLATE_NAME_ARG" ]; then
# AskUserQuestion: "Template name to check:" (free text)
TEMPLATE_NAME_ARG="$USER_INPUT"
fi
RESULT=$(curl -s "https://graph.facebook.com/v20.0/${WABA_ACCOUNT_ID}/message_templates?name=${TEMPLATE_NAME_ARG}&fields=name,status,rejected_reason,quality_score" \
-H "Authorization: Bearer ${WABA_TOKEN}")
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " TEMPLATE STATUS: ${TEMPLATE_NAME_ARG}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "$RESULT" | jq -r '.data[]? | "Status: \(.status)\nQuality: \(.quality_score.score // "N/A")\nRejection reason: \(.rejected_reason // "—")"' 2>/dev/null
STATUS=$(echo "$RESULT" | jq -r '.data[0].status // "NOT_FOUND"')
case "$STATUS" in
APPROVED) echo "Template is ready to use with send-template." ;;
REJECTED) echo "Template was rejected. Review the rejection reason and create a revised template." ;;
PENDING) echo "Template is pending review (typically 24-48h)." ;;
NOT_FOUND) echo "Template '${TEMPLATE_NAME_ARG}' not found. Run list-templates to see available templates." ;;
esac
catalog
View and manage the product catalog linked to your WhatsApp Business Account.
# List linked catalogs
CATALOGS=$(curl -s "https://graph.facebook.com/v20.0/${WABA_ACCOUNT_ID}?fields=catalog_id" \
-H "Authorization: Bearer ${WABA_TOKEN}")
CATALOG_ID=$(echo "$CATALOGS" | jq -r '.catalog_id // empty')
if [ -z "$CATALOG_ID" ]; then
echo "No product catalog linked to this WhatsApp Business Account."
echo "To link a catalog: go to Meta Business Manager → Commerce Manager → link your catalog to WhatsApp."
exit 0
fi
# List products in catalog
PRODUCTS=$(curl -s "https://graph.facebook.com/v20.0/${CATALOG_ID}/products?fields=id,name,retailer_id,price,availability,url&limit=20" \
-H "Authorization: Bearer ${WABA_TOKEN}")
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " WHATSAPP PRODUCT CATALOG"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Catalog ID: ${CATALOG_ID}"
echo ""
printf "| %-25s | %-12s | %-10s | %-12s |\n" "Product Name" "SKU" "Price" "Availability"
printf "|%s|%s|%s|%s|\n" "---------------------------" "--------------" "------------" "--------------"
echo "$PRODUCTS" | jq -r '.data[]? | [.name, (.retailer_id // "—"), (.price // "—"), (.availability // "—")] | @tsv' 2>/dev/null | \
while IFS=$'\t' read -r name sku price avail; do
printf "| %-25s | %-12s | %-10s | %-12s |\n" "${name:0:25}" "${sku:0:12}" "$price" "$avail"
done
PRODUCT_COUNT=$(echo "$PRODUCTS" | jq '.data | length // 0')
echo ""
echo "Total products shown: ${PRODUCT_COUNT} (use catalog in message templates with product cards)"
MM Lite note: Marketing Messages Lite (AI-optimized sends) is a beta API as of 2026. It is not implemented here — check Meta's developer documentation for current availability and enrollment requirements.
setup
Configure WhatsApp Business API credentials.
Before asking, auto-scan for existing credentials:
PREFS_PATH="${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json"
# Scan env vars
printenv WHATSAPP_BUSINESS_TOKEN WHATSAPP_PHONE_NUMBER_ID WHATSAPP_BUSINESS_ACCOUNT_ID 2>/dev/null
# Scan Doppler
for proj in $(doppler projects --json 2>/dev/null | jq -r '.[].slug'); do
for cfg in dev stg prd; do
doppler secrets --project "$proj" --config "$cfg" --json 2>/dev/null | \
jq -r --arg p "$proj" --arg c "$cfg" 'to_entries[] | select(.key | test("WHATSAPP_BUSINESS|WHATSAPP_PHONE"; "i")) | "\(.key)=\(.value.computed) (doppler:\($p)/\($c))"'
done
done
# Shell profiles
grep -h 'WHATSAPP_BUSINESS\|WHATSAPP_PHONE' ~/.zshrc ~/.bashrc ~/.zprofile ~/.envrc 2>/dev/null | grep -v '^#'
# Existing plugin config
jq -r '.user_config.whatsapp_business_token // empty' "$PREFS_PATH" 2>/dev/null && echo "✓ whatsapp_business_token already configured"
jq -r '.user_config.whatsapp_phone_number_id // empty' "$PREFS_PATH" 2>/dev/null && echo "✓ whatsapp_phone_number_id already configured"
jq -r '.user_config.whatsapp_business_account_id // empty' "$PREFS_PATH" 2>/dev/null && echo "✓ whatsapp_business_account_id already configured"
If credentials not found, guide user:
- AskUserQuestion: "Where do you have your WhatsApp Business credentials?" options
[Paste token now, Find in Meta dashboard, Skip]
Where to find credentials in Meta:
WHATSAPP_BUSINESS_TOKEN: Meta Developer Portal → Your App → WhatsApp → API Setup → Temporary access token (or generate a permanent System User token)WHATSAPP_PHONE_NUMBER_ID: Same page → "From" phone number → Phone Number IDWHATSAPP_BUSINESS_ACCOUNT_ID: Meta Business Manager → Business Settings → WhatsApp Accounts → Account ID
Collect each value via AskUserQuestion (free text):
- WhatsApp Business Token
- Phone Number ID
- Business Account ID (WABA ID)
Save via plugin config:
PREFS_PATH="${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json"
ops_user_config_set() { # write into preferences.json .user_config.<key>
local key="$1" value="$2" tmp
[ -n "$key" ] && [ -f "$PREFS_PATH" ] || return 0
tmp="$(mktemp "${PREFS_PATH}.XXXXXX")" || return 0
if jq --arg k "$key" --arg v "$value" '.user_config[$k] = $v' "$PREFS_PATH" >"$tmp" 2>/dev/null; then
chmod 600 "$tmp"; mv -f "$tmp" "$PREFS_PATH"
else
rm -f "$tmp"
fi
}
ops_user_config_set whatsapp_business_token "$WABA_TOKEN"
ops_user_config_set whatsapp_phone_number_id "$WABA_PHONE_ID"
ops_user_config_set whatsapp_business_account_id "$WABA_ACCOUNT_ID"
- Smoke test:
TEST=$(curl -s "https://graph.facebook.com/v20.0/${WABA_PHONE_ID}" \
-H "Authorization: Bearer ${WABA_TOKEN}")
NAME=$(echo "$TEST" | jq -r '.display_phone_number // empty')
if [ -n "$NAME" ]; then
echo "WhatsApp Business ✓ connected — Phone: ${NAME}"
else
echo "WhatsApp Business ✗ connection failed: $(echo "$TEST" | jq -r '.error.message // "invalid token or phone ID"')"
fi
Report: WhatsApp Business ✓ connected or ✗ invalid — [error].
| 1 | |
| 2 | name ops-whatsapp-biz |
| 3 | description "OPS on-demand: This skill should be used when the user asks to \"whatsapp business\", \"template message\"…" |
| 4 | argument-hint '[send-template|list-templates|create-template|check-template|catalog|setup]' |
| 5 | allowed-tools |
| 6 | - Bash |
| 7 | - Read |
| 8 | - Write |
| 9 | - Grep |
| 10 | - AskUserQuestion |
| 11 | effort medium |
| 12 | maxTurns 30 |
| 13 | |
| 14 | |
| 15 | # OPS ► WHATSAPP BUSINESS |
| 16 | |
| 17 | Load `ops-rules` before acting. Public repo (no personal data). Outbound: one draft → one approval → one send. If `AskUserQuestion` / `Workflow` are missing, follow Rule 10 in `ops-rules` (Hermes: numbered options / two-turn Telegram card; `delegate_task`). |
| 18 | |
| 19 | WhatsApp Business Cloud API — distinct from wacli personal WhatsApp. |
| 20 | |
| 21 | **Key difference:** This skill uses the WhatsApp Business Cloud API (Meta Graph API) for business-to-customer messaging at scale. `wacli` is for personal WhatsApp messaging. Different credentials, different phone numbers, different use cases. |
| 22 | |
| 23 | ## Credential Resolution |
| 24 | |
| 25 | |
| 26 | PREFS_PATH="${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json" |
| 27 | |
| 28 | # WhatsApp Business credentials (separate from wacli personal) |
| 29 | WABA_TOKEN="${WHATSAPP_BUSINESS_TOKEN:-$(jq -r '.user_config.whatsapp_business_token // empty' "$PREFS_PATH" 2>/dev/null)}" |
| 30 | WABA_PHONE_ID="${WHATSAPP_PHONE_NUMBER_ID:-$(jq -r '.user_config.whatsapp_phone_number_id // empty' "$PREFS_PATH" 2>/dev/null)}" |
| 31 | WABA_ACCOUNT_ID="${WHATSAPP_BUSINESS_ACCOUNT_ID:-$(jq -r '.user_config.whatsapp_business_account_id // empty' "$PREFS_PATH" 2>/dev/null)}" |
| 32 | |
| 33 | # Doppler fallback |
| 34 | if [ -z "$WABA_TOKEN" ]; then |
| 35 | WABA_TOKEN="$(doppler secrets get WHATSAPP_BUSINESS_TOKEN --plain 2>/dev/null)" |
| 36 | fi |
| 37 | if [ -z "$WABA_PHONE_ID" ]; then |
| 38 | WABA_PHONE_ID="$(doppler secrets get WHATSAPP_PHONE_NUMBER_ID --plain 2>/dev/null)" |
| 39 | fi |
| 40 | if [ -z "$WABA_ACCOUNT_ID" ]; then |
| 41 | WABA_ACCOUNT_ID="$(doppler secrets get WHATSAPP_BUSINESS_ACCOUNT_ID --plain 2>/dev/null)" |
| 42 | fi |
| 43 | |
| 44 | |
| 45 | **Credential check**: If `WABA_TOKEN` or `WABA_PHONE_ID` is empty, print: |
| 46 | `WhatsApp Business not configured. Run /ops:whatsapp-biz setup to configure credentials.` |
| 47 | and stop. |
| 48 | |
| 49 | |
| 50 | |
| 51 | ## Sub-command Routing |
| 52 | |
| 53 | Route `$ARGUMENTS`: |
| 54 | |
| 55 | | Input | Action | |
| 56 | | ----------------------- | ----------------------------------------------------------- | |
| 57 | | (empty), list-templates | List all templates with approval status | |
| 58 | | send-template | Send an approved template message to one or more recipients | |
| 59 | | create-template | Guided template creation wizard | |
| 60 | | check-template \<NAME\> | Poll approval status for a specific template | |
| 61 | | catalog | View and manage linked product catalog | |
| 62 | | setup | Configure WhatsApp Business API credentials | |
| 63 | |
| 64 | |
| 65 | |
| 66 | ## list-templates |
| 67 | |
| 68 | List all message templates for the WhatsApp Business Account. |
| 69 | |
| 70 | |
| 71 | RESULT=$(curl -s "https://graph.facebook.com/v20.0/${WABA_ACCOUNT_ID}/message_templates?fields=name,status,category,language,components&limit=50" \ |
| 72 | -H "Authorization: Bearer ${WABA_TOKEN}") |
| 73 | |
| 74 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 75 | echo " WHATSAPP BUSINESS TEMPLATES" |
| 76 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 77 | echo "" |
| 78 | printf "| %-30s | %-10s | %-12s | %-8s |\n" "Template Name" "Status" "Category" "Language" |
| 79 | printf "|%s|%s|%s|%s|\n" "--------------------------------" "------------" "--------------" "----------" |
| 80 | |
| 81 | echo "$RESULT" | jq -r '.data[]? | [.name, .status, .category, .language] | @tsv' 2>/dev/null | \ |
| 82 | while IFS=$'\t' read -r name status category language; do |
| 83 | STATUS_ICON="○" |
| 84 | [ "$status" = "APPROVED" ] && STATUS_ICON="✓" |
| 85 | [ "$status" = "REJECTED" ] && STATUS_ICON="✗" |
| 86 | [ "$status" = "PENDING" ] && STATUS_ICON="…" |
| 87 | printf "| %-30s | %s %-9s | %-12s | %-8s |\n" "${name:0:30}" "$STATUS_ICON" "$status" "${category:0:12}" "$language" |
| 88 | done |
| 89 | |
| 90 | TOTAL=$(echo "$RESULT" | jq '.data | length // 0') |
| 91 | APPROVED=$(echo "$RESULT" | jq '[.data[]? | select(.status == "APPROVED")] | length') |
| 92 | echo "" |
| 93 | echo "Total: ${TOTAL} templates | Approved: ${APPROVED}" |
| 94 | echo "" |
| 95 | echo "Note: Per-template pricing applies since July 2025. Check Meta pricing page for current rates." |
| 96 | |
| 97 | |
| 98 | |
| 99 | |
| 100 | ## send-template |
| 101 | |
| 102 | Send an approved template message to one or more recipients. |
| 103 | |
| 104 | **Before sending**, collect via AskUserQuestion: |
| 105 | |
| 106 | Template name (free text — use `list-templates` first to see available) |
| 107 | Recipient phone number(s) — free text (single number or comma-separated list, include country code, e.g. +14155552671) |
| 108 | |
| 109 | If template has variables, ask for each parameter value via AskUserQuestion (free text). |
| 110 | |
| 111 | **Cost note**: Always display approximate cost before sending. Utility templates: ~$0.005/msg; Marketing: ~$0.025/msg (US rates — varies by country). Show AskUserQuestion to confirm before bulk sends > 10 recipients. |
| 112 | |
| 113 | |
| 114 | # Parse recipients |
| 115 | IFS=',' read -ra RECIPIENTS <<< "$PHONE_NUMBERS" |
| 116 | RECIPIENT_COUNT=${#RECIPIENTS[@]} |
| 117 | |
| 118 | # For bulk sends > 10, confirm |
| 119 | if [ $RECIPIENT_COUNT -gt 10 ]; then |
| 120 | # AskUserQuestion: "Send to ${RECIPIENT_COUNT} recipients? Estimated cost: ~$$(awk "BEGIN {printf \"%.2f\", $RECIPIENT_COUNT * 0.025}") for marketing templates." options [Send, Cancel] |
| 121 | [ "$CONFIRM" = "Cancel" ] && echo "Cancelled." && exit 0 |
| 122 | fi |
| 123 | |
| 124 | # Send to each recipient |
| 125 | SUCCESS=0; FAILED=0 |
| 126 | for PHONE in "${RECIPIENTS[@]}"; do |
| 127 | PHONE=$(echo "$PHONE" | tr -d ' ') |
| 128 | |
| 129 | RESP=$(curl -s -X POST "https://graph.facebook.com/v20.0/${WABA_PHONE_ID}/messages" \ |
| 130 | -H "Authorization: Bearer ${WABA_TOKEN}" \ |
| 131 | -H "Content-Type: application/json" \ |
| 132 | -d "{ |
| 133 | \"messaging_product\": \"whatsapp\", |
| 134 | \"to\": \"${PHONE}\", |
| 135 | \"type\": \"template\", |
| 136 | \"template\": { |
| 137 | \"name\": \"${TEMPLATE_NAME}\", |
| 138 | \"language\": {\"code\": \"en_US\"}, |
| 139 | \"components\": ${TEMPLATE_COMPONENTS_JSON:-[]} |
| 140 | } |
| 141 | }") |
| 142 | |
| 143 | MSG_ID=$(echo "$RESP" | jq -r '.messages[0].id // empty') |
| 144 | if [ -n "$MSG_ID" ]; then |
| 145 | SUCCESS=$((SUCCESS + 1)) |
| 146 | else |
| 147 | FAILED=$((FAILED + 1)) |
| 148 | ERR=$(echo "$RESP" | jq -r '.error.message // "unknown error"') |
| 149 | echo " Failed for ${PHONE}: ${ERR}" |
| 150 | fi |
| 151 | done |
| 152 | |
| 153 | echo "" |
| 154 | echo "Sent: ${SUCCESS}/${RECIPIENT_COUNT} messages delivered" |
| 155 | [ $FAILED -gt 0 ] && echo "Failed: ${FAILED} — check phone number format (+country_code + number)" |
| 156 | |
| 157 | |
| 158 | **Template components format** (build `TEMPLATE_COMPONENTS_JSON` from user input): |
| 159 | |
| 160 | For templates with header + body variables: |
| 161 | |
| 162 | |
| 163 | [ |
| 164 | { |
| 165 | "type": "header", |
| 166 | "parameters": [{ "type": "text", "text": "{{header_value}}" }] |
| 167 | }, |
| 168 | { |
| 169 | "type": "body", |
| 170 | "parameters": [ |
| 171 | { "type": "text", "text": "{{var1}}" }, |
| 172 | { "type": "text", "text": "{{var2}}" } |
| 173 | ] |
| 174 | } |
| 175 | ] |
| 176 | |
| 177 | |
| 178 | For templates with no variables: `TEMPLATE_COMPONENTS_JSON=[]` |
| 179 | |
| 180 | |
| 181 | |
| 182 | ## create-template |
| 183 | |
| 184 | Guided template creation wizard. |
| 185 | |
| 186 | Step 1 — Collect basic info: |
| 187 | |
| 188 | AskUserQuestion: "Template category?" options `[MARKETING, UTILITY, AUTHENTICATION, More...]` |
| 189 | |
| 190 | If More: AskUserQuestion: `[AUTHENTICATION, UTILITY, Skip]` |
| 191 | |
| 192 | Step 2 — AskUserQuestion: "Template name (lowercase, underscores only, e.g. order_confirmation):" (free text) |
| 193 | |
| 194 | Step 3 — AskUserQuestion: "Language?" options `[en_US, en_GB, es, fr]` |
| 195 | |
| 196 | Step 4 — Collect body text (free text). Instruct user: "Use {{1}}, {{2}} etc for variables." |
| 197 | |
| 198 | Step 5 — AskUserQuestion: "Add a header?" options `[Text header, Image header, No header, Video header]` |
| 199 | |
| 200 | Step 6 — AskUserQuestion: "Add call-to-action button?" options `[URL button, Phone button, No button, Quick reply]` |
| 201 | |
| 202 | |
| 203 | # Build components array |
| 204 | COMPONENTS='[{"type":"BODY","text":"'"${BODY_TEXT}"'"}]' |
| 205 | |
| 206 | # Add header if selected |
| 207 | if [ "$HEADER_TYPE" = "Text header" ]; then |
| 208 | HEADER_TEXT_INPUT="..." # collected via AskUserQuestion |
| 209 | COMPONENTS=$(echo "$COMPONENTS" | jq '. + [{"type":"HEADER","format":"TEXT","text":"'"${HEADER_TEXT_INPUT}"'"}]') |
| 210 | elif [ "$HEADER_TYPE" = "Image header" ]; then |
| 211 | COMPONENTS=$(echo "$COMPONENTS" | jq '. + [{"type":"HEADER","format":"IMAGE","example":{"header_handle":["<upload_handle>"]}}]') |
| 212 | fi |
| 213 | |
| 214 | # Add button if selected |
| 215 | if [ "$BUTTON_TYPE" = "URL button" ]; then |
| 216 | BUTTON_URL="..." # collected via AskUserQuestion: "Button URL:" |
| 217 | BUTTON_TEXT="..." # collected via AskUserQuestion: "Button text:" |
| 218 | COMPONENTS=$(echo "$COMPONENTS" | jq '. + [{"type":"BUTTONS","buttons":[{"type":"URL","text":"'"${BUTTON_TEXT}"'","url":"'"${BUTTON_URL}"'"}]}]') |
| 219 | elif [ "$BUTTON_TYPE" = "Phone button" ]; then |
| 220 | BUTTON_PHONE="..." # collected via AskUserQuestion: "Phone number for button:" |
| 221 | BUTTON_TEXT="..." |
| 222 | COMPONENTS=$(echo "$COMPONENTS" | jq '. + [{"type":"BUTTONS","buttons":[{"type":"PHONE_NUMBER","text":"'"${BUTTON_TEXT}"'","phone_number":"'"${BUTTON_PHONE}"'"}]}]') |
| 223 | fi |
| 224 | |
| 225 | RESP=$(curl -s -X POST "https://graph.facebook.com/v20.0/${WABA_ACCOUNT_ID}/message_templates" \ |
| 226 | -H "Authorization: Bearer ${WABA_TOKEN}" \ |
| 227 | -H "Content-Type: application/json" \ |
| 228 | -d "{ |
| 229 | \"name\": \"${TEMPLATE_NAME}\", |
| 230 | \"category\": \"${CATEGORY}\", |
| 231 | \"language\": \"${LANGUAGE}\", |
| 232 | \"components\": ${COMPONENTS} |
| 233 | }") |
| 234 | |
| 235 | TEMPLATE_ID=$(echo "$RESP" | jq -r '.id // empty') |
| 236 | STATUS=$(echo "$RESP" | jq -r '.status // empty') |
| 237 | |
| 238 | if [ -n "$TEMPLATE_ID" ]; then |
| 239 | echo "Template submitted (ID: ${TEMPLATE_ID}, status: ${STATUS})." |
| 240 | echo "Approval typically takes 24-48 hours. Check status with: /ops:whatsapp-biz check-template ${TEMPLATE_NAME}" |
| 241 | else |
| 242 | echo "Template creation failed: $(echo "$RESP" | jq -r '.error.message // "unknown error"')" |
| 243 | fi |
| 244 | |
| 245 | |
| 246 | |
| 247 | |
| 248 | ## check-template \<NAME\> |
| 249 | |
| 250 | Poll approval status for a specific template by name. |
| 251 | |
| 252 | |
| 253 | TEMPLATE_NAME_ARG=$(echo "$ARGUMENTS" | awk '{print $2}') |
| 254 | if [ -z "$TEMPLATE_NAME_ARG" ]; then |
| 255 | # AskUserQuestion: "Template name to check:" (free text) |
| 256 | TEMPLATE_NAME_ARG="$USER_INPUT" |
| 257 | fi |
| 258 | |
| 259 | RESULT=$(curl -s "https://graph.facebook.com/v20.0/${WABA_ACCOUNT_ID}/message_templates?name=${TEMPLATE_NAME_ARG}&fields=name,status,rejected_reason,quality_score" \ |
| 260 | -H "Authorization: Bearer ${WABA_TOKEN}") |
| 261 | |
| 262 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 263 | echo " TEMPLATE STATUS: ${TEMPLATE_NAME_ARG}" |
| 264 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 265 | echo "" |
| 266 | |
| 267 | echo "$RESULT" | jq -r '.data[]? | "Status: \(.status)\nQuality: \(.quality_score.score // "N/A")\nRejection reason: \(.rejected_reason // "—")"' 2>/dev/null |
| 268 | |
| 269 | STATUS=$(echo "$RESULT" | jq -r '.data[0].status // "NOT_FOUND"') |
| 270 | case "$STATUS" in |
| 271 | APPROVED) echo "Template is ready to use with send-template." ;; |
| 272 | REJECTED) echo "Template was rejected. Review the rejection reason and create a revised template." ;; |
| 273 | PENDING) echo "Template is pending review (typically 24-48h)." ;; |
| 274 | NOT_FOUND) echo "Template '${TEMPLATE_NAME_ARG}' not found. Run list-templates to see available templates." ;; |
| 275 | esac |
| 276 | |
| 277 | |
| 278 | |
| 279 | |
| 280 | ## catalog |
| 281 | |
| 282 | View and manage the product catalog linked to your WhatsApp Business Account. |
| 283 | |
| 284 | |
| 285 | # List linked catalogs |
| 286 | CATALOGS=$(curl -s "https://graph.facebook.com/v20.0/${WABA_ACCOUNT_ID}?fields=catalog_id" \ |
| 287 | -H "Authorization: Bearer ${WABA_TOKEN}") |
| 288 | CATALOG_ID=$(echo "$CATALOGS" | jq -r '.catalog_id // empty') |
| 289 | |
| 290 | if [ -z "$CATALOG_ID" ]; then |
| 291 | echo "No product catalog linked to this WhatsApp Business Account." |
| 292 | echo "To link a catalog: go to Meta Business Manager → Commerce Manager → link your catalog to WhatsApp." |
| 293 | exit 0 |
| 294 | fi |
| 295 | |
| 296 | # List products in catalog |
| 297 | PRODUCTS=$(curl -s "https://graph.facebook.com/v20.0/${CATALOG_ID}/products?fields=id,name,retailer_id,price,availability,url&limit=20" \ |
| 298 | -H "Authorization: Bearer ${WABA_TOKEN}") |
| 299 | |
| 300 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 301 | echo " WHATSAPP PRODUCT CATALOG" |
| 302 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 303 | echo "Catalog ID: ${CATALOG_ID}" |
| 304 | echo "" |
| 305 | printf "| %-25s | %-12s | %-10s | %-12s |\n" "Product Name" "SKU" "Price" "Availability" |
| 306 | printf "|%s|%s|%s|%s|\n" "---------------------------" "--------------" "------------" "--------------" |
| 307 | echo "$PRODUCTS" | jq -r '.data[]? | [.name, (.retailer_id // "—"), (.price // "—"), (.availability // "—")] | @tsv' 2>/dev/null | \ |
| 308 | while IFS=$'\t' read -r name sku price avail; do |
| 309 | printf "| %-25s | %-12s | %-10s | %-12s |\n" "${name:0:25}" "${sku:0:12}" "$price" "$avail" |
| 310 | done |
| 311 | |
| 312 | PRODUCT_COUNT=$(echo "$PRODUCTS" | jq '.data | length // 0') |
| 313 | echo "" |
| 314 | echo "Total products shown: ${PRODUCT_COUNT} (use catalog in message templates with product cards)" |
| 315 | |
| 316 | |
| 317 | **MM Lite note**: Marketing Messages Lite (AI-optimized sends) is a beta API as of 2026. It is not implemented here — check Meta's developer documentation for current availability and enrollment requirements. |
| 318 | |
| 319 | |
| 320 | |
| 321 | ## setup |
| 322 | |
| 323 | Configure WhatsApp Business API credentials. |
| 324 | |
| 325 | **Before asking**, auto-scan for existing credentials: |
| 326 | |
| 327 | |
| 328 | PREFS_PATH="${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json" |
| 329 | # Scan env vars |
| 330 | printenv WHATSAPP_BUSINESS_TOKEN WHATSAPP_PHONE_NUMBER_ID WHATSAPP_BUSINESS_ACCOUNT_ID 2>/dev/null |
| 331 | |
| 332 | # Scan Doppler |
| 333 | for proj in $(doppler projects --json 2>/dev/null | jq -r '.[].slug'); do |
| 334 | for cfg in dev stg prd; do |
| 335 | doppler secrets --project "$proj" --config "$cfg" --json 2>/dev/null | \ |
| 336 | jq -r --arg p "$proj" --arg c "$cfg" 'to_entries[] | select(.key | test("WHATSAPP_BUSINESS|WHATSAPP_PHONE"; "i")) | "\(.key)=\(.value.computed) (doppler:\($p)/\($c))"' |
| 337 | done |
| 338 | done |
| 339 | |
| 340 | # Shell profiles |
| 341 | grep -h 'WHATSAPP_BUSINESS\|WHATSAPP_PHONE' ~/.zshrc ~/.bashrc ~/.zprofile ~/.envrc 2>/dev/null | grep -v '^#' |
| 342 | |
| 343 | # Existing plugin config |
| 344 | jq -r '.user_config.whatsapp_business_token // empty' "$PREFS_PATH" 2>/dev/null && echo "✓ whatsapp_business_token already configured" |
| 345 | jq -r '.user_config.whatsapp_phone_number_id // empty' "$PREFS_PATH" 2>/dev/null && echo "✓ whatsapp_phone_number_id already configured" |
| 346 | jq -r '.user_config.whatsapp_business_account_id // empty' "$PREFS_PATH" 2>/dev/null && echo "✓ whatsapp_business_account_id already configured" |
| 347 | |
| 348 | |
| 349 | **If credentials not found**, guide user: |
| 350 | |
| 351 | AskUserQuestion: "Where do you have your WhatsApp Business credentials?" options `[Paste token now, Find in Meta dashboard, Skip]` |
| 352 | |
| 353 | **Where to find credentials in Meta:** |
| 354 | |
| 355 | `WHATSAPP_BUSINESS_TOKEN`: Meta Developer Portal → Your App → WhatsApp → API Setup → Temporary access token (or generate a permanent System User token) |
| 356 | `WHATSAPP_PHONE_NUMBER_ID`: Same page → "From" phone number → Phone Number ID |
| 357 | `WHATSAPP_BUSINESS_ACCOUNT_ID`: Meta Business Manager → Business Settings → WhatsApp Accounts → Account ID |
| 358 | |
| 359 | Collect each value via AskUserQuestion (free text): |
| 360 | WhatsApp Business Token |
| 361 | Phone Number ID |
| 362 | Business Account ID (WABA ID) |
| 363 | |
| 364 | Save via plugin config: |
| 365 | |
| 366 | |
| 367 | PREFS_PATH="${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json" |
| 368 | ops_user_config_set() { # write into preferences.json .user_config.<key> |
| 369 | local key="$1" value="$2" tmp |
| 370 | [ -n "$key" ] && [ -f "$PREFS_PATH" ] || return 0 |
| 371 | tmp="$(mktemp "${PREFS_PATH}.XXXXXX")" || return 0 |
| 372 | if jq --arg k "$key" --arg v "$value" '.user_config[$k] = $v' "$PREFS_PATH" >"$tmp" 2>/dev/null; then |
| 373 | chmod 600 "$tmp"; mv -f "$tmp" "$PREFS_PATH" |
| 374 | else |
| 375 | rm -f "$tmp" |
| 376 | fi |
| 377 | } |
| 378 | |
| 379 | ops_user_config_set whatsapp_business_token "$WABA_TOKEN" |
| 380 | ops_user_config_set whatsapp_phone_number_id "$WABA_PHONE_ID" |
| 381 | ops_user_config_set whatsapp_business_account_id "$WABA_ACCOUNT_ID" |
| 382 | |
| 383 | |
| 384 | Smoke test: |
| 385 | |
| 386 | |
| 387 | TEST=$(curl -s "https://graph.facebook.com/v20.0/${WABA_PHONE_ID}" \ |
| 388 | -H "Authorization: Bearer ${WABA_TOKEN}") |
| 389 | NAME=$(echo "$TEST" | jq -r '.display_phone_number // empty') |
| 390 | if [ -n "$NAME" ]; then |
| 391 | echo "WhatsApp Business ✓ connected — Phone: ${NAME}" |
| 392 | else |
| 393 | echo "WhatsApp Business ✗ connection failed: $(echo "$TEST" | jq -r '.error.message // "invalid token or phone ID"')" |
| 394 | fi |
| 395 | |
| 396 | |
| 397 | Report: `WhatsApp Business ✓ connected` or `✗ invalid — [error]`. |
| 398 |
Discussion
Browse more free Claude skills.