SuperAPI AI Directory

conversation-inbox-triage

General & other

View repository

Updated 10-15 22:15

About this skill

Requires the conversations command group from hub-cli PR #251. hubspot conversations --help is authoritative. Archive uses the dry-run/digest/confirm guard pattern — follow it exactly.

Paging: never triage from a --format jsonl redirect

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. So threads list --format jsonl > file silently stops at 100 threads: on a busy inbox the operator sees a clean run while every thread past the first page is never assigned, never archived, and missing from the counts. Raising --limit does not fix it — the endpoint caps the page size.

Use this helper for every bulk or counting operation:

# 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
}

The bare --format jsonl calls in steps 2–4 are fine for eyeballing the first page interactively. Steps 6–8 mutate or count, so they page.

1. Discover inboxes

Inbox IDs are portal-specific. Always enumerate at the start of a session — never hardcode.

conv_page /tmp/inboxes.jsonl conversations inboxes list
# {"id":"123","name":"Support Inbox","createdAt":"...","updatedAt":"..."}
# {"id":"124","name":"Sales Chat","createdAt":"...","updatedAt":"..."}

To see channels inside an inbox (email, live chat, form, etc.):

conv_page /tmp/channels.jsonl conversations channels list --inbox-id 123
# {"id":"456","name":"Support Email","type":"EMAIL","inboxId":"123"}

2. List open threads

threads list requires --inbox-id. Filter client-side on .status for a focused view:

# all threads in an inbox
hubspot conversations threads list --inbox-id 123 --format jsonl

# only OPEN threads
hubspot conversations threads list --inbox-id 123 --format jsonl \
| jq -c 'select(.status == "OPEN")'

Output shape per row:

{"id":"111","status":"OPEN","createdAt":"2026-07-28T10:02:00Z","latestMessageTimestamp":"2026-07-30T14:22:00Z","assignedTo":"A-78901","inboxId":"123","originalChannelId":"456","originalChannelAccountId":"148662","associatedContactId":"73235","spam":false,"archived":false}

A thread has no channelType — it names its originating channel by id, in originalChannelId, which you join against channels list (step 8). subject is also not guaranteed; read it through a // "(no subject)" fallback.

3. Find unassigned threads

assignedTo is null or absent for unassigned threads:

hubspot conversations threads list --inbox-id 123 --format jsonl \
| jq -c 'select(.status == "OPEN") | select(.assignedTo == null or .assignedTo == "")'

4. Find stale threads (no reply within N hours)

latestMessageTimestamp is ISO 8601 and compares lexicographically.

# macOS: threads with no activity in 24 hours
CUTOFF=$(date -u -v-24H +%Y-%m-%dT%H:%M:%SZ)
# Linux: CUTOFF=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)

hubspot conversations threads list --inbox-id 123 --format jsonl \
| jq -c --arg cutoff "$CUTOFF" \
    'select(.status == "OPEN") | select(.latestMessageTimestamp < $cutoff)'

For 48-hour and 72-hour variants change -v-24H to -v-48H / -v-72H.

5. Look up an actor ID

assign takes an actor ID. Get a team member's actor ID by their agent ID or email. HubSpot actor IDs use the A- prefix:

hubspot conversations actors get --id A-123456 --format jsonl

To map owner IDs to actor IDs, cross-reference hubspot owners list --format jsonl (returns .email and .id) and use the actor ID format A-<ownerId> as a starting point — verify with actors get that the ID resolves.

owners list needs a service key. Under an OAuth login it returns 403 This endpoint does not support user-level OAuth tokens, so set HUBSPOT_ACCESS_TOKEN to a service key before the owner-to-actor lookup. The conversations commands themselves work under either auth mode.

6. Assign a thread

hubspot conversations threads assign --id 111 --actor-id A-123456
# {"id":"111","ok":true}

Bulk assign (all unassigned open threads to one actor). Two things matter here: read the id list from a file, not a pipe — a pipeline-fed while runs in a subshell, so counters and failure lists are discarded when it ends — and report a non-zero status at the end, so a partial run is never mistaken for a complete one.

ACTOR=A-123456
conv_page /tmp/threads.jsonl conversations threads list --inbox-id 123

jq -r 'select(.status == "OPEN")
       | select(.assignedTo == null or .assignedTo == "")
       | .id' /tmp/threads.jsonl > /tmp/assign_ids.txt

assign_threads() {
  actor=$1; ids=$2
  assigned=0
  : > /tmp/assign_failed.txt

  while read -r tid; do
    if out=$(hubspot conversations threads assign --id "$tid" --actor-id "$actor" 2>&1); then
      assigned=$((assigned + 1))
    else
      printf '%s\t%s\n' "$tid" "$out" >> /tmp/assign_failed.txt
    fi
  done < "$ids"

  failed=$(wc -l < /tmp/assign_failed.txt)
  echo "Assigned $assigned of $(wc -l < "$ids"); $failed failed"
  [ "$failed" -eq 0 ] || { echo "Failures:"; cat /tmp/assign_failed.txt; return 1; }
}

assign_threads "$ACTOR" /tmp/assign_ids.txt

7. Archive resolved threads

Archive is guarded: run --dry-run first to get a digest, then re-run with --digest and --confirm (value = thread ID).

# Single thread — two steps
hubspot conversations threads archive --id 111 --dry-run
# {"id":"111","digest":"blast-29cfdd48b583","ok":true,"executed":false,"dry_run":true}

hubspot conversations threads archive --id 111 --digest blast-29cfdd48b583 --confirm 111
# {"id":"111","ok":true}

Bulk archive — preview, then execute

Bulk archive runs as two separate phases with a human decision between them. Do not collapse them into one loop that dry-runs and archives each thread in turn: that starts destroying threads before anyone has seen the full candidate set, and there is no point at which the operation can be declined as a whole.

Phase 1 — preview. Read-only; nothing is mutated.

INBOX=123
conv_page /tmp/threads.jsonl conversations threads list --inbox-id "$INBOX"

jq -c 'select(.status == "CLOSED")
       | {id, last_activity: .latestMessageTimestamp, assigned_to: .assignedTo}' \
  /tmp/threads.jsonl > /tmp/archive_candidates.jsonl

echo "Candidates for archive: $(wc -l < /tmp/archive_candidates.jsonl)"
jq -r '"\(.id)  \(.last_activity // "?")  \(.assigned_to // "unassigned")"' \
  /tmp/archive_candidates.jsonl

Stop here and get the candidate list approved. Re-run phase 1 as often as you like; it changes nothing.

Persist candidate ids, not digests. A digest is only valid for 5 minutes (DIGEST_TTL_SECONDS in src/hub/guard.rs), so digests minted during preview would come back digest_expired after any realistic review pause. Phase 2 therefore mints each digest immediately before it uses it.

Phase 2 — execute. Only after the list above is approved.

jq -r '.id' /tmp/archive_candidates.jsonl > /tmp/archive_ids.txt

archive_threads() {
  ids=$1
  archived=0
  : > /tmp/archive_failed.txt

  while read -r tid; do
    preview=$(hubspot conversations threads archive --id "$tid" --dry-run 2>&1)
    digest=$(printf '%s' "$preview" | jq -r '.digest // empty' 2>/dev/null)
    if [ -z "$digest" ]; then
      printf '%s\tdry-run\t%s\n' "$tid" "$preview" >> /tmp/archive_failed.txt
      continue
    fi

    if out=$(hubspot conversations threads archive --id "$tid" --digest "$digest" --confirm "$tid" 2>&1); then
      archived=$((archived + 1))
    else
      printf '%s\tarchive\t%s\n' "$tid" "$out" >> /tmp/archive_failed.txt
    fi
  done < "$ids"

  failed=$(wc -l < /tmp/archive_failed.txt)
  echo "Archived $archived of $(wc -l < "$ids"); $failed failed"
  [ "$failed" -eq 0 ] || { echo "Failures (id / phase / error):"; cat /tmp/archive_failed.txt; return 1; }
}

archive_threads /tmp/archive_ids.txt

A failing thread is recorded and the run continues, but the function returns non-zero and prints every failure, so a partially drained inbox is never reported as a clean run. Re-running phase 2 on the same id file is safe — already-archived threads simply fail and are listed.

To undo, use restore:

hubspot conversations threads restore --id 111

8. Produce a triage summary

Count threads by status across an inbox:

echo "=== Inbox 123 triage summary ==="
conv_page /tmp/threads.jsonl conversations threads list --inbox-id 123

echo "Total threads:      $(wc -l < /tmp/threads.jsonl)"
echo "Open:               $(jq -c 'select(.status=="OPEN")' /tmp/threads.jsonl | wc -l)"
echo "Closed:             $(jq -c 'select(.status=="CLOSED")' /tmp/threads.jsonl | wc -l)"
echo "Unassigned (open):  $(jq -c 'select(.status=="OPEN") | select(.assignedTo==null or .assignedTo=="")' /tmp/threads.jsonl | wc -l)"

# Breakdown by channel type. Threads carry originalChannelId, not a type name,
# so build a channelId -> type map from `channels list` and join on it.
conv_page /tmp/channels.jsonl conversations channels list --inbox-id 123
CHANNEL_TYPES=$(jq -s 'map({key: (.id|tostring), value: .type}) | from_entries' /tmp/channels.jsonl)

echo ""
echo "=== By channel type ==="
jq -r --argjson types "$CHANNEL_TYPES" \
  '$types[(.originalChannelId | tostring)] // "UNKNOWN"' /tmp/threads.jsonl \
| sort | uniq -c | sort -rn

For a multi-inbox summary, loop over all inbox IDs from step 1 and aggregate.

Known constraints

  • threads list requires --inbox-id — there is no portal-wide thread listing.
  • No server-side filter on status or assignedTo — filter client-side with jq.
  • Every list command pages at 100 rows and only --format json exposes the cursor (.meta.next). Bulk and counting steps must use conv_page; a bare --format jsonl redirect silently truncates and makes a partial run look complete.
  • Threads carry no channelType — join originalChannelId against channels list for a type. A thread can switch channels mid-conversation, so this describes where it started.
  • Bulk archive cannot be collapsed into a single pipe through --digest/--confirm; each thread must be archived individually with its own two-step flow, and the candidate set must be previewed and approved before any of it executes.
  • Digests expire 5 minutes after the --dry-run that minted them, so they cannot be collected up front and applied later — mint each one immediately before use.
  • Drive bulk loops from a file rather than a pipe: a pipeline-fed while body runs in a subshell, so failure counts collected inside it are lost when the loop exits.
  • actors get looks up a single actor by ID; there is no actors list command. Start from hubspot owners list for owner-to-actor mapping.