npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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).

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 app

dev 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" (validate warns; the app refuses).
  • Reserved names: name may 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): enum at most 256 options and 16 KB in total, const at most 4 KB.
  • Updates: a new version that adds or rewords MCP tools, adds or retypes ports, adds secret settings or changes displayName/author/homepage asks 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, localStorage or cookies — use card.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 — use card.ui.confirm, card.openLink;
  • <form> submit events fire (the frame has allow-forms), but submission itself never navigates: the host cancels every submission and makes form.submit() a no-op (CSP form-action 'none' backs it up). Call event.preventDefault() in your submit handler anyway and do the work in JS;
  • never trust window message events. Any other card on the canvas can postMessage your frame. The SDK's port (handed over by the app from window.parent) is the only trusted channel;
  • copying: copy/cut event handlers and document.execCommand('copy') do nothing (a card could otherwise replace the clipboard on a click). Write text with card.copyText (needs clipboard.write); the user's own copy of selected text still works;
  • no nested frames (iframe, object, embed are removed), and only blob workers: new Worker('x.js') fails in an opaque origin, so fetch the script, wrap it in a Blob and start the worker from URL.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 your title/message as 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 sees settings.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 your network permission (or localhost with network.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 payload

Topics (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]
  • validate runs 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.
  • pack lists 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).
  • validate and pack are safe to run on a folder someone sent you: git runs with the folder's own hooks and core.fsmonitor disabled, and card text is printed with escape sequences and bidi characters removed.
  • dev finds the app's data folder (%APPDATA%/NeuroSquad, ~/Library/Application Support/NeuroSquad, ~/.config/NeuroSquad, or --user-data-dir / NEUROSQUAD_USER_DATA_DIR), links the folder, runs npm run watch (or npm run build -- --watch) when present, and streams the card's log. Press r to reload, q to quit. dev runs 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 sets resolve.dedupe: ['react', 'react-dom']; install-links=true in .npmrc also 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