# Порты: карточки говорят с карточками

> Типизированные входы и выходы по стрелкам — общеизвестные типы и свои типы на JSON Schema, emit, send, запрос и ответ, сохраняемые значения, как узнать, что принимает подключённая карточка, и порты встроенных заметки, списка задач, доски задач, стикера, агента и терминала.

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

Порты позволяют карточке обмениваться **типизированными данными** с карточками, соединёнными с ней
стрелками: ваш радар тестов отправляет упавшие тесты в список задач, заметка передаёт свой текст
вашему резюмирующему инструменту, две свои карточки договариваются о собственном формате. Порты
объявляются в [манифесте](https://docs.neurosquad.ai/ru/card-sdk/manifest#ports); приложение само маршрутизирует, преобразует и
проверяет каждое значение.

## Направление

Стрелка **от карточки 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`
([безопасное подмножество](https://docs.neurosquad.ai/ru/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')
```

Значение проверяется по схемам выхода и доставляется **каждой соседней карточке ниже по стрелке**,
у которой есть совместимый вход. У каждой из них приложение выбирает вход: помеченный `default`,
иначе вход ровно того же типа, иначе первый совместимый (входы-запросы через `emit` не выбираются
никогда). Если типы различаются, значение преобразуется (см. ниже) и проверяется по схемам этого
входа. Вы получаете число карточек, которые его приняли.

Чтобы отправить одной конкретной карточке и, при желании, в конкретный вход:

```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`). Помощники `portsCompatible`,
`portCoercion`, `coercePortValue` и `pickInputFor` экспортируются из SDK, если хотите рассуждать об
этом сами.

## Запросы: спросить и ответить

Вход с `"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
```

Исключение в обработчике возвращает ошибку; промис спрашивающего отклоняется. Запросы, пришедшие
до регистрации обработчика, ждут почти до своего дедлайна, а потом получают «no handler». Тайм-аут
по умолчанию 30 с, максимум 120 с (`TIMEOUT`). Запрос к усыплённой карточке будит её (до 10 с) или
падает с `UNAVAILABLE`.

## Сохраняемые значения

Пометьте выход `"retain": true`, и приложение будет хранить его последнее значение (до 256 КБ):

- карточка, подключённая **позже**, сразу получает его один раз;
- любая подключённая карточка может прочитать его в любой момент, куда бы ни смотрела стрелка:

```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` — харнесс для агентов и терминалов, имя пакета для своих карточек. Каждый порт —
это `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` |
| ИИ-агент | `prompt` (`ns:text`, по умолчанию): отправляет промпт — ровно как [`agents.prompt`](https://docs.neurosquad.ai/ru/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 МБ (сохраняемое — до 256 КБ); не больше 20 сообщений в секунду на выход. Значение,
не прошедшее схему, отклоняется с `INVALID_PARAMS` и точным путём — приложение никогда не доставит
то, что не соответствует объявленному получателем.

> Между двумя своими карточками порты не требуют разрешений: согласие — это стрелка, которую провёл
> пользователь. Данные идут только по стрелкам, только из объявленных выходов в объявленные входы и
> только в направлении стрелки.
