
Bulk Operations
- 902 installs
- 18 repo stars
- Updated July 23, 2026
- hubspot/agent-cli-skills
Pipe HubSpot CLI JSONL through jq to bulk update, delete, upsert, and associate CRM records without one-off UI clicks.
About
Bulk Operations is an agent skill for HubSpot’s agent CLI that documents JSON reshape recipes you actually run in production: stream search results into jq, emit `{id, properties:…}` or upsert rows, and feed them back into update, delete, or association commands—with dry-run called out where it matters. Solo builders wiring CRM automation for a SaaS or API-led business use it when spreadsheets or the UI cannot keep up with segment fixes, lifecycle stage promotion, orphaned contact cleanup, or linking contacts to companies after a import. Every example assumes JSONL from `hubspot objects` commands and consistent `.properties` shaping so the agent does not invent fragile one-offs. It is intermediate complexity because you need comfort with jq, HubSpot object types, and idempotent batch thinking, but it dramatically shortens repetitive growth ops once HubSpot CLI auth is in place.
- JSONL patterns for hubspot objects list, search, get, update, delete, and upsert
- Read → update pipeline with jq nesting under .properties
- CSV → upsert contacts with idProperty email and dry-run safety
- Batch association create from search results without xargs loops
- Server-side gaps filled with jq select (e.g. revenue thresholds, regex filters)
Bulk Operations by the numbers
- 902 all-time installs (skills.sh)
- +145 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #314 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hubspot/agent-cli-skills --skill bulk-operationsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 902 |
|---|---|
| repo stars | ★ 18 |
| Last updated | July 23, 2026 |
| Repository | hubspot/agent-cli-skills ↗ |
What it does
Pipe HubSpot CLI JSONL through jq to bulk update, delete, upsert, and associate CRM records without one-off UI clicks.
Files
Resources
| File | When to use |
|---|---|
resources/json-patterns.md | Reshape patterns for turning a read into an update payload, a search into a delete list, a CSV into an upsert stream. |
Source of truth
hubspot <command> --help is authoritative. If anything in this file contradicts --help, trust --help and tell the user. Run hubspot objects types once at the start of a session to see what object types exist in this portal (standard + custom).
Output shape
Every read command (list, search, get) emits JSONL — one JSON object per line:
{"id":"123","properties":{"email":"jane@example.com","firstname":"Jane"},"createdAt":"...","updatedAt":"...","archived":false,"url":"..."}--properties email,firstname limits which fields the server returns under .properties. Downstream jq should use .properties.email, not .prop_email.
Write commands (create, update, upsert, delete, merge, associations create) accept JSONL on stdin and emit JSONL — one result per input line: {"id":"123","ok":true,"data":{...}} or {"id":"123","ok":false,"error":{"status":...,"message":"..."}}. Order of results matches input order.
Read in batch — never one-by-one
The CLI accepts multiple IDs natively. Never pipe IDs into xargs -I{} hubspot objects get ... — that spawns one CLI process per record.
# Positional args (small, known list)
hubspot objects get --type contacts 12345 67890 23456 --properties email,firstname
# Stdin from another command — one CLI call total
hubspot associations list --from companies:67890 --to contacts \
| jq -c '{id}' \
| hubspot objects get --type contacts --properties email,firstname,jobtitle
# Bare IDs on stdin also work
printf '12345\n67890\n23456\n' | hubspot objects get --type contacts --properties emailA single hubspot objects get reads up to ~100 IDs per call via the batch endpoint. For more, page in chunks of 100.
Bulk flow: paginate first, then reshape, then write
When operating on all records of a type (or all matches of a filter), always start with `pagination-loop.sh` — never run a bare list or search to "check how many there are." A bare call returns at most 100 records and you will have to re-fetch them anyway.
The canonical bulk pattern is:
1. Paginate all records to a JSONL file 2. Reshape with jq into the write payload 3. Pipe to the write command (update, delete, etc.) with --dry-run first
Pagination
list and search return at most 100 records per call. Use resources/pagination-loop.sh to collect all pages into a single JSONL file:
bash resources/pagination-loop.sh <object_type> <output_file> [properties] [extra_flags...]Examples:
# All contacts with specific properties
bash resources/pagination-loop.sh contacts /tmp/contacts.jsonl email,firstname,lastname
# Search with a filter (passes extra flags through to the CLI)
bash resources/pagination-loop.sh contacts /tmp/leads.jsonl email,firstname '--filter' 'lifecyclestage=lead'
# All deals, default properties
bash resources/pagination-loop.sh deals /tmp/deals.jsonlThe script pages through --after cursors automatically, prints progress to stderr, and writes JSONL to the output file. Run it as a single foreground command — do not background it or reconstruct the loop inline.
Write in batch — always pipe
Write commands accept JSONL on stdin. The transformation between a read shape and a write shape is a jq reshape:
| Write command | Required per-line shape |
|---|---|
objects create | {"properties":{"field":"value"}} |
objects update | {"id":"123","properties":{"field":"value"}} |
objects upsert | {"idProperty":"email","id":"jane@example.com","properties":{...}} (or use --id-property email once) |
objects delete | {"id":"123"} |
objects merge | {"primary":"123","secondary":"456"} |
associations create | {"from":"contacts:123","to":"companies:456"} |
Use plural object names in from/to (contacts:, not contact:).
Safe destructive workflow
Every destructive op (delete, merge, bulk update) supports --dry-run. The gating depends on row count:
≤100 rows — dry-run emits one preview line per record:
{"ok":true,"dry_run":true,"executed":false,"mutation_kind":"RecordMutation","command":"objects delete contacts","target":{"kind":"contacts_record","id":"123","name":"123"}}Re-run without --dry-run to execute.
>100 rows — dry-run emits a single BulkData line with a digest and an apply_command_hint:
{"ok":true,"dry_run":true,"executed":false,"mutation_kind":"BulkData","portal":"123456","target":{"name":"202 records"},"impact":{"records_affected":202,"reversible":false},"digest":"blast-29cfdd48b583","expires_in_seconds":300,"apply_command_hint":"hubspot objects delete contacts --digest blast-29cfdd48b583 --confirm '202'"}You must re-run with --digest <hash> --confirm <value> within 5 minutes. The confirm value is the record count (deletes) or the secondary ID (merge). Read it off apply_command_hint.
Three-step pattern:
# 1. Preview
hubspot objects search --type contacts --filter "lifecyclestage=subscriber" \
| jq -c '{id}' \
| hubspot objects delete --type contacts --dry-run \
| tee /tmp/preview.jsonl
# 2. Lift the digest + confirm value (only present for >100 rows)
digest=$(jq -r 'select(.mutation_kind=="BulkData") | .digest' /tmp/preview.jsonl)
confirm=$(jq -r 'select(.mutation_kind=="BulkData") | .impact.records_affected' /tmp/preview.jsonl)
# 3. Execute — re-pipe the SAME inputs
hubspot objects search --type contacts --filter "lifecyclestage=subscriber" \
| jq -c '{id}' \
| hubspot objects delete --type contacts --digest "$digest" --confirm "$confirm"Recovery via hubspot history
Every destructive op (and its dry-run) is logged locally. Check what happened in the last hour and what's reversible:
hubspot history --since 1h --format table
hubspot history --since 24h --kind BulkData # only bulk ops
hubspot history --since 7d --kind MetadataDestroy # schema deleteshistory does not currently restore records — it's an audit log. If you deleted something by mistake, capture the history line and tell the user to restore via the UI.
Upsert beats search-then-create
For "create if missing, update if present" (the enrichment pattern), use upsert — one CLI call per record, no race condition:
cat external.jsonl \
| jq -c '{idProperty:"email", id:.email, properties:{firstname:.first, lastname:.last, company:.company}}' \
| hubspot objects upsert --type contacts --dry-run
# Or set idProperty once:
cat external.jsonl \
| jq -c '{id:.email, properties:{firstname:.first}}' \
| hubspot objects upsert --type contacts --id-property emailRate-limit hygiene
There is no true batch endpoint behind update/delete/upsert — the CLI issues one API call per stdin line. Test with head -n 50 before piping a 50k-row file. If the API starts 429ing, the per-line output will show {"ok":false,"error":{"status":429,...}} — split your input file and retry the failed lines.
Common reshapes
See resources/json-patterns.md for the full set. The two you need 90% of the time:
# Read → update payload
hubspot objects search --type contacts --filter "industry=Tech" \
| jq -c '{id, properties:{lifecyclestage:"marketingqualifiedlead"}}' \
| hubspot objects update --type contacts
# Search → delete list
hubspot objects search --type contacts --filter "!email" \
| jq -c '{id}' \
| hubspot objects delete --type contacts --dry-runKnown constraints
- Some destructive operations may be blocked under user-OAuth (browser login); set
HUBSPOT_ACCESS_TOKEN(private app token) when running deletes if the CLI returns a permission error. hubspot owners listreturns CRM users; there is noteamsobject. For team-level operations, group byhubspot_owner_idclient-side.- No Lists API, no sequences/cadences API in the current CLI surface.
JSON reshape patterns
All examples assume JSONL input from hubspot objects list|search|get. Output always nests under .properties; --properties a,b limits the field set returned.
These are the reshapes you actually use. Skip anything you can derive.
---
Read → update
hubspot objects search --type contacts --filter "industry=Tech" \
| jq -c '{id, properties:{lifecyclestage:"marketingqualifiedlead"}}' \
| hubspot objects update --type contactsRead → delete
hubspot objects search --type contacts --filter "!email" \
| jq -c '{id}' \
| hubspot objects delete --type contacts --dry-runRead → batch get (one call, no xargs)
hubspot associations list --from companies:67890 --to contacts \
| jq -c '{id}' \
| hubspot objects get --type contacts --properties email,firstnameCSV → upsert
# external.csv: email,firstname,lastname,company
tail -n +2 external.csv \
| jq -R -c 'split(",") | {idProperty:"email", id:.[0], properties:{firstname:.[1], lastname:.[2], company:.[3]}}' \
| hubspot objects upsert --type contacts --dry-runRead → association create
hubspot objects search --type contacts --filter "company~acme" \
| jq -c '{from:("contacts:"+.id), to:"companies:456"}' \
| hubspot associations createNumeric / regex filtering server-side can't express
# Companies with revenue > 1M
hubspot objects list --type companies \
| jq -c 'select((.properties.annualrevenue // "0") | tonumber > 1000000)'
# Exclude obvious junk emails (server-side ~ is whole-token only)
hubspot objects list --type contacts \
| jq -c 'select(.properties.email | test("test|noreply|placeholder"; "i") | not)'Union and de-dupe two searches
( hubspot objects search --type contacts --filter "lifecyclestage=lead"
hubspot objects search --type contacts --filter "lifecyclestage=marketingqualifiedlead"
) | jq -s -c 'unique_by(.id)[]'Frequency table (count by field)
hubspot objects list --type contacts --properties lifecyclestage \
| jq -r '.properties.lifecyclestage // "(unset)"' \
| sort | uniq -c | sort -rnExport to CSV / TSV
hubspot objects list --type contacts --properties email,firstname,lastname \
| jq -r '[.properties.email, .properties.firstname, .properties.lastname] | @csv'#!/bin/bash
# pagination-loop.sh
#
# Generic pagination loop for hubspot CLI commands.
#
# The CLI returns at most 100 records per call. When --format json is used,
# the response envelope looks like:
# { "data": [...], "meta": { "next": "<cursor>" } }
#
# When meta.next is absent or null, there are no more pages.
# Pass the cursor value back as --after on the next call.
#
# USAGE:
# bash pagination-loop.sh <object_type> <output_file> [properties] [extra_flags...]
#
# EXAMPLES:
# bash pagination-loop.sh contacts /tmp/contacts.jsonl
# bash pagination-loop.sh contacts /tmp/contacts.jsonl email,firstname,lastname
# bash pagination-loop.sh contacts /tmp/leads.jsonl email,firstname '--filter lifecyclestage=lead'
#
# To use search instead of list, pass --filter flags as extra_flags.
set -eo pipefail
OBJECT_TYPE="${1:?Usage: pagination-loop.sh <object_type> <output_file> [properties] [extra_flags...]}"
OUTPUT_FILE="${2:?Usage: pagination-loop.sh <object_type> <output_file> [properties] [extra_flags...]}"
PROPERTIES="${3:-}"
shift 3 2>/dev/null || shift $#
EXTRA_FLAGS=("$@")
LIMIT=100
# ── Build base command args ──────────────────────────────────────────────────
# Auto-detect: use "objects search" when --filter is present, "objects list" otherwise
SUBCOMMAND="list"
for flag in "${EXTRA_FLAGS[@]}"; do
if [ "$flag" = "--filter" ]; then
SUBCOMMAND="search"
break
fi
done
BASE_ARGS=(hubspot objects "$SUBCOMMAND" --type "$OBJECT_TYPE" --limit "$LIMIT" --format json)
if [ -n "$PROPERTIES" ]; then
BASE_ARGS+=(--properties "$PROPERTIES")
fi
if [ ${#EXTRA_FLAGS[@]} -gt 0 ]; then
BASE_ARGS+=("${EXTRA_FLAGS[@]}")
fi
# ── Pagination loop ──────────────────────────────────────────────────────────
> "$OUTPUT_FILE"
after=""
page=0
echo "Paginating ${OBJECT_TYPE} → ${OUTPUT_FILE}" >&2
while true; do
page=$((page + 1))
if [ -z "$after" ]; then
result=$("${BASE_ARGS[@]}")
else
result=$("${BASE_ARGS[@]}" --after "$after")
fi
count=$(echo "$result" | jq '.data | length')
echo "$result" | jq -c '.data[]' >> "$OUTPUT_FILE"
echo " page ${page}: ${count} records" >&2
next=$(echo "$result" | jq -r '.meta.next // empty')
if [ -z "$next" ]; then
break
fi
after="$next"
done
total=$(wc -l < "$OUTPUT_FILE" | tr -d ' ')
echo "Done. ${total} total records written to ${OUTPUT_FILE}" >&2