Files, clipboard & usage
Files in the workspace folder
Needs fs.read to read and fs.write
to change. Every path is relative to the workspace folder — the project folder the user chose
for the workspace — with / or \ separators; '' or '.' is the folder itself.
// Read
const pkg = JSON.parse(await card.fs.readText('package.json')) as { name: string }
const logo = await card.fs.readBytes('public/logo.png')
const info = await card.fs.stat('src/index.ts') // { path, type, size, mtimeMs }
const { entries, truncated } = await card.fs.list('src', { recursive: true, maxEntries: 2000 })
render(pkg.name, logo.byteLength, info.size, entries.length, truncated)
// Write (atomically: a temporary file, then a rename)
await card.fs.writeText('reports/latest.md', '# Report\n', { createDirs: true })
await card.fs.mkdir('reports/archive')
await card.fs.trash('reports/old.md') // to the OS trash; there is no hard delete
// Write only if nobody changed it since you read it
const before = await card.fs.stat('TODO.md')
await card.fs.writeText('TODO.md', '- [ ] ship it\n', { ifMtimeMs: before.mtimeMs })| Method | Result |
|---|---|
stat(path) | { path, type: 'file' or 'directory', size, mtimeMs } |
list(path?, { recursive?, maxEntries? }) | { entries: { path, type, size? }[], truncated } — ≤ 5 000 entries; recursive listing skips .git and node_modules unless you list inside them |
readText(path, { maxBytes? }) | UTF-8 text |
readBytes(path, { maxBytes? }) | Uint8Array |
read(path, { encoding?, maxBytes? }) | { data, encoding, size, truncated } — the raw result |
writeText(path, text, { createDirs?, ifMtimeMs? }) | the new FsStat |
writeBytes(path, bytes, { createDirs?, ifMtimeMs? }) | the new FsStat |
mkdir(path) | FsStat |
trash(path) | moves a file or folder to the OS trash (60 a minute) |
watch(path, handler, { recursive? }) | resolves with a function that stops watching |
Reads and writes are up to 10 MB each.
Watching:
const stop = await card.fs.watch('reports', ({ path, type }) => {
render(`${path} ${type === 'rename' ? 'was added or removed' : 'changed'}`)
}, { recursive: true })
// later
await stop()Changes are debounced by 100 ms; at most 20 watchers per card; watchers stop when the card’s page
is unloaded (watch again after launch === 'resumed').
The fence. Absolute paths and any .. are refused outright. After that, the app resolves the
real path of the target (or, for a new file, of its nearest existing folder) and refuses it unless
it is inside the workspace folder — so a symbolic link or junction that points outside does not
help. All of these fail with FS_DENIED; other file system problems (not found, not a folder…)
with FS_ERROR, and a failed ifMtimeMs check with FS_ERROR and data.conflict.
No file access at all — every fs.* call fails with FS_DENIED — when the workspace folder is
the user’s home folder, the root of a drive, or contains NeuroSquad’s own data folder.
Protected paths
Writes, mkdir and trash are refused (FS_DENIED) for these, at any depth, checked on the path
you give and on the real path behind any link — they would let a card run code through git, the
user’s agents or their tools:
- anything inside a folder named
.git(including a submodule’s or worktree’s.gitfile),.gitmodules,.gitattributes; - the folders
.claude,.codex,.cursor,.gemini,.qwen,.opencode,.kilocode,.windsurf,.continue,.vscode,.idea,.husky,.devcontainerand.github/workflows; - the files
.mcp.json,CLAUDE.md,CLAUDE.local.md,AGENTS.md,GEMINI.md,QWEN.md,.cursorrules,.windsurfrules,opencode.json,opencode.jsonc,.envrc,.npmrc,.yarnrc,.yarnrc.yml,.pnpmfile.cjs.
Reading them is allowed with fs.read.
There is no file picker in version 1: a card works with the workspace folder only. To point a card
at a file, let the user type the path in a setting, or receive an ns:file-ref over a port.
Clipboard
Needs clipboard.write — a good candidate for an
optional permission.
await card.copyText('npm test -- --grep checkout')At most once a second, ≤ 1 MB. The browser’s own navigator.clipboard does not work in a card, and
reading the clipboard is not possible at all.
Token usage and costs
Needs usage.read.
const summary = await card.usage('7d') // 'today' (default), '7d' or '30d'
const dollars = (summary.totalCostMicroUsd / 1_000_000).toFixed(2)
render(`$${dollars}${summary.partial ? ' + unpriced models' : ''}`)
for (const row of summary.rows) {
render(row.name, row.harness, row.inputTokens, row.outputTokens, row.costMicroUsd ?? 'no price')
}The same numbers as the app’s Usage page, for the card’s workspace: period, from and
to (local days, end exclusive), and per agent inputTokens, outputTokens, cacheReadTokens,
cacheWriteTokens and costMicroUsd. Money is in whole micro-dollars (1 000 000 = $1). A model
without a known price has costMicroUsd: null — never 0 — is left out of totalCostMicroUsd, and
sets partial: true.