Imported from vercel-labs/native (
skill-data/ts-core/SKILL.md). Install upstream withnpx skills add vercel-labs/native --skill ts-core. Copyright stays with the author.
name: ts-core description: Authoring guide for the primary Native SDK app-logic path: TypeScript app cores - Model, Msg, update, and the pure functions they call, written in the closed app-core subset, checked by the @native-sdk/core frontend, and compiled ahead-of-time to native code by the external core compiler. Use for new apps unless the user explicitly chose Zig, when writing or modifying a src/core.ts app core, fixing subset checker errors (NS1001-NS1069), or deciding how to express state, messages, text (bytes and the byte-text string methods), text input, continuous controls (sliders, scroll), effects (Cmd), subscriptions (Sub), the host-event wiring channels (frameMsg, keyMsg, pinchMsg, dropMsg, appearanceMsg, chromeMsg, envMsgs, app manifest assets), derived values, the view_unbound lint opt-out, local mutation of owned arrays, or how to split a core into modules under src/ (relative imports, namespace imports, @native-sdk/core/text, @native-sdk/core/events). Use ts-services alongside this guide for src/services work.
Author app cores in the TypeScript subset
TypeScript is the primary app-authoring language. 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. You write it as a TypeScript module rooted at src/core.ts - splitting into more modules under src/ when it grows (see "Splitting a core into modules") - and the build checks the whole import graph with the @native-sdk/core frontend and compiles it to native code with the external core compiler. 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. The same file is executable TypeScript: it typechecks with stock tsc and runs unmodified under node, so you can poke behavior with plain node scripts before the native build.
A whole TS app starts as three files of truth and zero Zig: src/core.ts (this guide; plus core-class modules it imports under src/), src/app.native (the markup view over the core's model), and app.json (windows, identity, permissions). Existing app.zon manifests remain supported. Optional ordinary-TypeScript service modules live under src/services/ and are never imported by the core; load native skills get ts-services for that boundary. native init scaffolds the three-file base; the build detects src/core.ts in the tree (never a flag or config — a tree with both src/core.ts and src/main.zig is a teaching error) and generates the wiring outside the app. The loop:
native dev --core # the fastest loop: run the core under node's virtual host —
# dispatch Msgs as JSON lines ({"kind":"add"}, {"$bytes":"…"}
# for bytes payloads, {"advance":1000} to run virtual timers),
# watch the model + effect transcript. Logic only, no renderer.
native dev # build and run the real app (markup hot reload)
native check # subset-check core.ts + validate markup + app.json/app.zon
native build # ReleaseFast binary; native test runs the app's tests
The complete reference app in this idiom is examples/soundboard in the SDK repo: the soundboard music library as three files and zero Zig — const catalog tables, REAL audio through the Cmd.audioPlay stream, scrub-to-seek on a markup slider, a motion-gated Sub.timer playback clock, the full text-edit engine on a search field, controlled scroll, registered cover assets, the width-adaptive grid through the frame channel, clipboard, and context menus, with an end-to-end suite driving the shipping markup.
The contract
export interface Model { /* readonly data fields only */ }
export type Msg =
| { readonly kind: "add" }
| { readonly kind: "toggle"; readonly id: number };
// ...one arm per thing that can happen; at least two arms
export function initialModel(): Model { /* pure */ }
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) {
// one case per arm; the switch must be exhaustive (no default needed
// once every arm is present — a missing arm is a build error)
}
}
updateis pure and synchronous: next model out, plus optionally command data describing effects. When a dispatch needs an effect, declare the return typeModel | [Model, Cmd<Msg>]and return[nextModel, cmd]— the runtime interprets the command after the model commits and dispatches any result back to you as aMsg.initialModelmay return the same pair ([Model, Cmd<Msg>]) to run a boot effect once at install, and an app that needs recurring timers exportssubscriptions(model): Sub<Msg>. See "Effects are Cmd data" below.- Exported helper functions (
export function doneCount(model: Model): number) compile to public native functions — and every exported helper taking exactly ONE Model parameter also becomes a Model declaration markup binds by the helper's own name ({doneCount}), so derived values need no model field. One binding name per member: a helper that collides with a field (or another helper) is a taught NS1031. - Update-only state (fields, helpers, or Msg kinds nothing in markup binds or dispatches — host-fired timer arms, persistence bookkeeping) is declared once:
export const viewUnbound = ["nextId", "tick"] as const;. It emits as theview_unboundopt-outnative check's unbound-state lint reads; a name outside the model surface is a taught NS1032. Entries are the TypeScript names exactly as declared ("nextId") and Msg kinds as theirkindtags — the same names markup binds, because there are no other names. - Names: your names are your names — fields, helpers, and locals emit into Zig with their TS spellings (
doneTodaystaysdoneToday), and markup binds them verbatim. String-literal unions emit as native enums.
Tests against the compiled app
Put Node test files directly in tests/*.test.ts and run native test. Import NativeApp and findWidget from @native-sdk/core/testing only in tests, never in src/core.ts. Each NativeApp.start() launches a separate headless native process with the real scriptc-compiled core and markup. Drive click/action/menu, assert returned model and accessibility snapshots, inspect and answer fake requests with respond, and fire declared timers with fireTimer. Supply explicit declared launch values with NativeApp.start({ environment: [{ name: "APP_MODE", value: "manual" }] }); they are journaled and replay does not read ambient environment. Framework app directories still use appDataDirectory. Always close() in finally or use await using. verifyReplay() verifies checkpoints and the final model in a fresh runtime; afterward only snapshot/close are allowed. Model bytes are JSON number arrays; native identities are decimal strings. The host uses a fixed clock and fake effects, without production persistence/services/app-data startup. See https://native-sdk.dev/docs/testing for the complete API and limits.
The arena mental model
Everything update builds lives in a per-dispatch bump arena that is freed wholesale after the returned model is committed, so spreads, map, and filter are cheap by construction. 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 for free.
That is why the immutable style is not a performance tax: { ...model, tasks: model.tasks.map(...) } copies one small struct and one pointer array, never the world.
Both regions are the compiled core's own: the frame arena bounds one dispatch's transients, the model heap holds the committed model between dispatches, and the compiler's determinism fences keep every dispatch allocation-shaped and replayable.
What the subset means
The subset is TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Concretely: every basic statement, operator, and declaration form of the language compiles (every loop shape including do...while, labels with labeled break/continue, switch with default, the full assignment-operator family, ** and the shifts, const record destructuring, namespace imports over your own modules). What does not compile falls into exactly two families, each with a named teaching rule: the ECOSYSTEM the binary cannot carry (npm packages, Node/DOM APIs, regex/JSON/Promise/generator machinery, eval — no JS engine ships), and the constructs that would break a core's guarantees (purity and determinism, fixed shapes, one text representation, functions as declarations not values, static types with no runtime tags). Classes and exceptions are NOT in either family: data classes and throw/try/catch/finally compile (see below) — only their guarantee-breaking tails (inheritance, unsafe finally, untagged thrown values) teach. A construct that fails with a generic error instead of a teaching rule is a checker bug — the grammar matrix test (grammar_matrix.test.ts) pins every grammar production to its verdict so no silent gap can appear.
The banned families at a glance (each diagnostic names the fix and the reason at the site):
- No JS engine ships:
eval,new Function, dynamicimport(),debugger(NS1013); regexes (NS1040); BigInt/Symbol (NS1044); generators (NS1042); async/Promises (NS1002); npm imports (NS1035). - Purity and determinism: mutation of SHARED data — parameters, model/msg trees, module tables, escaped locals (NS1001/NS1022/NS1051; your own function-local scratch arrays mutate freely, see "Local mutation" below), module-level
let(NS1010), ambient time/randomness/IO (NS1005), effects outside the Cmd/Sub return paths (NS1017/NS1025). - Fixed shapes and layouts: the class machinery beyond data classes —
extends/super/abstract(NS1055), accessors/#-privates/class expressions/this-as-a-value (NS1056/NS1006), mutable statics (NS1010), generic classes (NS1053) — plusdelete/getters/setters/computed keys (NS1012),for/in(NS1009), Map/Set (NS1011), runtime type/shape tests —typeof/in/instanceof/Object.*(NS1041). Data classes themselves compile —staticmethods,static readonlyconsts, and erasedprivate/protectedincluded (see "Data classes" below). - One text representation: string indexing (NS1004),
+concatenation and tagged templates (NS1018),stringmodel fields (NS1024), the byte-text stays-out spellings —charCodeAt/normalize/replaceand friends on bytes teach the byte-honest form (NS1060; the supported method surface is under "Text is bytes"). - Functions are declarations — or const local helpers: nested function declarations, non-const function values,
?.()(NS1046), and const helpers that capture/escape/under-annotate (NS1054 — the legal shape is below under "Local function values"); fixed arity — no defaults, rest,arguments, call spreads (NS1019); generics live on module-level declarations and monomorphize per call site (NS1050/NS1053). - The mapping stays exact:
varhoisting (NS1049), loose==(NS1048), comma/void/assignment-as-value outside a for-header (NS1043), array/parameter destructuring (NS1045),export default/export =/export * from(NS1047 — export lists and named value re-exports compile), namespace-alias-as-value and SDK namespace imports (NS1039).
What compiles (v1)
Model and message shapes:
interfacewithreadonlyfields; nested interfaces;T | nullfor optional data.- Model field types:
number,boolean, string-literal unions ("all" | "active" | "done"→ native enum), numeric-literal unions,Uint8Array(bytes), a nested interface,readonly Interface[](arrays of object types), primitive arrays (readonly number[],readonly boolean[], arrays of literal-union tags), a tag-discriminated union ({ kind: "list" } | { kind: "detail"; note: Note }— arms may carry records, bytes, and primitive arrays), andT | nullover any of these. Model unions compile to native tagged unions; switching arms inupdateretires the old arm's payload automatically at commit. Msg: a discriminated union on areadonly kindstring tag, with primitive / bytes / interface payload fields. It must be a real union — give it at least two arms, or TypeScript collapses the alias to a plain object type and the checker rejects it.
Logic:
switchon any union'skindtag —msg.kind(the Msg dispatch) and model-field unions (switch (model.view.kind)) alike — with member case labels, label stacking (case "a": case "b": body),break, and a trailingdefaultcovering the unnamed arms (without adefaultthe switch must be exhaustive — NS1015; with every arm named thedefaultis JS dead code and emits nothing) — andswitchon a string-literal-union or numeric-literal-union value (switch (model.filter)) — an uncovered member skips the switch exactly like JS (adefaultanywhere but last is a taught stop in both forms) — andswitchon a plainnumberorstringvalue, lowered to an if/else chain with exact JS semantics: strict equality per case (NaN matches nothing,-0matches0, strings compare contents), cases tested in source order,defaultmatching only after every case misses wherever it sits (an emptydefault:stacking onto the next body included);if/else; classicfor (let i = 0; ...)including countdowns (i--,i -= k), multi-counter inits (let lo = 0, hi = n), and comma incrementors (lo++, hi--— the for-header is the one home for comma sequences);do { ... } while (cond)(the body runs before the first test;continuejumps to the test, exactly node);for (const x of xs)over arrays andUint8Array, withbreak/continue, plus the indexed pair formfor (const [i, x] of xs.entries())(exactly the[index, element]two-identifier binding — the index is the loop index, integer-classed; other tuple shapes stay taught);while; labeled statements on loops and blocks with labeledbreak/continue(outer: for (...) { ... continue outer; }— a labeledcontinuein a classic for still runs the incrementor, like JS);letlocals with reassignment (andlet x: number;declared-then-assigned); ternaries;&&/||/!; the empty statement;.const { total, done: doneCount } = stats;— record-field destructuring into const locals (a compile-time alias per field, renames included). Array patterns, parameter patterns, defaults, rest, and nesting are taught (NS1045 — positions can be silently absent in JS; fields cannot).import * as util from "./util.ts"— a namespace import over your own modules is pure dot-syntax:util.helper(x),util.CONST, andutil.Cfgin type positions all resolve to the target module's flat names. The alias is not a value (storing or passingutilitself is taught), and the intrinsic@native-sdk/coremodule is always imported by name (NS1039 — the purity rules recognizeCmd/Sub/asciiBytes/utf8Bytesby their imported names).- Object spread
{ ...model, field: v }, array spreads in any shape — append[...xs, x], prepend[x, ...xs], multi-spread[...a, x, ...b](each compiles to one exact-size copy) —.length, indexingxs[i]. - Array methods, lowered to inlined loops and exact-size arena copies:
.map/.filter/.find/.findIndex/.some/.every/.reduce/.toSorted/.slice/.concat/.indexOf/.includes..mapis type-changing —tasks.map((t) => t.id)produces a number array,t => t.titlea bytes array, and a callback that can returnnullproduces an optional-element array. Callbacks on map/filter/find/findIndex/some/every may take the(element, index)pair — the index is the loop index, integer-classed (.reducestays(acc, x): its index parameter is not in v1, and no callback takes the third JS parameter, the array itself — reference the array by name). Array-method calls may sit directly inif/else ifand ternary conditions (if (xs.some((x) => x > 3))) — the scan lowers to a loop just before the branch; awhilecondition cannot (it re-evaluates per iteration — hoist into the loop body or restructure). Callbacks are arrows (expression or block body), inlinefunctionexpressions, or a BARE REFERENCE to a module-level function or const helper (xs.map(encodeTurn),xs.toSorted(byAscending)— the referenced body inlines exactly like the arrow spelled at the site); in a block body every code path must end in an explicitreturn(falling off the end would be JSundefined, which has no mapping — a taught stop). JS semantics hold exactly:.sliceresolves negative and out-of-range indices the JS way,.indexOfnever matchesNaNwhile.includesdoes,.some/.everykeep their vacuous defaults on empty arrays, and.reduceneeds its initial value (the no-initial form throws on an empty array in JS, so it is a taught NS1007 — pass the starting accumulator)..indexOf/.includeswork on scalar elements (numbers, tags, booleans); on record arrays JS compares references, which has no native mapping — match a field with.find/.findIndexinstead. - Local mutation — your own scratch is yours; shared data is immutable. An array your function CREATES — an array literal (
const stack: number[] = [],const st = [1, 2, 3]) or a fresh copy (.slice()/.map()/.filter()/.concat()/.toSorted()) — is locally owned, and the full mutating method set works on it with exact JS semantics:push(...items),pop(),shift(),unshift(...items),splice(start, deleteCount?, ...items)(negative/overshooting indices clamp the JS way; the value is the removed array, also yours),reverse(),fill(v, start?, end?), in-placesort(cmp), and indexed writesxs[i] = v. A parser stack, a work queue, a copy-then-sort — all legal, deterministic, and byte-identical to node. Ownership ends at the first ESCAPE: once the array is returned from a callback, stored into a record/array/model, aliased by a second binding (const b = a), or passed where the callee could keep or mutate it, mutating it afterwards is a taught NS1051 — finish mutating first, then let it escape (an early-exitreturnis fine: execution ends there, so mutations on the other path stay legal). Two loosenings keep real code flowing. BORROWING: passing an owned array into areadonly T[]parameter is NOT an escape when the callee only READS it (element/property access, iteration, spreads, further borrowing passes — no return of it, no store, no onward pass into a mutable position; recursion over borrowed slices included), so measure-mutate-measure loops work (total(out); out.push(x); total(out)). REASSIGNED-OWNING: aletbinding whose EVERY assignment installs a fresh owning construction (a literal or a copy —w = xs.filter(...),acc = []) stays owned through the reassignments; ONE mixed assignment (an alias, a parameter, a helper result) and the binding never owns (NS1001 names it). Never owned: parameters, model/msg data, moduleconsttables, aliases, mixed reassigned bindings, and arrays produced by helper calls (copy with.slice()to own one). After the value escapes it is an ordinary immutable value; the commit walkers and sharing discipline are unaffected because ownership ended before the escape. - Local-mutation shape notes:
push/unshiftreturn the new length in JS, which has no mapping — mutate as a statement and read.lengthafter;sort/reverse/fillreturn the same array — mutate as a statement, then use the array by name (return copy.sort(cmp)is a taught stop; the canonical form isconst copy = xs.slice(); copy.sort(cmp); return copy;);pop()/shift()returnT | undefined— the same one-empty the.findmiss produces, so test=== undefinedor fold with??(stack.pop() ?? fallback); spread arguments (out.push(...xs)) stay taught — one element per iteration;xs[xs.length] = von an owned array IS a push (the one growth shape — compound forms likexs[xs.length] += vread the missing slot first and stay taught), and other out-of-bounds writes are JS sparse arrays with no mapping (they trap on the native bounds check in safe builds — keep writes inside0..length-1); changing the LENGTH of the array afor...of(or one of its own callbacks) is iterating is a taught stop (JS walks the live array; fixed-length writes during iteration are fine and identical to node);copyWithinstays out of v1 (splice/fill cover it). .toSorted(cmp)sorts a copy in one expression;.sort(cmp)sorts in place on an array you own (on shared data it keeps the NS1022 teaching, which names the copy idiom). Both comparators follow the same rules: return a sign —(a, b) => a - bfor ascending numbers, or explicit -1/0/1 branches; a boolean comparator is wrong in JS itself (false claims equality) and is rejected by the types plus a taught NS1023. The comparator-less arity sorts by string ToString order in JS ([10, 9]stays[10, 9]), which has no float-text mapping — pass a comparator. Both sorts are stable exactly like JS: comparator 0 (or NaN) keeps the original order of the pair. One honesty note: a comparator that is inconsistent over the actual data (e.g.a - bwhen elements can beNaN) is implementation-defined in JS itself, so node and native may then disagree — keep comparators consistent.- A
.findmiss is the core's one empty value: JS spells itundefined, so test the result with=== undefined(never=== null— the checker teaches the difference) or fold it away with??:tasks.find((t) => t.id === id) ?? fallback. - Optional chaining
?.on property chains (model.sel?.at ?? 0), element hops (m?.xs[0] ?? 0,xs?.[i] ?? d), and method hops on supported receivers (xs?.slice(0, 2),xs?.includes(3) ?? false— every mapped array/bytes method): each hop null-propagates exactly like JS, and the chain value is optional — end it in??or compare it against a real value. A?.chain compared againstnull/undefinedis a taught error (NS1021), andg?.()on a function value stays taught. - Null-guard narrowing through
&&/||chains, exactly the way TS narrows:if (x !== null && x.items.length > 0), the flipped order (null !== x), the||dual (x === null || x.items.length === 0, including as an early-exit guard — the code after the exit stays narrowed), ternary conditions (x !== null && x.at > 0 ? x.at : -1), andwhile (cur !== null && cur.n > 0)loops (re-tested per iteration; assigning the guarded local drops the narrowing for what follows, like TS). Relational comparisons on guarded optionals (cls !== null && cls < lim) work too. - Nullish
??, comparisons (including===onstring-typed values — content equality, same as node;==/!=are taught NS1048 — coercion),+ - * / % **on numbers, unary+/-, and the bitwise family& | ^ ~ << >> >>>— all with JS number semantics (/is float division,%truncates,**is float pow with the exact JS corners —1 ** NaNis NaN,(-1) ** Infinityis NaN, right-associative2 ** 3 ** 2is 512; bitwise and shifts are ToInt32 with the shift count masked & 31,>>>yielding the unsigned 32-bit value; unary+is the identity on numbers).**and/results are float-classed; bitwise/shift operands are integer-required positions (a float operand is a taught NS1016). - Every compound assignment as a statement:
+= -= *= /= %= **= &= |= ^= <<= >>= >>>=, each exactlyx = x op v, plus the guarded forms&&=/||=(boolean targets; the right side evaluates only when assigned, like JS) and??=(optional targets; assigns only when null). A number++/--/assignment may sit in a VALUE position when the split statement is provably order-exact — the variable's only mention in the statement, in a position JS cannot skip (arr[i++],const n = ++count,const z = (y = 5); postfix yields the pre-step value, everything else the post-step value, exactly JS); every other value-position form is taught (NS1043 — ternary branches, short-circuit right operands, loop conditions, or a second mention of the variable). - The Math batch, every corner pinned to node:
Math.min/Math.max(any arity —Math.min()is Infinity,Math.max()is -Infinity, NaN propagates, -0 orders below +0),Math.round(half toward +Infinity),Math.floor/Math.ceil/Math.trunc(NaN/Infinity propagate; the -0 results keep their sign, soMath.ceil(-0.5)is -0),Math.abs(clears the zero sign),Math.sign(NaN stays NaN, a zero keeps its sign),Math.sqrt(negative input is NaN,sqrt(-0)is -0).Number.isInteger/Number.isFinite/Number.isNaNclassify like node, and theNaN/Infinityglobals are ordinary number values. Math calls over compile-time constants fold to their exact JS value —const HALF = Math.floor(5 / 2)is the integer 2,5 % 0is NaN,-5 % 5is -0. A bare-0literal (and constant arithmetic folding to -0, like0 * -1) is a float value — only f64 carries the signed zero — so it cannot flow into an index or another integer-required slot. The integer rule: floor/ceil/trunc/abs/sign of an integer-classed value stays integer-classed, but of a float value stays float (floor of NaN is NaN), sobytes[Math.floor(x / 2)]over a floatxis still a taught NS1016 — keep index flows integer end to end. - Template literals with integer holes (
`${n} of ${total}`) feedingutf8BytesorasciiBytes(below). Float-valued holes are not in v1 (JS float-to-string fidelity is a runtime v2 surface). - Module-level
constnumbers and strings fold to comptime constants, and const tables emit as rodata (no arena, shared for free at commit): arrays of numbers / booleans / strings / literal-union members (const WEEKDAYS = [3, 5, 2],const ORDER: readonly Filter[] = ["done", "all"]), records annotated with an interface (const LIMITS: Limits = { lo: 1, hi: 9 }), and arrays of records (const SEEDS: readonly Task[] = [...], names asutf8Bytesliterals). Element access,.length,for...of, and the array methods all work over tables. Everything inside must fold at compile time — no spreads, no calls exceptasciiBytesorutf8Byteson a literal — and a record table needs its interface annotation (an unannotated{ ... }is a taught stop naming the fix). Helper functions; recursion. - Generics — module-level, monomorphized per call site. A generic
function,interface, ortypedeclares type parameters and instantiates from tsc's RESOLVED type arguments (explicit or inferred):export function pick<T>(xs: readonly T[], i: number): T { return xs[i]; }called with tasks emitspick__Task, with numberspick__f64(a barenumbertype argument is always f64 — the JS-exact class), one readable Zig fn per distinct instantiation, deduped. Generics over records, unions, arrays, optionals, and bytes all work; generic interfaces/aliases instantiate structurally (Box<Task>emitsBox__Task;type Opt<T> = T | nullresolves straight through); generics may recurse and call other generics (the inner call resolves at the outer's instantiation).typeof CONSTtype-query aliases resolve through tsc too (const LIMIT = 9; type Limit = typeof LIMIT). The boundaries teach: a call site whose type argument stays abstract (pick([])infersnever;any/unknown, unnamed literal unions) is NS1053 — annotate the call or name the alias; generic function VALUES and generic entry points are NS1050. - Data classes — fields, one constructor, plain methods, statics; no inheritance.
class Task { title: Uint8Array; done: boolean = false; constructor(title: Uint8Array) { this.title = title; } toggle(): void { this.done = !this.done; } isDone(): boolean { return this.done; } }emits as a plain struct plus module-level functions;new Task(...)constructs a record-shaped value (field initializers run in declaration order, then the constructor body).staticmembers are per-class module declarations: astaticmethod lowers to a receiver-less module fn under the class's mangled name (Task.fromRow(...)resolves toTask__fromRow), and astatic readonlyfield with an initializer is a module const (Task.LIMIT— the module-const value rules apply: numbers/strings fold, tables need their annotation); a MUTABLE static is module state and teaches NS1010, and inside a static member reach other statics by the class name, neverthis(NS1056).private/protectedkeywords are accepted and ERASED — tsc enforces them at the type level, which is their whole meaning (#-fields stay taught: runtime privacy brands).thisreaches instance fields and methods (this.count,this.step()) — anything that letsthisescape as a value (returning it, storing it, passing it) is taught (NS1056), so fluent chaining is out. Mutation follows exactly the array ownership rule: an instance your function creates withnewmutates freely — direct field writes (t.count = 1,t.count += 2) and methods that writethis— until it ESCAPES (returned, passed, stored, aliased — then NS1051), and parameters/model data never mutate (NS1001); methods that only read are callable on anything. Fields require type annotations; instances flow between functions, sit in arrays, and compare/narrow like records. The class TAIL teaches by name:extends/super/abstract(NS1055 — compose, or model variants as akind-union), getters/setters/#-privates/accessor/class expressions (NS1056), mutable statics (NS1010), generic classes (NS1053), parameter properties (NS1008),instanceof(NS1041 — akindfield is the tag that exists). Class instances stay LOCAL values in v1: storing one in the Model tree is taught (NS1056) — keep records (interfaces) in the Model and construct the class where behavior is needed. - Exceptions —
throw/try/catch/finallyas pure control flow. Inside a core, exceptions are deterministic:throwcarries a subset VALUE and unwinds to the nearest enclosingcatch— across helper calls, out of array-method callbacks (athrowinside.map's callback exits the whole loop, like JS), through nestedtrys, withfinallyrunning on every path (fall-through,return,break/continue, and throw alike). The discipline is two rules. First (NS1057): thrown values are kind-tagged subset shapes — throw kind-discriminated records (throw { kind: "bad_digit", at: i } as ParseError, whereParseErroris an interface with a string-literalkindfield or akind-discriminated union; a single-shape core may also throw a number), and SEVERAL distinct shapes may throw: the checker collects every shape the core throws into its implicit thrown union. The catch binding IS that union — narrow it in place with kind tests, noasceremony:catch (e) { if (e.kind === "bad_digit") return -e.at; if (e.kind === "io") return e.code; return -1; }(orswitch (e.kind)— tsc cannot prove exhaustiveness over the implicit union, so give the switch adefaultor a trailing return). Bare rethrow (throw e;) re-raises the bound value — a narrowed arm included — andcatch { ... }needs no binding; the single-asform (const err = e as ParseError;) stays legal in single-shape cores (and for a DECLARED union whose arms equal the thrown set — declaretype AppError = ... | ...andas AppErrorworks). What teaches: untagged values in a heterogeneous set, two shapes sharing onekindwith different payloads, asserting one member shape of a multi-shape core, the binding escaping untyped into a call/store/return, andthrow new Error(...)(engine error objects carry stack traces with no native layout). Second (NS1058):finallynever redirects control flow — noreturn/throw/break-out inside it (JS's own no-unsafe-finally rule; loops fully inside the finally may break within themselves). An UNCAUGHT throw that reaches an exported function's boundary is a defined deterministic panic — exactly where node's process would crash. A throw mid-mutation of an owned array keeps the mutations applied so far, exactly like JS — the catch sees the array as node would. - Local function values — const helpers hoist.
const scale = (x: number): number => x * 3;(arrow orfunctionexpression) hoists to an ordinary module-level fn when it is capture-free (module constants and other const helpers are fine to reference; enclosing locals/params are not — pass them as parameters), fully annotated (every parameter and the return type), and used only by direct calls (scale(v), recursion included) or as an array-method callback (xs.map(scale), comparators included). Everything else teaches NS1054: captures, missing annotations,letbindings, returning/storing the value, passing it to your own functions, calling through a record field. Capturing a locally-owned array also ENDS its ownership at the capture (a later mutation is the NS1051 teach) — the stored closure would retain the reference.
Not yet in v1 — genuine roadmap deferrals, each stopping with a loud, tailored NS9001 naming the rewrite (never missing basic syntax; the banned-with-a-rule families live in "What the subset means" above). The rule-level deferrals carry class: "deferred" in the diagnostics catalogue — NS1011 (Map/Set), NS1019 (fixed arity), NS1040 (regexes), NS1042 (generators), NS1044 (BigInt/Symbol) — and their diagnostics say the capability is deliberately deferred, not impossible; the list below is the method-level remainder: .toSorted()/.sort() without a comparator (JS ToString ordering; pass (a, b) => a - b), .reduce without an initial value (a taught NS1007 — JS throws on an empty array) or with an index parameter (use a classic loop), .indexOf/.includes on record arrays (match a field with .find/.findIndex), .join on number arrays (elements are float-valued; join byte values instead), float values (/, **, Math.round, Math.sqrt, float Math.floor-family results) where an integer is required such as an index (a taught NS1016 — those values can be fractional or NaN), Math methods beyond the batch above, Number methods beyond the three classifiers, float-valued template holes (JS float-to-string fidelity is a runtime v2 surface), arrays of unions (readonly View[]) or arrays of byte-strings (readonly Uint8Array[]) as model fields (wrap the element in a single-field interface), a collect spawn's stderr tail (v1 delivers the exit code and stdout; stderr is not surfaced — put diagnostics on stdout or check the code), per-line truncation flags (a stdout or response line over its configured bound arrives cut, without a flag), and subscriptions beyond Sub.timer, Sub.globalShortcut, and generated live queries (one-shot needs are Cmd.delay, and process/fetch/audio streams are Cmd-initiated, not subscribed).
Effects are Cmd data
update never performs an effect — it can return one, as inert data, alongside the next model. Import the factories from the SDK and declare the pair-return type:
import { Cmd } from "@native-sdk/core";
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "add":
return [{ ...model, count: model.count + 1 }, Cmd.persist()];
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]
case "ship":
return [model, Cmd.batch([Cmd.persist(), Cmd.host("beep", model.count)])];
}
}
The command set (Cmd wire format v7):
Cmd.none— no effects; returning a bareModelis sugar for[model, Cmd.none].Cmd.persist()— snapshot the just-committed Model through the engine-owned store. Requires"persist"inapp.zoncapabilities plus.persist = .{ .version, .restore = .{ .ok, .none, .err } }; the host owns canonical encoding, debounce/coalescing, atomic app-data placement, backup recovery, and replay.Cmd.now("tick")— request a timestamp; the runtime dispatches the named Msg arm with the time (ms) as its payload. The target arm must carry exactly one number field ({ kind: "tick", at: number }), and tsc checks that for you.Cmd.host(name, ...args)— a fire-and-forget host command by literal name; the host decides what the name means. Args are numbers, OR exactly one bytes payload: aUint8Array(Cmd.host("clipboard.write", model.draft)) or a flat inline record of number/boolean/Uint8Arrayfields (Cmd.host("cfg.save", { gain: model.gain, on: model.muted, label: asciiBytes("main") })) — the record lowers to one bytes payload from your types at build time, byte-identical under node and native. Anything else (a smuggled string, a nested record, a payload plus extra args) is a taught error (NS1020/NS1026).Cmd.request(name, payload, { key?, ok, err })— a routed host command: the host performsnamewith the payload (same bytes/record rules) and dispatches exactly one result back to you as an ordinary Msg — theokarm with the result bytes on success, or theerrarm with the error bytes on failure. Both arms must carry exactly oneUint8Arrayfield ({ kind: "loaded", body: Uint8Array }), checked by tsc and taught by NS1027. The routing is data — string-literal arm names, never callbacks — so the result decoder derives from your Msg types at build time. The optionalkey(a string literal) names the in-flight effect: issuing a request whose key is already in flight replaces it (the old result is dropped), which is the debounce/exactly-one-in-flight discipline.- Typed app services are the deliberate record-valued arm of this family: declare shared data shapes outside
src/services/, import generated constructors from@native-sdk/services, and let those constructors encode requests and prove the typed success Msg arm. Seenative skills get ts-services. RawCmd.requestremains bytes-oriented. Cmd.cancel(key)— drop the in-flight keyed effect with that key, silently: a cancelled raw request, buffered named engine op (readFile/writeFile/fetch/clipboardRead), streamed file read, or armed delay dispatches NEITHER arm. Live spawn, streaming-fetch, streaming-service, and streamed write sinks are the exceptions: cancel ends them througherr: cancelled; a half-written sink is observable and must never disappear silently.Cmd.batch([a, b])— several commands from one dispatch, performed in order.
The named engine ops
These map directly onto the host's effect engine — files, HTTP, the clipboard, notifications, one-shot timers. Routed operations follow the Cmd.request rules (inline { key?, ok, err }, string-literal arm names, arm shapes checked by tsc and taught by NS1027), with one difference from request: each op's ok arm has the op's OWN result shape. Keys follow the one keyed-effect rule everywhere: issuing a keyed op whose key is already in flight REPLACES the old one (the superseded op's result is dropped — no message), and Cmd.cancel(key) drops it silently. Every err arm carries exactly one Uint8Array field and receives a machine-readable reason; fire-and-forget writes and notifications have no routing arms. Paths, URLs, and bodies are bytes (asciiBytes for literals); dynamic values the engine refuses at runtime surface through the err arm, while compile-time-knowable bounds for routed operations stop the build (NS1030). Fire-and-forget operations fail closed under the host's validation instead.
Cmd.store.set(key, bytes, { key?, ok, err })/get/delete/scan/setMany— capability-gated, engine-owned per-record storage. Declare"store"inapp.zon; keys are UTF-8 strings up to 512 bytes and values are bytes up to 1 MiB.setManyapplies 1–64 entries atomically.get's ok bytes are[1][value...]for a hit and[0]for a miss, keeping an empty value distinct from absence.scan(prefix, { limit?, after? }, route)returns a little-endian length-prefixed page of key/value pairs plus an opaque next-key cursor; pass those next-key bytes back asafter(a known literal cursor may be a string). The limit defaults to 100 and is capped at 256. Writes run off-loop in issue order, a synchronous read waits for earlier writes in the same command walk, every result journals, and failures route one ofio_failed,over_bound,bad_key,rejected, orbusy. The SQLite backing and app-data path are never part of the app-facing API.- Checked relational SQLite: declare
"sqlite"inapp.zon. Add append-only, contiguoussrc/schema/NNNN_name.sqlmigrations (STRICTordinary tables), commit the generatedsrc/schema/migrations.lock.json, and add named statements insrc/queries.sql:-- name: notesInFolder,-- name: moveNote :exec, or-- name: notesInFolder :live.native checkapplies the migrations and prepares every query with real SQLite, then generates flatCmd.q<Name>(params, route)query constructors, typedCmd.q<Name>(params)exec members composed only throughCmd.qTx([...], route),Sub.q<Name>(key, params, route)for:live, row/param interfaces, anddecode<Name>Page. TEXT/BLOB rows areUint8Array; parameters inferred from TEXT columns wrap bytes automatically; raw or uninferred dynamic TEXT (for example an FTSMATCHterm) usesdbText(bytes). INTEGER is an exactnumberand generated decoders reject values outside ±(2^53−1). Live queries rerun after committed writes to their generated table dependency set, coalesced once per command frame.native db new-migration <name>,status, andreset --yesown development migration operations. Do not edit or reuse a shipped migration number. Cmd.db.query(sql, params, { key?, page, done, err })/Cmd.db.exec(statements, { key?, ok, err })— the permanent raw SQLite escape hatch. The engine ownsapp.db; parameters arenull | number | string | Uint8Array | dbText(bytes) | boolean. A read delivers 256-row/256-KiB pages thendone, with one result capped at 8,192 rows or 8 MiB; crossing either total bound rejects the whole result, so useLIMITand keyset pagination for larger collections. An exec commits its 1–64 statements as ONE transaction. SQL remains pathless:ATTACH/DETACH/VACUUM INTO, TEMP schema objects, and engine lifecycle PRAGMAs are denied. Query keys replace/cancel silently; duplicate transaction keys reject loudly. Error bytes areconstraint,busy,io_failed,corrupt,misuse,rejected, orcancelled. Every page/terminal journals and replay never opens SQLite. Prefer declared queries; NS1420 nudges raw query literals toward them.Cmd.readFile(path, { key?, ok, err })— read a whole file.okarm: oneUint8Arrayfield with the content.errreasons:not_found,io_failed,truncated(the file exceeds the engine's 1 MiB read bound — a cut file never passes as whole),rejected. Paths are at most 1024 bytes.Cmd.writeFile(path, bytes, { key?, ok, err })— write a whole file (parent directories created, an existing file replaced whole; at most 1 MiB).okarm: NO payload fields ({ kind: "wrote" }) — a successful write has nothing to report.errreasons:io_failed,rejected.Cmd.appendFile(path, bytes, route)appends one bounded payload;Cmd.statFile(path, route)returns{ exists, size, mtimeMs };Cmd.deleteFile(path, route)deletes one file with a payload-lessokarm and routes a missing path asnot_found(a final symlink is unlinked without deleting its target).Cmd.readFileStream(path, { key?, chunk, done, err })delivers 256-KiB chunks then the total; reissuing or cancelling its key silently replaces/drops the read. Atomic exports open withCmd.writeFileStream(key, path, route), send one acknowledgedwriteFileChunk(≤1 MiB) at a time, thenwriteFileClose; a duplicate sink rejects, overlapping chunk/close routesout_of_order, and sink cancellation is loud. Stream chunks spill to the session blob store for byte-identical replay. Raw paths under this app's resolved data/config/cache/state/logs/temp roots need no grant; every external path requires thefilesystempermission. The runtime resolves existing parents and symlinks before checking (an in-root symlink pointing out is external); NS1074 catches certainly-external literals.Cmd.fetch({ url, method?, headers?, body?, timeoutMs? }, { key?, ok, err })— a buffered HTTP(S) exchange.okarm: exactly two fields, onenumberand oneUint8Array({ kind: "fetched", status: number, body: Uint8Array }) — matched by type, so the names are yours. The status is the real HTTP status: a 404 is stillok(an HTTP-level error is a delivered response).errreasons:connect_failed,tls_failed,protocol_failed,timed_out,rejected, andtruncated(the body exceeded the engine's 256 KiB buffered bound — never delivered silently cut). The spec is an inline object:urlbytes (≤ 2 KiB),methodone of"GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"(default GET),headersan inline flat record — names are compile-time ASCII, values are string literals OR runtime bytes ({ authorization: bearerToken(model.apiKey), "content-type": "application/json" }— how a launch-supplied key rides anAuthorizationheader; ≤ 8 headers, ≤ 1 KiB total, NS1029/NS1030),bodybytes (≤ 64 KiB),timeoutMsa positive integer literal (engine default when omitted).Cmd.clipboardWrite(bytes)— put bytes on the system clipboard, fire-and-forget: there is no routing, and a refused or over-bound write is dropped by design.Cmd.clipboardRead({ key?, ok, err })— read the clipboard.okarm: oneUint8Arrayfield with the text.errreasons:failed(no clipboard service, over-bound content, pasteboard error),rejected.Cmd.showNotification({ id?, title, subtitle?, body?, actionLabel?, actionCommand? })— request a desktop notification, fire-and-forget:titleis required bytes (1–128),subtitle,id,actionLabel, andactionCommandare optional bytes (up to 128), andbodyis optional bytes (up to 1024). A nonemptyidreplaces the app's earlier notification with that id where the OS supports replacement.actionLabelandactionCommandmust be supplied together; activation dispatchesactionCommandthrough the ordinary app-command path while the process is running. The host validates every field and command name before entering the platform service; invalid or unavailable requests fail closed, and Focus / Do Not Disturb and user settings remain authoritative after acceptance. Fake execution and session replay never display one, while the null test platform can activate a recorded notification deterministically.Cmd.openExternalUrl(url)— open an HTTP(S) URL in the system browser, fire-and-forget.urlis bytes; the runtime validates it and enforcessecurity.navigation.external_linksbefore entering the platform service. Invalid, denied, or unavailable requests fail closed; fake execution and replay never open one.Cmd.revealPath(path)— reveal a byte path in Finder, Files, or Explorer, fire-and-forget. Runtime path validation stays in force; invalid or unavailable requests fail closed, and fake execution/replay never reveal one.Cmd.credentials.set(key, secret, { key?, ok, err })/Cmd.credentials.get(key, route)/Cmd.credentials.delete(key, route)— app-scoped Keychain on macOS/iOS, Secret Service/libsecret on Linux, Credential Manager on Windows, and AndroidKeyStore-wrapped authenticated ciphertext on Android. The authored credential key is a NUL-free string (UTF-8, ≤256 bytes); the manifest app id (≤128 bytes) supplies the service namespace, and secrets areUint8Arrayvalues through 2,560 bytes (the cross-platform floor set by WinCred). Set/delete deliver empty bytes onok; get delivers secret bytes; the closed err reasons aremiss,denied,locked,io_failed,over_bound, andrejected. Declare BOTH"credentials"inapp.zoncapabilities and permissions; NS1071 warns when the capability is absent, NS1072 errors when permission is absent, and NS1073 reservescore.credentials.*for the typed factories. Never retain a returned secret in Model: consume its Msg immediately to construct the next effect. Recording stores only a successful get's length, per-session salt, and a placeholder digest independent of the secret (never the secret or a blob); replay delivers deterministic same-length placeholder bytes, and devhost keeps an in-memory map while printing only<redacted, N bytes>.Cmd.keyboard.sendChord({ key, primary?, command?, control?, option?, shift? }, { key?, ok, err })/Cmd.keyboard.accessStatus({ prompt? }, route)— send a key chord into whichever app has keyboard focus (other apps, through the OS; this is not in-app automation). The chord has theSub.globalShortcutbinding shape (keyisasciiBytesfrom the shortcut key vocabulary) but needs no modifier. Both arms carry oneUint8Array: chordokis empty, accessokisgranted,denied, ornot_required. Err reasons arerejected(missing"keyboard_input"permission — NS1075 catches it at check time, including rawnative-sdk.keyboard.*requests),unsupported(Linux, mobile),invalid_request,denied(macOS Accessibility not granted; nothing posted),blocked(Windows refused the input), andfailed.okmeans posted, not delivered: Windows drops input to elevated windows without an error. Do not send a chord equal to one of the app's own global shortcuts. Replay, fake execution, and devhost never post input.Cmd.formatLocalTime(timestampMs, "date" | "time" | "datetime", { key?, ok, err })— format epoch milliseconds (the same unitCmd.nowproduces) through the host's current locale and local time zone;okcarries localized UTF-8 bytes. This is deliberately an effect rather than a pure helper: locale/timezone are ambient state, so the observed bytes journal and replay without consulting the replay machine. Err reasons areinvalid_request,unsupported, andfailed.Cmd.delay(key, ms, "fired")— a keyed ONE-SHOT timer: dispatches the named arm once,msfrom now, with the fire time (ms) as its single number payload (the same arm shapeCmd.nowandSub.timertarget). Re-issuing a live delay key re-arms it from now — that is the debounce discipline (Cmd.delay("autosave", 800, "save_now")on every keystroke, one fire after the pause).Cmd.cancel(key)drops it silently. The interval is 1ms to one year; a literal outside that stops the build (NS1030).
Model persistence is capability-gated. Without "persist", NS1028 warns that Cmd.persist() has no linked host binding; declaring the capability without issuing the command produces the inverse warning. Configure a monotonic schema version and three boot routes in app.zon: .persist = .{ .version = 1, .restore = .{ .ok = "restored", .none = "fresh_boot", .err = "restore_failed" } }. The ok/none routes name void Msg arms; err names a one-Uint8Array-field arm receiving corrupt, version_unknown, migrate_failed, io_failed, or rejected from boot restore or a later write. native check validates those route names and payloads, and native dev --core runs the same fence before starting its virtual host. Check stores the last accepted version/model-fingerprint pair under .native/cache and warns with NS1068 until a changed Model advances the version; runtime fingerprint checks remain the hard gate on cold checkouts. An older binary also refuses writes with version_unknown while a newer-schema snapshot is installed, preserving that snapshot across a rollback. A pure optional migrate(snapshot: Uint8Array, fromVersion: number): Model hook handles older versions (throw to fail). Version 1 snapshots the whole Model in a tagged, length-delimited engine format, so never put credentials in it. Cmd.readFile/writeFile are for user-visible files, exports, and blobs, not routine model storage.
The streaming ops: fetch, spawn, audio, and channels
Four effect families deliver MANY results from one command — a keyed stream the app opens imperatively and drives (this is the opposite of Sub: a Sub is declared from the model and the host reconciles it; a stream is a Cmd with a lifecycle you cancel or stop). Routing still follows the Cmd.request rules: string-literal arm names, shapes checked by tsc and taught by NS1027.
Cmd.fetch({ url, method?, headers?, body?, timeoutMs?, maxLineBytes? }, { key?, line, ok, err })— line-stream an HTTP(S) response. Each complete response line dispatcheslineas oneUint8Arrayfield, so SSE and NDJSON parsers can update the Model incrementally. Exactly ONE terminal follows: a delivered, lossless response (non-2xx included) dispatchesokas one number field carrying the HTTP status; a cut/dropped line dispatcheserr: truncated, and transport failure, timeout, rejection, or cancellation dispatcheserrwith that reason.maxLineBytesraises the 4 KiB per-line default up to 256 KiB for large event records; the per-line truncation flag is not exposed directly because any such loss makes the terminal loud. A duplicate live key is rejected rather than replaced so two responses cannot splice into one logical stream;Cmd.cancel(key)ends it loudly througherr: cancelled.Cmd.spawn(argv, { key?, stdin?, line?, exit, err })— run a subprocess, streaming stdout line by line.argvis an inline array literal of bytes elements ([asciiBytes("/bin/ps"), asciiBytes("-axo")], at most 16 elements, 2 KiB total; the array shape is NS1029, the bounds NS1030), andstdin(optional bytes, ≤ 4 KiB) is written to the child once. Each stdout line dispatches thelinearm (oneUint8Arrayfield) as it arrives, across dispatches; omitlineto drop lines (an exit-only spawn, e.g. piping stdin topbcopy). Exactly ONE terminal ends the stream: a clean exit dispatches theexitarm — one number field carrying the exit code (a non-zero code is stillexit: the process ran; its failure code is yours to read) — and every other end dispatcheserrwith the reason bytes:signaled,cancelled,rejected(a duplicate live key, or dynamic argv/stdin the engine refused),spawn_failed(the binary could not start). Lines over the engine's 4 KiB line bound arrive cut.Cmd.spawn(argv, { key?, stdin?, collect: true, exit, err })— the same child, whole stdout buffered instead of streamed (the system-monitor shape: runps, parse the block). Nolinearm (NS1027 teaches the conflict). Theexitarm is a two-field record — one number field (the exit code) and oneUint8Arrayfield (the collected stdout, up to 512 KiB), matched by type likeCmd.fetch's arm. Collected stdout over the bound routeserrwithtruncated— a cut block never parses as whole.Cmd.cancel(key)aimed at a live spawn ends the child mid-stream; the stream'serrarm dispatches withcancelled— loud on purpose, because killing a process is an observable event (the contrast with buffered named ops, whose cancel is silent). Spawn keys share streaming fetch's duplicate discipline: a spawn whose key is already streaming is rejected (errgetsrejected), never replaced — a running subprocess is never killed implicitly; cancel it first.Cmd.audioPlay(key, { path?, url?, cachePath?, expectedBytes? }, { event })— open the audio event stream. One player is the whole surface, so a newaudioPlayalways REPLACES the current playback (the one key-reuse exception besidesCmd.request). The source cascade is the engine's: the localpathis tried first, a missing file falls through tourl(streamed progressively, cached atcachePathwhen given, integrity-gated byexpectedBytes— omitted/0 means unknown size). At least one ofpath/urlis required (NS1029); each is bytes, at most 1 KiB (NS1030). Prefer OMITTINGcachePathfor URL sources: when the app wiring configures a caches directory (TsUiApp'saudio_cache_dir), the host derives the conventional content-addressed cache path from the URL itself — your update never builds filesystem paths, and replay re-derives the same path by construction. PasscachePathonly to override that convention.- The
eventarm is the one SDK-fixed record shape, six fields matched by NAME:state(theAudioStatestring-literal union — import it from@native-sdk/core/events, or declare an alias with exactly the members"loaded" | "position" | "completed" | "failed" | "rejected" | "spectrum"in any order; the runtime matches members by name),positionMs: number,durationMs: number(milliseconds; the duration is the player's estimate),playing: boolean,buffering: boolean(true while a streamed url is stalled waiting for bytes), andbands: Uint8Array(the 32 spectrum band magnitudes, 0–255 each, all zeros outside"spectrum"events). Every playback event dispatches this arm —"failed"(unplayable source, decode/device failure) and"rejected"(an empty or over-long source) included, so failure is never silence — untilCmd.audioStopcloses the stream."completed"fires once at the natural end and does NOT close the stream: starting the next track from it is the idiom. Cmd.audioPause(key)/Cmd.audioResume(key)/Cmd.audioStop(key)/Cmd.audioSeek(key, ms)/Cmd.audioSetVolume(key, volume)— fire-and-forget control verbs: no result of their own; their consequences arrive on the event stream (audioResumeon a dead player reports one"failed"event, never silence). A verb whose key names no open stream is a no-op.audioStopis the audio stream's close — no events for the key after it (Cmd.canceldoes not apply to audio). Volume is clamped 0..1 and remembered across tracks; a literal outside 0..1 (or a negative seek literal) stops the build (NS1030).Cmd.channelOpen(key, { event })— open an EXTERNAL-SOURCE channel under the app's numeric key: the host stages a long-lived, thread-safe posting seam its NATIVE side feeds — embedders and platform-services extensions post bytes from their own threads (sockets, watchers, workers), and each accepted post dispatches theeventarm as one"data"event. Posting is deliberately not a TS verb — compiled cores are single-threaded, so the core opens, closes, and receives while the posting handle lives on the native side (Effects.channelHandle(key)).keymay be any number expression, a positive integer below 2^53 (a certain-to-be-refused literal stops the build, NS1030). Theeventarm is a five-field record matched by NAME:key(the channel key echoed verbatim, so concurrent channels sharing one arm stay distinguishable; a key the wire cannot carry exactly echoes 0),state(theChannelStateunion — import it from@native-sdk/coreor declare an alias with exactly the three members"data" | "closed" | "rejected"in any order; checked BOTH directions, since a narrower union would silently drop states the host emits),bytes(Uint8Array— the post's payload on"data"events, empty otherwise), anddroppedPending/droppedTotal(numbers — the honest back-pressure counters: posts the native handle refused since the previous delivered event, and over the channel's whole life; refused posts count, never silence). One channel per key at a time — a duplicate live key dispatches `"
Truncated - read the full file at https://github.com/vercel-labs/native/blob/HEAD/skill-data/ts-core/SKILL.md.
