Skip to content
SendSquared

CLI Scripts

Small tools that pay for themselves

Every script here runs against your own account with the SendSquared CLI. Copy one, run it, change the filters. No server, no build step, nothing to install beyond the CLI itself.

Why these matter to Claude Code and other agents

An agent pointed at a new system spends its first several turns guessing: inventing flags, getting errors back, correcting itself. These scripts remove that. Each one is a worked example of a real question answered with real commands, so a model can read the shape of the problem instead of rediscovering it.

  • Paste one in and ask for a variant. “Do this but for Vrbo stays only, and group by unit” is a small edit to working code, not a blank page.
  • Every flag here is verified. Agents hallucinate CLI options more than almost anything else. Nothing on this page uses a flag that does not exist, so a model copying from it starts from truth, and sendsquared commands --json lets it check anything it adds.
  • Fetchable as plain text. Each script is also served at /cli/scripts/<name>.sh, so an agent can curl the source instead of scraping a page, and the full code is in this page’s structured data too.
  • JSON by default means composable. The CLI emits JSON unless told otherwise, so an agent can pipe any of this into jq, into another CLI, or into its own reasoning without parsing human-formatted tables.
install once
curl -fsSL https://cli.sendsquared.com/install.sh | bash
Revenue ~5s script read only

Gap-night finder

Finds the orphan one and two night holes between bookings over the next 30 days and prints the guest arriving next door, so you can offer the night to someone already coming.

Returns: A table of gap nights with the adjacent reservation and guest contact id.

reservations gap-nights
View the script
#!/usr/bin/env bash
# Open gap nights in the next 30 days, biggest hole first.
set -euo pipefail

START=$(date -u +%F)
END=$(date -u -v+30d +%F 2>/dev/null || date -u -d "+30 days" +%F)

sendsquared reservations gap-nights \
  --start "$START" --end "$END" \
  --timezone America/Chicago \
  --min-gap 1 --gap-side departure \
> /tmp/.gapnights.json

TOTAL=$(jq '[.data[]?] | length' /tmp/.gapnights.json)
if [ "$TOTAL" -eq 0 ]; then
  echo "no open gap nights between $START and $END"
  rm -f /tmp/.gapnights.json; exit 0
fi

# next_gap_date is null when nothing is booked after the stay at all, which is
# a bigger hole than a gap, not a smaller one. Both are worth surfacing.
jq -r '
  .data[]
  # the API returns the stay and the next booked date; the gap is the span between
  | . as $r
  | (if $r.next_gap_date == null then 999
      else (((($r.next_gap_date | sub("\\.[0-9]+Z$";"Z") | fromdateiso8601)
             - ($r.departure_date | sub("\\.[0-9]+Z$";"Z") | fromdateiso8601)) / 86400) | floor)
      end) as $gap
  | select($gap >= 1)
  | [ (if $gap == 999 then "open" else ($gap|tostring) end),
      ($r.departure_date | split("T")[0]),
      (if $r.next_gap_date == null then "nothing booked" else ($r.next_gap_date | split("T")[0]) end),
      ($r.unit_id // "-"),
      ($r.contact_id // "-"),
      (((($r.contact_first_name // "") + " " + ($r.contact_last_name // "")) | ltrimstr(" ") | rtrimstr(" "))) ]
  | @tsv
' /tmp/.gapnights.json \
| sort -rn \
| head -40 \
| awk -F'\t' 'BEGIN{printf "%-5s %-12s %-15s %-10s %-10s %s\n","GAP","FREE FROM","BOOKED AGN","UNIT","CONTACT","GUEST"}
    {printf "%-5s %-12s %-15s %-10s %-10s %s\n",$1,$2,$3,$4,$5,$6}'

rm -f /tmp/.gapnights.json
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/gap-night-finder.sh view raw
Data hygiene ~30s per 10k stays script read only

Reservation audit

The one built for Wilderness. Walks every reservation and flags the ones that will quietly break automations: no guest attached, no email, no phone, or a stay with no unit.

Returns: Counts by defect plus a CSV of every reservation that needs attention.

reservations listreservations count
View the script
#!/usr/bin/env bash
# Reservations that will silently break automations.
#
# Bounded by default. A full portfolio can run to hundreds of thousands of
# stays, and walking all of them takes long enough that nobody runs the script
# twice. PAGES=0 removes the cap when you do want the whole history.
set -euo pipefail

PAGES=${1:-20}   # 20 pages x 1000 = the 20k most recent stays; 0 = everything
OUT=reservation-audit-$(date +%F).csv
echo "id,reservation_number,arrival,issue" > "$OUT"
BEFORE=""
SEEN=0
PAGENO=0

while :; do
  PAGENO=$((PAGENO+1))
  if [ "$PAGES" -ne 0 ] && [ "$PAGENO" -gt "$PAGES" ]; then break; fi
  PAGE=$(sendsquared reservations list --limit 1000 ${BEFORE:+--before "$BEFORE"})
  COUNT=$(jq '.data | length' <<<"$PAGE")
  [ "$COUNT" -eq 0 ] && break

  jq -r '
    .data[]
    | . as $r
    | [ (if $r.contact_id == null then "no guest attached" else empty end),
        (if ($r.contact.primaryEmail // "") == "" then "no email" else empty end),
        (if (($r.contact.mobile_phone // "") == "" and ($r.contact.home_phone // "") == "") then "no phone" else empty end),
        (if (($r.units // []) | length) == 0 then "no unit" else empty end),
        (if ($r.arrival_date // "") == "" then "no arrival date" else empty end) ]
    | .[] as $issue
    | [ $r.id, ($r.reservation_number // ""), (($r.arrival_date // "") | split("T")[0]), $issue ]
    | @csv
  ' <<<"$PAGE" >> "$OUT"

  BEFORE=$(jq -r '[.data[].id] | min' <<<"$PAGE")
  SEEN=$((SEEN+COUNT))
  [ "$COUNT" -lt 1000 ] && break
done

TOTAL=$(tail -n +2 "$OUT" | wc -l | tr -d ' ')
echo "--- scanned $SEEN reservations, $TOTAL findings"
tail -n +2 "$OUT" | sed 's/.*,"\(.*\)"$/\1/' | sort | uniq -c | sort -rn | sed 's/^/  /'
echo "--- full list: $OUT"
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/reservation-audit.sh view raw
Diagnostics ~10s script read only

Why didn’t my automation fire?

The question the dashboard cannot answer. Replays every trigger evaluation for a workflow and groups the rejections by reason, so you see exactly which condition is eliminating contacts.

Returns: Fired vs rejected counts, then rejection reasons ranked by frequency.

events triggersworkflows list
View the script
#!/usr/bin/env bash
# Usage: ./why-didnt-it-fire.sh [workflowId] [sinceYYYY-MM-DD]
# With no workflow id it reports across every automation.
set -euo pipefail
WORKFLOW=${1:-}
SINCE=${2:-$(date -u -v-7d +%F 2>/dev/null || date -u -d "-7 days" +%F)}

EV=$(sendsquared events triggers --limit 1000 --from "$SINCE" ${WORKFLOW:+--workflow "$WORKFLOW"})
N=$(jq '.data | length' <<<"$EV")
echo "--- $N trigger evaluations since $SINCE"

echo
echo "outcome"
jq -r '.data[].outcome // "unknown"' <<<"$EV" | sort | uniq -c | sort -rn

echo
echo "why contacts were rejected (the message the engine recorded)"
jq -r '.data[] | select(.outcome != "fired") | .message // "no message recorded"' <<<"$EV" \
  | sed 's/[0-9]\{4,\}/<id>/g' | sort | uniq -c | sort -rn | head -15

echo
echo "by automation"
jq -r '.data[] | "\(.outcome)\t\(.workflow_name // ("workflow " + (.workflow_id|tostring)))"' <<<"$EV" \
  | sort | uniq -c | sort -rn | head -15
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/why-didnt-it-fire.sh view raw
Operations ~10s script writes data

Abandoned call triage

Every caller who hung up before an agent picked up yesterday, turned into a task assigned to whoever owns callbacks. Missed calls during peak season are lost bookings.

Returns: One task per abandoned call, attached to the contact where one is known.

reports runtasks create
View the script
#!/usr/bin/env bash
# Yesterday's abandoned calls -> callback tasks.
set -euo pipefail
DAY=${1:-$(date -u -v-1d +%F 2>/dev/null || date -u -d "-1 day" +%F)}
AGENT=${2:?pass the agent to assign callbacks to (id, name or email)}

CALLS=$(sendsquared reports run --type call_abandonment_detail --from "$DAY" --to "$DAY")

jq -r '.data[] | [ (.contactId // ""), (.callerNumber // .fromNumber // ""), (.waitSeconds // 0) ] | @tsv' <<<"$CALLS" \
| while IFS=$'\t' read -r CONTACT NUMBER WAIT; do
    sendsquared tasks create \
      --label "Call back $NUMBER" \
      --description "Abandoned after ${WAIT}s waiting on $DAY." \
      --due "$(date -u +%F)" \
      --agent "$AGENT" \
      ${CONTACT:+--contact "$CONTACT"} \
      --output quiet
    echo "task queued for $NUMBER (waited ${WAIT}s)"
  done
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/abandoned-call-triage.sh view raw
Operations ~20s script read only

Silent arrivals

Guests arriving in the next three days who have had no message from you at all. Catches the stays that slipped through a paused automation or a broken template.

Returns: Arrival date, unit, guest and contact id for every silent upcoming stay.

reservations listcontacts timeline
View the script
#!/usr/bin/env bash
# Upcoming arrivals with no outbound message on the contact.
set -euo pipefail
DAYS=${1:-3}
TODAY=$(date -u +%F)
UNTIL=$(date -u -v+${DAYS}d +%F 2>/dev/null || date -u -d "+${DAYS} days" +%F)

echo "arrivals $TODAY to $UNTIL with nothing sent"
sendsquared reservations list --limit 1000 --sort arrival_date:asc \
| jq -r --arg from "$TODAY" --arg to "$UNTIL" '
    .data[]
    | select(.contact_id != null and (.arrival_date // "") != "")
    | select(.cancelled_at == null)
    | (.arrival_date | split("T")[0]) as $d
    | select($d >= $from and $d <= $to)
    | [ $d, (.unit_type // "-"), ((.pms_first_name // "") + " " + (.pms_last_name // "")), .contact_id ] | @tsv
  ' \
| while IFS=$'\t' read -r ARR UNIT GUEST CONTACT; do
    SENT=$(sendsquared contacts timeline "$CONTACT" 2>/dev/null \
      | jq '[.data[]? | select(.direction == "outbound")] | length')
    if [ "${SENT:-0}" -eq 0 ]; then
      printf "  %-12s %-18s %-26s contact %s\n" "$ARR" "$UNIT" "$GUEST" "$CONTACT"
    fi
  done
echo "done"
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/silent-arrivals.sh view raw
Reporting ~8s script read only

Morning scoreboard

Yesterday on one screen: calls, SMS, email and agent productivity. Pipe it into Slack on a cron and the team starts the day with the same numbers.

Returns: A compact digest of yesterday across every channel.

reports run
View the script
#!/usr/bin/env bash
# Yesterday across every channel, in one block.
set -euo pipefail
DAY=${1:-$(date -u -v-1d +%F 2>/dev/null || date -u -d "-1 day" +%F)}

echo "SendSquared report for $DAY"
echo

for R in call_volume_stats sms_volume_stats email_campaign_overview; do
  echo "## $R"
  sendsquared reports run "$R" --from "$DAY" --to "$DAY" -o md 2>/dev/null | sed -n '1,14p' || echo "  (unavailable)"
  echo
done
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/morning-scoreboard.sh view raw
Operations ~10s script read only

Guidebook gaps

Which units have no guidebook attached. Every unit without one is a guest ringing the front desk to ask the wifi password.

Returns: Units with no guidebook, and guidebooks assigned to nothing.

reports runguidebooks listguidebooks unit-assignments
View the script
#!/usr/bin/env bash
# Guidebook coverage: how many stays actually open the book.
set -euo pipefail
FROM=${1:-$(date -u -v-90d +%F 2>/dev/null || date -u -d "-90 days" +%F)}
TO=${2:-$(date -u +%F)}

echo "--- saturation by guidebook, $FROM to $TO"
sendsquared reports run guidebook_saturation --from "$FROM" --to "$TO" \
| jq -r '
  .data[]
  | [ (.guidebook_name // ("guidebook " + (.guidebook_id|tostring))),
      (.total_reservations // 0), (.opened_reservations // 0), (.saturation_pct // 0) ]
  | @tsv
' \
| sort -t$'\t' -k4 -n \
| awk -F'\t' 'BEGIN{printf "  %-40s %8s %8s %8s\n","GUIDEBOOK","STAYS","OPENED","PCT"}
    {printf "  %-40s %8s %8s %7s%%\n",$1,$2,$3,$4}'

echo
echo "--- guidebooks with no units assigned"
sendsquared guidebooks list --limit 200 \
| jq -r '.data.guidebooks[]? | [.id, (.name // .public_name // "untitled")] | @tsv' \
> /tmp/.gb.tsv
UNASSIGNED=0
while IFS=$'\t' read -r ID NAME; do
  [ -z "$ID" ] && continue
  N=$(sendsquared guidebooks unit-assignments "$ID" 2>/dev/null | jq '[.data[]?] | length' 2>/dev/null || echo 0)
  if [ "${N:-0}" -eq 0 ]; then echo "  $ID  $NAME"; UNASSIGNED=$((UNASSIGNED+1)); fi
done < /tmp/.gb.tsv
if [ "$UNASSIGNED" -eq 0 ]; then echo "  none, every guidebook has units"; fi
rm -f /tmp/.gb.tsv
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/guidebook-gaps.sh view raw
Data hygiene ~1min per 50k contacts script read only

Duplicate guest sweep

Finds contacts sharing an email address, and prints the merge command for each pair. Duplicates split a guest’s stay history in two, which is how a fifth-time visitor gets a first-time-guest email.

Returns: Email addresses held by more than one contact, with a ready merge command.

contacts listcontacts merge
View the script
#!/usr/bin/env bash
# Contacts sharing an email address.
#
# primaryEmail is an object ({id, contact_id, email_address, ...}), not a string.
# Email is the only axis worth sweeping. Phone numbers are unique per guest in
# SendSquared, so two contacts cannot collide on one and a phone pass would
# only ever return noise.
#
# Bounded by default: a large book runs to hundreds of thousands of contacts
# and an unbounded walk will not finish in a coffee break.
set -euo pipefail
PAGES=${1:-15}   # 15 pages x 1000 contacts; 0 = the whole book
TMP=$(mktemp)
BEFORE=""
SEEN=0
PAGENO=0

while :; do
  PAGENO=$((PAGENO+1))
  if [ "$PAGES" -ne 0 ] && [ "$PAGENO" -gt "$PAGES" ]; then break; fi
  PAGE=$(sendsquared contacts list --limit 1000 ${BEFORE:+--before "$BEFORE"})
  N=$(jq '.data | length' <<<"$PAGE")
  [ "$N" -eq 0 ] && break
  SEEN=$((SEEN+N))
  jq -r '.data[] | select(.primaryEmail != null) | select((.primaryEmail.email_address // "") != "") | [ (.primaryEmail.email_address | ascii_downcase), .id ] | @tsv' <<<"$PAGE" >> "$TMP"
  BEFORE=$(jq -r '[.data[].id] | min' <<<"$PAGE")
  [ "$N" -lt 1000 ] && break
done

echo "--- scanned $SEEN contacts"
sort "$TMP" | awk -F'\t' '
  { ids[$1] = ids[$1] " " $2; n[$1]++ }
  END {
    c = 0
    for (e in n) if (n[e] > 1) {
      c++
      list = ids[e]; sub(/^ +/, "", list)
      printf "  %-42s %s\n", e, list
      split(list, a, " ")
      printf "    merge: sendsquared contacts merge %s %s\n", a[1], a[2]
    }
    if (c == 0) print "  no duplicate email addresses found"
  }' | head -60
rm -f "$TMP"
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/duplicate-guests.sh view raw
Revenue ~5s script read only

Cart abandonment ROI

What the abandonment recovery actually earned, by channel, over any window. The number to bring to a renewal conversation.

Returns: Recovered bookings and revenue attributed to abandonment recovery.

reports run
View the script
#!/usr/bin/env bash
# Abandonment recovery. Money fields are integer cents.
set -euo pipefail
FROM=${1:-$(date -u -v-90d +%F 2>/dev/null || date -u -d "-90 days" +%F)}
TO=${2:-$(date -u +%F)}

R=$(sendsquared reports run cart_abandon_conversion --from "$FROM" --to "$TO")
N=$(jq '.data | length' <<<"$R")

echo "window: $FROM to $TO"
if [ "$N" -eq 0 ]; then
  echo "no cart abandonment data in this window."
  echo "if you expect some, check the cart_abandon trigger is live:"
  echo "  sendsquared workflows list | jq -r '.data[] | select(.trigger_type==\"cart_abandon\") | .name'"
  exit 0
fi
jq -r '.data[] | to_entries | map("  \(.key): \(.value)") | .[]' <<<"$R"
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/cart-abandon-roi.sh view raw
Reporting ~5s script read only

NPS drift alert

Compares this month’s survey ratings with last month and exits non-zero when the average drops, so cron can email you only when something is actually wrong.

Returns: Month-over-month rating delta, with a non-zero exit on a decline.

reports runsurveys list
View the script
#!/usr/bin/env bash
# Survey response pulse, month over month.
#
# survey_responses returns { data, cols } rather than a flat array: cols
# describes the columns the web app would render, data holds the rows. Read the
# shape before trusting any field name here, because the question set differs
# per survey.
set -euo pipefail
THIS_FROM=$(date -u +%Y-%m-01)
LAST_FROM=$(date -u -v-1m +%Y-%m-01 2>/dev/null || date -u -d "-1 month" +%Y-%m-01)
TODAY=$(date -u +%F)

count() {
  sendsquared reports run survey_responses --from "$1" --to "$2" 2>/dev/null \
    | jq '[.data.data[]?] | length'
}

NOW=$(count "$THIS_FROM" "$TODAY")
PREV=$(count "$LAST_FROM" "$THIS_FROM")

echo "survey responses"
printf "  last month : %s\n  this month : %s\n" "${PREV:-0}" "${NOW:-0}"

if [ "${PREV:-0}" -eq 0 ] && [ "${NOW:-0}" -eq 0 ]; then
  echo
  echo "No survey responses in either window. Check surveys are live:"
  echo "  sendsquared surveys list --stats"
  exit 0
fi

awk -v a="${PREV:-0}" -v b="${NOW:-0}" 'BEGIN {
  if (a == 0) { print "\n  first month with responses"; exit 0 }
  d = (b - a) * 100 / a
  printf "\n  change: %+.0f%%\n", d
  if (d < -20) { print "  DOWN sharply, worth a look"; exit 1 }
  print "  within normal range"
}'
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/nps-drift.sh view raw
Data hygiene ~20s script read only

Stale segment sweep

Segments nobody has sent to, and segments that now match zero contacts. Both quietly rot after a season and make the segment list unusable.

Returns: Empty segments, and segments not referenced by any live campaign.

groups listcontacts listcampaigns list
View the script
#!/usr/bin/env bash
# Segments that are empty, and segments no live campaign targets.
#
# Note the variable name: GROUPS is a reserved array in bash (the caller's
# group memberships), so assigning to it silently does nothing and the script
# reads back a group id instead of the API response.
set -euo pipefail

SEGMENTS=$(sendsquared groups list --type segment)
TOTAL=$(jq '[.data[]?] | length' <<<"$SEGMENTS")
echo "--- $TOTAL segments"

USED=$(sendsquared campaigns list --limit 500 | jq -r '[.data[]? | .action_id // empty] | unique | join(" ")')

echo
echo "empty segments"
EMPTY=0
while IFS=$'\t' read -r ID NAME; do
  [ -z "$ID" ] && continue
  N=$(sendsquared contacts list --group "$ID" --limit 1 2>/dev/null | jq '[.data[]?] | length')
  if [ "${N:-0}" -eq 0 ]; then echo "  $ID  $NAME"; EMPTY=$((EMPTY+1)); fi
done < <(jq -r '.data[]? | [.id, (.name // "untitled")] | @tsv' <<<"$SEGMENTS")
if [ "$EMPTY" -eq 0 ]; then echo "  none"; fi

echo
echo "segments no live campaign targets"
UNUSED=0
while IFS=$'\t' read -r ID NAME; do
  [ -z "$ID" ] && continue
  case " $USED " in *" $ID "*) : ;; *) echo "  $ID  $NAME"; UNUSED=$((UNUSED+1)) ;; esac
done < <(jq -r '.data[]? | [.id, (.name // "untitled")] | @tsv' <<<"$SEGMENTS")
if [ "$UNUSED" -eq 0 ]; then echo "  none"; fi
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/stale-segments.sh view raw
Revenue ~15s script read only

Stalled pipeline sweep

Every open lead whose follow-up date has passed, or that never had one set, grouped by the agent who owns it. The quiet leak in most sales pipelines is not lost deals, it is forgotten ones.

Returns: Overdue and unscheduled leads per agent, with estimated value at risk.

leads listlead-status listagents list
View the script
#!/usr/bin/env bash
# Open leads that have gone quiet. estimated_value is in whole cents.
set -euo pipefail
TODAY=$(date -u +%F)
LIMIT=${1:-1000}
ALL=$(mktemp)

# One call, not a paging loop: `leads list --page` is currently ignored by the
# API and every page returns the same rows, so looping silently multiplies the
# same leads. Ask for more than you have instead and check the summary.
R=$(sendsquared leads list --limit "$LIMIT")
echo "--- $(jq -r '.summary // "leads"' <<<"$R")"

jq -r --arg today "$TODAY" '
  .data[]
  | select(.archived_at == null and .closed_at == null)
  | . as $l
  | (if   ($l.followup_at // "") == "" then "never scheduled"
     elif (($l.followup_at | split("T")[0]) < $today) then "overdue"
     else empty end) as $state
  | [ ($l.user_id // 0 | tostring), $state, ($l.estimated_value // 0),
      $l.id, (($l.contact_first_name // "") + " " + ($l.contact_last_name // "")), ($l.subject // "") ]
  | @tsv
' <<<"$R" > "$ALL"

if [ ! -s "$ALL" ]; then echo "no open leads are overdue or unscheduled"; rm -f "$ALL"; exit 0; fi

echo "--- leads needing attention, by owner (user_id)"
printf "  %-12s %6s %14s\n" "OWNER" "LEADS" "VALUE AT RISK"
awk -F'\t' '{n[$1]++; v[$1]+=$3} END {
  for (k in n) printf "  %-12s %6d %14s\n", k, n[k], sprintf("$%.0f", v[k]/100)
}' "$ALL" | sort -k2 -rn

echo
echo "--- ten to work first"
sort -t$'\t' -k3 -rn "$ALL" | head -10 \
  | awk -F'\t' '{printf "  %-16s lead %-9s %-26s %s\n",$2,$4,$5,substr($6,1,40)}'
rm -f "$ALL"
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/stalled-pipeline.sh view raw
Revenue ~5s script read only

Lead source ROI

Revenue by lead source next to lead count, so you can see which sources produce money and which only produce volume. The number to take into a marketing spend review.

Returns: Revenue, lead count, and revenue per lead for every source.

reports runlead-sources list
View the script
#!/usr/bin/env bash
# Which lead sources actually pay. total_value is in whole cents.
set -euo pipefail
FROM=${1:-$(date -u +%Y-01-01)}
TO=${2:-$(date -u +%F)}

sendsquared reports run lead_revenue_source --from "$FROM" --to "$TO" \
| jq -r '
  .data[]
  | [ (.source_label // "unattributed"),
      (.total_count // 0),
      (.won_count // 0),
      (((.total_value // 0) | tonumber) / 100 | floor) ]
  | @tsv
' \
| sort -t$'\t' -k4 -rn \
| awk -F'\t' 'BEGIN{printf "  %-34s %7s %6s %12s %12s\n","SOURCE","LEADS","WON","VALUE","PER LEAD"}
    { per = ($2+0) > 0 ? $4/$2 : 0
      printf "  %-34s %7s %6s %12s %12s\n",$1,$2,$3,"$"$4,sprintf("$%.0f",per) }'
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/lead-source-roi.sh view raw
Reporting ~5s script read only

Lost reason post-mortem

Why deals died last quarter, ranked, with the revenue attached to each reason. Price objections and slow follow-up need very different fixes, and this tells you which one you actually have.

Returns: Lost reasons ranked by count and by the value that walked.

reports runlost-reasons list
View the script
#!/usr/bin/env bash
# Why we lost, ranked by deals then value.
set -euo pipefail
FROM=${1:-$(date -u -v-90d +%F 2>/dev/null || date -u -d "-90 days" +%F)}
TO=${2:-$(date -u +%F)}

ROWS=$(sendsquared reports run lead_lost_reason --from "$FROM" --to "$TO")
[ "$(jq '.data | length' <<<"$ROWS")" -eq 0 ] && { echo "no closed-lost leads in $FROM to $TO"; exit 0; }

echo "--- why deals were lost, $FROM to $TO"
jq -r '
  .data[]
  | [ ((.lost_reason // "no reason recorded") | gsub("[\\t\\n]";"") ),
      ((.total_leads // 0) | tonumber),
      (((.value // 0) | tonumber) / 100 | floor) ]
  | @tsv
' <<<"$ROWS" \
| awk -F'\t' '{n[$1]+=$2; v[$1]+=$3} END {for (k in n) printf "%s\t%d\t%d\n", k, n[k], v[k]}' \
| sort -t$'\t' -k2 -rn \
| awk -F'\t' 'BEGIN{printf "  %-38s %7s %12s\n","LOST REASON","DEALS","VALUE"}
    {printf "  %-38s %7s %12s\n",$1,$2,"$"$3}'

echo
echo "--- which sources lose the most"
jq -r '.data[] | [ (.source_label // "unattributed"), ((.total_leads // 0) | tonumber) ] | @tsv' <<<"$ROWS" \
| awk -F'\t' '{n[$1]+=$2} END {for (k in n) printf "  %-38s %s\n", k, n[k]}' | sort -k2 -rn | head -8
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/lost-reason-postmortem.sh view raw
Operations ~20s script writes data

Overdue follow-up nudge

Sends a templated email to every lead whose follow-up date has passed. Runs a dry run first and refuses to send unless you pass --send, because the failure mode here is emailing your whole pipeline by accident.

Returns: A merged dry-run preview, then the sends once you confirm.

leads listleads send-emailemail-templates list
View the script
#!/usr/bin/env bash
# Usage: ./followup-nudge.sh <templateId> [--send]
# Without --send this only previews. That default is deliberate.
set -euo pipefail
TEMPLATE=${1:?pass a template id, see: sendsquared email-templates list}
CONFIRM=${2:-}
TODAY=$(date -u +%F)

IDS=$(
  sendsquared leads list --limit 100 \
  | jq -r --arg today "$TODAY" '
      .data[]
      | select((.archived // false) | not)
      | select((.followupAt // "") != "")
      | select((.followupAt | split("T")[0]) < $today)
      | .id
    ' | tr '\n' ' '
)

[ -z "${IDS// /}" ] && { echo "nothing overdue"; exit 0; }
echo "overdue leads: $IDS"

echo "--- dry run"
sendsquared leads send-email $IDS --template "$TEMPLATE" --dry-run

if [ "$CONFIRM" = "--send" ]; then
  echo "--- sending"
  sendsquared leads send-email $IDS --template "$TEMPLATE"
else
  echo
  echo "Preview only. Re-run with --send once the merge above looks right."
fi
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/followup-nudge.sh view raw
Revenue one-time setup builds automation writes data

Build: gap-night offer automation

Creates a real automation on a gap_night trigger, so the offer goes out the moment a hole opens between bookings. The finder script tells you the gaps exist; this one works them without you.

Returns: A live workflow id, triggered natively on gap nights.

workflows createworkflows trigger-typessms-templates list
View the script
#!/usr/bin/env bash
# Build the gap-night offer automation. Run once.
# Usage: ./build-gap-night-automation.sh <smsTemplateId>
set -euo pipefail
TEMPLATE=${1:?pass an SMS template id, see: sendsquared sms-templates list}

STEPS=$(cat <<JSON
[
  { "type": "Wait", "value": "2h", "label": "Let the PMS settle before offering" },
  { "type": "Sms",  "value": $TEMPLATE, "label": "Offer the open night to the adjacent guest" }
]
JSON
)

sendsquared workflows create \
  --name "Gap night offer" \
  --description "Fires when a gap night opens next to an existing stay." \
  --trigger-type gap_night \
  --trigger-value gap_night.departure \
  --steps "$STEPS"

echo "Built. Review it in the app before enabling, then:"
echo "  sendsquared workflows list"
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/build-gap-night-automation.sh view raw
Revenue one-time setup builds automation writes data

Build: multi-channel cart recovery

A three-step abandonment sequence in one command: email first, then SMS a day later only if they still have not booked. Multi-channel recovery is where the measurable revenue is.

Returns: A live abandonment workflow with an email and an SMS leg.

workflows createemail-templates listsms-templates list
View the script
#!/usr/bin/env bash
# Build multi-channel cart abandonment recovery. Run once.
# Usage: ./build-cart-abandon-recovery.sh <emailTemplateId> <smsTemplateId>
set -euo pipefail
EMAIL=${1:?pass an email template id}
SMS=${2:?pass an sms template id}

STEPS=$(cat <<JSON
[
  { "type": "Wait",  "value": "1h",     "label": "Give them an hour to come back on their own" },
  { "type": "Email", "value": $EMAIL,   "label": "Recovery email" },
  { "type": "Wait",  "value": "1d",     "label": "Wait a day" },
  { "type": "Sms",   "value": $SMS,     "label": "SMS nudge if still unbooked" }
]
JSON
)

sendsquared workflows create \
  --name "Cart abandonment recovery" \
  --description "Email, then SMS a day later, for abandoned booking carts." \
  --trigger-type cart_abandon \
  --trigger-value cart_abandon.created \
  --steps "$STEPS"
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/build-cart-abandon-recovery.sh view raw
Operations one-time setup builds automation writes data

Build: pre-arrival sequence

A time-based automation that fires 36 hours before arrival. This is the one that makes the silent-arrivals script stop finding anything, which is the point.

Returns: A workflow on reservation.arrival_date with a negative offset.

workflows createemail-templates list
View the script
#!/usr/bin/env bash
# Build the pre-arrival sequence. Run once.
# Usage: ./build-pre-arrival.sh <emailTemplateId> [hoursBefore]
set -euo pipefail
EMAIL=${1:?pass an email template id, see: sendsquared email-templates list}
HOURS=${2:-36}

STEPS=$(cat <<JSON
[
  { "type": "Email", "value": $EMAIL, "label": "Pre-arrival details and directions" }
]
JSON
)

# time_based triggers need a sync interval: how often to re-evaluate who is due.
sendsquared workflows create \
  --name "Pre-arrival (${HOURS}h before)" \
  --description "Sends arrival details ${HOURS} hours before check-in." \
  --trigger-type time_based \
  --trigger-value reservation.arrival_date \
  --time-offset "-${HOURS}h" \
  --sync-interval 6h \
  --steps "$STEPS"
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/build-pre-arrival.sh view raw
Operations one-time setup builds automation writes data

Build: low-rating service recovery

When a survey comes back low, a task lands on a manager immediately rather than being found in a report next month. Service recovery has a short half-life.

Returns: A workflow on survey.rating_low that opens a task the same day.

workflows createtask-types listagents list
View the script
#!/usr/bin/env bash
# Build low-rating service recovery. Run once.
# Usage: ./build-low-rating-rescue.sh <taskTypeId> [followerUserIds]
set -euo pipefail
TASK_TYPE=${1:?pass a task type id, see: sendsquared task-types list}
FOLLOWERS=${2:-}

STEPS=$(cat <<JSON
[
  { "type": "Task",
    "value": $TASK_TYPE,
    "label": "Low rating: call this guest",
    "dueIn": "4h",
    "priority": 1${FOLLOWERS:+,
    "followers": [$FOLLOWERS]}
  }
]
JSON
)

sendsquared workflows create \
  --name "Low rating rescue" \
  --description "Opens an urgent callback task when a survey rating comes back low." \
  --trigger-type survey \
  --trigger-value survey.rating_low \
  --steps "$STEPS"
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/build-low-rating-rescue.sh view raw
Revenue one-time setup builds automation writes data

Build: lead follow-up automation

Replaces the overdue follow-up cron with a native automation on lead.followup_at, so the reminder fires on the date the rep set rather than whenever a script last ran.

Returns: A workflow that opens a follow-up task on the lead follow-up date.

workflows createtask-types list
View the script
#!/usr/bin/env bash
# Build the lead follow-up automation. Run once.
# Usage: ./build-lead-followup.sh <taskTypeId>
set -euo pipefail
TASK_TYPE=${1:?pass a task type id, see: sendsquared task-types list}

STEPS=$(cat <<JSON
[
  { "type": "Task", "value": $TASK_TYPE, "label": "Follow up on this lead", "dueIn": "2h", "priority": 2 }
]
JSON
)

sendsquared workflows create \
  --name "Lead follow-up due" \
  --description "Opens a task when a lead reaches its follow-up date." \
  --trigger-type time_based \
  --trigger-value lead.followup_at \
  --sync-interval 1h \
  --steps "$STEPS"
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/build-lead-followup.sh view raw
Revenue ~1 min of agent time MCP prompt read only

Playbook: the quarterly revenue story

One prompt that makes the agent compose four revenue reports into a narrative rather than four tables. The work is in the joining, which is the part a person usually does by hand in a spreadsheet.

Returns: A written quarter-over-quarter revenue narrative with the numbers behind each claim.

reports runreports types
View the prompt
Using SendSquared, build me a quarter-over-quarter revenue picture.

1. Run lead_revenue for this quarter and the previous quarter.
2. Run lead_revenue_source for both, and work out which sources grew, which
   shrank, and which appeared or disappeared entirely.
3. Run cart_abandon_conversion for both and tell me whether recovery is
   carrying more or less of the total than it was.
4. Run lead_lost_reason for both and tell me whether the reasons we lose
   have changed shape.

Then write it as a short narrative, not a table dump. Lead with the single
biggest mover and say whether it is volume or value driving it. Money fields
come back as integer cents, so divide by 100 before quoting any figure, and
show the date ranges you used so I can check them.
Operations ~1 min of agent time MCP prompt read only

Playbook: call centre health review

Asks the agent to decide whether a call problem is a staffing problem or a process problem, by reading volume by hour against abandonment and per-agent load together.

Returns: A staffing-versus-process diagnosis with the hours that need cover.

reports runcalls list
View the prompt
Using SendSquared, review our call centre for the last 30 days.

Pull call_volume_stats, call_volume_by_hour, call_abandonment_summary and
call_volume_by_agent.

Answer one question with them: when we lose calls, is it because we are
understaffed at that hour, or because something in the process is failing?

Line call_volume_by_hour up against the abandonment numbers and name the
specific hours where demand outruns cover. Then look at call_volume_by_agent
and tell me whether the load is spread evenly or whether one or two people are
absorbing it. If the abandonment is flat across hours and agents, say so, and
say that it points at process rather than headcount.

Finish with the three changes you would make, most valuable first.
Diagnostics ~2 min of agent time MCP prompt read only

Playbook: automation health audit

Finds the automations that are quietly broken. An automation that fires but drops every contact at step three looks healthy on a dashboard and does nothing in reality.

Returns: A ranked list of automations that are failing, with the step where each one leaks.

reports runworkflows listevents triggers
View the prompt
Using SendSquared, audit every automation we have running.

Start with workflows list so you know what exists. Then for the active ones
pull trigger_funnel, trigger_failures, automation_step_performance and
automation_queue.

I am looking for three failure shapes specifically:

- Automations that never fire at all. The trigger is wrong or nothing matches.
- Automations that fire but lose almost everyone at one particular step. That
  is usually a stop condition after a Wait that is stricter than intended.
- Automations with a queue that keeps growing. Something downstream is stuck.

For anything in the second group, use events triggers with --outcome rejected
on that workflow and tell me the actual rejection reason rather than guessing.

Rank what you find by how many contacts are affected, and for each one say
whether the fix is the trigger, a condition, or the content.
Reporting ~1 min of agent time MCP prompt read only

Playbook: channel mix and response times

Where guests actually reach you and how fast each channel gets answered. Teams usually discover the channel they invest least in is the one guests prefer.

Returns: Volume and response time per channel, with the mismatch called out.

reports run
View the prompt
Using SendSquared, show me how guests actually contact us and how quickly
each channel gets answered, over the last 90 days.

Pull the volume and response-duration reports for every channel we run: email,
sms, whatsapp, airbnb and calls. Use email_volume_by_agent,
email_response_duration, sms_volume_stats, sms_response_duration,
whatsapp_volume_stats, whatsapp_response_duration, airbnb_volume_by_agent,
airbnb_response_duration and call_volume_stats.

Put them in one table ordered by volume, with median response time beside each.

Then tell me the mismatch: the channel carrying the most guest contact that
also has the worst response time. That is where the next hire or the next
automation should go. If a channel has almost no volume, say whether that is
because guests do not use it or because we never gave them the option.
Diagnostics ~1 min of agent time MCP prompt read only

Playbook: trace one guest end to end

Everything that happened to a single guest, assembled across reservations, calls, messages and automations. The answer to an escalation, in one prompt.

Returns: A chronological account of every touch, with gaps and failures flagged.

contacts searchcontacts timelinereservations contactreports run
View the prompt
Using SendSquared, trace this guest end to end: <name, email or phone>.

Find the contact, then assemble a single chronology from their timeline, their
reservations, their calls, and contact_journey from the reports.

Present it as one timeline in date order, with the channel on each entry.

Then tell me three things the raw list will not:
- Any automation that should have fired for this guest and did not. Use
  events triggers scoped to the contact to confirm rather than infer.
- Any gap longer than a week between a guest message and our reply.
- Whether their stay history is complete, or whether it looks split across
  more than one contact record.

If anything looks wrong, say what you would check next.
Revenue ~8s script read only

Owner pacing snapshot

Where each owner’s revenue sits against the same point last year. The report to paste into an owner update before they ask.

Returns: Per-owner pacing against the prior year, ready to paste.

reports run
View the script
#!/usr/bin/env bash
# Owner pacing. This report takes a single as-of date, not a range.
set -euo pipefail
AS_OF=${1:-$(date -u +%F)}

R=$(sendsquared reports run owner_pacing --date "$AS_OF")
N=$(jq '[.data[]?] | length' <<<"$R")
if [ "$N" -eq 0 ]; then
  echo "no owner pacing rows as of $AS_OF (owner reporting may not be enabled on this account)"
  exit 0
fi
sendsquared reports run owner_pacing --date "$AS_OF" -o md
Or fetch it directly:
curl -O https://sendsquared.com/cli/scripts/owner-pacing.sh view raw

Full command reference in the CLI documentation, or run sendsquared commands to list all 255 of them.

Running these scripts

Do these scripts need anything besides the CLI?

Only bash, jq, and a logged-in CLI. Run sendsquared auth login once, then sendsquared doctor to confirm connectivity. Nothing here needs a server, a build step, or a dependency install.

Will a script touch my data?

Each one is labelled. Most are read-only and safe to run against production immediately. The two that write (abandoned call triage creates tasks, and anything that sends) say so on the card, and you should read them before running.

Can I hand these to an AI agent instead of running them myself?

Yes, and that is the intended use. Paste one into Claude Code, Codex, or Cursor and ask it to adapt the filters to your portfolio. Because the CLI emits JSON by default and ships sendsquared commands --json for discovery, an agent can verify every flag before it runs anything.

My account does not have one of the reports a script uses.

Run sendsquared reports types to see the report catalogue enabled for your tenant. Report availability follows the modules you are licensed for, so a guidebook or survey script needs that product turned on.

Can I contribute a script?

Please do. Send what you built to your success manager or open a thread from the CLI docs. The useful ones get added here with your name on them.

Ready to Get Started?

See how SendSquared can transform your hospitality operations.

Schedule a Demo