# Workspace IM API Reference

> Canonical path: `specs/im/workspace-im-api-reference.md`
> Status: target design
> Related: `specs/im/workspace-im-sync-protocol.md`, `specs/message/scope-model.md`, `specs/service-account/service-account.md`, `specs/app/app-notification.md`

本文是 [`workspace-im-sync-protocol.md`](./workspace-im-sync-protocol.md) 的配套 API 参考。协议层面的概念（snapshot/delta/ack/resume 闭环、event log 语义、checkpoint 约束）已在协议 spec 中完整定义，本文只补充具体的 endpoint、request/response 格式和 mutation 约定。

## 1. 设计原则

- **首屏只走 `snapshot`**：不要多接口拼首屏。
- **实时状态只走 `events/stream`**：收到 event 后不要默认 refetch。
- **历史消息只走 `scopes/:scopeId/messages`**：不参与实时补偿。
- **Inbox 与 Conversation sidebar 按 surface 分离**：Inbox 使用 `conversations` 全量 view model；Conversation sidebar 和 All Threads 使用 IM-only subscribed thread surface，避免把 KB 等非 IM subsystem thread 混入 IM 导航。
- **所有 mutation 返回 `eventSeq`**：UI 等 stream patch 成为最终权威状态。

## 2. Snapshot API

### GET /api/v1/workspaces/:workspaceId/snapshot

进入 workspace、刷新页面、event gap 太大时重建客户端状态。

Query：

| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `conversationLimit` | int | conversation 列表数量上限，默认 50 |
| `threadLimit` | int | subscribed thread 列表数量上限，默认 15 |
| `includeChannels` | bool | 可选，缺省 `true`；`false` 时不查询 channel directory |
| `includeDMChannels` | bool | 可选，缺省 `true`；`false` 时不查询 DM directory，并忽略 `dmLimit` |
| `dmLimit` | int | DM directory 数量上限；仅在 `includeDMChannels=true` 时解析 |
| `activeScopeId` | string | 可选，当前活跃 scope，用于返回该 scope 的最近消息 |
| `messageLimit` | int | 活跃 scope 的消息数量上限，默认 50 |
| `clientId` | string | 可选，读取该 client 的 stream cursor |
| `advanceStreamCursor` | bool | 可选；仅供与当前 user/workspace 匹配的 `workspace-im:v1:<workspaceId>:<userId>:<instanceId>`，或 miniapp route source 的 `miniapp:<installationId>:<workspaceId>` bootstrap client 使用；snapshot 完整组装后把该 client checkpoint 单调推进到本次 `snapshotEventSeq`；请求失败后必须重新 snapshot，不能退回旧 cursor |
| `ifContentETag` | string | 可选 opaque validator；与本次完整组装出的 cacheable content hash 相等时返回 lightweight JSON 200，而不是 304 |

`includeChannels` 和 `includeDMChannels` 只接受单个显式
`true`/`false`。缺省保持旧客户端行为。显式空值、重复值或其它拼写返回
400。即使某个 directory 被省略，对应 response 数组也保持非 null；客户端必须
读取 `coverage` 区分“已查询且为空”与“未查询”。

推荐的 Inbox/gap-recovery 请求为：

```text
conversationLimit=20
threadLimit=15
includeChannels=false
includeDMChannels=false
```

该请求不逐 conversation 读取 messages，也不在首屏查询 channel/DM
directory。目录只在用户打开对应 sidebar、browse/discover、直达 route 或搜索时走
已有的显式目录接口；目录完成不阻塞 Inbox 首屏。

完整 Response（HTTP 200）：

```json
{
  "workspaceId": "w1",
  "memberId": "m1",
  "snapshotEventSeq": 123456,
  "snapshotVersion": "workspace-im-v1",
  "contentETag": "workspace-im-content-v1-...",
  "streamCursor": {
    "clientId": "workspace-im:v1:w1:u1:instance1",
    "ackedEventSeq": 123456,
    "latestEventSeq": 123456
  },
  "serverTime": "2026-06-09T00:00:00Z",
  "viewer": {},
  "workspace": {},
  "coverage": {
    "channels": false,
    "dmChannels": false
  },
  "members": [],
  "scopes": [],
  "channels": [],
  "dmChannels": [],
  "conversations": {
    "items": [],
    "nextCursor": "...",
    "hasMore": true
  },
  "unread": {
    "total": 12,
    "byConversation": {}
  },
  "activeMessages": {
    "scopeId": "s1",
    "messages": [],
    "hasOlder": true,
    "hasNewer": false,
    "oldestSeq": 100,
    "newestSeq": 150
  }
}
```

`coverage.channels=true` 或 `coverage.dmChannels=true` 表示对应数组已查询，
此时 `[]` 是 authoritative empty。`false` 表示数组被省略，客户端不得用该
`[]` 清空已有目录缓存。旧服务端缺少 `coverage` 时按旧契约处理：两个数组均被
包含。

当 `ifContentETag` 命中时仍返回 HTTP 200：

```json
{
  "notModified": true,
  "contentETag": "workspace-im-content-v1-...",
  "snapshotEventSeq": 123500,
  "streamCursor": {
    "clientId": "workspace-im:v1:w1:u1:instance1",
    "ackedEventSeq": 123500,
    "latestEventSeq": 123500
  },
  "serverTime": "2026-06-09T00:00:05Z"
}
```

validator 来自本次鉴权后实际读取并组装出的稳定 cacheable content，包括
viewer/workspace/member identity、bounded conversation page、thread/unread 与
coverage；它排除 `snapshotEventSeq`、`streamCursor`、`serverTime` 和 validator
本身。服务端必须先完成同一套 cacheable 读取再比较 validator，因此该机制只减少
unchanged response body、传输与客户端 JSON/normalization 成本，不宣称跳过 SQL。
命中时仍返回最新 realtime fence；只有 cacheable content 完整组装且 hash 编码
成功后，才按既有规则单调推进 cursor。组装或 hash 编码失败时不得推进 cursor；
cursor 也不得因 event retention 造成 wire 回退。

客户端只能在 cache identity 与当前 server/account/member/workspace/schema/variant
完全匹配时发送 validator 并复用缓存。缓存 miss、validator 不匹配或收到无法与
本地 cache 对应的 lightweight response 时，客户端去掉 validator 重试一次完整
snapshot；第二次仍是 lightweight response 则 fail closed，不能循环。老客户端
不发送新参数，始终得到旧形态的完整 200；新客户端连接忽略新参数的旧服务端时，
把完整 200 安全降级为 legacy full。

## 3. Events / Stream API

### WebSocket /api/v1/workspaces/:workspaceId/events/stream

连接参数：

| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `accessToken` | string | 认证 token |
| `clientId` | string | 客户端标识，用于 checkpoint 隔离 |
| `lastAckedEventSeq` | int | 可选，最后已 ack 的 event seq |
| `snapshotVersion` | string | 可选，当前持有的 snapshot 版本 |

服务端 hello：

```json
{
  "type": "hello",
  "workspaceId": "w1",
  "latestEventSeq": 123500,
  "resumeFrom": 123456,
  "snapshotRequired": false,
  "serverTime": "..."
}
```

事件格式：

```json
{
  "eventSeq": 123501,
  "type": "message.append",
  "workspaceId": "w1",
  "scopeId": "s1",
  "conversationId": "c1",
  "payload": {},
  "patches": {
    "messages": {},
    "conversation": {},
    "unread": {},
    "threadSummary": {}
  },
  "createdAt": "..."
}
```

事件类型：

| type | 说明 |
| --- | --- |
| `message.append` | 新消息 |
| `message.update` | 消息编辑 |
| `message.delete` | 消息删除 |
| `reaction.patch` | reaction 变化 |
| `conversation.upsert` | conversation 列表项变化 |
| `conversation.unread.patch` | 未读数变化 |
| `conversation.read.patch` | 已读状态变化 |
| `conversation.pin.patch` | 置顶状态变化 |
| `thread.summary.patch` | thread 摘要变化 |
| `scope.upsert` | scope 元数据或 membership 变化 |
| `scope.membership.patch` | scope 成员变化 |
| `task.patch` | 任务状态变化 |
| `agent.status.patch` | agent 状态变化 |
| `knowledge_qa.turn.updated` | owner-local 知识问答 turn 投影变化；客户端只刷新事件指定的 thread |

客户端规则：

- 按 `eventSeq` 顺序 apply。
- 如果发现 gap，暂停 apply，调用 resume。
- 如果 resume 失败或 gap 超窗口，重新拉 snapshot。

### WebSocket control frames

`ack`、`resume`、`subscribeVisibleScope` 和 `unsubscribeVisibleScope` 都只作为 `events/stream` WebSocket frame 发送；目标态不注册 HTTP ack、HTTP resume、SSE 或 short-poll 端点。

## 4. Scopes API

scope 是消息承载面，可以是 channel、DM、reply thread、task thread、service account conversation 等。

### GET /api/v1/workspaces/:workspaceId/scopes

查询 workspace 下可见 scopes。通常只作为管理/兜底接口；首屏优先走 snapshot。

Query：

| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `kind` | string | `channel`, `dm`, `thread`, `task`, `service_account` |
| `cursor` | string | 分页游标 |
| `limit` | int | 默认 50 |

### GET /api/v1/workspaces/:workspaceId/scopes/:scopeId

返回单个 scope 的基础信息和 viewer membership。

### POST /api/v1/workspaces/:workspaceId/scopes/:scopeId/subscribe

订阅 scope。Response 不需要让前端再 refetch 多个列表，应返回对应 eventSeq 或 conversation patch：

`scopes.type=service_account` 不支持该 mutation。服务号固定覆盖 Workspace 全部 active Human，membership 仅由 Workspace Human lifecycle 与服务号 audience reconciler 维护，不提供订阅入口。

```json
{
  "ok": true,
  "eventSeq": 123530,
  "conversationPatch": {}
}
```

### DELETE /api/v1/workspaces/:workspaceId/scopes/:scopeId/subscribe

取消订阅 scope。`scopes.type=service_account` 不支持该 mutation；服务号收件范围固定为 Workspace 全部 active Human，不提供退订入口，也不会因管理员增删而改变收件 membership。

## 5. Messages API

### GET /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages

历史分页，只读历史消息，不承担实时一致性。

Query：

| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `beforeSeq` | int | 向前翻页 |
| `afterSeq` | int | 向后翻页 |
| `aroundMessageId` | string | 定位到某条消息附近 |
| `limit` | int | 默认 50 |

Response：

```json
{
  "scopeId": "s1",
  "messages": [],
  "oldestSeq": 100,
  "newestSeq": 150,
  "hasOlder": true,
  "hasNewer": false
}
```

### POST /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages

发送消息。

`scopeId` 是唯一 target discriminator。`scopes.type=service_account` 时，该
public route 强制进入 service-account resolver，校验 management_kind=workspace
与 Human manager basis，并使用服务号 principal/member 作为 durable author、
service/routing principal、notify principal 与 message-idempotency subject；Human
manager 作为 permission、caused-by、execution、Audit 与 optional byline actor；
平台服务号消息由 platform broadcast delivery route 使用同一 MessagePlan writer
物化。其它 scope type 使用各自注册的 create branch。Request body 不携带
author-mode override。

Request：

```json
{
  "clientMutationId": "uuid",
  "content": "before\n\n![diagram](<attachment://att_1>)\n\n[demo.mp4](<attachment://att_2>)\n\nafter",
  "attachmentIds": ["att_1", "att_2"],
  "replyToMessageId": null
}
```

`content` 是 canonical Markdown 源文本（`body` 仅作兼容 alias）。正文附件引用使用
`attachment://<attachmentId>`：图片使用 Markdown image，视频、音频和普通文件使用
标准 Markdown link。引用在 `content` 中出现的位置和顺序定义消息排版；
`attachmentIds` 只定义本次 create 要 claim 的附件集合、幂等输入和后续权限边界，
不得用其数组顺序重建排版。发送方必须把正文引用的 ID 同时放进
`attachmentIds`，读取方也只能解析当前 message attachment sidecar 中存在的 ID。

支持 MIME-aware media 的客户端根据 sidecar `contentType` 把 video/audio link
升级为受控播放器；只支持附件链接的旧客户端保持鉴权下载。没有在 `content`
中引用的附件（包括旧消息和仅附件消息）仍由客户端在正文后显示为 sidecar，
而已被引用的附件不应重复显示。

HTML 附件沿用相同的标准 link，例如
`[file.html](<attachment://att_html>)`，不引入新的 wire shape。客户端优先根据
sidecar `contentType=text/html` 识别，并可用 `.html` 文件名作兼容 fallback；支持
该能力的客户端在引用位置显示小型静态网页预览，点击后以同一份净化内容放大，
旧客户端或预览失败时仍显示鉴权下载。静态预览必须使用无脚本、无表单、无跳转、
无外网资源权限的 sandbox iframe；交互式 JavaScript 预览不属于此协议，未来若
支持必须运行在与 AgentTeam 应用隔离的独立 preview origin。

Response：

```json
{
  "accepted": true,
  "alreadySent": false,
  "clientMutationId": "uuid",
  "eventSeq": 123540,
  "messageId": "m1",
  "acceptedSeq": 151
}
```

`clientMutationId` 映射到 `scope_messages.client_message_id`。普通直接发送和
workspace service-account direct send 都使用
`idempotency_subject_member_id=author_member_id`；服务号分支的 author member 是
本地 service author member。Canonical create-payload
digest 覆盖 resolved message semantics / message_type、canonical content、ordered
attachment ids、reply/source refs、optional publisher byline 与 client-owned
message metadata。Server 生成的
message id、scope/workspace seq、timestamps、AuthorProfile projection 和
`service_direct_post` message self-ref 在 digest 绑定后生成，并位于 digest 之外。

同四元幂等 key 与同 payload digest 的重试返回原 messageId、accepted seq 和
`alreadySent=true`。同 key 的 digest 相异时返回 typed
`idempotency_conflict`，并保持原 message projection。

mutation response 只表示已接受。真正更新 UI 的权威路径是 `message.append` event。这样避免 HTTP response 更新一次、WebSocket 又更新一次、最后 refetch 再校准一次。

### PATCH /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId

编辑消息。返回 `eventSeq`。

### DELETE /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId

删除消息。返回 `eventSeq`。

### POST /api/v1/workspaces/:workspaceId/messages/:messageId/app-notification-actions/:actionKey

对 `app_interactive_card` 执行 Human callback action。Request 携带
`cardRevision`、随机 `clientActionId` 与按 registered input schema 校验的 `input`。
Resolver 校验 message read authority、notification lifecycle、card revision、action
actor policy 与 expiry，并创建幂等 action invocation 和
`agentteam.app.notification.action` service-event delivery。

Response 返回 action invocation id、delivery state 与 authoritative card
projection。App runtime 完成业务 mutation 后通过 notification update/resolve API
推进同一 message/card revision；客户端通过 `message.update` 收敛最新 card state。

## 6. Conversations API

### GET /api/v1/workspaces/:workspaceId/conversations

Workspace Inbox 的全量 conversation 列表。该列表可以包含 channel、reply thread、DM、task、service account 以及其它 subsystem 投射到 inbox 的 conversation；Conversation sidebar 不应复用该全量列表作为 thread preview。

Query：

| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `kind` | string | `all`, `channel`, `dm`, `thread`, `task`, `serviceAccount` |
| `status` | string | `active`, `archived` |
| `pinned` | bool | 可选，筛选置顶 |
| `unread` | bool | 可选，筛选未读 |
| `cursor` | string | 分页游标 |
| `limit` | int | 默认 50 |

### GET /api/v1/workspaces/:workspaceId/threads

Conversation sidebar 和 All Threads 的 IM-only subscribed thread surface。该接口只返回 IM subsystem 下当前用户可见且已订阅的 reply thread preview；KB 等非 IM subsystem thread 仍可通过 Inbox 的 `conversations` view model 出现。

Response：

```json
{
  "items": [
    {
      "conversationId": "c1",
      "kind": "thread",
      "scopeId": "s1",
      "title": "...",
      "sortSeq": 123500,
      "lastMessage": {},
      "unread": {
        "count": 3,
        "hasUnread": true
      },
      "viewerState": {
        "pinned": false,
        "muted": false,
        "readThroughSeq": 123450
      }
    }
  ],
  "nextCursor": "...",
  "hasMore": true
}
```

### GET /api/v1/workspaces/:workspaceId/conversations/:conversationId

读取单个 conversation row。

### POST /api/v1/workspaces/:workspaceId/conversations/:conversationId/read

标记已读。

Request：

```json
{
  "readThroughSeq": 123550
}
```

Response：

```json
{
  "ok": true,
  "eventSeq": 123560
}
```

服务端通过 stream 发：

```text
conversation.read.patch
conversation.unread.patch
```

### POST /api/v1/workspaces/:workspaceId/conversations/:conversationId/actions

统一 conversation action surface 支持 `mark_read`、`mark_unread`、`mute`、
`unmute`、`pin`、`unpin`、`hide` 与 `restore`。Conversation registry 先解析
kind 与 scope，再调用 owning message/notification/member-state contract。
`serviceAccount:{messageScopeId}` 使用 service-account resolver 与 active direct
membership。

该 surface 的 `pin` / `unpin` 更新个人 conversation_member_state；shared
message Pin 与 top notice 使用 Message Pin 的 target-level API。

`mark_read` 可以携带 `readThroughSeq`；省略时使用本次 authorized conversation
view 的最新可见 seq。其它 action 使用各自 sidecar version、unique key 或单调
cursor precondition。Response 返回 eventSeq 与 authoritative conversation patch。

### POST /api/v1/workspaces/:workspaceId/conversations/actions/batch

显式选择的批量已读使用 bounded request：

```json
{
  "action": "mark_read",
  "items": [
    {
      "conversationId": "serviceAccount:scp_...",
      "readThroughSeq": 123550
    }
  ],
  "requestId": "req_..."
}
```

`items` 包含 1–100 个唯一 conversation id，并携带客户端已经呈现的
readThroughSeq。服务端按 id 解析 kind、workspace、scope、membership 与 read
authority，并校验 readThroughSeq 属于当前 authorized scope frontier。每个 item
在自己的 scope transaction 中原子推进 cursor；response 返回与输入 item 对齐的
success 或 typed error result 以及 batch eventSeq。requestId 对首次提交的 item
集合、frontier 与结果提供幂等重试。执行成本为 O(selected conversation ids)，
并从输入 ids 直接构建 candidate set。

### POST /api/v1/workspaces/:workspaceId/conversations/:conversationId/pin

置顶。返回 `eventSeq`，UI 通过 stream patch 更新。

### DELETE /api/v1/workspaces/:workspaceId/conversations/:conversationId/pin

取消置顶。返回 `eventSeq`。

### POST /api/v1/workspaces/:workspaceId/conversations/:conversationId/archive

归档。返回 `eventSeq`。

## 7. Threads API

thread 是一种 conversation，thread API 只保留 thread 特有动作。

### POST /api/v1/workspaces/:workspaceId/messages/:messageId/thread

打开或创建 reply thread。

Response：

```json
{
  "threadScopeId": "s_thread",
  "conversationId": "c_thread",
  "eventSeq": 123570
}
```

同时 stream 发：

```text
thread.summary.patch
conversation.upsert
```

### GET /api/v1/workspaces/:workspaceId/threads/:threadScopeId/summary

兜底读取 thread summary。正常 UI 不应该高频依赖它。

## 8. Reactions API

### PUT /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/reactions/:emoji

添加 reaction。

Response：

```json
{
  "ok": true,
  "eventSeq": 123580
}
```

stream 发 `reaction.patch`。前端直接 patch message reaction aggregate，不再 invalidate 整个 message page。

### DELETE /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/reactions/:emoji

移除 reaction。

## 9. 完整路由汇总

```text
# Snapshot
GET    /api/v1/workspaces/:workspaceId/snapshot

# Events
WS     /api/v1/workspaces/:workspaceId/events/stream

# Scopes
GET    /api/v1/workspaces/:workspaceId/scopes
GET    /api/v1/workspaces/:workspaceId/scopes/:scopeId
POST   /api/v1/workspaces/:workspaceId/scopes/:scopeId/subscribe
DELETE /api/v1/workspaces/:workspaceId/scopes/:scopeId/subscribe
POST   /api/v1/workspaces/:workspaceId/scopes/:scopeId/owner-context-dismiss
GET    /api/v1/workspaces/:workspaceId/scopes/:scopeId/notifications
POST   /api/v1/workspaces/:workspaceId/scopes/:scopeId/notifications/self
POST   /api/v1/workspaces/:workspaceId/scopes/:scopeId/notifications/agents/:agentId
POST   /api/v1/workspaces/:workspaceId/scopes/:scopeId/notifications/agents/all

# Messages
GET    /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages
POST   /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages
PATCH  /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId
DELETE /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId
GET    /api/v1/workspaces/:workspaceId/messages/search
GET    /api/v1/workspaces/:workspaceId/messages/:messageId/delivery
POST   /api/v1/workspaces/:workspaceId/messages/:messageId/agent-delivery
POST   /api/v1/workspaces/:workspaceId/messages/:messageId/app-notification-actions/:actionKey
POST   /api/v1/workspaces/:workspaceId/messages/:messageId/rewind

# DM scopes
GET    /api/v1/workspaces/:workspaceId/dm-channels
POST   /api/v1/workspaces/:workspaceId/dm-channels
GET    /api/v1/workspaces/:workspaceId/dm-channels/:channelId

# Conversations
GET    /api/v1/workspaces/:workspaceId/conversations
GET    /api/v1/workspaces/:workspaceId/conversations/summary
POST   /api/v1/workspaces/:workspaceId/conversations/actions
POST   /api/v1/workspaces/:workspaceId/conversations/actions/batch
GET    /api/v1/workspaces/:workspaceId/conversations/:conversationId
POST   /api/v1/workspaces/:workspaceId/conversations/:conversationId/actions
POST   /api/v1/workspaces/:workspaceId/conversations/:conversationId/read
POST   /api/v1/workspaces/:workspaceId/conversations/:conversationId/pin
DELETE /api/v1/workspaces/:workspaceId/conversations/:conversationId/pin
POST   /api/v1/workspaces/:workspaceId/conversations/:conversationId/archive
POST   /api/v1/workspaces/:workspaceId/conversations/:conversationId/unread
POST   /api/v1/workspaces/:workspaceId/conversations/:conversationId/mute
DELETE /api/v1/workspaces/:workspaceId/conversations/:conversationId/mute

# Threads
POST   /api/v1/workspaces/:workspaceId/messages/:messageId/thread
GET    /api/v1/workspaces/:workspaceId/threads/:threadScopeId/summary

# Reactions
PUT    /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/reactions/:emoji
DELETE /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/reactions/:emoji
```
