conversation-inbox-triage

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese
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.
需要使用 hub-cli PR #251 中的
conversations
命令组。
hubspot conversations --help
是权威说明。归档操作采用 dry-run/digest/confirm 防护模式——请严格按照该模式执行。

Paging: never triage from a
--format jsonl
redirect

分页:切勿通过
--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
.
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:
bash
undefined
每个 conversations
list
命令默认使用
--limit 100
并返回下一页的游标。**
--format jsonl
只打印数据行——游标仅由
--format json
暴露,位于
.meta.next
。**因此,
threads list --format jsonl > file
会在 100 个线程处静默停止:在繁忙的收件箱中,操作人员看到的是一次正常结束的运行,而第一个分页之后的所有线程都未被分配、未被归档,也未计入统计。提高
--limit
也无法解决——该端点会对页面大小设置上限。
所有批量处理或计数操作都要使用以下辅助函数:
bash
undefined

conv_page <output-file> <conversations list args...>

conv_page <output-file> <conversations list args...>

Pages a conversations list endpoint to exhaustion, writing one JSON object

Pages a conversations list endpoint to exhaustion, writing one JSON object

per line to <output-file>.

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

第 2–4 步中直接使用 `--format jsonl` 没问题,适合交互式快速查看第一页。第 6–8 步会修改数据或进行统计,因此必须分页。

1. Discover inboxes

1. 发现收件箱

Inbox IDs are portal-specific. Always enumerate at the start of a session — never hardcode.
bash
conv_page /tmp/inboxes.jsonl conversations inboxes list
收件箱 ID 与具体 portal 绑定。始终在每次会话开始时枚举收件箱——切勿硬编码。
bash
conv_page /tmp/inboxes.jsonl conversations inboxes list

{"id":"123","name":"Support Inbox","createdAt":"...","updatedAt":"..."}

{"id":"123","name":"Support Inbox","createdAt":"...","updatedAt":"..."}

{"id":"124","name":"Sales Chat","createdAt":"...","updatedAt":"..."}

{"id":"124","name":"Sales Chat","createdAt":"...","updatedAt":"..."}


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

```bash
conv_page /tmp/channels.jsonl conversations channels list --inbox-id 123

要查看某个收件箱中的渠道(email、live chat、form 等),请执行:

```bash
conv_page /tmp/channels.jsonl conversations channels list --inbox-id 123

{"id":"456","name":"Support Email","type":"EMAIL","inboxId":"123"}

{"id":"456","name":"Support Email","type":"EMAIL","inboxId":"123"}

undefined
undefined

2. List open threads

2. 列出开放线程

threads list
requires
--inbox-id
. Filter client-side on
.status
for a focused view:
bash
undefined
threads list
要求指定
--inbox-id
。如需获得聚焦结果,请在客户端按
.status
过滤:
bash
undefined

all threads in an inbox

all threads in an inbox

hubspot conversations threads list --inbox-id 123 --format jsonl
hubspot conversations threads list --inbox-id 123 --format jsonl

only OPEN threads

only OPEN threads

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

Output shape per row:
```json
{"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.
hubspot conversations threads list --inbox-id 123 --format jsonl
| jq -c 'select(.status == "OPEN")'

每行的输出结构:

```json
{"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}
会话线程没有
channelType
——它通过
originalChannelId
中的 ID 标识原始渠道,需要与
channels list
(第 8 步)进行关联。
subject
也不保证存在;请使用
// "(no subject)"
作为回退值读取。

3. Find unassigned threads

3. 查找未分配线程

assignedTo
is null or absent for unassigned threads:
bash
hubspot conversations threads list --inbox-id 123 --format jsonl \
| jq -c 'select(.status == "OPEN") | select(.assignedTo == null or .assignedTo == "")'
assignedTo
为 null 或不存在,表示线程未分配:
bash
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)

4. 查找陈旧线程(N 小时内无回复)

latestMessageTimestamp
is ISO 8601 and compares lexicographically.
bash
undefined
latestMessageTimestamp
采用 ISO 8601 格式,可以按字典序比较。
bash
undefined

macOS: threads with no activity in 24 hours

macOS: threads with no activity in 24 hours

CUTOFF=$(date -u -v-24H +%Y-%m-%dT%H:%M:%SZ)
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)

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

对于 48 小时和 72 小时版本,将 `-v-24H` 改为 `-v-48H` 或 `-v-72H`。

5. Look up an actor ID

5. 查询 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:
bash
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.
assign
接受 actor ID。可通过团队成员的 agent ID 或 email 获取其 actor ID。HubSpot actor ID 使用
A-
前缀:
bash
hubspot conversations actors get --id A-123456 --format jsonl
要将 owner ID 映射为 actor ID,请交叉查询
hubspot owners list --format jsonl
(返回
.email
和
.id
),并以 actor ID 格式
A-<ownerId>
作为起点——再使用
actors get
验证该 ID 能否正确解析。
owners list
需要 service key。在 OAuth 登录模式下,该命令会返回
403 This endpoint does not support user-level OAuth tokens
,因此在执行 owner 到 actor 的映射前,请将
HUBSPOT_ACCESS_TOKEN
设置为 service key。
conversations
命令本身在任一认证模式下均可工作。

6. Assign a thread

6. 分配线程

bash
hubspot conversations threads assign --id 111 --actor-id A-123456
bash
hubspot conversations threads assign --id 111 --actor-id A-123456

{"id":"111","ok":true}

{"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.

```bash
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

批量分配(将所有未分配的开放线程分配给一个 actor)。这里有两个关键点:ID 列表必须从**文件**读取,而不是管道——由管道提供输入的 `while` 循环在子 shell 中运行,因此循环结束时计数器和失败列表都会丢失;并且最后要返回非零状态,避免将部分完成的运行误认为完整完成。

```bash
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

7. 归档已解决的线程

Archive is guarded: run
--dry-run
first to get a digest, then re-run with
--digest
and
--confirm
(value = thread ID).
bash
undefined
归档操作有防护机制:先运行
--dry-run
获取 digest,然后使用
--digest
和
--confirm
重新运行(其值 = 线程 ID)。
bash
undefined

Single thread — two steps

Single thread — two steps

hubspot conversations threads archive --id 111 --dry-run
hubspot conversations threads archive --id 111 --dry-run

{"id":"111","digest":"blast-29cfdd48b583","ok":true,"executed":false,"dry_run":true}

{"id":"111","digest":"blast-29cfdd48b583","ok":true,"executed":false,"dry_run":true}

hubspot conversations threads archive --id 111 --digest blast-29cfdd48b583 --confirm 111
hubspot conversations threads archive --id 111 --digest blast-29cfdd48b583 --confirm 111

{"id":"111","ok":true}

{"id":"111","ok":true}

undefined
undefined

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.
bash
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.
bash
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
:
bash
hubspot conversations threads restore --id 111
批量归档分为两个独立阶段,中间需要人工决策。不要将它们合并为一个循环,逐个执行 dry-run 并归档线程:这样会在任何人查看完整候选集之前就开始销毁线程,而且整个操作将不存在可供整体拒绝的节点。
阶段 1——预览。只读;不会修改任何内容。
bash
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
在此停止并获取候选列表的批准。阶段 1 可以任意次重新运行;它不会造成任何变更。
保存候选线程的 ID,而不是 digest。digest 仅在 5 分钟内有效(
DIGEST_TTL_SECONDS
位于
src/hub/guard.rs
中),因此,在预览期间生成的 digest 会在任何合理的人工审查暂停后返回
digest_expired
。所以,阶段 2 会在使用每个 digest 前立即生成它。
阶段 2——执行。仅在上方列表获得批准后执行。
bash
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\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\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
失败的线程会被记录,运行会继续,但函数将返回非零状态并打印每项失败,因此不会将仅部分完成归档的收件箱报告为正常完成。对同一 ID 文件重新运行阶段 2 是安全的——已归档的线程只会失败并被列出。
如需撤销,请使用
restore
:
bash
hubspot conversations threads restore --id 111

8. Produce a triage summary

8. 生成分流汇总

Count threads by status across an inbox:
bash
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)"
统计一个收件箱中各状态的线程数量:
bash
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,

Breakdown by channel type. Threads carry originalChannelId, not a type name,

so build a channelId -> type map from
channels list
and join on it.

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

如需汇总多个收件箱,请遍历第 1 步获得的所有收件箱 ID 并进行聚合。

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.
  • threads list
    要求指定
    --inbox-id
    ——不存在 portal 范围的线程列表。
  • 没有针对
    status
    或
    assignedTo
    的服务端过滤器——请使用
    jq
    在客户端过滤。
  • 每个
    list
    命令每页返回 100 行,且只有
    --format json
    会暴露游标(
    .meta.next
    )。批量处理和统计步骤必须使用
    conv_page
    ;直接使用
    --format jsonl
    重定向会静默截断结果,使部分运行看起来像完整运行。
  • 线程不携带
    channelType
    ——需将
    originalChannelId
    与
    channels list
    进行关联以获取类型。线程可能会在会话过程中切换渠道,因此该类型描述的是会话开始时的渠道。
  • 不能将批量归档简化为一条串联
    --digest
    /
    --confirm
    的管道流程;每个线程都必须采用各自的两步流程单独归档,并且必须先预览并批准整个候选集,然后才能执行任何归档。
  • 由某次
    --dry-run
    生成的 digest 会在 5 分钟后过期,因此不能预先收集后再统一应用——必须在每次使用前立即生成。
  • 批量循环应由文件而非管道驱动:由管道提供输入的
    while
    循环体在子 shell 中运行,因此循环退出时,内部收集的失败计数会丢失。
  • actors get
    按 ID 查询单个 actor;没有 actors list 命令。可从
    hubspot owners list
    开始进行 owner 到 actor 的映射。