Requires the conversations command group from hub-cli PR #251. hubspot conversations --help is authoritative. Also read communication-history/SKILL.md for CRM activity history (calls, emails, notes) — this skill covers the Conversations inbox surface only.
How threads associate to contacts
conversations threads list accepts --associated-contact-id. You must also supply --inbox-id — there is no portal-wide contact thread lookup without knowing the inbox. To cover all inboxes, enumerate them first (step 1), then query per inbox.
Paging: never collect a full result set with --format jsonl
Every conversations list command defaults to --limit 100 and returns a cursor for the next page. --format jsonl prints only the data rows — the cursor is surfaced only by --format json, at .meta.next. Piping --format jsonl into a file therefore silently truncates at the first 100 rows, and raising --limit is not a fix because the endpoint caps the page size.
Use this helper for anything that must see every row:
# conv_page <output-file> <conversations list args...>
# Pages a conversations list endpoint to exhaustion, writing one JSON object
# per line to <output-file>.
conv_page() {
out=$1; shift
after=""
: > "$out"
while :; do
if [ -n "$after" ]; then
page=$(hubspot "$@" --format json --after "$after") || return 1
else
page=$(hubspot "$@" --format json) || return 1
fi
printf '%s' "$page" | jq -c '.data[]' >> "$out"
after=$(printf '%s' "$page" | jq -r '.meta.next // empty')
[ -n "$after" ] || break
done
}
A bare --format jsonl is still fine for eyeballing the first page interactively.
1. Enumerate inboxes
conv_page /tmp/inboxes.jsonl conversations inboxes list
# {"id":"123","name":"Support Inbox","createdAt":"...","updatedAt":"..."}
# {"id":"124","name":"Sales Chat","createdAt":"...","updatedAt":"..."}
2. Find all threads for a contact
Loop over inbox IDs and collect threads associated with the contact:
CONTACT_ID=73235
: > /tmp/contact_threads.jsonl # truncate output file
while IFS= read -r inbox; do
inbox_id=$(echo "$inbox" | jq -r '.id')
conv_page /tmp/inbox_threads.jsonl conversations threads list \
--inbox-id "$inbox_id" \
--associated-contact-id "$CONTACT_ID"
cat /tmp/inbox_threads.jsonl >> /tmp/contact_threads.jsonl
done < /tmp/inboxes.jsonl
echo "Total threads found: $(wc -l < /tmp/contact_threads.jsonl)"
Thread output shape:
{"id":"111","status":"OPEN","createdAt":"2026-07-28T10:02:00Z","latestMessageTimestamp":"2026-07-30T14:22:00Z","assignedTo":"A-78901","inboxId":"123","originalChannelId":"1000","originalChannelAccountId":"148662","associatedContactId":"73235","spam":false,"archived":false}
Two fields that are easy to assume and are not on a thread:
- No
channelType. A thread names its originating channel by id only, inoriginalChannelId. Resolve it to a human-readable type by joining againstconversations channels list, which returns{"id":"1000","name":"Support Email","type":"EMAIL","inboxId":"123"}(step 3 below). - No guaranteed
subject.status(OPEN/CLOSED), timestamps,assignedToand the channel ids are the dependable fields. Always read a subject through a// "(no subject)"fallback.
3. Resolve channel ids to channel types
: > /tmp/channels.jsonl
while IFS= read -r inbox; do
inbox_id=$(echo "$inbox" | jq -r '.id')
conv_page /tmp/inbox_channels.jsonl conversations channels list --inbox-id "$inbox_id"
cat /tmp/inbox_channels.jsonl >> /tmp/channels.jsonl
done < /tmp/inboxes.jsonl
# {"1000":"EMAIL","1001":"LIVE_CHAT"}
jq -s 'map({key: (.id|tostring), value: .type}) | from_entries' \
/tmp/channels.jsonl > /tmp/channel_types.json
Helper used by the steps below to label a thread:
CHANNEL_TYPES=$(cat /tmp/channel_types.json)
4. Fetch messages for each thread
messages list pages like every other list endpoint, so page it rather than taking the first 100:
: > /tmp/all_messages.jsonl
while IFS= read -r thread; do
tid=$(echo "$thread" | jq -r '.id')
conv_page /tmp/thread_messages.jsonl conversations messages list --thread-id "$tid"
jq -c --argjson thread "$thread" --argjson types "$CHANNEL_TYPES" \
'. + {
_threadId: $thread.id,
_subject: ($thread.subject // "(no subject)"),
_channelType: ($types[($thread.originalChannelId | tostring)] // "UNKNOWN")
}' /tmp/thread_messages.jsonl \
>> /tmp/all_messages.jsonl
done < /tmp/contact_threads.jsonl
Message output shape:
{"id":"222","type":"MESSAGE","text":"Hello, I have a billing question.","sender":{"actorId":"A-456","name":"Jane Doe"},"createdAt":"2026-07-30T14:20:00Z"}
To get a single message in full detail:
hubspot conversations messages get --thread-id 111 --message-id 222 --format jsonl
5. Compact timeline for an account brief
Produce a human-readable thread summary ordered by most recent activity. Note the -s: the file is JSONL, so without slurping, sort_by is handed one object at a time and jq exits 5 with Cannot index string with string instead of printing a timeline.
echo "=== Conversation History for Contact $CONTACT_ID ==="
jq -sr --argjson types "$CHANNEL_TYPES" \
'sort_by(.latestMessageTimestamp) | reverse[] |
"\(.latestMessageTimestamp[0:10]) \($types[(.originalChannelId | tostring)] // "?") [\(.status)] \(.subject // "(no subject)")"' \
/tmp/contact_threads.jsonl
Key message excerpts (first message per thread):
echo ""
echo "=== First message per thread ==="
while IFS= read -r thread; do
tid=$(echo "$thread" | jq -r '.id')
subject=$(echo "$thread" | jq -r '.subject // "(no subject)"')
first_msg=$(hubspot conversations messages list --thread-id "$tid" --limit 1 --format jsonl \
| jq -r '.text // .richText // "(no text)" | .[0:200]')
echo "[$tid] $subject: $first_msg"
done < /tmp/contact_threads.jsonl
6. Structured account brief section
Emit a JSON block suitable for embedding in a larger account brief:
jq -n \
--argjson threads "$(jq -s '.' /tmp/contact_threads.jsonl)" \
--argjson types "$CHANNEL_TYPES" \
--arg contact_id "$CONTACT_ID" \
'{
conversations: {
contact_id: $contact_id,
total_threads: ($threads | length),
open_threads: ($threads | map(select(.status=="OPEN")) | length),
channels: ($threads | map($types[(.originalChannelId | tostring)] // "UNKNOWN") | unique),
latest_activity: ($threads | sort_by(.latestMessageTimestamp) | last | .latestMessageTimestamp),
threads: ($threads | map({
id: .id,
subject: (.subject // "(no subject)"),
status: .status,
channel: ($types[(.originalChannelId | tostring)] // "UNKNOWN"),
last_activity: .latestMessageTimestamp,
assigned_to: (.assignedTo // null)
}))
}
}'
Combining with CRM activity history
For a full pre-call brief, combine this skill with communication-history/SKILL.md:
CONTACT_ID=73235
echo "=== CRM Activity (calls, emails, notes) ==="
hubspot activities list --contact $CONTACT_ID --limit 10 \
| jq -r '"\(.timestamp[0:10]) \(.type) \(.title)"'
echo ""
echo "=== Conversations Inbox Threads ==="
# ... run the thread loop from step 2 above ...
Known constraints
threads listrequires--inbox-id— must loop over all inboxes to get a full picture.- No server-side filter on date range — filter with
jqclient-side usinglatestMessageTimestamp. - Every
listcommand pages at 100 rows and only--format jsonexposes the cursor (.meta.next). Useconv_pagefor anything that must be complete; a bare--format jsonlredirect silently truncates. - Threads carry no
channelType— joinoriginalChannelIdagainstconversations channels listto get a type. A thread can also switch channels mid-conversation, sooriginalChannelIddescribes where the thread started, not every channel it used. subjectis not a guaranteed field on a thread; always use a// "(no subject)"fallback.textvsrichText: some message types carry formatted content inrichTextrather thantext. Check both fields when extracting excerpts.