7.5k

TypeScript Cores

An app core is a Native SDK app's deterministic logic: Model (the app state), Msg (a discriminated union of everything that can happen), update(model, msg) (the one pure transition function), and the pure helpers they call. By default you write it as one TypeScript module — src/core.ts — the @native-sdk/core frontend checks it, and the external core compiler builds it to native code at build time. No JS engine ships in the binary: the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason.

This is the two-tier shape of the toolkit: Zig is how everything works — the engine, the runtime, every widget — and TypeScript plus Native markup are how applications are authored. A whole app starts as three files and zero Zig: src/core.ts, src/app.native, and app.zon. When ordinary TypeScript work needs filesystem access, JSON, regexes, Map, Date, classes, or child processes, add modules under src/services/; they compile to native code too and answer the core through the same effect→Msg boundary as every other external action. Writing the core in Zig instead (App Model) is first-class by choice — same loop, same runtime — and extending the toolkit itself (custom widgets and render passes) is always Zig.

The same core.ts is executable TypeScript: it typechecks with stock tsc and runs unmodified under node, which is what makes the fastest dev loop possible:

native dev --core   # run the core under node's virtual host: dispatch Msgs as
                    # JSON lines, watch the model + effect transcript
native dev          # build and run the real app (markup hot reload)
native check        # subset-check core.ts + validate markup + app.zon
native build        # ReleaseFast binary; native test runs the app's tests

The contract

src/core.ts
// Model: readonly data fields only.
export interface Model {
  readonly count: number;
}

// Msg: one arm per thing that can happen, at least two arms.
export type Msg =
  | { readonly kind: "add" }
  | { readonly kind: "reset" };

// Pure: the same model every time.
export function initialModel(): Model {
  return { count: 0 };
}

// One case per arm; the switch must be exhaustive (no default needed once
// every arm is present — a missing arm is a teaching error at build time).
export function update(model: Model, msg: Msg): Model {
  switch (msg.kind) {
    case "add":
      return { ...model, count: model.count + 1 };
    case "reset":
      return { ...model, count: 0 };
  }
}

update is pure and synchronous: it never mutates model or msg, never performs IO, and returns the next model — plus optionally command data describing effects (below). Purity is what makes the core testable as a plain function, replayable deterministically, and identical in behavior under node and native.

A complete core in the idiom — readonly interfaces, a tagged Msg, spread updates, map/filter, bytes for text, derived exports:

src/core.ts
import { utf8Bytes } from "@native-sdk/core";

export type Bytes = Uint8Array;
export type Filter = "all" | "active" | "done";

export interface Task {
  readonly id: number;
  readonly title: Bytes;
  readonly done: boolean;
}

export interface Model {
  readonly tasks: readonly Task[];
  readonly nextId: number;
  readonly filter: Filter;
  readonly draft: Bytes;
}

export type Msg =
  | { readonly kind: "add" }
  | { readonly kind: "toggle"; readonly id: number }
  | { readonly kind: "set_filter"; readonly filter: Filter }
  | { readonly kind: "draft_edit"; readonly text: Bytes };

export function initialModel(): Model {
  return {
    tasks: [{ id: 1, title: utf8Bytes("Ship the core"), done: false }],
    nextId: 2,
    filter: "all",
    draft: new Uint8Array(0),
  };
}

export function visibleTasks(model: Model): readonly Task[] {
  if (model.filter === "active") return model.tasks.filter((t) => !t.done);
  if (model.filter === "done") return model.tasks.filter((t) => t.done);
  return model.tasks;
}

export function doneCount(model: Model): number {
  return model.tasks.filter((t) => t.done).length;
}

export function update(model: Model, msg: Msg): Model {
  switch (msg.kind) {
    case "add": {
      if (model.draft.length === 0) return model;
      const task: Task = { id: model.nextId, title: model.draft, done: false };
      return {
        ...model,
        tasks: [...model.tasks, task],
        nextId: model.nextId + 1,
        draft: new Uint8Array(0),
      };
    }
    case "toggle":
      return {
        ...model,
        tasks: model.tasks.map((t) => (t.id === msg.id ? { ...t, done: !t.done } : t)),
      };
    case "set_filter":
      return { ...model, filter: msg.filter };
    case "draft_edit":
      return { ...model, draft: msg.text };
  }
}

Markup binds your model's field names exactly as you wrote them: nextId binds as {nextId} (the core's model keeps the TS spellings), string-literal unions bind as their member name ({filter} renders all), and record arrays iterate with <for each="tasks" as="t" key="id">. Exported helpers taking exactly one Model parameter join the binding surface as derived values — {doneCount} reads doneCount, and slice-returning ones like visibleTasks drive for each — so derived data needs no model field. Update-only state nothing in markup binds (host-fired timer arms, bookkeeping fields) is declared once as export const viewUnbound = ["tick"] as const; so native check's unbound-state lint stays honest.

Why the immutable style is free

Everything update builds lives in a per-dispatch arena that is freed wholesale after the returned model is committed. At commit, only nodes your update actually created are copied into the persistent model heap — everything you spread through unchanged is shared with the previous model. { ...model, tasks: model.tasks.map(...) } copies one small struct and one pointer array, never the world.

Both regions have fixed, build-time capacities (1 MiB each by default): the frame arena bounds one dispatch's transients, the model heap bounds the committed model. Neither grows at runtime, so binaries stay allocation-free and replay stays trivially deterministic. Overflowing one is a defined runtime panic naming the region, never silent corruption.

The subset posture

App cores are written in a closed subset of TypeScript, and the subset means one precise thing: TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Every basic statement, operator, and declaration form compiles: plain interfaces, discriminated unions, switch (with default arms), every loop shape (for, for...of, while, do...while, labels with labeled break/continue), the full operator and assignment family (**, shifts, += through ??=), const record destructuring, namespace imports, spreads, the array methods (.map/.filter/.find/.reduce/.toSorted/...), Math, template literals — everything with exact JS semantics, pinned so node and native always agree (a machine-checked grammar matrix classifies every production of the language, so nothing is missing by accident). Classes and exceptions compile too: data classes (fields, a constructor, methods, static methods and static readonly consts, erased private/protectednew Task(...), this.count, Task.fromRow(...), mutation under the same local-ownership rule as arrays) compile to plain structs plus functions, and throw/try/catch/finally is deterministic control flow — a thrown kind-tagged subset value unwinds to the nearest catch (several distinct shapes may throw; the checker collects them into the core's thrown union, and catch (e) narrows it with plain kind tests, no as ceremony), finally runs on every path, and an uncaught throw is a defined panic exactly where node would crash. What isn't available is exactly two families: the ecosystem the core cannot carry (npm packages, regexes, JSON, Promises, eval) and constructs that would break the core's guarantees (class inheritance, async/await — asynchrony is command data, Map/Set, module-level let, Date.now()/Math.random() inside update, runtime type tests, text as indexable strings — a core's text is bytes). Each has an idiomatic replacement the checker teaches by ID — kind-tagged error shapes narrowed in the catch, time and randomness arrive as message payloads, keyed data is an id-keyed array, and ordinary static-tier work moves behind a src/services/ request. Immutability is a rule about SHARED data, not a style: mutation is legal on locally-owned arrays — a scratch array your function creates (a literal or a .slice() copy) takes push/pop/splice/in-place sort, the xs[xs.length] = v append, and the rest with exact JS semantics until the value escapes; a let reassigned only from fresh copies stays owned, passing into a readonly T[] reader parameter borrows instead of escaping, and the checker teaches only at the real boundaries. Generics are ordinary TypeScript too: a module-level generic function, interface, or type alias monomorphizes per call site from tsc's own resolved type arguments — one native function per instantiation. These rules scope to the core class. Files under src/services/ skip NS1001–NS1064 and are judged by the same pinned compiler's ordinary static tier instead; the class boundary rules NS1065–NS1067 keep the deterministic core and ambient-authority service separate. Where the ecosystem fits has its own page: Where Packages Go.

Every rule in the catalogue carries a class. A guarantee rule protects a core invariant — determinism and replay, fixed shapes, immutability of shared data, the one text representation — and is permanent. A deferred rule bans nothing those invariants require; the capability waits on a deliberate easing decision, and its diagnostic says so.

  • guarantee — permanent: NS1001 (shared data is immutable), NS1002 (updates are synchronous), NS1005 (update is deterministic), NS1010 (module state lives in the Model), the byte-text rules (NS1004, NS1018, NS1024, NS1060), and every other rule not listed as deferred.
  • deferred — awaiting an easing decision: NS1011 (Map/Set), NS1019 (fixed arity: parameter defaults, rest, arguments, call spreads), NS1040 (regular expressions), NS1042 (generators), NS1044 (BigInt/Symbol).
// One generic helper; tsc resolves each call's type arguments.
export interface Task { readonly id: number; readonly done: boolean; }
export function pick<T>(xs: readonly T[], i: number): T {
  return xs[i];
}
export function firstTask(tasks: readonly Task[]): Task { return pick(tasks, 0); }
export function lastNum(ns: readonly number[]): number { return pick(ns, ns.length - 1); }
// The Zig-core equivalent: one monomorphic fn per distinct instantiation.
pub fn pick__Task(xs: []const Task, i: i64) Task {
    return xs[uz(i)];
}
pub fn pick__f64(xs: []const f64, i: i64) f64 {
    return xs[uz(i)];
}

One rule deserves calling out early: text is bytes. Dynamic, user-visible text lives in the Model as Uint8Arraystring is for literals, string-literal-union tags, and === comparisons. Turn display literals and templates into UTF-8 with utf8Bytes; use asciiBytes only when ASCII is part of the value's contract, such as a command name, key, or protocol token:

import { asciiBytes, utf8Bytes } from "@native-sdk/core";

const label = utf8Bytes(`${done} of ${total} done`); // per-dispatch UTF-8
const seed = utf8Bytes("Café…");                    // UTF-8 rodata
const command = asciiBytes("app.refresh");          // guaranteed ASCII

The compiler folds both byte intrinsics at compile time; under node the same imports run as plain functions with the same result. asciiBytes fails with NS1064 when a literal/template contains non-ASCII and throws RangeError if called directly with such text under node. utf8Bytes encodes Unicode exactly like TextEncoder, including U+FFFD for lone surrogates. Observing a string's code units (.length, s[i]) is a taught error because UTF-16 and UTF-8 would disagree, and + concatenation is taught away because runtime string building needs a JS string heap the binary does not carry.

Bytes still read like text: the everyday string methods work directly on Uint8Array values, with byte-honest semantics — every length, offset, and index is a BYTE length/offset (never a character count: é measures 2), search is byte-wise, and case mapping is Unicode simple case mapping (code point to code point from the Unicode tables, locale-free, no special casing — ß stays ß; invalid UTF-8 passes through unchanged). The compiled core and node run the same methods from the same generated tables, so both produce identical bytes by construction.

MethodByte-honest meaning
toUpperCase() / toLowerCase()Unicode simple case mapping over UTF-8 (locale-free); fresh bytes
repeat(n)The bytes repeated n times; repeat(0) is empty, a negative literal is a build error (JS throws RangeError there)
startsWith(b) / endsWith(b) / includes(b)Byte-wise prefix/suffix/substring tests with a bytes needle; includes(65) with a number keeps TypedArray element search — one byte value
indexOf(b) / lastIndexOf(b)First/last BYTE offset of the byte substring, -1 when absent (a number argument searches one byte value)
padStart(n, fill?) / padEnd(n, fill?)Pad to n BYTES (not characters) with fill bytes (default " "), last repetition truncated by bytes
trim() / trimStart() / trimEnd()Strip the JS whitespace set decoded over UTF-8; a view, no copy
split(sep)Split on a bytes separator into Uint8Array[] (String.split shapes; the parts array is yours to mutate); an empty separator literal is a taught stop
at(i)The byte value at a byte index (negatives count from the end), or undefined out of range

What stays out teaches its reason and the byte-honest alternative by name: charCodeAt/charAt/codePointAt read UTF-16 code units bytes do not have (read b[i]/.at(i)), the locale family (localeCompare, toLocaleUpperCase) depends on ambient locale state, the regex-taking methods (match, search) need a regex engine the binary does not carry, and normalize/replace/replaceAll are named deferrals with their rewrites.

Effects are Cmd data

update never performs an effect — it can return one, as inert data, alongside the next model. Declare the pair-return type and build commands inline in the return path (never stored in the model, a message, or a local — that is what keeps replay honest):

src/core.ts
import { Cmd } from "@native-sdk/core";

export interface Model {
  readonly count: number;
  readonly lastTick: number;
}

export type Msg =
  | { readonly kind: "add" }
  | { readonly kind: "request_time" }
  | { readonly kind: "tick"; readonly at: number };

export const viewUnbound = ["tick", "lastTick"] as const;

export function initialModel(): Model {
  return { count: 0, lastTick: -1 };
}

export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
  switch (msg.kind) {
    case "add":
      return { ...model, count: model.count + 1 };
    case "request_time":
      return [model, Cmd.now("tick")]; // dispatches { kind: "tick", at: <ms> }
    case "tick":
      return { ...model, lastTick: msg.at }; // bare model = [model, Cmd.none]
  }
}

The runtime interprets the command after the model commits and dispatches any result back as an ordinary Msg — routing is data (string-literal arm names, never callbacks), so the result decoder derives from your Msg types at build time. initialModel may return the same pair to run one boot effect before the first view build (loading a store is the canonical use). The vocabulary:

CommandWhat it does
Cmd.noneNo effects; returning a bare Model is sugar for it
Cmd.now("tick")Request a timestamp; dispatches the named arm with the time (ms) as its one number payload
Cmd.delay(key, ms, "fired")A keyed one-shot timer; re-issuing a live key re-arms it from now — the debounce discipline
Cmd.readFile(path, { key?, ok, err })Read a whole file; ok carries the content bytes, err a reason (not_found, io_failed, truncated, ...)
Cmd.writeFile(path, bytes, { key?, ok, err })Write a whole file (parents created, replaced whole); ok carries no payload — a successful write has nothing to report
Cmd.appendFile / Cmd.statFile / Cmd.deleteFileAppend one bounded payload, inspect { exists, size, mtimeMs }, or delete one file with explicit not_found handling
Cmd.readFileStream / Cmd.writeFileStream + writeFileChunk/writeFileCloseRead 256-KiB chunks without a total-size cliff, or build an atomic export one acknowledged chunk at a time — see Files & Streaming
Cmd.fetch(spec, { key?, ok, err })A buffered HTTP(S) exchange; ok carries { status, body } (a 404 is still ok — a delivered response), err the transport reason
Cmd.fetch(spec, { key?, line, ok, err })A line-streamed HTTP(S) exchange for SSE/NDJSON; each line carries bytes as it arrives, then ok carries the terminal HTTP status or err the transport reason
Cmd.clipboardWrite(bytes) / Cmd.clipboardRead({ key?, ok, err })System clipboard: write is fire-and-forget, read routes the text bytes back
Cmd.showNotification({ id?, title, subtitle?, body?, actionLabel?, actionCommand? })Show or replace a desktop notification, fire-and-forget; paired action fields dispatch through the ordinary app-command path while the process is running
Cmd.openExternalUrl(url) / Cmd.revealPath(path)Open an allowed HTTP(S) URL in the system browser or reveal a path in Finder/Files/Explorer; both are fire-and-forget and fail closed
Cmd.credentials.set(...) / Cmd.credentials.get(...) / Cmd.credentials.delete(...)App-scoped access to the OS credential store; get returns secret bytes, missing items route miss, and the manifest must declare the credentials capability and permission
Cmd.formatLocalTime(timestampMs, style, route)Format an epoch timestamp as localized date, time, or datetime text in the host's current time zone
Cmd.spawn(argv, { key?, stdin?, line?, exit, err })Run a subprocess, streaming stdout line by line; collect: true buffers whole stdout into the exit arm instead
Cmd.audioPlay(key, source, { event }) + audioPause/audioResume/audioStop/audioSeek/audioSetVolumeThe audio player: one event stream (loaded, position, completed, failed, spectrum, ...) until audioStop closes it
Cmd.showWindow(label) / Cmd.hideWindow(label) / Cmd.quitApp()The menu-bar lifecycle verbs: show or retain-but-hide the labeled window, and gracefully terminate the app
Cmd.setDockPresence(visible)Switch macOS between regular Dock/app-switcher presence and accessory/headless behavior; unsupported hosts ignore it
Cmd.launchAtLoginStatus(route) / Cmd.setLaunchAtLogin(enabled, route)Query or change the installed app bundle's SMAppService registration; the ok bytes name enabled, disabled, requires_approval, or not_found
Cmd.imageLoad(id, source, { event }) + imageCancel(id)/imageUnregister(id)Load an image at runtime under the model-owned numeric ImageId your markup binds; one event result — loaded with the decoded width/height, or a failure class. imageCancel ends a live load loudly (state cancelled) and frees the id; imageUnregister releases a loaded image's registry slot (no result — synchronous, like registration) — see Dynamic Images
Cmd.channelOpen(key, { event }) / channelClose(key)Open an external-source channel under an app-chosen numeric key: the native side holds the posting handle and feeds bytes from its own threads, and every post arrives through the one event arm with the back-pressure counters aboard. channelClose flushes staged posts, dispatches exactly one closed event with the final drop totals, and frees the key
Cmd.audioCaptureStart(key, spec, { event }) / audioCaptureStop(key)Capture microphone or system audio as bounded, timestamped, interleaved signed-16 LE PCM chunks. The stream reports started, data, failed, stopped, and rejected, with observable drop counters
Cmd.persist()Snapshot the just-committed Model through the engine-owned, capability-gated atomic store; restore arrives through the manifest's configured boot Msg route — see Model Persistence
Cmd.store.set/get/delete/scan/setManyPersist independent byte records in the engine-owned, capability-gated record store; every result returns through the declared Msg route — see Record Store
Cmd.db.query(sql, params, route) / Cmd.db.exec(statements, route)Run read-only relational queries as bounded row pages or commit a statement list atomically through the engine-owned SQLite database — see Relational SQLite
Cmd.host(name, ...args) / Cmd.request(name, payload, { key?, ok, err })App-defined host commands by literal name: fire-and-forget, or routed with exactly one result Msg back
Cmd.cancel(key) / Cmd.batch([a, b])Drop an in-flight keyed effect; issue several commands from one dispatch, in order

Result arms are ordinary Msg arms with the shape the effect produces — one Uint8Array field for raw host results and errors, a generated service result record, one number field for timer fires and stream totals, no fields for write acknowledgments, and one number plus one Uint8Array field for a buffered fetch's result — and tsc checks the shapes for you. Buffered engine effects and streamed file reads replace a live same-key predecessor and cancel silently. Live Cmd.spawn, streaming-fetch, streaming-service, and streamed write-sink keys reject duplicates so two producers cannot splice; cancelling those is loud through err: cancelled. A streaming fetch whose line is cut or dropped also ends with err: truncated, never a misleading successful status. Every routed err arm receives a machine-readable reason.

Platform state stays on the same effect boundary. Cmd.openExternalUrl(url) enforces security.navigation.external_links before entering the browser; Cmd.revealPath(path) uses the desktop file manager. Credential operations take byte service and account identifiers plus the standard { key?, ok, err } route: set/delete return empty bytes on ok, get returns the secret, and a missing item routes not_found. Cmd.formatLocalTime(timestampMs, "date" | "time" | "datetime", route) returns localized UTF-8 bytes using the current host locale and time zone. That formatting is deliberately a Cmd—not a pure helper—so session recording captures the observed text and replay never re-reads ambient locale or timezone state.

Durable in-memory state uses Cmd.persist(): declare the persist capability, configure the boot routes and schema version, then return the command beside the committed model. The engine owns canonical serialization, trailing-edge coalescing, atomic app-data placement, backup recovery, migration, and journal/replay. See Model Persistence for the complete setup. Raw file commands remain for user-visible files, exports, and blobs; Files & Streaming covers their bounds, atomic sink protocol, replay, and filesystem permission gate.

Independent byte records use Cmd.store: declare the store capability, then route set/get/delete/scan/setMany results back to Msg arms. The engine owns the app-data path, SQLite schema, atomic batches, pagination, and replay boundary. See Record Store.

External sources — sockets, file watchers, native worker threads — reach update through a channel. Cmd.channelOpen(key, { event }) opens a long-lived stream under an app-chosen numeric key, and every event dispatches the one event arm as a five-field record; state must be a named string-literal-union alias carrying exactly the three members — a narrower union would silently drop states the host emits, so the build refuses it. Posting is not a TS verb: compiled cores are single-threaded by design, so the posting handle lives on the native side (Effects.channelHandle(key)), where embedders and platform-services extensions post bytes from their own threads. Back-pressure is honest — posts the native handle refused count into droppedPending/droppedTotal on the next delivered event, never silence — and a duplicate open on a live key dispatches rejected. Cmd.channelClose(key) ends the stream: staged posts flush, exactly one closed event carries the final totals, and the key frees.

src/core.ts
import { Cmd } from "@native-sdk/core";

export type ChannelState = "data" | "closed" | "rejected";

export interface Model {
  readonly samples: number;
  readonly dropped: number;
  readonly live: boolean;
}

export type Msg =
  | { readonly kind: "start" }
  | { readonly kind: "stop" }
  | {
      readonly kind: "feed";
      readonly key: number;
      readonly state: ChannelState;
      readonly bytes: Uint8Array;
      readonly droppedPending: number;
      readonly droppedTotal: number;
    };

export const viewUnbound = ["feed"] as const;

export function initialModel(): Model {
  return { samples: 0, dropped: 0, live: false };
}

export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
  switch (msg.kind) {
    case "start":
      return [{ ...model, live: true }, Cmd.channelOpen(1, { event: "feed" })];
    case "stop":
      return [model, Cmd.channelClose(1)];
    case "feed":
      switch (msg.state) {
        case "data":
          return { ...model, samples: model.samples + 1, dropped: msg.droppedTotal };
        case "closed":
          return { ...model, live: false, dropped: msg.droppedTotal };
        case "rejected":
          return { ...model, live: false };
      }
  }
}

Audio input uses the same bounded, wake-driven stream transport without requiring native posting code. Cmd.audioCaptureStart(key, { source, sampleRate?, channels? }, { event }) captures the microphone or the desktop output mix. The supported canonical rates are 16, 24, and 48 kHz; channels are mono or stereo; the default is 48 kHz mono. Each data event carries at most 20 ms of interleaved signed 16-bit little-endian PCM in pcm, plus timestampMs, frames, the delivered format, and drop counters. Microphone and system capture can run concurrently, but only one stream per source is live; starting that source again stops the prior key. Cmd.audioCaptureStop(key) quiesces the native callback, drains accepted chunks, then emits one stopped terminal. A key remains occupied until that terminal is delivered, so wait for stopped before reusing it. Add "microphone" and/or "system_audio" to app.zon permissions so packaged macOS apps receive the required usage descriptions and consent prompts.

src/core.ts
import { Cmd, type AudioCaptureState, type AudioCaptureSource } from "@native-sdk/core";

export type Msg =
  | { readonly kind: "record" }
  | { readonly kind: "stop" }
  | { readonly kind: "audio_chunk"; readonly key: number; readonly state: AudioCaptureState; readonly source: AudioCaptureSource; readonly sampleRate: number; readonly channels: number; readonly timestampMs: number; readonly frames: number; readonly pcm: Uint8Array; readonly droppedPending: number; readonly droppedTotal: number };

// In update:
// case "record": return [model, Cmd.audioCaptureStart(1, { source: "microphone", sampleRate: 48000, channels: 1 }, { event: "audio_chunk" })];
// case "stop": return [model, Cmd.audioCaptureStop(1)];

Model-derived menu-bar status items

A src/core.ts app can own its complete native menu-bar item without custom Zig wiring. Export statusItem(model): StatusItemState; the generated launcher installs its icon, tooltip, click/open commands, presentation, and rows from the boot model, then re-derives all of them after committed updates. It patches shell, presentation, and menu independently and never recreates the item just because model state changed.

src/core.ts
import { asciiBytes, utf8Bytes } from "@native-sdk/core";
import { type StatusItemState } from "@native-sdk/core/events";

export function statusItem(model: Model): StatusItemState {
  return {
    iconPath: asciiBytes("assets/menu-bar.svg"),
    tooltip: utf8Bytes("Sync status"),
    activationCommand: asciiBytes("app.sync"),
    alternateActivationCommand: asciiBytes(""),
    openCommand: asciiBytes("app.sync"),
    presentation: { title: model.syncing ? utf8Bytes("SYNC…") : utf8Bytes("READY"), width: 62, tone: model.failed ? "critical" : "normal", iconOpacity: model.stale ? 0.5 : 1, monospaced: true },
    items: [
      { id: 1, label: utf8Bytes("Open"), command: asciiBytes("app.open"), separator: false, enabled: true, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
      { id: 2, label: utf8Bytes("Sync now…"), command: asciiBytes("app.sync"), separator: false, enabled: !model.syncing, detail: asciiBytes(""), role: "command", key: asciiBytes("r"), modifiers: { primary: true, command: false, control: false, option: false, shift: false } },
    ],
  };
}

Import the canonical records and unions from @native-sdk/core/events. Presentation includes byte title, numeric width, normal | warning | critical tone, iconOpacity in 0…1, and monospaced. Rows include id/label/command/separator/enabled plus secondary detail, semantic role, key equivalent, and all five modifier booleans. Actionable ids are unique and non-zero, and there are at most 32 rows. commandMsg(name): Msg | null maps row selection, status-button activation, Option-activation, and menu-open refresh into the ordinary update loop. See System Tray and the zero-Zig examples/menu-bar app for the full hide/Open/Quit lifecycle.

Export statusItems(model): readonly StatusItemDescriptor[] when the app needs several independent items. Each descriptor adds stable non-zero id identity and a live visible flag to the same shell/presentation/menu record. Adding/removing descriptors creates/removes only those ids; icon, title, tooltip, visibility, activation/open commands, and menu changes patch in place. Export either the singular or collection helper, not both. macOS supports up to eight simultaneous items; every item keeps its own 32-row menu.

Model-declared secondary windows

Export windows(model): readonly WindowDescriptor[] to derive the live secondary-window set from model state. Construct entries with windowDescriptor from @native-sdk/core, import WindowDescriptor from @native-sdk/core/events, and put each window's markup at src/windows/<label>.native. Spell the constructor label as a literal label: asciiBytes("<label>"); native check and every build reject dynamic labels or a label without that matching root. Window roots can import shared components nested under src/windows/; the generated launcher embeds and hot-reloads the complete import closure. Adding/removing descriptors creates/closes only those windows; all open windows rebuild from the same committed model.

closePolicy accepts "quit" (the default) or "hide". A "quit" user close routes onCloseCommand through commandMsg, where the app maps it to the Msg that clears its open flag. A "hide" close retains the same native window and view and dispatches no close command; Cmd.showWindow(label) reveals it. Model-declared secondary windows are desktop-only. See examples/system-monitor-ts.

restorePolicy accepts "clamp_to_visible_screen" (the default) or "center_on_primary". Model-declared windows do not restore persisted frames. On macOS, "center_on_primary" centers a fresh descriptor with no authored x/y; Windows and Linux currently keep their native default placement.

titlebar accepts "standard", "hidden_inset", "hidden_inset_tall", or "chromeless". Transparent Windows windows require "chromeless"; because that removes the system buttons, fully skinned windows must draw working close/minimize controls.

Subscriptions are Sub data

Recurring effects are declared, not issued: export subscriptions(model): Sub<Msg> and return descriptors derived from the current model. After every commit the host reconciles the returned set against its active timers by key — a new key (or a changed interval) arms a timer, a missing key cancels it — so starting, stopping, and re-tuning timers is just returning different data:

src/core.ts
import { Sub } from "@native-sdk/core";

export interface Model {
  readonly running: boolean;
  readonly fast: boolean;
  readonly ticks: number;
}

export type Msg =
  | { readonly kind: "toggle" }
  | { readonly kind: "set_fast" }
  | { readonly kind: "tick"; readonly at: number };

export const viewUnbound = ["tick"] as const;

export function initialModel(): Model {
  return { running: false, fast: false, ticks: 0 };
}

export function update(model: Model, msg: Msg): Model {
  switch (msg.kind) {
    case "toggle":
      return { ...model, running: !model.running };
    case "set_fast":
      return { ...model, fast: true };
    case "tick":
      return { ...model, ticks: model.ticks + 1 };
  }
}

export function subscriptions(model: Model): Sub<Msg> {
  if (!model.running) return Sub.none;
  return Sub.batch([
    Sub.timer("tick", model.fast ? 250 : 1000, "tick"),
    Sub.timer("autosave", 30000, "tick"),
  ]);
}

Keep the Sub-vs-stream line straight: a Sub is declarative — derived from the model, started and stopped by reconciliation, never opened or closed by the app. The multi-result streams (Cmd.fetch's response lines, Cmd.spawn's stdout lines, Cmd.audioPlay's events, Cmd.channelOpen's posts, and audio capture chunks) are Cmd-initiated — imperative opens with a keyed lifecycle the app drives. If the effect should exist exactly while some model state holds, it wants a Sub; if the app decides when it starts and ends, it is a stream.

Text input from markup

A markup text control (<text-field text="{draft}" on-input="draft_edit" />) needs a bytes field the control renders and a Msg arm carrying the text-input event. The event union mirrors the runtime's event vocabulary structurally — import it (import { type TextInputEvent } from "@native-sdk/core/text", also re-exported by @native-sdk/core/events) or declare the same shape in your core; the pairing rules are in Native UI: Messages. Your update reduces the events over the draft bytes; a minimal reducer (append / backspace / clear) covers simple fields. Full caret/selection/IME fidelity is one import: the SDK ships the byte-splice text engine as @native-sdk/core/text (below).

Splitting a core into modules

A core that outgrows one file splits into modules under src/ except src/services/: relative imports spelled with their real filenames (./parsers.ts — the same file runs under node, whose loader resolves real files), src/ as the hard boundary (../ and npm packages are teaching errors), and no runtime cycles (import type back-edges are fine and idiomatic — a helper module typically type-imports Model from the entry). The core may not import service files, even type-only; shared subset-legal shapes live in an ordinary core-class module which a service may import. Export lists and value re-exports are ordinary module surface: export { helper, doneCount as remaining } binds names over existing declarations, and export { parsePs } from "./parsers.ts" forwards another module's export by name — what stays out is export default, export =, and export * from (the core's flat namespace resolves by name, so every export names what it binds). core.ts stays the entry module and the app's public face: update, initialModel, subscriptions, the wiring channels, themeState / themePack / statusItem / statusItems / windows, and the exported binding helpers live there (declared and exported under their own names — a rename or re-export cannot bind an entry point), and imported modules hold the machinery they call. The SDK also ships library modules in the same subset — @native-sdk/core/text is the byte-splice text engine (caret, selection, IME composition, ASCII case-insensitive compare), and @native-sdk/core/events is the canonical event and shell vocabulary (TextInputEvent re-exported, ScrollState, FrameEvent, KeyEvent, PinchPhase/PinchEvent, ColorScheme, ThemeState, the chrome records, AudioState/AudioEvent, status-item records, and WindowDescriptor) so no core re-types it — compiled into your core when imported and absent when not.

// src/core.ts — the entry module: Model, Msg, update, and the exports
// markup binds. Imports feed them.
import { parseSample, type Sample } from "./parsers.ts";
import { containsIgnoreCase } from "@native-sdk/core/text";

// src/parsers.ts — a module of the same core, plain subset TypeScript:
import type { Model } from "./core.ts"; // type-only back-edges are legal
export interface Sample { readonly value: number; }
export function parseSample(bytes: Uint8Array): Sample | null { /* ... */ }
// src/main.zig — a Zig core splits the ordinary way:
const parsers = @import("parsers.zig");

// src/parsers.zig
pub const Sample = struct { value: i64 };
pub fn parseSample(bytes: []const u8) ?Sample { ... }

TypeScript services

The core is the app's deterministic logic — Model, Msg, update; services do the app's imperative work. A service operation is a directly exported, non-default named synchronous function under src/services/, taking zero or one explicitly typed request and declaring a contract-encodable result. Crossing shapes live in an exported, subset-legal module outside src/services/ so the core and service import one declaration. The operation name is <module-basename>.<export>; native check projects its complete type table into services.contract.json, checks both classes, and generates the typed core client:

src/core.ts
import { feedsParse } from "@native-sdk/services";

case "parse":
  return [
    model,
    feedsParse({ source: model.source, caseSensitive: false }, {
      key: "parse",
      ok: "parsed",       // one ParseResult field
      err: "parse_failed", // one Uint8Array field
    }),
  ];

The core never receives a synchronous handle. Its update returns a command, the typed result crosses back as the named Msg arm, and the runtime journals that result like every other effect. Replay parks the request and feeds the recorded result without starting the carrier. The service itself is ordinary static-tier TypeScript — Node built-ins, fetch, regexes, JSON, Map/Set, Date, classes — running with the app's privileges on a supervised, lazily started carrier: a sibling child process with a sanitized environment by default, or an explicitly selected in-process worker-thread pool. Writing operations, kind-tagged error throws, streaming and cancellation, exact vendored npm, and the boundary rules NS1065–NS1067 have their own chapter: TypeScript Services.

The dev loop

native dev --core is the fastest loop for logic work: the core runs under Node with a virtual host — dispatch Msgs as JSON lines ({"kind":"add"}, {"$bytes":"…"} for bytes payloads), advance a virtual clock ({"advance":1000}) to fire timers deterministically, and watch the committed model and effect transcript. Service requests run in an isolated Node worker through the same generated contract: vendored hashes are verified, request/results use the same codecs and error arms, cooperative cancellation/deadlines interrupt CPU-bound work, and stream chunks use the same channel-event shape. Pair --script msgs.ndjson with --watch to replay a scenario on every edit. The devhost also consumes NATIVE_SDK_SESSION_RECORD/NATIVE_SDK_SESSION_REPLAY (the environment set by native automate record|replay): it writes the native journal format, and replay starts no service worker. Service-only recordings cross between it and the packaged runtime; packaged recordings containing other effect families use native automate replay, and devhost rejects those records explicitly. native dev runs compiled services for real beside the native app. Quick Start shows a full transcript.

native dev keeps markup instant — .native edits hot-reload into the running window — but a src/core.ts edit rebuilds the core through the external core compiler and restarts the app: seconds per rebuild (roughly 3-6s warm), not sub-second. The core loop in the real window is restart-shaped; keep logic iteration under native dev --core and rebuild when you want to see it live.

native check runs the subset checker (real tsc semantics plus the app-core rules) over the core class, emits and validates the service contract, runs the pinned compiler's coverage verdict over each independent service root, then validates markup and app.zon. Every diagnostic names the rule, the idiomatic rewrite, and the reason — write to them up front and the loop stays fast.

Build targets

Builds compile everything in the app — the core archive, any service executables or in-process archives, and the runner — for one stated target. The default is the build host; -Dtarget selects a cross desktop target following the pinned compiler's build matrix: Linux and Windows GNU targets build from any macOS, Linux, or Windows host, and macOS targets build on a macOS host (Apple linking needs the host toolchain's SDK). A Windows MSVC target builds natively on a matching Windows host; cross-Windows builds use the GNU ABI because Zig supplies that target's CRT and system libraries. An explicitly spelled Linux -gnu target also states its glibc version — x86_64-linux-gnu.2.36 or later, or x86_64-linux-musl — because the compiled runtime needs glibc 2.36+ (a bare -gnu spelling lands on Zig's older default floor and is refused with the same teaching). The executable name and packaging follow the target OS.

Mobile targets compile the same core as a static archive merged into the mobile embed library, which native dev|package --target ios|android link into the toolkit hosts exactly as they do for Zig cores. The mobile matrix is aarch64 only: aarch64-ios and aarch64-ios-simulator build on a macOS host against the selected Apple SDK with an iOS 15.0 floor, and aarch64-linux-android builds on any desktop host against an installed NDK (ANDROID_NDK_ROOT, or the newest ndk/<version> under the SDK) with an API 26 floor. Services on mobile run only on the in-process pool — mobile apps cannot spawn a sibling process — so service_carrier = "auto" resolves to the pool there and an explicit "child" is refused with a teaching; desktop builds of the same app keep the child carrier under auto. The vendored npm lane is unchanged. Model persistence (persist), boot images, and URL media caching are not wired on mobile yet.

Editor support

Editor support is stock tsc — no extension, no plugin. The scaffold ships package.json and tsconfig.json as the editor-and-versioning surface: the tsconfig mirrors the compiler options the checker itself builds its program with (strict, moduleResolution: "bundler", verbatimModuleSyntax, exactOptionalPropertyTypes, …), so what your editor flags is what native check flags, and @native-sdk/core (plus subpaths like @native-sdk/core/text) resolves through node_modules like any package. Apps with services also receive an ignored node_modules/@native-sdk/services editor package whenever native check or native dev --core regenerates the typed client; authored src/ stays clean. Until @native-sdk/core is published to npm, the CLI materializes that package copy itself — exactly the files the published artifact will contain — and native check/dev/build keep it fresh against the SDK (native doctor reports skew). After the publish, a plain npm install writes identical core content and takes over. 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.

Outgrowing the subset

The compiled core is a native static archive, not generated source: native check checks and leaves nothing behind, and there is no emitted Zig to read or adopt. If logic needs ambient APIs or ordinary static-tier TypeScript, keep deterministic state transitions in the core and move that work into src/services/. Port the core to Zig only when the logic tier itself needs capabilities outside both TypeScript classes; the App Model page covers that wiring.

Where the subset ends

The core owns app state and decisions — the app's deterministic logic. Services own imperative application work in ordinary TypeScript: parsing, filesystem transforms, environment inspection, subprocesses, and other ambient operations whose results cross back as messages. The toolkit-extension tier — custom widgets, rasterizer work, new engine-owned effects, and platform integration — remains Zig by design: that layer is the machinery itself, and Building Components is its guide. Services are not a backdoor storage engine or general FFI surface.

Reference

The complete core guide ships as native skills get ts-core; typed service contracts, vendored npm, streaming, authority, and transport limits ship as native skills get ts-services. Both are written for AI agents and precise enough for humans.