# Ports: cards talking to cards

> Typed inputs and outputs over arrows — well-known types and your own JSON Schema types, emit, send, request/response, retained values, discovering what a connected card accepts, and the built-in note, todo, task board, sticky, agent and terminal ports.

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

Ports let a card exchange **typed data** with the cards it is connected to by arrows: your test
radar pushes failures into a todo list, a note feeds its text into your summariser, two custom
cards agree on their own format. You declare ports in the [manifest](https://docs.neurosquad.ai/en/card-sdk/manifest#ports);
the app routes, converts and validates every value.

## Direction

An arrow **from card A to card B** carries A's **outputs** to B's **inputs**. For a pair connected
both ways, data flows both ways. (Tools and agent permissions do not care about direction; ports
do.)

`card.ports.peers` lists every card connected to yours, with `direction`:

- `downstream` — your card → peer: your outputs reach its inputs;
- `upstream` — peer → your card: its outputs reach your inputs;
- `both`.

## Types

Every port has a type: a **well-known** `ns:*` type, or a **custom** `<package-name>/<type-name>`
type with a JSON Schema.

| Type | Value |
| --- | --- |
| `ns:any` | Any JSON. As an input it accepts every output type unchanged. |
| `ns:text` | A string (≤ 1 000 000). |
| `ns:markdown` | A Markdown string (≤ 1 000 000). |
| `ns:number` | A number. |
| `ns:boolean` | `true` or `false`. |
| `ns:json` | An object or an array. |
| `ns:url` | A URI string. |
| `ns:image` | `{ mimeType, data, alt? }` — base64 PNG, JPEG, WebP or GIF, ~700 KB at most. |
| `ns:file-ref` | `{ path, line? }` — a file in the workspace folder. Receiving one grants no file access. |
| `ns:task` | `{ text, id?, status?, column?, assignee? }` — `status` is `pending`, `active`, `done` or `error`. |
| `ns:tasks` | An array of up to 200 `ns:task`. |
| `ns:task-patch` | `{ ref, text?, status?, column? }` — change one task; `ref` is its id or exact text. |
| `ns:table` | `{ columns: string[], rows: (string, number, boolean or null)[][] }` |
| `ns:event` | `{ type, data?, at? }` |
| `ns:trigger` | An empty signal: `{}` or `null`. |

A **custom type** is named after your package — `test-radar/coverage` — and must carry a `schema`
(the [safe subset](https://docs.neurosquad.ai/en/card-sdk/manifest#json-schema), no `pattern`). Another card can declare the
same type to interoperate with yours. Any port may also add a `schema` on top of its type; values
must match both.

## Sending: `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')
```

The value is checked against the output's schemas, then delivered to **every downstream peer** that
has a compatible input. On each peer the app picks the input: the one marked `default`, else one of
exactly the same type, else the first compatible one (request inputs are never picked by `emit`).
The value is converted if the types differ (below) and checked against that input's schemas. You
get the number of peers that received it.

To aim at one peer, and optionally one input:

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

## Receiving

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

`message` is `{ from, fromKind, output, input, type, sourceType, data, at }` — `type` is **your**
input's type (after conversion), `sourceType` the sender's declared output type before conversion
(absent on older app versions). Leave out `input` to receive on all inputs. Messages that arrive before you
subscribe are kept (up to 100) and delivered to your first listener.

## Conversion between types

An output reaches an input of a different type only where there is a conversion:

| Input type | Accepts outputs of type | How |
| --- | --- | --- |
| same type | same type | unchanged |
| `ns:any` | anything | unchanged |
| `ns:text` | `markdown`, `url` | unchanged |
|  | `number`, `boolean` | `String(value)` |
|  | `json` | pretty JSON |
| `ns:markdown` | `text`, `url` | unchanged |
|  | `number` | `String(value)` |
|  | `json` | a fenced `json` code block |
|  | `table` | a Markdown table |
|  | `tasks` | a checklist (`- [x] done`, `- [ ] open`) |
| `ns:tasks` | `task` | wrapped in an array |
|  | `text`, `markdown` | one task per non-empty line (list markers and checkboxes stripped) |
| `ns:json` | `table`, `tasks`, `task`, `event`, `file-ref`, `task-patch` | unchanged |
| `ns:trigger` | `event`, `text`, `number`, `boolean`, `json` | becomes `{}` — "something happened" |

Custom types only match the same custom type (or `ns:any`). The helpers `portsCompatible`,
`portCoercion`, `coercePortValue` and `pickInputFor` are exported by the SDK if you want to reason
about it yourself.

## Requests: ask and answer

An input with `"mode": "request"` answers questions. Declare the reply's type in `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
```

Throwing in the handler sends an error back; the asker's promise rejects. Requests that arrive
before you register the handler wait until shortly before their deadline, then get "no handler".
Default timeout 30 s, max 120 s (`TIMEOUT`). A request to a suspended card wakes it (up to 10 s),
or fails with `UNAVAILABLE`.

## Retained values

Mark an output `"retain": true` and the app keeps its last value (≤ 256 KB):

- a peer connected **later** receives it once, right away;
- any connected peer can read it at any time, whichever way the arrow points:

```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
```

Retained values are how a card catches up after being suspended — stream messages sent while it was
unloaded are not queued.

## Discovering peers

A card can find out what the cards it is connected to output and accept — including the built-in
ones — and adapt:

```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` is `{ cardId, kind, name, type?, direction, inputs, outputs }`. `kind` is `custom`,
`note`, `todo`, `kanban`, `sticky`, `agent`, `terminal` or `other` (a card without ports, such as a
browser). `type` is the harness for agents and terminals, the package name for custom cards. Each
port is a `PortInfo`: `{ id, label, description?, type, schema?, mode, response?, retain, default,
permission? }` — `permission` is set on built-in ports and names what **your** card needs to use it.

Your own ports: `card.ports.inputs`, `card.ports.outputs`, or `card.ports.describe()`.

Three helpers cover what most cards need before sending:

```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)
}
```

## Built-in cards

The app's own cards take part through adapters. Using one needs the permission in the last column —
on **your** card.

| Card | Inputs | Outputs | Needs |
| --- | --- | --- | --- |
| Note | `append` (`ns:markdown`, default): adds a paragraph at the end · `replace` (`ns:markdown`): replaces the whole note | `text` (`ns:markdown`, retained): the note, on every change | `cards.connected` |
| Todo list | `add` (`ns:tasks`, default): adds items · `update` (`ns:task-patch`): changes one item's text or status | `items` (`ns:tasks`, retained): all items, on every change | `cards.connected` |
| Task board | `add` (`ns:tasks`, default): adds unassigned tasks · `update` (`ns:task-patch`): moves or edits one task | `tasks` (`ns:tasks`, retained): all tasks with their columns | `cards.connected` |
| Sticky | `title` (`ns:text`, default): sets the title | `title` (`ns:text`, retained) | `cards.connected` |
| AI agent | `prompt` (`ns:text`, default): sends a prompt — exactly like [`agents.prompt`](https://docs.neurosquad.ai/en/card-sdk/api/agents#prompting) with its defaults | `status` (`ns:event`, retained): `{ type: "status", data: { status } }` | `agents.prompt` / `agents.read` |
|  |  | `reply` (`ns:markdown`, retained): the final message when a turn ends — Claude Code and Hermes Agent | `agents.output` |
| Terminal | `command` (`ns:text`, default): runs one command | `exit` (`ns:event`): `{ type: "exit", data: { command, exitCode } }` after each command | `terminals.write` / `agents.output` |

So with an arrow from your card to a note, `card.ports.emit('summary', '# Done')` appends a
paragraph to it; with an arrow from a todo list to your card, your `ns:tasks` input gets every
change of the list.

## Limits

A value ≤ 1 MB (a retained value ≤ 256 KB); at most 20 messages a second per output. A value that
fails a schema is refused with `INVALID_PARAMS` and the exact path — the app never delivers
something that does not match what the receiver declared.

> Ports need no permission between two custom cards: the user drawing the arrow is the consent. Data
> flows only over arrows, only from declared outputs to declared inputs, and only in the arrow's
> direction.
