Where Packages Go
An app core is pure TypeScript: no npm packages run inside it, because no JS engine ships in the binary. That line is drawn on purpose, and it buys the properties the rest of the toolkit stands on — byte-identical record→replay, headless testing of the whole app, automation over real state, and native dispatch speed with zero allocation at runtime. The language inside the core is complete (TypeScript Cores covers exactly what that means); the ecosystem lives at the edges, and every edge has a first-class pattern.
The question behind "can I use npm?" is almost always one of these five:
| You want | The pattern | Where the code runs |
|---|---|---|
| Filesystem, JSON/regex parsing, transforms, or imperative work in ordinary TypeScript | A compiled module under src/services/ | A native service-host process, reached through @native-sdk/services commands |
| An HTTP API — including AI/LLM endpoints | Cmd.fetch with routed results | The effect engine, in the binary |
| An npm-heavy UI (an editor, a charting stack, an existing React app) | Embed a web frontend | A WebView surface, full npm |
| A Node library for one job | Cmd.spawn a node sidecar | A subprocess, streaming lines back |
| A pure utility (parsing, formatting, math) | Usually nothing — or vendor it under src/ | Inside the core, compiled to native |
Compiled TypeScript services
Put ordinary static-tier TypeScript under src/services/ when the work needs Node built-ins, regexes, JSON, Map/Set, Date, classes, or ambient process authority. Each directly exported, non-default named synchronous function becomes an operation named <module-basename>.<export>. Its request and result may be shared, contract-encodable records; the core calls the generated constructor from @native-sdk/services, so success and failure still arrive as Msgs and record/replay remains offline.
This is compiled native code with no JavaScript engine. native vendor . package@X.Y.Z resolves an exact package graph once, with lifecycle scripts disabled, into checked-in src/services/vendor/ sources and hash facts in app.zon. Builds are offline: every byte is verified and scriptc receives only the explicit --npm-static package list—never automatic or dynamic fallback. native check preserves scriptc's coverage note and refuses anything below 100% static coverage. The checked-in five-package calibration passed three small source-shipping utilities and refused two (nanoid and micromark), so package support is intentionally selective. Services run in a lazily started child process by default, with an explicit in-process opt-in where the compiler can localize the target archive; TypeScript Services covers the exact platform/architecture matrix, typed calls, streaming, cancellation, authority, and crash recovery. examples/service-feed-reader is the reference: Cmd.fetch downloads a feed, the service parses the bytes into typed records through the generated client, and the recorded loop replays without the service or the network.
Calling APIs, AI endpoints included
Most packages people reach for first — API clients, AI SDKs — are HTTP wrappers. The HTTP is already in the toolkit: Cmd.fetch can perform a buffered exchange and route { status, body }, or line-stream an SSE/NDJSON response through repeated Msgs. The request is data, every response event is a message, and a recorded session replays the whole conversation with zero network — which is not something an SDK dependency can offer. A complete buffered client for an OpenAI-compatible chat endpoint:
import { Cmd, asciiBytes } from "@native-sdk/core";
export interface Model {
readonly answer: Uint8Array;
readonly waiting: boolean;
}
export type Msg =
| { readonly kind: "ask" }
| { readonly kind: "answered"; readonly status: number; readonly body: Uint8Array }
| { readonly kind: "ask_failed"; readonly reason: Uint8Array };
export const viewUnbound = ["answered", "ask_failed"] as const;
export function initialModel(): Model {
return { answer: new Uint8Array(0), waiting: false };
}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "ask":
if (model.waiting) return model; // one request in flight, by model state
return [
{ ...model, waiting: true },
Cmd.fetch(
{
url: asciiBytes("http://127.0.0.1:11434/v1/chat/completions"),
method: "POST",
headers: { "content-type": "application/json" },
body: asciiBytes('{"model":"local-model","messages":[{"role":"user","content":"Say hi in five words"}]}'),
timeoutMs: 120000,
},
{ key: "chat", ok: "answered", err: "ask_failed" },
),
];
case "answered":
// The status is the real HTTP status - a 404 is a delivered
// response. Parse the body in pure TypeScript over bytes; the
// The Chatbot example ships the complete JSON walk.
return { ...model, waiting: false, answer: msg.body };
case "ask_failed":
// The transport reason ("timed_out", "connect_failed", ...) -
// failure is never silence.
return { ...model, waiting: false, answer: msg.reason };
}
}// The same exchange on the Zig effects channel.
.ask => fx.fetch(.{
.key = chat_key,
.method = .POST,
.url = "http://127.0.0.1:11434/v1/chat/completions",
.headers = &.{.{ .name = "content-type", .value = "application/json" }},
.body = "{\"model\":\"local-model\",\"messages\":[...]}",
.on_response = Effects.responseMsg(.answered),
}),
.answered => |response| model.recordAnswer(response), // copy response.bodyFor token-by-token UI, request the endpoint's streaming mode and add a line route:
Cmd.fetch(
{
url: endpoint,
method: "POST",
headers: { accept: "text/event-stream", authorization: bearerToken },
body: requestBody,
timeoutMs: 120000,
maxLineBytes: 65536,
},
{ key: "chat", line: "chat_event", ok: "chat_done", err: "chat_failed" },
)chat_event carries one Uint8Array field for each complete SSE/NDJSON line; parse its data: payload and append the delta to the assistant message in the Model. chat_done carries one number field with the terminal HTTP status. Cancellation and transport failures reach chat_failed as reason bytes, including cancelled, so a partially displayed answer never ends silently.
The flagship examples/chatbot uses that streaming shape against Vercel AI Gateway: the Gateway URL and openai/gpt-5.6-luna default are fixed, a dropdown inside the prompt group lists the Luna, Terra, and Sol variants in that order, AI_GATEWAY_API_KEY and an optional initial NATIVE_SDK_CHAT_MODEL override arrive through the env channel, and every choices[0].delta.content extends the visible pending assistant reply before [DONE] and the terminal status commit it to history. Its end-to-end suite pins the request, observes partial UI updates, and replays every stream line without network or launch variables.
Full npm ecosystem UIs
When the point is the ecosystem itself — a code-editor component, an existing React or Next.js app, a charting library — embed a web frontend. This is a first-class surface, not a workaround: the WebView is a component of the native shell, the bridge carries typed messages between the web page and the app, and the scaffold wires the dev-server/bundled-assets split for you:
native init my_app --frontend next # or vite | react | svelte | vuenpm lives in the web surface, where a JS engine actually exists; the native tier keeps the window, menus, tray, dialogs, and everything else the frontend cannot reach. Web Content covers the full setup, including production asset bundling.
Node as a worker
A Node library that does one discrete job — render a template, run a linter, transform a file — can run as a sidecar process: Cmd.spawn starts it, stdin carries the job, and stdout streams back line by line as Msgs. This is the raw pattern, honestly raw: argv is a compile-time array literal, you own the line protocol, each stdout line is bounded at 4 KiB (use collect: true to buffer whole output instead, up to 512 KiB), and the child needs node on the host — which is a real deployment decision, not a footnote.
case "render":
return [
{ ...model, rendering: true },
Cmd.spawn([asciiBytes("/usr/local/bin/node"), asciiBytes("scripts/render.mjs")], {
key: "render",
stdin: model.template,
line: "render_line",
exit: "render_done",
err: "render_failed",
}),
];Cancellation (Cmd.cancel("render")), the exit code on the exit arm, and machine-readable failure reasons all follow the standard effect contract — see the streaming ops. examples/system-monitor-ts is the reference for the collect shape: spawn a tool, parse its whole output in the core.
Pure utilities
For leaf-node utilities — date math, CSV rows, number formatting — the honest answer is that most of them dissolve: text is bytes and data is records in a core, so the code you would have imported is often a short function over Uint8Array you can see all of. When you do want library code, two channels exist:
import { parseCsvRow } from "./csv.ts"; // vendored under src/
import { containsIgnoreCase } from "@native-sdk/core/text"; // the SDK library channel- Vendor subset-clean code under
src/outsidesrc/services/. It compiles into the core like your own modules (splitting a core into modules); the subset checker tells you immediately — by rule ID, with the rewrite — whether it fits. - Vendor ordinary static-tier code or exact npm packages under
src/services/. Classes, regexes, JSON,Map/Set,Date, Node built-ins, and imperative transforms stay in TypeScript and compile into the service host; runnative vendor . package@X.Y.Zfor a package, then reach its typed exported operations through@native-sdk/services. @native-sdk/core/*is the curated library channel: SDK modules written in the same subset, compiled into your core when imported and absent when not. Today that is@native-sdk/core/text— the byte-splice text engine (caret, selection, IME, case-insensitive search) — and@native-sdk/core/events— the canonical event record types markup and the wiring channels match. The channel grows with the toolkit; JSON encoding/parsing over bytes, today demonstrated inexamples/chatbot/src/api.ts, is the kind of module it exists to absorb.
One thing deliberately does not exist: a package manager for cores. A core's import graph is exactly its class under src/ plus the SDK modules. Services stay hermetic: local and npm sources are checked in under src/services/, app.zon pins each npm name/version/tree hash, and build performs no install or network step. Nothing arrives at build time that you have not checked in.