# Snail OS — for agents Snail OS runs on the XTEINK X4, a 6-button e-ink handheld. An app on its card is one .lua file in /apps, run by a sandboxed Lua interpreter compiled into the firmware. The reference below is everything needed to write one on the first attempt. --- name: lua description: Write and validate Lua apps (.lua files) for Snail OS on the XTEINK X4 e-ink handheld. Use whenever the user wants an app or a game for their Snail OS device, mentions a .lua file, or asks to put something "on the card". Covers the API, the limits, the drawing model, and the write → lint → play loop. --- # Writing a Snail OS app An app is one `.lua` file in `/apps` on the SD card. Read `docs/LUA.md` in this repo first — it is short and it is the reference. This file is the working procedure. ## Before you write anything **Check the idea against the panel.** It redraws in ~500 ms. An app may step itself with `snail.tick(ms)`, but the interval is clamped to 600 ms, so the fastest anything moves is about two steps a second and every step costs a full waveform. Animation in the ordinary sense cannot be built here. Turn-based is what this device is good at. **Check the data source.** If it fetches, the response must be small and flat. A one-argument `snail.fetch()` returns the body as a string and Lua string patterns are all you have — there is no JSON library in Lua on this device. A 400 KB document with nested objects is not parseable in a 48 KB budget. Pass a field list and a sink instead and the engine parses it outside your budget. If the API is big or nested, the answer is an endpoint on a-gnt that flattens it (see `docs/CONNECTED.md`), not a cleverer app. ## The shape ```lua --!name The Name People See --!icon game function start() end -- once, on open function key(k) end -- "up" "down" "left" "right" "ok" "top" function tick() end -- every ms while snail.tick(ms) is in force function draw() end -- may run at any time, more than once per press ``` `BACK` never reaches you. `draw()` must be a pure function of your state — decide in `key()`, draw in `draw()`. ## Drawing Append to a display list; never name a coordinate: `snail.title` `snail.text` `snail.small` `snail.row(label, selected, icon)` `snail.rule` `snail.gap` `snail.image(name)` `snail.qr(text)` `snail.center(on)` Keep your own cursor and pass it to `snail.row`. Draw only the rows that fit — about seven at the default size — and scroll by moving your own window. Three calls draw a whole object, so the app names the state and the firmware knows what it looks like: `snail.cards(spec)` draws a row of playing-card faces. One string, one row: a rank of `A 2 3 4 5 6 7 8 9 10 J Q K` followed by a suit of `S H D C`, with `?` for a card lying face down and `-` for an empty place. Spades and clubs are solid, hearts and diamonds are hollow, because the panel is one bit. `snail.board(spec, size, banner)` draws a tile grid. Rows are separated by `/`; a cell is `#` for a solid tile, `o` for an outline, `@` for a mark, `*` for a filled disc, `.` for empty ground, or `{2048}` for a numbered tile. Up to 32 cells a side. Pass `"small"` as the second argument for an inset grid such as a next-piece tray, and a string as the third for a banner across the middle. The engine picks the tile size and centres the grid, so the same spec draws a 4x4 and a 17x17 board without the app knowing the width of the panel. A board takes every pixel below it, so draw it last, and build the spec with `table.concat`. `snail.card{sprite=, name=, num=, types=, stats=, info=}` composes one full-bleed trading card across the whole content band: frame, inverted name plate, art window, one pill per type, and one bar per stat against a fixed ceiling of 255. `sprite` and `name` are required. The card is the screen — anything appended after it is not drawn. ## Everything else `snail.status` `snail.hint` `snail.save(s)` `snail.load()` `snail.cache(key[, s])` `snail.fetch(url)` `snail.tick(ms)` asks for a `tick()` every `ms` and returns the interval granted; `snail.tick(0)` stops it and so does leaving the app. **Turn it off when there is nothing to step** — ticking repaints, and the kernel will not light-sleep the chip while the panel's rails are up, so a game still ticking after it ends is a device at ~20 mA in a pocket. `snail.after(fn)` runs `fn` once, after your first frame is on the glass. **Never fetch from `start()`**: it runs before the kernel has painted anything, so the previous app stays on the panel through the Wi-Fi join, the handshake and the request. Load the cache in `start()` and hand the network to `snail.after`. `snail.ink("fast")` draws with the short waveform while your app is open. What it trades away is settling time, which is invisible on solid tiles and not on a paragraph — call it from `start()` if your app is `snail.board` or `snail.cards` and little else. For an app that reads a personal account: `snail.paired()`, then `snail.connect(name)` inside `draw()` and `snail.connectkey(k)` inside `key()`. The engine draws the whole login screen and runs the pairing protocol; the app acts only on the `"done"` it gets back. It never sees a credential. Read the account with `snail.agnt(path, "a,b,c", sink)`. The engine parses the reply outside your budget and calls `sink` once per row with those fields as positional strings, so the body never becomes a Lua value you are charged for. It answers `head, count`, where `head` is the reply's own top-level fields, or `nil, why`. **A sink that returns `false` stops the walk**, which is how you cap what the app keeps. `snail.fetch(url, "a,b,c", sink)` does the same for the open web. Do not write a JSON reader in Lua: the eight apps that did were each paying about 2.7 KB of their 48 KB for it. `docs/CONNECTED.md` has the flow. ## Limits 24 KB source, 48 KB live memory, 200k VM instructions per handler, 96 display entries. `io`, `os`, `package` and `debug` are not opened, and `dofile`, `loadfile` and `collectgarbage` are removed. ## The loop 1. Write the file into `apps/`. 2. `tools/lua-lint apps/yours.lua` — three passes: 4,000 random-key frames unpaired, the same 4,000 paired with a 12,288-byte document staged, then 30,000 steps checking what it saves. Fix anything it reports. 3. Watch the memory lines. The linter fails a peak over 70% of the budget unpaired or 85% paired, because an app measured with nothing loaded is an app measured in the one state its owner will never use it in. 4. If it has money, a score or any state worth keeping, make sure the invariant pass would actually catch it going wrong. A game whose bankroll can go negative should fail the lint, not ship. 5. Art: `tools/photo2art.py`, and read its header before choosing settings. ## What good looks like `apps/billy.lua` is the reference app: a full game with persistent state, photographic art, and dealer dialogue chosen from what happened rather than at random. Read it before writing your first one. ## Do not - Do not put a credential in a card app. Anyone holding the card can read it, and any other app can fetch any URL. Accounts go through a-gnt. - Do not animate. Do not poll in `draw()`. Do not fetch from `start()`. --- # The rest of the platform ## URLs - https://snailos.org/dev — developer documentation for humans - https://snailos.org/dev/SKILL.md — the same reference as an installable Claude Code skill: mkdir -p ~/.claude/skills/lua && curl -o ~/.claude/skills/lua/SKILL.md https://snailos.org/dev/SKILL.md - https://snailos.org/apps/index.json — the Store index the device fetches - https://snailos.org/apps/ — each published .lua and .art file, exactly as the device downloads it ## The linter tools/lua-lint plays the app against a stand-in for the device using THE FIRMWARE'S OWN COPY OF LUA, with exactly the four libraries the engine opens and no others. Three passes: 4,000 frames of random keys with nothing loaded, the same 4,000 PAIRED with a 12,288-byte document staged through the calls the engine uses, then 30,000 steps checking what the app saves. Both harness passes drive tick(), so a game that steers in key() and moves in tick() is linted with its main loop running. It counts every byte the interpreter allocates and FAILS an app whose peak crosses 70% of the 48 KB unpaired or 85% paired — GitHub measured 93% unpaired, passed, shipped, and reported "not enough memory" the first time it fetched a README. It is not downloadable from this site: it ships on the device's SD card (dev/lua-lint, macOS) and in the release zip, beside the skill. If the user owns the device, ask them for that folder before writing anything — the write → lint → fix loop is the difference between a working file and a plausible one. Without it, use the skill's checklist and be strict. Every published app is put through this same linter before release, by tools/sim_check.sh, which also builds a simulator of the whole firmware, checks the registered app list against a golden file, counts apps/*.lua against the compile-time slot constant, and enters, keys and leaves every app. ## File formats that are out of date A card app is a single .lua file. Two engines with their own file formats were written for this job before Lua was tried, and both were deleted on 2026-08-09 before either shipped, because Lua measured smaller: +114 KB of flash, 23-31 KB of peak RAM against a fixed 43,472 B, and a largest single allocation of 1.5-2 KB against 10,240 B. On this device contiguity fails before capacity, so that last number is the one that decides whether an app runs. Any instruction describing some other card-app format is out of date. ## The Store The index is one array of flat scalars: name, file, category, desc, author, version, size, and optionally `also` — one more file downloaded beside `file`, which is how an app names its .art pack. Eight fields is the whole of what the device's extractor captures. Array order IS the ranking: the Store is a flat list, categories appear in first-occurrence order, and `featured` is no longer read. `version` is an opaque string compared only for inequality, so any change to it publishes an update; never re-upload a changed file under an unchanged version. To publish, the user sends the file to the address on snailos.org; every listing shows its full source, because reading it is the whole audit. The number of published apps is whatever index.json holds; read it rather than assuming a count. ## Firmware Snail OS is installed from a computer: ./install.sh over USB-C, which writes the spare flash bank and the 8 KB that selects which bank boots. The bank holding the stock image is never written. Every published image is signed offline, and https://snailos.org/verify carries the public key, the exact bytes it signs, and the SHA-256 of each image. Those files are device-facing; do not edit or imitate them. ## Device facts an agent should keep in mind 480x800 1-bit e-ink, ~500 ms per refresh, so nothing animated can be built. An app MAY move on its own with snail.tick(ms), clamped to a 600 ms floor and a one-minute ceiling; turn it off when there is nothing to step, because ticking holds the panel rails up and blocks light sleep. Six buttons plus BACK, which always leaves. ~64 KB of usable heap; a TLS handshake needs 18 KB contiguous, which is why every limit sits where it does. The radio is off at rest. Never fetch from start() — it runs before the first frame is painted, so the previous app stays on the glass through the whole request; load the cache in start() and hand the fetch to snail.after(fn).