端口:卡片之间的通信
端口让一张卡片与通过箭头相连的卡片交换类型化数据:你的测试雷达把失败项推送到待办清单,一篇笔记把正文喂给你的摘要卡片, 两张自定义卡片约定它们自己的格式。你在清单文件中声明端口;应用负责路由、转换并校验每一个值。
failures(ns:tasks)和 summary(ns:markdown)。方向
从卡片 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(安全子集,不允许 pattern)。
其他卡片可以声明同一个类型来与你的卡片互通。任何端口都可以在类型之外再加一个 schema;值必须同时满足两者。
发送:emit
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 校验。
你会得到收到该值的对端数量。
要指定某一个对端,并可选地指定它的某个输入:
await card.ports.send(noteId, 'summary', '## Nightly run\n\nAll green.', { input: 'replace' })接收
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 中声明回复的类型:
{ "id": "lookup", "label": "Look up", "type": "ns:text", "mode": "request",
"response": { "type": "ns:json" } }// 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):
- 之后才连接的对端会立即收到一次;
- 任何相连的对端都可以随时读取它,无论箭头朝哪个方向:
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保留值是卡片在暂停之后补上进度的方式——卡片被卸载期间发送的流式消息不会排队。
探查对端
卡片可以查明与它相连的卡片——包括内置卡片——输出什么、接收什么,并据此调整行为:
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()。
大多数卡片在发送之前需要的,这三个辅助方法都能满足:
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 完全相同 | 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 被拒绝并给出精确路径——
应用永远不会投递与接收方声明不符的内容。
两张自定义卡片之间使用端口不需要任何权限:用户画出箭头就是同意。数据只沿箭头流动,只从声明的输出流向声明的输入,并且只沿箭头的方向。