FrameOS
Guide

Editing apps

Every node on a scene is an app you can fork and edit - in JavaScript, or in Nim.

Every blue and green node on a scene is an app: a small program with typed config fields, an optional image output, and access to the scene's state. FrameOS ships with ~40 built-in apps, and you can edit or write your own.

What's in the box, roughly:

  • Data: clocks, iCal calendars and agendas, weather, Home Assistant sensors, waste-collection schedules, JSON/XML parsing, HTTP downloads.
  • Images: photo galleries, albums from the Photos library, Unsplash, Wikimedia Commons, Immich, Google Photos, images from any URL, files off the frame's own SD card, OpenAI image generation, RTSP camera snapshots, headless Chromium screenshots.
  • Render: text, QR codes, calendars, charts, SVG, gradients and color fills, opacity, split layouts, zoom/pan (Ken Burns), resize and rotate.
  • Logic: if/else, set state, log, sleep control.

Self-hosted backend, FrameOS Cloud and standalone frames

JavaScript apps can be written and edited in the self-hosted backend, in FrameOS Cloud and on a standalone frame's admin page alike. Custom Nim apps and Nim code nodes need a compiled scene, which only the self-hosted backend can build - the cloud never compiles code. That path is legacy: convert such scenes to JavaScript where you can.

Edit any app

Click the pencil on a JavaScript app's node (or Edit App in its … menu) to open its source in a window over the scene. JavaScript apps are editable everywhere, the scene store's editor included - saved changes are forked onto that scene, leaving the original untouched.

Many of the apps that fetch photos and data are JavaScript apps that travel inside the scene, so they change without a firmware release and you can fork them like your own: FrameOS Gallery, Unsplash, Wikimedia Commons, Google Photos, Immich, OpenAI Image and Text, Home Assistant Sensor, Weather (Open-Meteo), the Belgian recycling calendar, Chart and Photo album (2026.10.13+; frames on older firmware are still sent the built-in Nim version where there was one). The rest of the built-in apps are compiled Nim, so in an interpreted scene their source opens read only, with an Open in GitHub link; to customize one, write a JavaScript app or an inline code node instead. In a legacy compiled scene on a Raspberry Pi, a scene-local Nim app still opens for editing, under a notice offering to convert the scene to interpreted.

Editing an app

An app declares what it touches on the frame (permissions), and the scene shows it before you install. Still, read the source before installing scenes or apps from strangers.

JavaScript apps

This is the normal way to write an app. They run on the frame inside an embedded QuickJS runtime - FrameOS's own fork, quickts, which parses TypeScript and JSX natively (2026.9.0+), so errors name your own file and line. No Node.js needed on the device, and no compiler, so your change is live on the frame seconds after you save it. Create one from the code templates (text, logic, SVG, or image), define its fields in config.json, and export plain functions:

app.ts
export function init(app: FrameOSApp): void {
  app.initialized = true
}

export function get(app: FrameOSApp, context: FrameOSContext): string {
  return `${app.config.prefix}: ${app.config.message}`
}

JS apps can fetch data with frameos.fetchText(), frameos.fetchJson() and frameos.httpRequest() - headers, methods and bodies, plus a find option that pulls snippets out of a page too big to parse on a microcontroller - and photos with frameos.fetchImage(), which downloads and decodes natively, straight into the canvas on an ESP32, so the pixels never enter the JavaScript heap (2026.10.11+). They return text, JSON, SVG markup, or images, and plug into scenes exactly like the built-in ones. They work on every kind of frame, and they're the only option on ESP32 and Pico boards.

An app that returns an image should size it by the canvas it's handed - context.imageWidth and imageHeight, already turned with the frame - never by the panel. When the picture's destination is known (a cell of a split, say), context.targetWidth, targetHeight and targetFit give its size and placement too (2026.10.20+). That's how the FrameOS Gallery app asks gallery.frameos.net for the picture already scaled to fit, as a JPEG 5 to 20 times smaller than the original; its Image format field brings back PNG or the untouched original.

Where an app keeps its values matters on an ESP32, which lets an app's interpreter go when memory runs short - before a big image decode, or after every render on a frame that deep-sleeps. Then module-level variables are gone and init() runs again before the next render. (A frame that stays awake keeps its interpreters between renders while memory allows, from 2026.10.21.) Keep values the scene shares in its state (frameos.setState()), and private ones with frameos.remember(key, value) / frameos.recall(key), which survive between renders everywhere (2026.10.13+). The browser preview behaves like an ESP32 when its memory limit is set to an ESP32 board, so the difference shows up before you deploy.

console.log() (and info, warn, error) writes to the frame's log. A get() that throws fails its node the way a built-in app does: the message is painted where the image would be, and nothing is cached. A file named <name>.test.ts among an app's sources is a test: the editor runs it in the same interpreter the app runs in, with describe, it, expect and mocks, and anything that would leave the interpreter fails until the test mocks it. Test files never reach an ESP32 (2026.10.16+). An app's config.json opens as a form - fields, outputs, settings, cache - with Source one click away.

An app can be more than one file. Its files are its whole module graph: app.ts can import { layout } from './helper' and import cities from './data.json', and the editor resolves those siblings as you type. Relative imports only - there is no npm on the frame, so a bare import x from 'lodash' fails with a message that says so - and stack traces name the file and line (helper.ts:5:3).

A select field in config.json (and a select state field) takes either plain strings or { "value": "dark", "label": "Dark theme" } pairs; the value is what your app sees, the label is what people pick. In the editor's options box that's one value | Label per line.

Scene JavaScript runs sandboxed: bounded memory and run time, no filesystem beyond the asset calls below, and network access only through the FrameOS fetch helpers.

Interfaces in JSX

From FrameOS 2026.10.21 an app whose main file is .tsx can return JSX from get() where it would return an image, and style it with Tailwind class names:

app.tsx
let count = 0

export function get(app: FrameOSApp) {
  return (
    <div className="flex flex-col items-center justify-center gap-4 bg-slate-900 text-white">
      <span className="text-7xl font-bold">{new Date().toTimeString().slice(0, 5)}</span>
      <button className="rounded-xl px-8 py-4 bg-sky-500 active:bg-sky-700 text-3xl"
        onClick={() => { count++ }}>
        Tapped {count} times
      </button>
    </div>
  )
}
  • An <svg> is drawn exactly as the same markup would be, without the markup being written or parsed.
  • Anything else is a UI, laid out at the size of the node's image: block and flex layout (grow, shrink, wrap, gap, justify, align), grid columns, absolute positioning, wrapped and truncated text, <img> for photos and inline <svg> icons. Tailwind's spacing, sizes, colors with /opacity, gradients, borders, corners, text sizes and weights work, as do the sm:…2xl: and portrait: / landscape: variants, which follow the size of the image being drawn.
  • Handlers: onClick, onDoubleClick, onLongPress, onPointerDown, onPointerUp, onPointerMove and onSwipe run on a touch screen's or a mouse's events, and the frame renders again after each. hover:, active: and group-hover: classes follow the pointer. On e-paper every one of those is a refresh, so they're for LCDs, HyperPixels, the browser preview and the SenseCAP Indicator.
  • Only what changed is redrawn on an LCD, and memo(Component) skips components whose props didn't change, as in React.
  • Photos that stay: frameos.fetchImage(url, { keep: true, width, height }) keeps the picture between renders until frameos.releaseImage(ref), so a UI isn't downloading it again on every tap.
  • Canvases: frameos.canvas(key, width, height, color) is a picture the app draws into with frameos.draw(canvas, <line … />), kept between renders - a paint page, a chart that grows a point at a time.

Not there yet: clipping (overflow-hidden), shadows, transforms and letter spacing.

Permissions

What an app may do on the frame is listed in its config.json under permissions:

  • assets - read the frame's assets folder (listAssets, readAsset, loadAssetImage…);
  • assets-write - also write and delete there (writeAsset, appendAsset, deleteAsset);
  • files and shell - for built-in apps: Local image reads any folder, Chromium screenshot and RTSP camera snapshot run programs.

You rarely write the list yourself: saving a scene fills each JavaScript app's list from its code. A scene asks for what its apps ask for, and every surface shows that as tags - reads assets folder, writes assets folder, reads all files, runs shell commands, and uses OpenAI key for each service key an app reads - on the scene list, in the drawer, on store tiles and in the install dialog. Installing a scene is approving its tags; Update to latest says which ones a new version adds and turns red when it gains shell, file or write access.

The frame enforces the lists from FrameOS 2026.10.16: an app that asks for neither assets nor assets-write gets a folder of its own, scenes/<sceneId>/ in the assets folder, for its asset calls, and one with assets alone can't write. Caching a download with fetchImage({ saveAsset }) needs no permission.

Nim apps

The built-in apps are written in Nim and compiled into the frame's binary

  • this is how a $15 Pi Zero 2 W stays fast. Reading them is the best way to learn what an app can do.

Editing that Nim source is a different matter: it only takes effect in a compiled scene on a Raspberry Pi, and every change means a rebuild. For new work, reach for JavaScript first. A minimal render app looks like this:

color.nim
import pixie
import options
import frameos/apps
import frameos/types

type
  AppConfig* = object
    inputImage*: Option[Image]
    color*: Color

  App* = ref object of AppRoot
    appConfig*: AppConfig

proc render*(self: App, context: ExecutionContext, image: Image) =
  image.fill(self.appConfig.color)

# called when used as a render app (blue node)
proc run*(self: App, context: ExecutionContext) =
  render(self, context, context.image)

# called when used as a data app (green node)
proc get*(self: App, context: ExecutionContext): Image =
  result = if self.appConfig.inputImage.isSome:
    self.appConfig.inputImage.get()
  elif context.hasImage:
    newImage(context.image.width, context.image.height)
  else:
    newImage(self.frameConfig.renderWidth(), self.frameConfig.renderHeight())
  render(self, context, result)

Things to know:

  • The render event is your starting point - it fires on the scene's timer, or when dispatched by another app.
  • The context carries the image you draw on (via pixie) and the scene's state, a standard Nim JsonNode: read with state{"field"}.getStr(), write with state{"field"} = %*("value").
  • State is cleared on every render; use instance variables on the App object to persist data between renders.
  • Learn by example: the built-in apps, types.nim, and the utils/ folder show what's available.

Where each kind runs

Raspberry PiESP32-S3Pico W / ESP32-C3
Built-in appsAll of themAll but Chromium screenshots and RTSPRendered on the backend
Custom JavaScript apps✅✅Rendered on the backend
Custom Nim appsLegacy compiled scenes only (self-hosted backend)❌❌
Nim code nodesLegacy compiled scenes only (self-hosted backend)❌ - use JS code nodes❌

SVG, fonts and text

The render/svg app rasterises SVG markup - a very practical way to lay out a panel, especially from a JavaScript app that generates the markup. <text> elements are typeset with the frame's own fonts and drawn as outlines, so labels come out sharp at any size - no need to layer render/text on top of an SVG for its labels. font-family is matched loosely against the fonts on the frame - "PT Sans", PTSans-Bold.ttf and font-weight="bold" all land on PTSans-Bold.ttf - and anything the frame doesn't have falls back to the built-in face rather than failing the drawing. text-anchor, dominant-baseline, <tspan>, entities and stroked or transformed text all work. FrameOS implements a subset of SVG rather than the whole spec: <defs> and external references are ignored, a Pi holds at most eight typefaces per SVG, and on a microcontroller SVG text always renders in the one built-in typeface.

On a Raspberry Pi you can upload your own TrueType fonts under the frame's assets and pick them in any text or font field. Drop a NotoColorEmoji.ttf in there and it's used automatically as a fallback for characters your font doesn't cover (SVG text excluded - color emoji are bitmaps with no outlines). An ESP32 with an SD card can load TTFs from fonts/ on the card too, one face at a time.

On this page