Порты: карточки говорят с карточками
Порты позволяют карточке обмениваться типизированными данными с карточками, соединёнными с ней стрелками: ваш радар тестов отправляет упавшие тесты в список задач, заметка передаёт свой текст вашему резюмирующему инструменту, две свои карточки договариваются о собственном формате. Порты объявляются в манифесте; приложение само маршрутизирует, преобразует и проверяет каждое значение.
failures (ns:tasks) и summary (ns:markdown).Направление
Стрелка от карточки A к карточке B несёт выходы A на входы B. Если карточки соединены в обе стороны, данные идут в обе стороны. (Инструментам и разрешениям агентов направление не важно, портам — важно.)
card.ports.peers перечисляет все карточки, соединённые с вашей, с полем direction:
downstream— ваша карточка → соседняя: ваши выходы доходят до её входов;upstream— соседняя → ваша: её выходы доходят до ваших входов;both— в обе стороны.
Типы
У каждого порта есть тип: общеизвестный ns:* или свой <package-name>/<type-name> с JSON
Schema.
| Тип | Значение |
|---|---|
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? } — PNG, JPEG, WebP или GIF в base64, не больше ~700 КБ. |
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 — её идентификатор или точный текст. |
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')Значение проверяется по схемам выхода и доставляется каждой соседней карточке ниже по стрелке,
у которой есть совместимый вход. У каждой из них приложение выбирает вход: помеченный default,
иначе вход ровно того же типа, иначе первый совместимый (входы-запросы через emit не выбираются
никогда). Если типы различаются, значение преобразуется (см. ниже) и проверяется по схемам этого
входа. Вы получаете число карточек, которые его приняли.
Чтобы отправить одной конкретной карточке и, при желании, в конкретный вход:
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). Помощники portsCompatible,
portCoercion, coercePortValue и pickInputFor экспортируются из SDK, если хотите рассуждать об
этом сами.
Запросы: спросить и ответить
Вход с "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Исключение в обработчике возвращает ошибку; промис спрашивающего отклоняется. Запросы, пришедшие
до регистрации обработчика, ждут почти до своего дедлайна, а потом получают «no handler». Тайм-аут
по умолчанию 30 с, максимум 120 с (TIMEOUT). Запрос к усыплённой карточке будит её (до 10 с) или
падает с UNAVAILABLE.
Сохраняемые значения
Пометьте выход "retain": true, и приложение будет хранить его последнее значение (до 256 КБ):
- карточка, подключённая позже, сразу получает его один раз;
- любая подключённая карточка может прочитать его в любой момент, куда бы ни смотрела стрелка:
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 — харнесс для агентов и терминалов, имя пакета для своих карточек. Каждый порт —
это 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 |
| ИИ-агент | 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 МБ (сохраняемое — до 256 КБ); не больше 20 сообщений в секунду на выход. Значение,
не прошедшее схему, отклоняется с INVALID_PARAMS и точным путём — приложение никогда не доставит
то, что не соответствует объявленному получателем.
Между двумя своими карточками порты не требуют разрешений: согласие — это стрелка, которую провёл пользователь. Данные идут только по стрелкам, только из объявленных выходов в объявленные входы и только в направлении стрелки.