Ports: cards talking to cards
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; the app routes, converts and validates every value.
failures (ns:tasks) and summary (ns:markdown).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, 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
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:
await card.ports.send(noteId, 'summary', '## Nightly run\n\nAll green.', { input: 'replace' })Receiving
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:
{ "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: stringThrowing 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:
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: stringRetained 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:
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:
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 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.