Skip to Content
卡片 SDKAPI 参考端口:卡片之间的通信

端口:卡片之间的通信

端口让一张卡片与通过箭头相连的卡片交换类型化数据:你的测试雷达把失败项推送到待办清单,一篇笔记把正文喂给你的摘要卡片, 两张自定义卡片约定它们自己的格式。你在清单文件中声明端口;应用负责路由、转换并校验每一个值。

1/6自定义卡片 Test radar 在清单文件中声明了两个输出: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:markdownMarkdown 字符串(≤ 1 000 000)。
ns:number数字。
ns:booleantrue 或 false。
ns:json对象或数组。
ns:urlURI 字符串。
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:textmarkdown、url原样
number、booleanString(value)
json格式化的 JSON
ns:markdowntext、url原样
numberString(value)
json一个 json 围栏代码块
table一个 Markdown 表格
tasks一个清单(- [x] done、- [ ] open)
ns:taskstask包装成数组
text、markdown每个非空行一个任务(去掉列表标记和复选框)
ns:jsontable、tasks、task、event、file-ref、task-patch原样
ns:triggerevent、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 Agentagents.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 被拒绝并给出精确路径—— 应用永远不会投递与接收方声明不符的内容。

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