Skip to Content

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:

  • network with hosts: https requests to those hosts;
  • network.local: http or https to localhost, 127.0.0.1 and ::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:

OptionMeaning
methodGET (default), HEAD, POST, PUT, PATCH, DELETE. A body without a method means POST.
headersAn object or [name, value] pairs. May contain {{secret:<key>}}.
bodyA string (sent as is), bytes (Uint8Array, ArrayBuffer), or any other JSON value (serialized, with content-type: application/json unless you set one). ≤ 5 MB.
responseTypetext (default), json (parsed by the app), base64 (binary), or stream.
timeoutMs1 000–120 000, default 30 000.
signalAn 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

  1. Scheme. https only, except http to this computer with network.local. No user name or password in the URL; the #fragment is not sent.
  2. Host. Exactly your declared patterns (api.example.com, *.example.com, host.example.com:8443). Anything else fails with HOST_NOT_ALLOWED.
  3. 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).
  4. Headers. You cannot set host, cookie, origin, referer, user-agent, content-length, connection, proxy-*, sec-*, x-forwarded-* and similar. The app sends User-Agent: NeuroSquad-Card/<app version> (<your card name>). No cookies are ever stored or sent, and set-cookie is never returned to you.
  5. Redirects. Followed by the app, at most 5, and every hop is checked again by rules 1–4.
  6. 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 with TOO_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.