AGENT SKILLBY HUBSPOT
macOS
AT A GLANCE
macOS is a go-to-market skill for AI agents. Daily briefings, pipeline snapshots, and win/loss analysis from the terminal — closing-this-week, open pipeline by stage/owner, and closed-won vs closed-lost over a period. It is published by hubspot in the hubspot/agent-cli-skills GitHub repository (21 stars) under the Apache-2.0 license. Install it with: npx skills add hubspot/agent-cli-skills --skill sales-reporting
- SOURCE REPOSITORY
- hubspot/agent-cli-skills (opens GitHub in a new tab)
- REPO STARS
- SOURCE REPOSITORY
- LICENSE
- Apache-2.0
- CATEGORIES
- SKILL.MD SIZE
- 16.8 KB412 LINES
QUICK ANSWER
- WHAT IT IS01
- Daily briefings, pipeline snapshots, and win/loss analysis from the terminal, closing-this-week, open pipeline by stage/owner, and closed-won vs closed-lost over a period. Its edge is a particular angle on go-to-market work, giving the agent tighter constraints than a plain macOS request.
- INPUTS02
- Context for go-to-market work: your goals, audience, constraints, and any source material the skill asks for.
- OUTPUT03
- A ready-to-use result for go-to-market work: the analysis, copy, or recommendations the agent produces.
INSTALL
One command adds this skill from its source repository.
npx skills add hubspot/agent-cli-skills --skill sales-reportingREAD THE SKILL.MD BELOW BEFORE YOU RUN IT. SKILLS INSTRUCT YOUR AGENT.
SKILL.MD
The full file, as published in the source repository. Copy it, download it or open it on GitHub.
ON THIS PAGE · 31
Source of truth#
hubspot <command> --help is authoritative. Build on bulk-operations/SKILL.md — JSONL shape, batch-read rules, and pagination live there. Reshape patterns: bulk-operations/resources/json-patterns.md. search/list cap at 100 rows per call; a result of exactly 100 is almost always truncated — paginate via bulk-operations/SKILL.md before aggregating.
Property and output shape notes#
- All CRM property values come back as strings in JSONL — booleans included.
hs_is_closed_wonis returned as"true"/"false"(string);amountis a numeric string. Usetonumberfor arithmetic; compare booleans as strings (== "true") when filtering client-side. - In
--filterexpressions,hs_is_closed_won=trueandhs_is_closed!=truework — the API parses the value. --propertiesreturns the standard nested shape:{"id":"123","properties":{"amount":"5000","dealname":"..."}}. Reference fields as.properties.amountin jq.- Stage IDs in
dealstageare portal-specific. Map them withhubspot pipelines stages --type deals --pipeline <id>. hubspot_owner_idis a numeric string. Resolve to a name withhubspot owners list(fields:id,firstName,lastName,email).
1. Daily briefing#
Date windows differ between macOS and GNU date:
# macOS
TODAY=$(date +%Y-%m-%d); NEXT_7=$(date -v+7d +%Y-%m-%d); YESTERDAY=$(date -v-1d +%Y-%m-%d)
# Linux
TODAY=$(date +%Y-%m-%d); NEXT_7=$(date -d '7 days' +%Y-%m-%d); YESTERDAY=$(date -d '1 day ago' +%Y-%m-%d)Deals closing in the next 7 days:
hubspot objects search --type deals \
--filter "closedate>$TODAY AND closedate<$NEXT_7 AND hs_is_closed!=true" \
--properties dealname,amount,closedate,hubspot_owner_idDeals updated in the last 24h:
hubspot objects search --type deals \
--filter "hs_lastmodifieddate>$YESTERDAY AND hs_is_closed!=true" \
--properties dealname,amount,dealstage,hs_lastmodifieddateOpen-pipeline summary line:
hubspot objects search --type deals --filter "hs_is_closed!=true" --properties amount \
| jq -rs '{count: length, value: ([.[].properties.amount | select(. != null) | tonumber] | add // 0 | round)}
| "Open pipeline: \(.count) deals, $\(.value)"'2. Pipeline snapshot#
By stage — count and amount per dealstage:
hubspot objects search --type deals --filter "hs_is_closed!=true" \
--properties dealstage,amount \
| jq -rs '
group_by(.properties.dealstage)
| map({stage: .[0].properties.dealstage, count: length,
total: ([.[].properties.amount | select(. != null) | tonumber] | add // 0 | round)})
| sort_by(-.total) | .[] | "\(.stage)\tcount: \(.count)\tvalue: $\(.total)"' \
| column -t -s$'\t'By owner:
hubspot objects search --type deals --filter "hs_is_closed!=true" \
--properties amount,hubspot_owner_id \
| jq -rs '
group_by(.properties.hubspot_owner_id)
| map({owner: .[0].properties.hubspot_owner_id, count: length,
total: ([.[].properties.amount | select(. != null) | tonumber] | add // 0 | round)})
| sort_by(-.total) | .[] | "owner \(.owner)\tdeals: \(.count)\tvalue: $\(.total)"' \
| column -t -s$'\t'To label owner IDs with names, dump the owners file once and join:
hubspot owners list | jq -r '"\(.id)\t\(.firstName) \(.lastName) <\(.email)>"' > /tmp/owners.tsv3. Win/loss analysis#
Filter on hs_is_closed_won=true for won; hs_is_closed=true AND hs_is_closed_won!=true for lost. Scope with closedate>=YYYY-MM-DD AND closedate<YYYY-MM-DD.
Closed won / lost in a period:
hubspot objects search --type deals \
--filter "hs_is_closed_won=true AND closedate>=2026-04-01 AND closedate<2026-07-01" \
--properties dealname,amount,closedate,hubspot_owner_id
hubspot objects search --type deals \
--filter "hs_is_closed=true AND hs_is_closed_won!=true AND closedate>=2026-04-01 AND closedate<2026-07-01" \
--properties dealname,amount,closedate,hubspot_owner_idWin rate by rep — pull all closed deals in the period, group, divide. Note: hs_is_closed_won lands as a string, so compare == "true".
hubspot objects search --type deals \
--filter "hs_is_closed=true AND closedate>=2026-01-01" \
--properties hubspot_owner_id,hs_is_closed_won,amount \
| jq -rs '
group_by(.properties.hubspot_owner_id)
| map({owner: .[0].properties.hubspot_owner_id,
total: length,
won: ([.[] | select(.properties.hs_is_closed_won == "true")] | length),
won_value: ([.[] | select(.properties.hs_is_closed_won == "true")
| .properties.amount | select(. != null) | tonumber] | add // 0 | round)})
| map(. + {win_rate: ((.won / .total * 100) | round)})
| sort_by(-.won_value)
| .[] | "owner \(.owner)\twon: \(.won)/\(.total)\trate: \(.win_rate)%\twon: $\(.won_value)"' \
| column -t -s$'\t'Revenue by close month (won deals):
hubspot objects search --type deals \
--filter "hs_is_closed_won=true AND closedate>=2026-01-01" \
--properties amount,closedate \
| jq -rs '
group_by(.properties.closedate[0:7])
| map({month: .[0].properties.closedate[0:7], count: length,
revenue: ([.[].properties.amount | select(. != null) | tonumber] | add // 0 | round)})
| sort_by(.month) | .[] | "\(.month)\tdeals: \(.count)\trevenue: $\(.revenue)"' \
| column -t -s$'\t'Known limitations#
hubspot pipelines stagesdoes not expose stage probability — won/lost stages can't be auto-identified from the stages list. Usehs_is_closed_wonon deals instead.- No team object — group by
hubspot_owner_idand resolve names fromhubspot owners listclient-side.
Supporting file: bulk-operations/SKILL.md#
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 agentThis 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 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:
- 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.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.
Supporting file: bulk-operations/resources/json-patterns.md#
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'FAQ
07 QUESTIONS
01What is the macOS agent skill?
Daily briefings, pipeline snapshots, and win/loss analysis from the terminal, closing-this-week, open pipeline by stage/owner, and closed-won vs closed-lost over a period. It is listed under go-to-market in the 100xLab agent skills directory.
02How do I install macOS in Cursor, Claude Code, or Codex?
Run npx skills add hubspot/agent-cli-skills --skill sales-reporting in the project where you want it, then ask your agent for the skill by name. The --skill flag installs only macOS, not every skill in the repository.
03What does the macOS SKILL.md cover?
The SKILL.md includes sections on Source of truth, Property and output shape notes, Daily briefing, Pipeline snapshot, Win/loss analysis and Known limitations. Read the full text on this page or in plain markdown at /resources/agent-skills/hubspot-agent-cli-skills--sales-reporting--1hv78dl/raw.
04Which agents can use macOS?
macOS installs with the cross-agent skills installer (npx skills add), which sets it up for agents such as Claude Code, Cursor and Codex. Each agent loads the SKILL.md when you ask for the skill by name.
05What license is macOS under?
The hubspot/agent-cli-skills repository declares the Apache-2.0 license. Read the license file in the repository before you copy or redistribute the skill.
06Is macOS free to use?
Reading, copying and installing macOS costs nothing: it is published in a public GitHub repository under the Apache-2.0 license. Reuse and redistribution follow that license.
07Where is the source of macOS?
macOS comes from https://github.com/hubspot/agent-cli-skills, which has 21 GitHub stars. The SKILL.md is at https://github.com/hubspot/agent-cli-skills/blob/HEAD/sales-reporting/SKILL.md. The author is hubspot.
Need an agent workflow built around your stack?
Tell us the workflow you want automated. We reply within 4 working hours (IST).
ATTRIBUTION
macOS was written by its original authors and is published in hubspot/agent-cli-skills (opens GitHub in a new tab) under the Apache-2.0 license. All rights stay with the original authors.
100xLab did not write, maintain or endorse this skill. This page lists it for discovery. Repo stars belong to the source repository, not to the skill. Review any skill before you install it.