@neurosquad/card-sdk
v1.3.0
Published
Build custom cards for NeuroSquad: a typed client for the card API, React hooks, an optional dark UI kit and the neurosquad-card CLI (create, dev, validate, pack).
Maintainers
Readme
@neurosquad/card-sdk
Build custom cards for NeuroSquad — the desktop canvas for CLI coding agents. A card is a small web app that lives on the canvas next to agents, terminals and notes. It can watch and prompt the agents it is connected to, exchange typed data with other cards over arrows, offer tools to agents over MCP, keep its own storage and settings, and reach the network through the app's proxy.
This package has everything for it:
| Import | What |
| --- | --- |
| @neurosquad/card-sdk | connect() and the typed Card client, errors, theme, i18n helper, the full contract (types, validators) |
| @neurosquad/card-sdk/react | CardProvider and hooks |
| @neurosquad/card-sdk/testing | createMockHost() — an in-memory host for unit tests and browser previews |
| @neurosquad/card-sdk/ui.css | optional dark UI kit on the app's live theme |
| @neurosquad/card-sdk/schema.json | JSON Schema of neurosquad-card.json |
| neurosquad-card (bin) | create, dev, validate, pack |
Zero runtime dependencies. React is an optional peer (18.2+ or 19).
Quick start
npx @neurosquad/card-sdk create my-card # no build step
npx @neurosquad/card-sdk create my-card --template react
cd my-card
npx @neurosquad/card-sdk dev # live in the appdev needs NeuroSquad running with Settings → Custom cards → Developer
mode on. It links the folder (you confirm in the app), reloads the card when
you save, and prints the card's log. Add the card from the canvas: + →
Custom card….
Share a card by pushing its folder to GitHub. Users install it by pasting
owner/repo (or owner/repo/sub/folder, …@tag) into Settings → Custom
cards; they see the permissions it asks for and approve them. Updates are
never automatic: every new commit is shown with the permission changes and
applied by a click.
The card and the manifest
A card folder has a neurosquad-card.json at its root:
{
"$schema": "./node_modules/@neurosquad/card-sdk/schema/neurosquad-card.v1.json",
"manifestVersion": 1,
"name": "test-radar",
"displayName": { "en": "Test radar", "ru": "Радар тестов" },
"version": "1.0.0",
"protocol": 1,
"icon": "icon.png",
"entry": "dist/index.html",
"card": { "defaultSize": { "w": 460, "h": 340 } },
"permissions": ["agents.read", "terminals.write", { "id": "network", "hosts": ["api.github.com"] }],
"settings": [{ "key": "command", "type": "string", "label": "Test command", "default": "npm test" }],
"ports": {
"inputs": [{ "id": "run", "label": "Run", "type": "ns:trigger" }],
"outputs": [{ "id": "failures", "label": "Failures", "type": "ns:tasks", "retain": true }]
},
"tools": [
{
"name": "run_tests",
"description": "Runs the project's tests and returns the failing ones.",
"inputSchema": { "type": "object", "properties": { "filter": { "type": "string" } } }
}
]
}npx @neurosquad/card-sdk validate checks it with the app's own validators and
shows the install dialog your users will see.
Rules the app enforces on the manifest:
- Text (names, descriptions, labels, reasons, tool descriptions) may not
contain control characters (tab and newline only in descriptions), bidi
overrides or isolates, zero-width characters or Unicode tag characters.
Only cards from the official organization may call themselves "NeuroSquad",
"official" or "verified" (
validatewarns; the app refuses). - Reserved names:
namemay not be a built-in card or tool family (telegram,squad,ports,watch,reference,timer,sticky,standup,image,mcp,skill,canvas,browser,terminal,agent,note,todo,kanban, …). A tool's exposed name (<name with - → _>_<tool>) may not equal a built-in tool (canvas_spawn_card,terminal_send_keys, …) and may not contain__(reserved for installed MCP servers). - Schemas (ports, tools):
enumat most 256 options and 16 KB in total,constat most 4 KB. - Updates: a new version that adds or rewords MCP tools, adds or retypes
ports, adds secret settings or changes
displayName/author/homepageasks the user again, like a new permission does.
The sandbox
The card runs in a sandboxed frame with its own opaque origin:
- no
window.api, no Node, no access to the app, other cards,localStorageor cookies — usecard.storage; - no inline scripts and no external resources: ship scripts, styles, fonts and images inside the package (data: and blob: images work);
- no popups,
alert/confirm, downloads or fullscreen — usecard.ui.confirm,card.openLink; <form>submit events fire (the frame hasallow-forms), but submission itself never navigates: the host cancels every submission and makesform.submit()a no-op (CSPform-action 'none'backs it up). Callevent.preventDefault()in your submit handler anyway and do the work in JS;- never trust
windowmessageevents. Any other card on the canvas canpostMessageyour frame. The SDK's port (handed over by the app fromwindow.parent) is the only trusted channel; - copying:
copy/cutevent handlers anddocument.execCommand('copy')do nothing (a card could otherwise replace the clipboard on a click). Write text withcard.copyText(needsclipboard.write); the user's own copy of selected text still works; - no nested frames (
iframe,object,embedare removed), and only blob workers:new Worker('x.js')fails in an opaque origin, so fetch the script, wrap it in aBloband start the worker fromURL.createObjectURL; - host prompts (
card.ui.confirm, permission requests, link confirmations) are drawn by the app outside your card, over a window-wide backdrop. The confirm dialog shows yourtitle/messageas quoted card text under the app's own heading, and the cancel button is always the app's. Secrets are never typed inside a card: the user enters them in the app's own settings dialog, and your card only seessettings.secrets[key] === true. Never draw a field that asks for a key — users are told a real prompt dims the whole window; - network only through
card.net.fetch, only to hosts in yournetworkpermission (or localhost withnetwork.local); - the frame is paused and eventually unloaded when nobody sees it — save state
in
card.lifecycle.onSuspend.
connect()
import { connect } from '@neurosquad/card-sdk'
const card = await connect()Import the SDK from your entry script: it listens for the app's handshake from
the moment it loads. connect() also applies the app theme to <html> as
--ns-* CSS variables and keeps <html lang> equal to the app language
(connect({ theme: false, syncLang: false }) to opt out).
Everything the host knows about the card is in card.context (kept current):
instance, workspace, visibility, expanded, theme, i18n,
settings, permissions, ports, peers, launch, spawnInit, limits,
and chrome — the title, status, badge, overview and attention the app still
shows for this card. Chrome survives frame reloads (dev reload, update,
resume), so after a reload check card.context.chrome?.attention and clear a
stale pulse with card.attention('none').
Chrome updates are throttled by the app (card.setStatus/setBadge/
setOverview 120 per minute, setTitle and ui.setMenu 30, attention
once per 10 s, settings.set 60, tools.setEnabled 30, ui.confirm 10,
usage.summary 6). For setTitle/setStatus/setBadge/setOverview/
attention the SDK sends one call at a time per method, folds calls made
meanwhile into one with the latest value, and when the app answers
RATE_LIMITED it retries the latest value after retryAfterMs — so calling
them on every render is fine and never throws for rate. The other methods
reject with RATE_LIMITED (error.retryAfterMs).
Any method, any event
const info = await card.call('card.getInfo') // typed params and result
const off = card.on('agents.turn', (turn) => console.log(turn)) // typed payloadTopics (agents.status, agents.turn, agents.changed, storage.changed) are
subscribed with the host automatically while a listener exists.
Namespaces
| | |
| --- | --- |
| Card chrome | setTitle, setStatus(text, { tone, busy }), setBadge(3), setOverview({ primary, secondary, progress, tone, icon }), attention('needs-input', msg), requestResize, openLink, focusCard, spawn('note') |
| card.ui | toast, confirm → boolean, setMenu([{ id, label, icon, onSelect }]), onMenu |
| card.storage | get(key, fallback), set, delete, keys(prefix), clear, usage; card.storage.package.* shared by every card of the package; onChange |
| card.settings | values, value(key), hasSecret(key), set(values), open(), onChange |
| card.agents | list, get, readScreen, lastReply, prompt(id, text, { whenBusy, submit }), usage(id, since, until?), timeline(id, since, until?), onStatus, onTurn, onChanged, onOutput(ids, handler) |
| card.terminals | run(id, command) → { exitCode, output }, write(id, text, { submit }) |
| card.ports | inputs, outputs, peers, emit(output, data), send, request, read, onMessage, onRequest(input, handler), onPeersChanged |
| card.tools | handle(name, handler), setEnabled |
| card.net | fetch(url, init) → CardResponse (ok, status, headers, text(), json(), bytes(), chunks(), lines()) |
| card.fs | stat, list, readText, readBytes, writeText, writeBytes, mkdir, trash, watch — paths relative to the workspace folder |
| card.permissions | all, has(id), request(...ids) for optional ones, onChange |
| card.lifecycle | onVisibility, onSuspend, onExpanded, onResized |
| card.log | debug/info/warn/error → the package log and the dev console |
| Other | usage(period), copyText, getWorkspace, host.capabilities(), host.supports(method), waitFor(event), close() |
Tools for agents
Declare the tool in the manifest, implement it in the card. Connected agents
see it as <card_name>_<tool> over the app's MCP server.
card.tools.handle<{ filter?: string }>('run_tests', async ({ filter }, call) => {
call.progress('running…') // shown on the arrow
const result = await card.terminals.run(shellId, `npm test -- ${filter ?? ''}`, { timeoutMs: 60_000 })
return result.exitCode === 0 ? 'All tests passed' : result.output
})Return a string, any JSON value, or a ToolResultPayload (toolText,
toolImage, toolError help). Throwing sends an error to the agent. Calls
that arrive before handle() is registered wait for it.
Ports
An arrow from card A to card B carries A's outputs to B's inputs. Types are
ns:* well-known types (ns:text, ns:markdown, ns:tasks, ns:table,
ns:event, ns:trigger, …) or <your-card>/<type> with a schema. Built-in
cards take part too: a note accepts ns:markdown (append), a checklist
ns:tasks, an agent a prompt, a terminal a command.
await card.ports.emit('failures', [{ text: 'auth › logs in', status: 'error' }]) // a note gets a Markdown list
card.ports.onMessage((text) => render(text), { input: 'text' })
card.ports.onRequest<string>('lookup', async (query) => search(query))ports.message carries the input's type (after conversion) and the
sender's declared output type in sourceType. Generic parameters accept your
own interfaces (card.ports.onMessage<Task>(…)).
Sending to a built-in card needs a permission (PortInfo.permission, e.g.
cards.connected for a note). Helpers:
import { hasDownstreamPeer, permissionForPeer, requestPermissions } from '@neurosquad/card-sdk'
if (!hasDownstreamPeer(card.ports.peers)) return hint('Draw an arrow to a note')
const needed = card.ports.peers.map((p) => permissionForPeer(p, { outputType: 'ns:markdown' }))
await requestPermissions(card, ...needed.filter((id) => id !== null)) // never throws
await card.ports.emit('summary', text)One agent's run: card.agents.usage
Since SDK 1.1 (NeuroSquad 0.1.264): the numbers of one AI agent connected to the
card by an arrow, inside a time window — model requests and tokens from the
harness's own log, prompts and working time from the app's status history (so
they are right for time your frame was off screen). Needs usage.read.
if (await card.host.supports('agents.usage')) {
const run = await card.agents.usage(agentId, startedAt) // until = now
run.requests // model requests (API round-trips)
run.inputTokens, run.outputTokens, run.cacheReadTokens, run.cacheWriteTokens, run.totalTokens
run.prompts, run.workingMs, run.elapsedMs, run.costMicroUsd
}A null token field means not reported by that harness or provider (no
such field in its log, a local server that sends no cache counts, the OpenAI
APIs' missing cache writes) — show it as such, never as 0. The official
Run Stats
card is built on it.
Requests on a timeline: card.agents.timeline
Since SDK 1.2 (NeuroSquad 0.1.264): the same arrow-connected agent's model
requests one by one — completion time, send time where the harness's log has
one (requestTimes: 'start-end', otherwise 'end'), tokens, tool calls — plus
its status as consecutive spans and its turns (prompt → finish). Needs
usage.read. The official Agent Pulse
card draws it as swimlanes.
Network
const res = await card.net.fetch('https://api.github.com/repos/acme/app/issues', {
headers: { authorization: 'Bearer {{secret:githubToken}}' }, // a "secret" setting, filled in by the user
responseType: 'json'
})
if (!res.ok) throw new Error(`GitHub said ${res.status}`)
const issues = await res.json<{ title: string }[]>()
const stream = await card.net.fetch(url, { responseType: 'stream' })
for await (const line of stream.lines()) handle(line)The card never sees secret values: the host puts them into headers for granted hosts only.
Conditional requests work: If-None-Match and If-Modified-Since are
forwarded, and every response header except set-cookie comes back
(etag, last-modified, x-ratelimit-*). A 204/304 (any empty body)
with responseType: 'json' gives json() === null.
Files
card.fs (fs.read/fs.write) reaches only the workspace folder. Writes,
folders and trash are refused for paths that would let a card run code or
steer agents: any .git path segment (nested repositories, submodule and
worktree pointer files), .gitmodules, .gitattributes, .claude/,
.codex/, .cursor/, .vscode/, .idea/, .husky/, .github/workflows/,
.gemini/, .qwen/, .opencode/, .mcp.json, CLAUDE.md,
CLAUDE.local.md, AGENTS.md, GEMINI.md, QWEN.md, .cursorrules,
.windsurfrules, opencode.json, .envrc, .npmrc, .yarnrc, .yarnrc.yml
(FS_DENIED). All of fs.* is refused when the workspace folder is the home
folder, a drive root, or contains the app's own data folder.
Errors
Every refusal is a CardSdkError with a code (PERMISSION_DENIED,
NOT_CONNECTED, NOT_VISIBLE, RATE_LIMITED, HOST_NOT_ALLOWED,
BUDGET_PAUSED, …), the failing method, data and a hint:
try {
await card.agents.prompt(agentId, 'Run the tests')
} catch (error) {
if (isCardSdkError(error, 'NOT_CONNECTED')) card.ui.toast('Connect me to an agent first')
else throw error
}Params are checked locally against the same schemas the app uses, so mistakes
fail fast with INVALID_PARAMS and the exact path.
React
import { CardProvider, useAgents, useStorage, useTool, useTranslator, usePaused } from '@neurosquad/card-sdk/react'
function App() {
const { agents } = useAgents()
const [notes, setNotes] = useStorage('notes', '')
useTool('read_notes', () => notes)
return <textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
}
createRoot(root).render(<CardProvider fallback={<p>Connecting…</p>}><App /></CardProvider>)Hooks: useCard, useCardContext, useSettings, useStorage, useAgents,
useAgentStatus, usePort, useEmit, usePeers, useTheme, useLanguage,
useVisibility, useExpanded, usePaused, useTool, usePortRequest,
useCardEvent, useTranslator.
Languages
The app speaks English, Russian and Chinese and switches live.
const t = createTranslator({
en: { failed_one: '{{count}} test failed', failed_other: '{{count}} tests failed' },
ru: { failed_one: '{{count}} тест упал', failed_few: '{{count}} теста упало', failed_many: '{{count}} тестов упало', failed_other: '{{count}} теста упало' }
}, card)
t('failed', { count: 3 })Manifest texts (displayName, labels, descriptions) take "text" or
{ "en": …, "ru": …, "zh": … }.
Styling
Cards style themselves any way they like. For a native look, the optional kit reads the app's live theme:
<link rel="stylesheet" href="vendor/ui.css" /> <!-- or import '@neurosquad/card-sdk/ui.css' -->
<body class="ns-kit">
<button class="ns-btn ns-btn--primary">Run</button>
<input class="ns-input" placeholder="Filter" />
<span class="ns-badge ns-badge--success">passing</span>
</body>Classes: ns-kit, ns-stack, ns-row, ns-spread, ns-scroll, ns-surface,
ns-title, ns-subtitle, ns-muted, ns-mono, ns-btn (--primary,
--secondary, --outline, --ghost, --danger, --sm, --lg, --icon),
ns-field, ns-label, ns-input, ns-textarea, ns-select, ns-switch,
ns-list, ns-list-item, ns-badge--*, ns-dot--*, ns-spinner,
ns-progress, ns-empty, ns-callout. Variables: --ns-<token> for every
THEME_TOKENS entry plus --ns-font-sans, --ns-font-mono.
Testing
import { createMockHost } from '@neurosquad/card-sdk/testing'
const host = createMockHost({ manifest, agents: [...], peers: [...], fetch: (req) => ({ body: { ok: true } }) })
const card = await host.connect()
await card.ports.emit('summary', '# Done')
expect(host.deliveries).toEqual([{ to: 'note-1', output: 'summary', input: 'append', data: '# Done' }])
await expect(host.callTool('run_tests', {})).resolves.toEqual({ content: [{ type: 'text', text: 'All tests passed' }] })The mock applies the app's checks in the app's order (params schemas,
permissions, visibility, optionally rate limits) and records everything:
calls, storage, deliveries, prompts, logs, toasts, chrome
(title/status/badge/overview/menu). Drive the card with setVisibility,
setLanguage, setSettings, setSecret, sendPortMessage, requestPort,
callTool, setAgentStatus, setAgentUsage, setAgentTimeline, agentOutput, suspend, touchFile. The same
mock powers the templates' browser preview when the page is opened outside the
app.
It behaves like the app where it matters: setAgentStatus also emits
agents.turn (start on entering working, end on leaving it for
finished/needs-input); callTool returns a rejected promise for bad
arguments; sendPortMessage checks that from.cardId is an upstream peer and
that the value fits the input's schema; permissions.request fails with
NOT_VISIBLE off screen; fs.write refuses the protected paths above. Options:
settings (initial values), secrets ({ key: value } for secret
settings — {{secret:key}} headers are filled, a missing one is
INVALID_PARAMS), agentUsage / agentTimeline (what agents.usage / agents.timeline return per agent, or a function of the call), enforceRateLimits (per-method throttles; off by default so
tests stay deterministic).
Test in the app too. The mock runs in your page, not in the sandboxed
frame, so CSP and sandbox differences (inline scripts, external resources)
only show up in NeuroSquad (neurosquad-card dev).
CLI
neurosquad-card create <folder> [--template vanilla|react] [--name <slug>] [--display-name <text>] [--force]
neurosquad-card dev [folder] [--user-data-dir <dir>] [--no-build] [--no-logs]
neurosquad-card validate [folder] [--json] [--app-version <x.y.z>]
neurosquad-card pack [folder] [--out <file.tgz>] [--dry-run] [--json] [--allow-secret-files]validateruns the manifest through the app's validators, applies the installer's file rules (paths, sizes, counts, icon format and squareness), lints the entry HTML for things the sandbox blocks, and previews the install dialog.packlists what would be installed (what git tracks plus untracked files that are not ignored — the GitHub tarball), prints the tree hash the app records, and writes a reproducible.tgz. It refuses files that look like credentials (.env*except.env.example,*.pem,*.key,*.p12,*.pfx,id_rsa*,.npmrc,.git-credentials,.netrc) unless you pass--allow-secret-files, and skips anything reached through a symbolic link or junction (it would be read from outside the folder).validateandpackare safe to run on a folder someone sent you: git runs with the folder's own hooks andcore.fsmonitordisabled, and card text is printed with escape sequences and bidi characters removed.devfinds the app's data folder (%APPDATA%/NeuroSquad,~/Library/Application Support/NeuroSquad,~/.config/NeuroSquad, or--user-data-dir/NEUROSQUAD_USER_DATA_DIR), links the folder, runsnpm run watch(ornpm run build -- --watch) when present, and streams the card's log. Pressrto reload,qto quit.devruns the folder's own npm scripts — only use it on folders you trust, or pass--no-build.- Linking a local SDK checkout (
"@neurosquad/card-sdk": "file:…") makes a symlink, and Vite can then load a second React next to the SDK ("invalid hook call"). The React template setsresolve.dedupe: ['react', 'react-dom'];install-links=truein.npmrcalso works.
Versioning
The wire protocol is versioned separately (CARD_PROTOCOL_VERSION, now 1).
New methods and events arrive within a protocol version; check with
card.host.supports('method.name'). A card built for a newer protocol is
refused by an older app with PROTOCOL_MISMATCH.
What changed in each version — new methods, the NeuroSquad version they need, permissions, migration notes — is in CHANGELOG.md (also on the docs site: https://docs.neurosquad.ai/en/card-sdk/changelog).
License
MIT
