Even Realities · Glasses OS · Platform Design

From handler apps to a developer platform

How a third party writes, packages, and ships an app for the glasses — the runtime that hosts it, the package it lives in, the ABI it speaks, and the tooling that builds it. A concrete proposal grounded in the prototype we already run.

Updated2026-06-14
StatusDesign proposal — builds on the working PoC
Companionimplementation.html — what exists today
00 · The question

Apps are written by other people

Today an "app" is a Rust struct compiled into the OS. That is fine for first-party demos, but the glasses platform only matters if third parties can build apps without our source tree, ship them as a self-contained artifact, and have a user install one the way they install an APK — pick it, grant permissions, run it. That requires four things we do not have yet:

A package

A distributable, versioned, signed artifact — code + manifest + assets — that installs without rebuilding the OS.

A runtime

A sandbox that loads untrusted third-party code safely, with hard memory / CPU limits and no ambient capabilities.

An SDK

A stable API surface + a macro that turns a developer's handler into a loadable module, hiding the wire ABI.

Tooling

A CLI to scaffold, build, validate, simulate, and package — with our two-process simulator as the dev loop.

The thesis: we already run a wasm module under wasmtime with a hard memory cap and fuel metering — that is the glasses brain. The same machinery, pointed at app modules on the phone, gives us a real platform with very little new infrastructure.
01 · Where we are — the handler model

An app is a trait, compiled in

An app implements one Rust trait. launch() returns the initial view (a restricted DSL tree, Node); tick() and the event hooks return partial updates (Op) or a new view. The glasses render the DSL and resolve view-state locally; only app-state events bubble back to this code on the phone.

pub trait App: Send {
    fn manifest(&self) -> Manifest;                       // id, name, icon
    fn launch(&mut self) -> Node;                          // initial view (DSL)
    fn tick(&mut self) -> Vec<Op> { vec![] }                // periodic partial updates
    fn menu_actions(&self) -> Vec<(&str, &str)> { vec![] }   // context-menu items
    fn on_action(&mut self, id: &str) -> Vec<Op> { vec![] } // a menu item was picked
    fn on_voice(&mut self, phase: &str) -> Vec<Op> { vec![] }// long-press dictation → ASR
    fn on_event(&mut self, code: u32, focus: usize) -> Option<Node> { None } // confirm/back
    fn supports_fullscreen(&self) -> bool { false }        // opt into full-screen
    fn set_fullscreen(&mut self, on: bool) -> Node { .. }   // the full-screen view
}

The catalog is a hardcoded make(id) factory; "install" just flips a flag; "launch" calls make() and drops the instance into one of three desktop slots. Everything runs in the phone process.

What's good here

The trait is the right shape — a small, declarative surface that already covers views, updates, menus, voice, navigation, and full-screen. We keep it verbatim.

What blocks a platform

It needs our source to compile, runs with full host trust, has no package or versioning, and the catalog is fixed at build time. None of that survives third parties.

02 · The target model

Apps are sandboxed WASM packages, loaded at runtime

An app compiles to a WebAssembly module. The phone runtime loads each installed app into its own wasmtime instance — a separate linear memory, a per-callback fuel budget, and zero capabilities except the imports we hand it. This is exactly how the glasses brain already runs; we are reusing a proven sandbox, not inventing one.

  installed packages                 phone = app authority                          glasses (render only)
  ┌───────────────┐    load .wasm    ┌──────────────────────────────────────────┐   ViewBundle / Op   ┌──────────────┐
  │ com.acme.chat │ ───────────────▶ │  wasmtime Store (per app)                  │ ───── BLE ──────▶  │ shell renders │
  │  app.wasm     │                  │   mem cap · fuel · capability imports only  │                    │ DSL, local    │
  │  manifest.toml│   capabilities   │  ─────────────────────────────────────────│   AppEvent bubble  │ view-state SM │
  │  assets/      │ ◀───granted at──▶ │  host: storage · timers · ASR · net (gated)│ ◀──────────────── │ (3 MB cap)    │
  └───────────────┘     install       └──────────────────────────────────────────┘                    └──────────────┘
Key boundary

Apps run on the phone, never on the glasses. The glasses are memory- and CPU-bound (3 MB SRAM); they only render the DSL and resolve local view-state. The phone has the headroom to host many sandboxed modules. This split already exists and is the right one.

Isolation for free

wasmtime gives memory isolation, a fuel ceiling per call (a runaway app can't hang the phone), and no syscalls unless we import them.

Any source language

Anything that targets wasm32 — Rust, C/C++, TinyGo, AssemblyScript, Zig. The ABI is language-agnostic.

Same types, same renderer

The DSL (shared) and the on-device LVGL renderer are unchanged. An app just produces Node/Op — it cannot draw arbitrary pixels.

03 · Language & runtime decision

WASM as the contract; Rust as the first-class SDK

We separate two questions. The package payload is always WebAssembly — that is the security and portability boundary. The SDK is the developer-facing layer on top, and it is Rust-first because the codebase, the DSL types, and the best wasm toolchain are all Rust today. Other-language SDKs can be layered onto the same ABI later.

OptionSandboxDXVerdict
WASM module, Rust-first SDKwasmtime — strong, already in useWrite the same trait + one macro; cargo build to wasmrecommended reuses everything we built
WASM module, any languageidenticalLower (hand-write the ABI, or wait for per-language SDKs)enabled, later same ABI, SDKs follow demand
Embedded scripting (JS / Lua)need a separate interpreter sandboxHigh for web devs, weak typingfuture layer compile to wasm or ship an interpreter-as-wasm host
Native dynamic library (.so / .dylib)none — full host trustFamiliarrejected no isolation; unsafe for third parties

The crucial property: an app can only do what its imports allow. A module with no host_net import physically cannot reach the network — capability is structural, not policy. AssemblyScript / JS support is genuinely valuable for adoption and is on the roadmap, but it rides the same ABI, so it is additive rather than a fork.

04 · The package — .glsapp

One signed archive; a static manifest the OS reads without running code

A package is a signed archive. The manifest is declarative and inspectable — the OS reads it to list the app, prompt for permissions, and build the context menu, all before the module ever executes. Code only runs after install + grant.

com.acme.chat-1.2.0.glsapp
├── manifest.toml      # identity, capabilities, menu, full-screen — read WITHOUT running code
├── app.wasm           # the compiled module (the only executable part)
├── assets/            # icon, vector art, subset fonts (addressed by the DSL)
└── SIGNATURE          # developer key → integrity + provenance
[app]
id      = "com.acme.chat"      # reverse-DNS, globally unique
name    = "Chat"
version = "1.2.0"              # semver; the store + OS gate upgrades on it
icon    = "assets/icon.svg"
min_os  = "1.0"               # host rejects modules newer than it understands

[capabilities]                 # the ONLY powers the module gets; prompted at install
storage   = true               #   persisted key-value store, scoped to this app
voice_asr = true               #   may request dictation (powers VoiceInput)
network   = ["api.acme.com"]   #   host-mediated egress, allow-list only
timers    = true               #   periodic tick() wakeups

[[menu]]                        # context-menu items — STATIC, see §07
id = "mark_read"
label = "Mark all read"
[[menu]]
id = "mute"
label = "Mute 1 hour"

[fullscreen]                    # opt into full-screen — see §08
supported = true
Static vs dynamic. Identity, capabilities, menu items, and full-screen support are static facts in the manifest. Views, updates, and action handling are dynamic — produced by the module at runtime. The OS leans on the static half to stay cheap and safe: it never has to run an app to know what it can do.
05 · The ABI — module ⇄ host

A small set of exports and capability imports

The contract mirrors today's trait, one wasm function per method, with the DSL passed as serialized bytes through a shared buffer — the exact inbox_ptr / length pattern the glasses brain already uses. The host drives the module; the module calls back only through granted imports.

Exports (the app implements)

app_launch()→ ViewBundle
app_tick()→ Ops
app_on_action(id)→ Ops
app_on_voice(phase)→ Ops
app_on_event(code, focus)→ ViewBundle?
app_set_fullscreen(on)→ ViewBundle
app_inbox_ptr() / _cap()shared buffer

Imports (the host grants, per capability)

host_log(ptr,len)always
host_now() → u64always
host_storage_get/setstorage
host_asr_request()voice_asr
host_http(req)network
host_timer(ms)timers

Bytes cross via the shared buffer (write args to app_inbox_ptr(), call the export, read the returned pointer/length) — no pointers are dereferenced across the boundary, so the module stays fully sandboxed. A binary encoding of Node/Op replaces JSON here for size, which also tightens the BLE budget downstream (see §10).

06 · The SDK — your handler is the app

The same trait, plus one macro

The whole point of the Rust SDK: a developer writes the trait they already write today, and a macro emits the wasm exports, the buffer plumbing, and the (de)serialization. The ABI is invisible.

use gls_sdk::*;                       // the SDK crate: App, Node, Op, host::*

struct Chat { open: Option<usize>, msgs: Vec<String> }

impl App for Chat {
    fn launch(&mut self) -> Node {
        Screen("CHAT", List(contacts()))         // DSL builders
    }
    fn on_event(&mut self, code: u32, focus: usize) -> Option<Node> {
        if code == CONFIRM { self.open = Some(focus); Some(self.conversation()) } else { None }
    }
    fn on_action(&mut self, id: &str) -> Vec<Op> {
        if id == "mark_read" { host::storage::set("unread", "0"); }   // a granted capability
        vec![]
    }
    fn supports_fullscreen(&self) -> bool { true }
    fn set_fullscreen(&mut self, on: bool) -> Node { self.view(on) }
}

register_app!(Chat);                  // ← emits app_launch / app_on_event / … wasm exports

The SDK ships the shared DSL types, ergonomic builders (Screen, List, RunningText, VoiceInput, …), and thin wrappers over the capability imports (host::storage, host::asr). A C / AssemblyScript SDK would expose the same shapes over the same ABI.

07 · Specifying the context menu

Declared in the manifest, dispatched to the app

Context-menu items move from a runtime call to a static manifest declaration. The shell already renders the menu without involving the app (it is a host-built list over the link); reading the items from the manifest makes that cheaper, lets the store preview them, and means a malformed app can't inject surprises into the system menu. The system owns Close and the Full Screen toggle; the app contributes the rest.

declaration (manifest)            shell renders                    user picks → app handles
  [[menu]]                        ┌─────────────────┐
  id = "mark_read"                │  CLOSE APP      │  (system)
  label = "Mark all read"   ───▶  │  FULL SCREEN    │  (system, if [fullscreen])  ──▶ app_on_action("mark_read")
                                  │  Mark all read  │  (from manifest)                 → returns Op[] (partial update)
  [[menu]]                        │  Mute 1 hour    │
  id = "mute" …                   └─────────────────┘

System items

Close always; Full Screen / Exit Full Screen when the manifest opts in. The shell adds these — apps can't remove them.

App items

Each manifest [[menu]] entry is an id + label (later: icon, enablement, destructive flag). Selecting one calls app_on_action(id); the app runs its own logic and returns updates.

08 · Full-screen & multiple views

Opt in statically; supply the view dynamically

This is now implemented end-to-end and is the template for richer view models. The manifest's [fullscreen] supported = true makes the shell offer the toggle; when picked, the host calls app_set_fullscreen(true) and the app returns a view designed for the whole 1280×720 — more list rows, a larger canvas, a different layout. The app owns that decision; the shell only changes geometry (full-bleed, neighbours hidden, gaze suspended).

menu: FULL SCREEN ──▶ host calls app_set_fullscreen(true)
                          │
       Conversate  ───────┴──▶ windowed: RunningText, 13 lines in a tile
                                full:     RunningText, 20 lines across the display  (same stream, more shown)
       Vector Image ──────────▶ windowed: image scaled to a tile column
                                full:     same DSL, scaled to the full width        (the renderer adapts)
Generalises to routing

Full-screen is really "the app has more than one view." The same mechanism extends to named views / routes the manifest can declare (e.g. list, detail, compose), so tooling can preview each and the shell can deep-link. An app is a small state machine returning Node trees; the host drives which one is shown.

Apps that don't opt in stay windowed-only and never see app_set_fullscreen — verified: the Timer app shows no full-screen item, Conversate and Vector Image do.

09 · Tooling — the glsdk CLI

The simulator is the dev loop

The biggest DX win is already built: the two-process simulator (phone runtime + glasses viewer + live telemetry) becomes glsdk sim. A developer sees their app on a real LVGL render, drives gestures, and watches per-tap latency, BLE bytes, memory, and fuel — without hardware.

commanddoes
glsdk new myappscaffold a Rust crate: the App stub, a manifest.toml, a sample view.
glsdk buildcargo build --target wasm32 + bundle wasm + manifest + assets into .glsapp.
glsdk validatestatic checks: manifest schema, required exports present, DSL nodes within the allowed vocabulary, capability sanity, bundle-size budget.
glsdk simload the module into a simulator slot and open the glasses viewer + telemetry — the live dev loop.
glsdk package / signproduce the signed, distributable artifact.

Because the glasses brain and the app share the same sandbox model, validate can measure exactly what ships: render fuel per view, bytes per bundle (BLE on-air time), and peak module memory.

10 · Install, lifecycle & governance

An APK-like flow, with hard limits

Install

Store or sideload → verify signature + manifest schema + min_os → show the capability prompt (the granted imports) → register in the catalog. The phone's App Manager UI already models install / launch / suspend / resume / close — it just sources the catalog from installed packages instead of a hardcoded list.

Lifecycle

Launch instantiates the module into a slot's Store; suspend/resume gate its tick(); close drops the Store (memory reclaimed). Persisted state survives via the storage capability, not the instance.

Resource governance

Per-app linear-memory cap, a fuel ceiling per callback (a bad app stalls itself, not the OS), and a bundle-size budget — large views cost real BLE latency, which the telemetry makes visible (a 9 KB image ≈ 150 ms on-air vs ≈ 25 ms for a small patch).

Compatibility

The DSL vocabulary and ABI are versioned. The manifest's min_os and a host capability list let the OS refuse a module that uses nodes or imports it doesn't support — forward and backward safe.

Trust model. Signing gives provenance; capabilities give least privilege; wasmtime gives containment. A reviewed, signed package with a declared capability set is auditable before it runs and contained while it runs — the two properties an app store needs.
11 · Migration path

What changes, what stays

PieceTodayPlatformEffort
DSL (shared)Rust types over the linkunchanged — also the SDK's typesnone
On-device renderer (LVGL)renders Nodeunchangednone
App traitin-processsame trait, now in the SDK cratenone
Cataloghardcoded make(id)registry of installed packagessmall
App runtimedirect Rust callswasmtime loader + ABI glue (reuse the glasses host)medium
Capabilitiesfull host trustimports gated by manifest grantsmedium
Package + CLI.glsapp format + glsdknew
Simulatorinternal toolglsdk sim — the dev looprepackage
Phasing

(1) Define the ABI + binary DSL encoding and move one existing app behind it as a wasm module loaded by the phone. (2) Add the manifest, capability imports, and the .glsapp loader/registry. (3) Ship glsdk (new / build / validate / sim / package). (4) Layer non-Rust SDKs and a store on top. Each phase is independently demoable in the simulator.