Skip to Content
Card SDKAPI referencePorts: cards talking to cards

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.

1/6A custom card, Test radar, declares two outputs in its manifest: 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.

TypeValue
ns:anyAny JSON. As an input it accepts every output type unchanged.
ns:textA string (≤ 1 000 000).
ns:markdownA Markdown string (≤ 1 000 000).
ns:numberA number.
ns:booleantrue or false.
ns:jsonAn object or an array.
ns:urlA 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:tasksAn 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:triggerAn 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 typeAccepts outputs of typeHow
same typesame typeunchanged
ns:anyanythingunchanged
ns:textmarkdown, urlunchanged
number, booleanString(value)
jsonpretty JSON
ns:markdowntext, urlunchanged
numberString(value)
jsona fenced json code block
tablea Markdown table
tasksa checklist (- [x] done, - [ ] open)
ns:taskstaskwrapped in an array
text, markdownone task per non-empty line (list markers and checkboxes stripped)
ns:jsontable, tasks, task, event, file-ref, task-patchunchanged
ns:triggerevent, text, number, boolean, jsonbecomes {} — “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: 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:
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:

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.

CardInputsOutputsNeeds
Noteappend (ns:markdown, default): adds a paragraph at the end · replace (ns:markdown): replaces the whole notetext (ns:markdown, retained): the note, on every changecards.connected
Todo listadd (ns:tasks, default): adds items · update (ns:task-patch): changes one item’s text or statusitems (ns:tasks, retained): all items, on every changecards.connected
Task boardadd (ns:tasks, default): adds unassigned tasks · update (ns:task-patch): moves or edits one tasktasks (ns:tasks, retained): all tasks with their columnscards.connected
Stickytitle (ns:text, default): sets the titletitle (ns:text, retained)cards.connected
AI agentprompt (ns:text, default): sends a prompt — exactly like agents.prompt with its defaultsstatus (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 Agentagents.output
Terminalcommand (ns:text, default): runs one commandexit (ns:event): { type: "exit", data: { command, exitCode } } after each commandterminals.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.