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.

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:
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:
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 thesm:…2xl:andportrait:/landscape:variants, which follow the size of the image being drawn. - Handlers:
onClick,onDoubleClick,onLongPress,onPointerDown,onPointerUp,onPointerMoveandonSwiperun on a touch screen's or a mouse's events, and the frame renders again after each.hover:,active:andgroup-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 untilframeos.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 withframeos.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);filesandshell- 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:
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
renderevent is your starting point - it fires on the scene's timer, or when dispatched by another app. - The
contextcarries theimageyou draw on (via pixie) and the scene'sstate, a standard Nim JsonNode: read withstate{"field"}.getStr(), write withstate{"field"} = %*("value"). - State is cleared on every render; use instance variables on the
Appobject 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 Pi | ESP32-S3 | Pico W / ESP32-C3 | |
|---|---|---|---|
| Built-in apps | All of them | All but Chromium screenshots and RTSP | Rendered on the backend |
| Custom JavaScript apps | ✅ | ✅ | Rendered on the backend |
| Custom Nim apps | Legacy compiled scenes only (self-hosted backend) | ❌ | ❌ |
| Nim code nodes | Legacy 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.
Photos
A photo library on the self-hosted backend and FrameOS Cloud - upload albums once, play them on any frame through a share link, grade them for the panel.
Big images on small devices
How FrameOS renders a 12-megapixel photo on a device with 8 MB of RAM - streaming decoders, bounded decodes, spooling and caching.