bulk-operations
hubspot/agent-cli-skills
Foundation patterns for bulk operations in the HubSpot CLI — JSONL piping, pagination, dry-run, and recovery.
What is bulk-operations?
This skill provides the core patterns for working with the HubSpot CLI in bulk: JSONL input/output, batch reading, pagination loops, safe destructive workflows with dry-run and digest confirmation, and audit logging via `hubspot history`. Every other HubSpot skill builds on these patterns.
- Batch read records via JSONL piping and native multi-ID endpoints instead of spawning one CLI process per record
- Paginate through large result sets automatically using `pagination-loop.sh` to collect all matching records
- Reshape read output into write payloads (create, update, upsert, delete, merge, associations) using `jq`
- Preview destructive operations with `--dry-run` before execution; for >100 rows, use digest + confirm to gate the real operation
- Recover from mistakes via `hubspot history` audit log, which tracks all destructive ops and their reversibility
- Count total matching records without paging using `hubspot objects count` to size jobs upfront
How to install bulk-operations
npx skills add https://github.com/hubspot/agent-cli-skills --skill bulk-operations- HubSpot CLI (`hubspot` command) installed and authenticated to a portal
- Bash shell for running `pagination-loop.sh` and piping commands
- Basic familiarity with `jq` for reshaping JSON between read and write shapes
How to use bulk-operations
- 1.Run `hubspot objects types` once per session to see available object types in your portal
- 2.For bulk operations on all records or a filtered set, start with `bash resources/pagination-loop.sh <type> <output_file> [properties] [filters]` to collect all pages into a JSONL file
- 3.Reshape the JSONL output using `jq` to match the required write shape (e.g., `jq -c '{id, properties: {field: .properties.field}}'`)
- 4.Pipe the reshaped JSONL to the write command with `--dry-run` first: `cat file.jsonl | hubspot objects update --type <type> --dry-run`
- 5.For >100 rows, extract the digest and confirm value from the dry-run output, then re-run with `--digest <hash> --confirm <value>` to execute
- 6.After any destructive operation, use `hubspot history --since 1h --format table` to audit what changed and check reversibility
Use cases
- Bulk update 5,000 contacts' lifecycle stage based on a filter, with dry-run preview and digest confirmation
- Delete all archived deals and merge duplicate company records in a single paginated, audited workflow
- Upsert a CSV of new leads by reshaping it to JSONL and piping through the upsert endpoint
- Search for contacts matching a filter, extract their IDs, and fetch specific properties in one batch call
- Audit what changed in the last 24 hours and identify which bulk operations are reversible
- HubSpot developers and agents automating CRM data operations
- Teams performing bulk migrations, cleanups, or syncs
- Anyone needing safe, auditable bulk delete or merge workflows
- Users integrating HubSpot CLI into scripts or agent-driven tasks
bulk-operations FAQ
Each `xargs` invocation spawns a separate CLI process, which is slow and wasteful. The CLI's batch endpoints accept multiple IDs in a single call (up to ~100 per call), so always pipe IDs directly to `hubspot objects get` or use the pagination loop.
`list` returns all records of a type in creation order; `search` applies filters and returns matches. Both return at most 100 records per call and require pagination for larger sets. Use `hubspot objects count --filter "..."` to size the job first without paging.
Run `--dry-run` first. The output will include an `impact` object with a `reversible` field. If `false`, the operation cannot be undone via `hubspot history` — the user must restore from backup or the UI.
The digest expires in 5 minutes. If you lose it, re-run the same read + reshape + dry-run pipeline to generate a new digest. There is no way to recover an expired digest.
`hubspot history` is an audit log only — it does not restore records. If a user deletes something by mistake, they must restore via the HubSpot UI or from a backup. Use `history` to confirm what was deleted and when.
Full instructions (SKILL.md)
Source of truth, from hubspot/agent-cli-skills.
name: bulk-operations
description: Foundation patterns for the hubspot CLI — JSONL piping, batch read, pagination, dry-run/digest/confirm for destructive ops, and hubspot history for recovery. Every other skill builds on this one.
triggers:
- "bulk update"
- "bulk create"
- "bulk delete"
- "process in bulk"
- "JSONL pipe"
- "pagination"
- "dry-run"
- "history"
- "undo"
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
This is the hubspot agent CLI; the hs developer CLI (@hubspot/cli) is a different tool and does not manage CRM data or workflows. 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).
Submit Feedback
Use the hubspot feedback command to send a message to the owners of this CLI tool. Pass --source agent so it's attributed to agent traffic (it defaults to user):
hubspot feedback "batch upsert timed out on 5k rows" --source agent
This can be anything from:
- Specific bugs and hiccups you encountered
- Things you wish you knew before using the CLI
- Anything your user got confused, frustrated, or upset about
- Anything the user asked for that you couldn't do
- Any tools, capabilities, or skills you wish existed that would make future tasks easier
It takes one short line, attaches to the active HubSpot account, and doesn't block the task — send it and keep going.
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. To size the job first, use hubspot objects count --type <t> [--filter "..."], which returns the total matching count (e.g. {"object_type":"contacts","total":42}) without paging.
The canonical bulk pattern is:
- Paginate all records to a JSONL file
- Reshape with
jqinto the write payload - Pipe to the write command (
update,delete, etc.) with--dry-runfirst
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 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 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.
For CRM property source history (who or what changed a property — WORKFLOW, INTEGRATION, IMPORT, CRM_UI), use hubspot objects history --type <t> --properties <p>. It flattens each property's version history into one row per change; UI-driven (CRM_UI) changes are excluded by default (--include-ui to keep them). Add --id <recordId> to read one record, or omit it to scan a page. This is separate from the local hubspot history audit log above — use it to investigate why a property changed after a bulk op.
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
objects deleteworks under both user-OAuth (browser login, with the object's write scope) and a service key; a 403 means the active token is missing that write scope. The exception is the--gdprpermanent purge, which requires a service key (HUBSPOT_ACCESS_TOKEN) — the GDPR endpoint does not accept user OAuth tokens. Some other destructive operations (e.g.associationsbatch/labels/limits,schemas delete) remain service-key-only and are enforced server-side, soHUBSPOT_SKIP_AUTH_CHECKwill not get a user token past them.hubspot owners listreturns CRM users; there is noteamsobject. For team-level operations, group byhubspot_owner_idclient-side.hubspot segmentsprovides CRM lists (Lists API):list,get,create,update(metadata),update-filters,delete,restore, andmembers-list/members-add/members-remove.hubspot sequencesprovides read-only access to Sales Hub sequences:list --user-id <id>(paginated,--namefilter),get <id> --user-id <id>(steps + settings), andenrollments <contact_id>(a contact's enrollment history). Sequences are a product API surface (Sales Hub Professional+,automation.sequences.readscope), not a CRM object type —objects list --type sequencesdoes not work, and there is no create/update/delete/enroll.- Maintaining this section: the command surface grows — do not assume an API is absent because it was when this was written. When a new command family ships (see
CHANGELOG.md), revisit these constraints.hubspot --helpis authoritative for what exists today.
Related skills
More from hubspot/agent-cli-skills and the wider catalog.

communication-history
Retrieve CRM activity history and assemble pre-call briefs from calls, emails, notes, meetings, and tasks.

crm-data-quality
Find incomplete records, normalize values in bulk, and dedupe contacts with HubSpot merge operations.

crm-lookup
Find CRM records by ID, email, domain, or name, and traverse associations for complete account context.

custom-object-management
Discover, create, update, and delete custom CRM object schemas in HubSpot.

customer-retention
Identify inactive/at-risk customers and create follow-up tasks at scale via CRM filters.

data-enrichment
Match external records to CRM contacts/companies by email or domain, then upsert enriched data in one pass.