Quick Start
Native SDK is the complete toolkit for building beautiful native desktop applications: declarative markup, a predictable message-based state model, a modern component library, a native renderer, and tooling for building, running, and packaging apps — no browser or WebView in the binary. Apps are authored in TypeScript and Native markup by default, or in Zig — same loop, same runtime, your choice. This page takes you from install to a running app.
Prerequisites
- macOS 11 or newer, Linux, or Windows
- Node.js 22.15+ (on the 23 line: 23.5+) for the default TypeScript scaffold — the TypeScript frontend (the checker) and the core dev loop run under it at build and dev time, and the external core compiler that builds the checked core to native code ships as an exact-pinned dependency of the CLI; the binary you ship carries no JS runtime. A Zig-core app (
--template zig-core) needs Node only when it declares relational SQLite, whose schema checker and migration generator run at build time.
Get the CLI
npm install -g @native-sdk/cli
native versionTwo things the CLI handles for you, so you never configure them:
- The SDK location. Apps build against the SDK the CLI ships with —
native initrecords the path automatically (override with--framework <sdk path>). - The Zig toolchain.
native dev|build|testuse the Zig on your PATH when its version is compatible, and otherwise offer to download the pinned version into~/.native/toolchains/(checksum-verified; pass--yesto skip the prompt in scripts). The toolkit requires Zig 0.16.0 — if you learned Zig on an older version, Zig 0.16 Notes maps the standard-library changes.
Create an app
native init my_app
cd my_appThis scaffolds a native-rendered app — and nothing else. There are no build files to maintain: the CLI owns the build graph, generating it under .native/build/ (gitignored) on each run. The app itself is three files of truth:
| File | Purpose |
|---|---|
src/core.ts | The logic: Model, Msg, update — plain TypeScript, compiled to native code at build time |
src/app.native | The entire UI: elements, layout, bindings, and message dispatch |
app.zon | App manifest: identity, window and view declarations, permissions, security policy |
assets/icon.png | The app icon source: one square image packaging turns into every platform's icon artifacts |
package.json, tsconfig.json | The editor surface: stock editor TypeScript resolves @native-sdk/core with full IntelliSense, and the tsconfig mirrors the checker's own compiler options |
.gitignore, README.md | Ignores for generated directories, and the commands on this page |
Editor support is stock tsc — no extension, no plugin. package.json and tsconfig.json exist for editors and versioning only, and node_modules/@native-sdk/core is a CLI-managed copy of the SDK package: materialized at init, kept fresh by native check/dev/build, and replaced transparently by npm install once the package is published to npm. None of it is build truth — builds check and compile against the SDK the CLI ships with and never read node_modules; delete it and every native verb still works.
There is no Zig in this tree and no language flag anywhere: the build detects src/core.ts and wires everything (package.json is not a language marker). Prefer to write the core in Zig? native init my_app --template zig-core scaffolds the same app with src/main.zig (plus generated full-loop tests in src/tests.zig) — the tree is the truth, and the build detects whichever core it carries. Zig is the language the whole toolkit is built in, and a Zig core is first-class by choice, not a fallback. Prefer to own build.zig from day one? Add --full to either template.
Run it
native devThe first run compiles the app and the SDK, so give it a minute; subsequent runs are incremental. A native window opens with a working counter. The whole view is src/app.native — a Native markup file:
<column gap="12" padding="16">
<row gap="8" cross="center">
<text grow="1">Counter</text>
<button size="sm" variant="ghost" on-press="reset">Reset</button>
</row>
<row gap="8" main="center" cross="center" grow="1">
<button variant="secondary" on-press="decrement">-</button>
<text>{count}</text>
<button variant="primary" on-press="increment">+</button>
</row>
<row gap="8" cross="center">
<switch checked="{ticking}" on-toggle="toggle_ticking">Tick every second</switch>
<text grow="1">ticks {tickCount}</text>
<button size="sm" on-press="stamp">Stamp</button>
</row>
<status-bar>total: {total} | stamped: {stampedMs}ms</status-bar>
</column>The markup binds values ({count}) and dispatches messages (on-press="increment"); it can never mutate state. All logic lives in the core — the scaffolded one already exercises the whole surface: state, messages, one pure update, a clock effect, and a repeating one-second timer. Both templates scaffold this same app. Here is its logic verbatim — the TypeScript template's src/core.ts, and the model-and-update section of the Zig template's src/main.zig (the rest of that file is the window and wiring declaration the TypeScript build generates for you). In TypeScript, effects are returned Cmd data and the timer is a declared Sub; in Zig, the same two ride the effects channel (fx.wallMs, fx.startTimer):
import { Cmd, Sub } from "@native-sdk/core";
export interface Model {
readonly count: number;
readonly ticking: boolean;
readonly tickCount: number;
readonly stampedMs: number;
}
export type Msg =
| { readonly kind: "increment" }
| { readonly kind: "decrement" }
| { readonly kind: "reset" }
| { readonly kind: "toggle_ticking" }
| { readonly kind: "stamp" }
| { readonly kind: "stamped"; readonly at: number }
| { readonly kind: "tick"; readonly at: number };
// `tick` and `stamped` are dispatched by the host (timer fires and the
// Cmd.now result), never from markup - this list keeps `native check`'s
// unbound-state lint honest about that.
export const viewUnbound = ["tick", "stamped"] as const;
export function initialModel(): Model {
return { count: 0, ticking: false, tickCount: 0, stampedMs: -1 };
}
// Exported single-model helpers become bindings too: `{total}` in
// app.native reads this.
export function total(model: Model): number {
return model.count + model.tickCount;
}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "increment":
return { ...model, count: model.count + 1 };
case "decrement":
return { ...model, count: model.count - 1 };
case "reset":
return { ...model, count: 0, tickCount: 0 };
case "toggle_ticking":
return { ...model, ticking: !model.ticking };
case "stamp":
// Effects are data: the host performs this after commit and
// dispatches `stamped` with the time.
return [model, Cmd.now("stamped")];
case "stamped":
return { ...model, stampedMs: msg.at };
case "tick":
return { ...model, tickCount: model.tickCount + 1 };
}
}
// Recurring effects are declared from the model: while `ticking` holds,
// the host fires `tick` every second; flip it off and the timer stops.
export function subscriptions(model: Model): Sub<Msg> {
if (!model.ticking) return Sub.none;
return Sub.timer("tick", 1000, "tick");
}pub const Msg = union(enum) {
increment,
decrement,
reset,
toggle_ticking,
stamp,
tick: native_sdk.EffectTimer,
// `tick` is dispatched by the host (the repeating timer fires),
// never from markup - this keeps the unbound-state lint honest
// about that.
pub const view_unbound = .{"tick"};
};
pub const Model = struct {
count: i64 = 0,
ticking: bool = false,
tick_count: i64 = 0,
stamped_ms: i64 = -1,
// Public single-model helpers become bindings too: `{total}` in
// app.native reads this.
pub fn total(model: *const Model) i64 {
return model.count + model.tick_count;
}
};
pub const Effects = native_sdk.Effects(Msg);
/// The repeating tick's effects-channel key: starting an active key
/// replaces the timer in place, so toggling never double-registers.
pub const tick_timer_key: u64 = 1;
pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
switch (msg) {
.increment => model.count += 1,
.decrement => model.count -= 1,
.reset => {
model.count = 0;
model.tick_count = 0;
},
.toggle_ticking => {
model.ticking = !model.ticking;
// Recurring effects are timers on the effects channel: while
// `ticking` holds, the host fires `tick` every second; flip
// it off and the timer stops.
if (model.ticking) {
fx.startTimer(.{
.key = tick_timer_key,
.interval_ms = 1000,
.mode = .repeating,
.on_fire = Effects.timerMsg(.tick),
});
} else {
fx.cancelTimer(tick_timer_key);
}
},
// The journaled clock read - deterministic under session replay,
// the Zig equivalent of the TypeScript starter's `Cmd.now`.
.stamp => model.stamped_ms = fx.wallMs(),
.tick => |timer| {
if (timer.outcome != .fired) return;
model.tick_count += 1;
},
}
}That is the whole loop: the model holds state, messages describe what happened, update is the only place state changes, and the view re-derives from the model. Markup binds the core's own field names exactly as written — tickCount → {tickCount} in TypeScript, tick_count → {tick_count} in Zig — and single-model helpers bind as derived values ({total}) in both. App Model walks through how the pieces connect, and TypeScript Cores covers the authoring tier in depth.
Edit while it runs
src/app.native is embedded into the binary and watched while native dev runs — native dev runs a Debug build by default, which is what arms the hot-reload watcher. Edit it — change a label, add a button — and the window updates within a couple of seconds without losing the count. Parse failures keep the last good view on screen. A src/core.ts edit is different: the core rebuilds through the external core compiler and the app restarts — a few seconds per rebuild, not sub-second — so for fast logic iteration use native dev --core (next section).
The fastest loop: the core under node
Logic changes have an even faster loop than rebuilding the app. native dev --core runs src/core.ts under node with a virtual host — no window, no renderer, just the dispatch cycle: send messages as JSON lines, watch the committed model and the effect transcript, and advance a virtual clock to fire timers deterministically:
printf '%s\n' '{"kind":"increment"}' '{"kind":"toggle_ticking"}' '{"advance":3000}' | native dev --corenative dev --core: the core-logic loop under node (update/effects, virtual clock) - not a renderer; `native dev` runs the app
model {"count":0,"ticking":false,"tickCount":0,"stampedMs":-1}
model {"count":1,"ticking":false,"tickCount":0,"stampedMs":-1}
model {"count":1,"ticking":true,"tickCount":0,"stampedMs":-1}
sub arm tick every 1000ms -> tick
fire tick -> tick @ 1000
model {"count":1,"ticking":true,"tickCount":1,"stampedMs":-1}
fire tick -> tick @ 2000
model {"count":1,"ticking":true,"tickCount":2,"stampedMs":-1}
fire tick -> tick @ 3000
model {"count":1,"ticking":true,"tickCount":3,"stampedMs":-1}Because the subset is executable TypeScript, the same file runs unmodified under node and compiles to the same behavior natively. Pair it with --script msgs.ndjson --watch to replay a scenario on every edit.
Check it
native checknative check validates the whole tree without building anything: src/core.ts runs the subset checker (typecheck plus the app-core rules, with teaching diagnostics that name the rule, the fix, and the reason), then every .native file under src/ and app.zon:
model contract: not yet built - bindings and app: icon names checked structurally only; run `native test` to enable typed checks
src/app.native: ok
info[manifest.valid]: app.zon is valid
checked 1 markup file, app.zon and src/core.ts (subset checker clean)The first line is honest about what a fresh tree can check: once a build has produced the model contract, the markup pass also verifies bindings, iterables, and message tags against the core's Model/Msg. Markup errors come back with file:line:column and a teaching message (native markup lsp provides the same diagnostics plus completion and hover in your editor). native test runs the app's test suite; the Zig template additionally scaffolds src/tests.zig — full-loop UI tests that click buttons through typed dispatch, headless, on any machine. See Testing for the full tiers, including driving the live app from the outside with automation.
Build a release binary
native buildThis produces an optimized binary and tells you where it landed:
built zig-out/bin/my-app (ReleaseFast)(The binary name comes from app.zon: native init my_app sets .name = "my-app".) Where native dev runs a Debug build to arm hot reload, native build produces an optimized ReleaseFast binary. The TypeScript core compiles to native code inside it — no JS engine, no interpreter. From there, Packaging turns it into a distributable app bundle with native package.
Escape hatch: own the build
If the app outgrows the managed graph — extra build steps, custom sources — run native eject once. It writes a build.zig/build.zig.zon you own into the app and never touches them again; native dev|build|test keep working, now driving your files through zig build. See the CLI reference.
Next steps
- App Model — the model/message/update loop, wiring, and hot reload
- TypeScript Cores — the app-core subset, effects, subscriptions, and the dev loop in depth
- Native UI — every element, attribute, and pattern in the markup
- Components — the component catalog
- State & Data Flow — derive-don't-store, bindings, and text editing state
- Zig 0.16 Notes — the standard-library idioms this SDK uses, mapped from the compile errors older Zig habits produce
- Examples — complete apps in the repository, from a calculator to a native shell
- Web Content — the secondary path for apps that embed an existing web frontend
- Platform Support — what each host supports today