connect() and the card
Everything a card does goes through one object, the card, which you get from connect():
import { connect } from '@neurosquad/card-sdk'
const card = await connect()
card.setStatus(`Hello, ${card.workspace.name}`, { tone: 'success' })connect() waits for the app to hand the card its private channel, introduces itself, and resolves
with a Card. Calling it again returns the same card.
Import the SDK from your entry script. It starts listening for the app’s handshake the moment
it loads. A card that loads the SDK late (a dynamic import() after a timer) can miss it and
fail with UNAVAILABLE.
connect(options?)
import { connect } from '@neurosquad/card-sdk'
const card = await connect({
theme: true, // apply the app theme as --ns-* CSS variables on <html> (default true)
syncLang: true, // keep <html lang> equal to the app language (default true)
forwardErrors: true, // send uncaught errors and rejections to the card log (default true)
timeoutMs: 10_000 // how long to wait for the app (default 10 000)
})| Option | Default | Meaning |
|---|---|---|
theme | true | false to leave your CSS alone; an element to put the --ns-* variables on it instead of <html>. See Theme. |
syncLang | true | Keep <html lang> equal to the app’s language. |
forwardErrors | true | Forward error and unhandledrejection to the card log. |
timeoutMs | 10000 | Waiting for the app’s channel, then for its answer. |
port | — | Use this channel instead of waiting for the app — for the mock host. |
It rejects with a CardSdkError:
UNAVAILABLE— the page is not inside a NeuroSquad card (opened on its own in a browser). For a preview, use the mock host, as the templates do.PROTOCOL_MISMATCH— the app is older than the card protocol your SDK speaks. The card shows “needs a newer NeuroSquad”.
The card object
| Member | What |
|---|---|
card.context | Everything the app told the card, kept current. |
card.setTitle / setStatus / setBadge / setOverview / attention / requestResize / openLink / focusCard / spawn | Card chrome |
card.ui | Toasts, confirm dialog, the card’s menu |
card.storage | Per-card and per-package storage |
card.settings | The settings form’s values |
card.agents | Agents, their statuses, output and prompts |
card.terminals | Commands in connected terminals |
card.ports | Typed data to and from connected cards |
card.tools | Tools for connected agents |
card.net | HTTP through the app’s proxy |
card.fs | Files in the workspace folder |
card.permissions | Grant state, asking for optional ones |
card.lifecycle | Visibility, suspend, expand, resize |
card.log | Lines for the card log |
card.copyText / usage / getWorkspace / getTheme / getI18n | Clipboard, usage, fresh snapshots |
card.host | Feature detection |
card.call / on / once / waitFor | Any method, any event |
card.close() / closed | Close the channel; later calls fail with UNAVAILABLE. |
The context
card.context is a HostContext: what the app sent when the card connected, updated from every
event (visibility, size, theme, language, settings, permissions, peers). The object is replaced,
never mutated, on each change — safe to use with React’s useSyncExternalStore.
import type { HostContext } from '@neurosquad/card-sdk'
function describe(context: HostContext): string {
const { instance, workspace, visibility, i18n, launch } = context
return `${instance.displayName} ${instance.version} in "${workspace.name}", ${visibility}, ${i18n.language}, started: ${launch}`
}
card.onContextChange((context) => render(describe(context)))| Field | Type | Meaning |
|---|---|---|
instance | CardInstanceInfo | instanceId (this card’s id on the canvas), packageId, name, displayName, version, commit (null for a linked folder), title, size, dev. |
workspace | WorkspaceInfo | id, name, and path (absolute) only with fs.read. |
visibility | 'visible', 'offscreen', 'overview' or 'hidden' | See Lifecycle. |
expanded | boolean | The card is expanded to fill the canvas. |
theme | ThemeSnapshot | The app’s colours, radii and fonts. |
i18n | { language, locale } | en, ru or zh, and a locale for Intl. |
settings | SettingsSnapshot | values, and secrets (which secret keys are set). |
permissions | PermissionState[] | Every declared permission with granted, optional, hosts, reason. |
ports | { inputs, outputs } | Your own ports, labels resolved. |
peers | PeerInfo[] | Cards connected by arrows. |
launch | 'created', 'opened', 'resumed', 'reloaded' or 'updated' | Why this frame started. |
spawnInit | JSON or absent | Data from the card that spawned this one, on its first start. |
chrome | CardChromeState or absent | What the app is showing for this card right now — title, status, badge, overview, attention — kept across reloads. See Card chrome. Absent on older app versions. |
appVersion | string | NeuroSquad’s version. |
limits | LIMITS | Every limit of the protocol, see Limits. |
protocol | number | The card protocol the app speaks. |
Shortcuts on the card: card.instanceId, card.instance, card.workspace, card.visibility,
card.expanded, card.size, card.theme, card.i18n, card.language, card.launch,
card.spawnInit, card.limits, card.appVersion.
launch tells you what happened: created (just added to the canvas), opened (the workspace
was opened), resumed (back after being suspended),
reloaded (the user pressed Reload, a file changed in dev mode, or a permission was revoked),
updated (a new version of your card was installed).
Any method, any event
The namespaces are sugar over two primitives, both fully typed from the protocol:
// Any method: params and result are typed from the method name.
const { keys } = await card.call('storage.keys', { scope: 'instance', prefix: 'draft:' })
const info = await card.call('card.getInfo')
// Any event: the payload is typed from the event name. Returns a function that removes the listener.
const off = card.on('agents.turn', (turn) => {
if (turn.phase === 'end') render(`${turn.agentId} finished a turn`)
})
off()
// Once, or as a promise.
card.once('lifecycle.expanded', ({ expanded }) => render(expanded))
const next = await card.waitFor('agents.status', (e) => e.status === 'needs-input', { timeoutMs: 60_000 })
render(next.agentId)Subscribable topics (agents.status, agents.turn, agents.changed, storage.changed) are
subscribed with the app automatically while at least one listener exists, and unsubscribed when the
last goes. agents.output needs agent ids — use card.agents.onOutput().
If a topic needs a permission you do not have, the listener simply gets nothing, and a warning goes
to the card log.
Events that could arrive before you attach a listener — ports.message, ui.menu, fs.changed —
are kept (up to 100) and delivered to the first listener, so nothing is lost between connect() and
your on().
All events
| Event | Payload | When |
|---|---|---|
lifecycle.visibility | { state } | Visibility changed. |
lifecycle.suspend | { graceMs } | The frame is about to be unloaded. Use card.lifecycle.onSuspend. |
lifecycle.expanded | { expanded } | Expanded or restored. |
lifecycle.resized | { w, h } | The card was resized. |
settings.changed | SettingsSnapshot | Settings changed (form or settings.set). |
theme.changed | ThemeSnapshot | The app’s theme changed. |
i18n.changed | { language, locale } | The app’s language changed. |
permissions.changed | PermissionState[] | A grant changed. |
ui.menu | { id } | A menu item you added was chosen. |
ports.message | see Ports | A value arrived on an input. |
ports.request | see Ports | A peer asks a request input. Use card.ports.onRequest. |
ports.peersChanged | PeerInfo[] | Arrows or peers changed. |
tools.call, tools.cancel | see Tools | An agent calls a tool. Use card.tools.handle. |
net.chunk | see Network | A streamed response chunk. Use response.chunks(). |
fs.changed | { watchId, path, type } | A watched file changed. Use card.fs.watch. |
storage.changed | { scope, key, byInstance } | Another copy of the card wrote a package key. Topic. |
agents.status | { agentId, status, at } | An agent’s status changed. Topic, agents.read. |
agents.turn | { agentId, phase, at } | A turn started or ended. Topic, agents.read. |
agents.changed | { agents } | Agents added, removed or renamed. Topic, agents.read. |
agents.output | { agentId, text, at } | Output of a connected agent. agents.output. |
host.ping | { seq } | Heartbeat — the SDK answers for you. |
Feature detection
New methods and events arrive within a protocol version. Check before you use one the user’s app might not have yet:
if (await card.host.supports('usage.summary')) {
const usage = await card.usage('today')
render(usage.totalCostMicroUsd)
}
const { protocol, methods, events } = await card.host.capabilities()
render(protocol, methods.length, events.length)
// A method newer than your SDK's types:
const result = await card.callUnchecked('some.newMethod', { any: 'params' })
render(result)Conventions
- Ids. Card and agent ids are the canvas ids — the same
cardIdyou get fromcard.ports.peers,card.agents.list()orcard.spawn(). - Connected means an arrow between the two cards, in either direction. Ports additionally care about the direction (see Ports).
- Every call is checked twice. The SDK validates params against the same schemas the app uses
and fails fast with
INVALID_PARAMSand the exact path; the app checks again, plus permissions, visibility, arrows and rate limits. - Only JSON crosses. No
Blob, noArrayBuffer: the helpers that take bytes (fs.writeBytes,net.fetchbodies) encode them as base64 for you. - Replies can come out of order; events arrive in the order the app sent them.
- Never trust
windowmessageevents. Any other card on the canvas canpostMessageyour frame. The only trusted channel is the one the SDK receives from the app atconnect(); do not add your ownmessagelisteners that act on what arrives.