Network
A card’s page cannot reach the network at all — its sandbox blocks every request, image and font
from outside the package. card.net.fetch goes through a proxy in the app instead, which lets
through only what the user granted:
networkwithhosts:httpsrequests to those hosts;network.local:httporhttpstolocalhost,127.0.0.1and::1— except NeuroSquad’s own local servers (its MCP server, remote access, developer link, the browser cards’ Chrome debugging ports), which are unreachable.
Fetch
const res = await card.net.fetch('https://api.github.com/repos/acme/app/issues?state=open', {
headers: { accept: 'application/vnd.github+json' },
responseType: 'json'
})
if (!res.ok) throw new Error(`GitHub said ${res.status} ${res.statusText}`)
const issues = await res.json<{ number: number; title: string }[]>()
render(issues.map((i) => `#${i.number} ${i.title}`))It reads like fetch, with a few differences:
| Option | Meaning |
|---|---|
method | GET (default), HEAD, POST, PUT, PATCH, DELETE. A body without a method means POST. |
headers | An object or [name, value] pairs. May contain {{secret:<key>}}. |
body | A string (sent as is), bytes (Uint8Array, ArrayBuffer), or any other JSON value (serialized, with content-type: application/json unless you set one). ≤ 5 MB. |
responseType | text (default), json (parsed by the app), base64 (binary), or stream. |
timeoutMs | 1 000–120 000, default 30 000. |
signal | An AbortSignal; aborting cancels the request in the app. |
The result is a CardResponse: ok, status, statusText, url (after redirects), headers
(get, has, iterable; names lowercase), truncated, and the body methods text(), json(),
bytes(), arrayBuffer(), chunks(), lines() — read the body once, like a Response.
// POST JSON, read binary.
const upload = await card.net.fetch('https://api.example.com/v1/render', {
method: 'POST',
body: { chart: 'coverage', width: 640 },
responseType: 'base64'
})
const png = await upload.bytes()
render(png.byteLength)Conditional requests and empty bodies. Conditional headers such as If-None-Match and
If-Modified-Since are forwarded, and every response header except set-cookie comes back (so
etag and rate-limit headers are there). An empty body — a 304 Not Modified or 204 No Content —
with responseType: 'json' gives null from res.json().
Secrets in headers
Never put an API key in your card’s code or storage. Declare a secret setting,
let the user type it into the app’s settings form, and reference it in a header value:
if (!card.settings.hasSecret('apiKey')) {
await card.settings.open()
} else {
const res = await card.net.fetch('https://api.example.com/v1/me', {
headers: { authorization: 'Bearer {{secret:apiKey}}' },
responseType: 'json'
})
render(await res.json())
}The app replaces {{secret:apiKey}} with the stored secret (this card’s, then the package-scope
one) on its way out — only for requests to granted internet hosts, never to this computer
(network.local). Your card never sees the value. Placeholders work only in header values — not
in the URL or the body, which end up in server logs — and the secret headers are dropped if a
redirect leads to another host. A placeholder for a secret the user has not set fails with
INVALID_PARAMS.
Streaming
For server-sent events, NDJSON or long downloads, ask for a stream. The promise resolves as soon as the headers arrive; the body comes in chunks (≤ 64 KB each):
const controller = new AbortController()
const stream = await card.net.fetch('https://api.example.com/v1/events', {
headers: { accept: 'text/event-stream' },
responseType: 'stream',
signal: controller.signal
})
for await (const line of stream.lines()) {
if (line.startsWith('data: ')) render(JSON.parse(line.slice(6)))
}
// controller.abort() ends it early.chunks() gives strings for text chunks and Uint8Array for binary ones; lines() splits on
line breaks; for await (const chunk of response) works too. A failed stream throws
NETWORK_ERROR. At most 4 streams open at once; a stream is closed after 5 minutes without data or
after 256 MB. WebSockets are not supported in version 1.
What the proxy enforces
- Scheme.
httpsonly, excepthttpto this computer withnetwork.local. No user name or password in the URL; the#fragmentis not sent. - Host. Exactly your declared patterns (
api.example.com,*.example.com,host.example.com:8443). Anything else fails withHOST_NOT_ALLOWED. - Address. The app resolves the name itself; for a public host grant, every address must be public — private networks, loopback, link-local and cloud metadata addresses are refused, and the request goes to the address that was checked (no DNS rebinding).
- Headers. You cannot set
host,cookie,origin,referer,user-agent,content-length,connection,proxy-*,sec-*,x-forwarded-*and similar. The app sendsUser-Agent: NeuroSquad-Card/<app version> (<your card name>). No cookies are ever stored or sent, andset-cookieis never returned to you. - Redirects. Followed by the app, at most 5, and every hop is checked again by rules 1–4.
- Limits. Request body ≤ 5 MB; response ≤ 10 MB (a longer text or base64 body comes back cut,
with
truncated: true; a longer JSON body fails withTOO_LARGE); 6 requests at once; 120 a minute; timeout up to 120 s — and the timeout covers reading the whole body, not just the headers.
Errors: HOST_NOT_ALLOWED (host or address not granted), NETWORK_ERROR (DNS, connection, TLS),
TIMEOUT, TOO_LARGE, RATE_LIMITED, PERMISSION_DENIED (no network permission at all). An HTTP
error status is not an exception — check res.ok.
The install dialog tells users that anything your card can see may be sent to the hosts you list. Keep the list short and specific: a wildcard or a generic host (a paste bin, a URL shortener) makes a careful user decline.