Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
hubspot avatar

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-operations

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs902
repo stars18
Last updatedJuly 23, 2026
Repositoryhubspot/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

SKILL.mdMarkdownGitHub ↗

Resources

FileWhen to use
resources/json-patterns.mdReshape 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 email

A 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.jsonl

The 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 commandRequired 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 deletes

history 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 email

Rate-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-run

Known 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 list returns CRM users; there is no teams object. For team-level operations, group by hubspot_owner_id client-side.
  • No Lists API, no sequences/cadences API in the current CLI surface.

Related skills

Automation & Workflowslifecycledistribution

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.