# 端口：卡片之间的通信

> 通过箭头的类型化输入和输出——知名类型和你自己用 JSON Schema 定义的类型、emit、send、请求/响应、保留值、探查相连卡片接收什么，以及内置笔记、待办清单、任务看板、便签、智能体和终端的端口。

Source: https://docs.neurosquad.ai/zh/card-sdk/api/ports

端口让一张卡片与通过箭头相连的卡片交换**类型化数据**：你的测试雷达把失败项推送到待办清单，一篇笔记把正文喂给你的摘要卡片，
两张自定义卡片约定它们自己的格式。你在[清单文件](https://docs.neurosquad.ai/zh/card-sdk/manifest#ports)中声明端口；应用负责路由、转换并校验每一个值。

## 方向

**从卡片 A 指向卡片 B** 的箭头把 A 的**输出**送到 B 的**输入**。双向相连的两张卡片，数据双向流动。（工具和智能体权限不关心方向；端口关心。）

`card.ports.peers` 列出与你的卡片相连的每张卡片，并带有 `direction`：

- `downstream`——你的卡片 → 对端：你的输出到达它的输入；
- `upstream`——对端 → 你的卡片：它的输出到达你的输入；
- `both`。

## 类型

每个端口都有一个类型：**知名**的 `ns:*` 类型，或带有 JSON Schema 的**自定义** `<package-name>/<type-name>` 类型。

| 类型 | 值 |
| --- | --- |
| `ns:any` | 任意 JSON。作为输入时原样接受所有输出类型。 |
| `ns:text` | 字符串（≤ 1 000 000）。 |
| `ns:markdown` | Markdown 字符串（≤ 1 000 000）。 |
| `ns:number` | 数字。 |
| `ns:boolean` | `true` 或 `false`。 |
| `ns:json` | 对象或数组。 |
| `ns:url` | URI 字符串。 |
| `ns:image` | `{ mimeType, data, alt? }`——base64 编码的 PNG、JPEG、WebP 或 GIF，最多约 700 KB。 |
| `ns:file-ref` | `{ path, line? }`——工作区文件夹中的一个文件。收到它并不意味着获得文件访问权。 |
| `ns:task` | `{ text, id?, status?, column?, assignee? }`——`status` 为 `pending`、`active`、`done` 或 `error`。 |
| `ns:tasks` | 最多 200 个 `ns:task` 的数组。 |
| `ns:task-patch` | `{ ref, text?, status?, column? }`——修改一个任务；`ref` 是它的 id 或确切文本。 |
| `ns:table` | `{ columns: string[], rows: (string, number, boolean or null)[][] }` |
| `ns:event` | `{ type, data?, at? }` |
| `ns:trigger` | 空信号：`{}` 或 `null`。 |

**自定义类型**以你的卡片包命名——例如 `test-radar/coverage`——并且必须带有 `schema`（[安全子集](https://docs.neurosquad.ai/zh/card-sdk/manifest#json-schema)，不允许 `pattern`）。
其他卡片可以声明同一个类型来与你的卡片互通。任何端口都可以在类型之外再加一个 `schema`；值必须同时满足两者。

## 发送：`emit`

```ts
const delivered = await card.ports.emit('failures', [
  { text: 'checkout › pays with a saved card', status: 'error' },
  { text: 'auth › logs in with SSO', status: 'error' }
])
if (delivered === 0) card.ui.toast('Connect me to a todo list to track these')
```

值先按输出的 schema 校验，然后发送给**每个下游对端**中有兼容输入的那些。应用会在每个对端上挑选输入：标记为 `default` 的那个；
否则类型完全相同的那个；再否则第一个兼容的（`emit` 永远不会选中请求型输入）。类型不同时会进行转换（见下文），并按该输入的 schema 校验。
你会得到收到该值的对端数量。

要指定某一个对端，并可选地指定它的某个输入：

```ts
await card.ports.send(noteId, 'summary', '## Nightly run\n\nAll green.', { input: 'replace' })
```

## 接收

```ts
card.ports.onMessage<string>((text, message) => {
  render(`${message.fromKind} card ${message.from} sent ${message.type} on ${message.output}`, text)
}, { input: 'notes' })
```

`message` 是 `{ from, fromKind, output, input, type, sourceType, data, at }`——`type` 是**你的**输入的类型（转换之后），`sourceType`
是发送方声明的输出类型（转换之前；旧版本的应用中不存在）。省略 `input` 则在所有输入上接收。
在你订阅之前到达的消息会被暂存（最多 100 条），并交给你的第一个监听器。

## 类型转换

只有存在转换规则时，一个输出才能到达不同类型的输入：

| 输入类型 | 接受的输出类型 | 方式 |
| --- | --- | --- |
| 同一类型 | 同一类型 | 原样 |
| `ns:any` | 任何类型 | 原样 |
| `ns:text` | `markdown`、`url` | 原样 |
|  | `number`、`boolean` | `String(value)` |
|  | `json` | 格式化的 JSON |
| `ns:markdown` | `text`、`url` | 原样 |
|  | `number` | `String(value)` |
|  | `json` | 一个 `json` 围栏代码块 |
|  | `table` | 一个 Markdown 表格 |
|  | `tasks` | 一个清单（`- [x] done`、`- [ ] open`） |
| `ns:tasks` | `task` | 包装成数组 |
|  | `text`、`markdown` | 每个非空行一个任务（去掉列表标记和复选框） |
| `ns:json` | `table`、`tasks`、`task`、`event`、`file-ref`、`task-patch` | 原样 |
| `ns:trigger` | `event`、`text`、`number`、`boolean`、`json` | 变成 `{}`——“发生了某件事” |

自定义类型只匹配同一个自定义类型（或 `ns:any`）。如果你想自己判断，SDK 导出了辅助函数 `portsCompatible`、`portCoercion`、`coercePortValue` 和 `pickInputFor`。

## 请求：提问与应答

带有 `"mode": "request"` 的输入用来回答问题。在 `response` 中声明回复的类型：

```json
{ "id": "lookup", "label": "Look up", "type": "ns:text", "mode": "request",
  "response": { "type": "ns:json" } }
```

```ts
// The answering card:
card.ports.onRequest<string>('lookup', async (query, request) => {
  const hits = await search(query, request.signal)   // request.signal aborts at the deadline
  return { query, hits }                               // checked against response.type
})

// The asking card (connected to it by an arrow, either direction):
const answer = await card.ports.request<{ hits: string[] }>(peerId, 'lookup', 'flaky tests', {
  timeoutMs: 10_000
})
render(answer.hits)

declare function search(q: string, signal: AbortSignal): Promise<string[]>
declare const peerId: string
```

在处理函数中抛出异常会把错误发回去；提问方的 Promise 会被拒绝。在你注册处理函数之前到达的请求会一直等到接近截止时间，然后收到“no handler”。
默认超时 30 秒，最长 120 秒（`TIMEOUT`）。发给暂停卡片的请求会唤醒它（最多 10 秒），否则以 `UNAVAILABLE` 失败。

## 保留值

把输出标记为 `"retain": true`，应用就会保留它的最后一个值（≤ 256 KB）：

- **之后**才连接的对端会立即收到一次；
- 任何相连的对端都可以随时读取它，无论箭头朝哪个方向：

```ts
const last = await card.ports.read<{ text: string }[]>(todoId, 'items')
if (last) render(`${last.data.length} items, as of ${new Date(last.at).toLocaleTimeString()}`)

declare const todoId: string
```

保留值是卡片在暂停之后补上进度的方式——卡片被卸载期间发送的流式消息不会排队。

## 探查对端

卡片可以查明与它相连的卡片——包括内置卡片——输出什么、接收什么，并据此调整行为：

```ts
import type { PeerInfo } from '@neurosquad/card-sdk'

function describePeer(peer: PeerInfo): string {
  const ins = peer.inputs.map((p) => `${p.id}:${p.type}`).join(', ') || 'none'
  const outs = peer.outputs.map((p) => `${p.id}:${p.type}`).join(', ') || 'none'
  return `${peer.name} (${peer.kind}, ${peer.direction}) — in: ${ins}; out: ${outs}`
}

card.ports.peers.forEach((peer) => render(describePeer(peer)))
card.ports.onPeersChanged((peers) => render(peers.map(describePeer)))

// Is there a checklist downstream that takes tasks?
const taskSink = card.ports.peers.find(
  (p) => p.direction !== 'upstream' && p.inputs.some((i) => i.type === 'ns:tasks')
)
render(taskSink?.name ?? 'no task list connected')
```

`PeerInfo` 是 `{ cardId, kind, name, type?, direction, inputs, outputs }`。`kind` 为 `custom`、`note`、`todo`、`kanban`、`sticky`、`agent`、`terminal`
或 `other`（没有端口的卡片，例如浏览器）。对智能体和终端，`type` 是 harness；对自定义卡片，是包名。每个端口都是一个 `PortInfo`：
`{ id, label, description?, type, schema?, mode, response?, retain, default, permission? }`——内置端口会设置 `permission`，表示**你的**卡片使用它需要什么权限。

你自己的端口：`card.ports.inputs`、`card.ports.outputs`，或 `card.ports.describe()`。

大多数卡片在发送之前需要的，这三个辅助方法都能满足：

```ts
import { hasDownstreamPeer, permissionForPeer, requestPermissions } from '@neurosquad/card-sdk'

async function sendSummary(text: string): Promise<void> {
  if (!hasDownstreamPeer(card.ports.peers)) {
    card.ui.toast('Draw an arrow from this card to a note')
    return
  }
  // Which permission does sending Markdown to each peer need? (cards.connected for a note)
  const needed = card.ports.peers
    .map((peer) => permissionForPeer(peer, { outputType: 'ns:markdown' }))
    .filter((id) => id !== null)
  // Asks only for what is missing; never throws — resolves with what is usable now.
  const usable = await requestPermissions(card, ...needed)
  if (usable.length === needed.length) await card.ports.emit('summary', text)
}
```

## 内置卡片

应用自带的卡片通过适配器参与进来。使用它们需要最后一列所列的权限——在**你的**卡片上。

| 卡片 | 输入 | 输出 | 需要 |
| --- | --- | --- | --- |
| 笔记 | `append`（`ns:markdown`，默认）：在末尾追加一段 · `replace`（`ns:markdown`）：替换整篇笔记 | `text`（`ns:markdown`，保留）：笔记内容，每次变化时发送 | `cards.connected` |
| 待办清单 | `add`（`ns:tasks`，默认）：添加条目 · `update`（`ns:task-patch`）：修改一个条目的文本或状态 | `items`（`ns:tasks`，保留）：所有条目，每次变化时发送 | `cards.connected` |
| 任务看板 | `add`（`ns:tasks`，默认）：添加未分配的任务 · `update`（`ns:task-patch`）：移动或编辑一个任务 | `tasks`（`ns:tasks`，保留）：所有任务及其所在列 | `cards.connected` |
| 便签 | `title`（`ns:text`，默认）：设置标题 | `title`（`ns:text`，保留） | `cards.connected` |
| AI 智能体 | `prompt`（`ns:text`，默认）：发送提示词——与使用默认值的 [`agents.prompt`](https://docs.neurosquad.ai/zh/card-sdk/api/agents#prompting) 完全相同 | `status`（`ns:event`，保留）：`{ type: "status", data: { status } }` | `agents.prompt` / `agents.read` |
|  |  | `reply`（`ns:markdown`，保留）：一轮结束时的最终消息——仅限 Claude Code 和 Hermes Agent | `agents.output` |
| 终端 | `command`（`ns:text`，默认）：运行一条命令 | `exit`（`ns:event`）：每条命令结束后发送 `{ type: "exit", data: { command, exitCode } }` | `terminals.write` / `agents.output` |

所以，有一条从你的卡片指向笔记的箭头时，`card.ports.emit('summary', '# Done')` 会在笔记末尾追加一段；
有一条从待办清单指向你的卡片的箭头时，你的 `ns:tasks` 输入会收到清单的每一次变化。

## 限制

单个值 ≤ 1 MB（保留值 ≤ 256 KB）；每个输出每秒最多 20 条消息。不满足 schema 的值会以 `INVALID_PARAMS` 被拒绝并给出精确路径——
应用永远不会投递与接收方声明不符的内容。

> 两张自定义卡片之间使用端口不需要任何权限：用户画出箭头就是同意。数据只沿箭头流动，只从声明的输出流向声明的输入，并且只沿箭头的方向。
