# Snail OS — agent reference Source SHA-256: 352c6b62fdc08c671b6805f82ea1bec8b5614e025aba5d81c5a27457c017f529 # Writing a Snail OS app An app is one `.lua` file in `/apps` on the SD card. Read `docs/LUA.md` in this matching source checkout for the full API. This file is the working procedure. Reference reviewed September 7, 2026 against the V2 source. Check https://snailos.org/firmware/latest.json for the public firmware version. The X3 panel is 528 × 792 pixels; the X4 is 480 × 800. Both are monochrome. ## Before you write anything **Check the idea against the panel.** It redraws in ~500 ms. An app may step itself with `snail.tick(ms)`, with a granted interval of 450–60000 ms in the reviewed source. Older firmware clamps short requests to its own minimum. Read the interval returned by snail.tick(ms); every step requests an e-ink refresh. 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, use a bounded native Snail Connect endpoint to flatten it before it reaches the card 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 visible window, and pass selection to `snail.row`. Measure both boards and every text size; a fixed row count does not describe every screen. Open a detail view for long content. 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 24576 source bytes, 49152 live Lua bytes, 200000 VM instructions per handler, 96 display entries and sixteen Lua launcher slots. Books do not use Lua slots. `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 native Snail Connect. - Do not animate. Do not poll in `draw()`. Do not fetch from `start()`. ## Cache and additional API `snail.save(s)` stores up to 1024 bytes under the app's filename identity; `snail.load()` returns the saved string or `nil`. Validate it before use. `snail.cache(key)` returns a cached value and age, or `nil`. `snail.cache(key, value)` writes it; `snail.cache(key, nil)` deletes that key. Keys hold 1–96 bytes and values up to 24576 bytes. Each app has eight entries and a 131072-byte cache quota. Load cached data first; refresh on explicit user action. Age is information, not an expiry instruction. Entries persist until replacement/deletion or least-recently-used eviction. `snail.rows(pathOrUrl, "a,b,c", sink)` accepts a native authenticated path or a public URL. The `items[]` field syntax walks an array; bare numbers arrive as numbers and `null` as `nil`. Fetch responses are capped at 24576 bytes. `snail.card{...}` takes `sprite`, `num`, `name`, `types`, `stats` and `info`. Draw a full board last; `*` draws a food disc and `{12}` a numbered tile. Build repeated strings with `table.concat` to limit allocations. ## Native Connect ownership https://connect.snailos.org owns V2 accounts, enrollment, OAuth grants and Snail Connect subscriptions. Native device routes are under `/api/device/`. The Lua name `snail.agnt` remains for compatibility with existing card apps. It does not assign backend ownership to a-gnt. Existing V1 sessions keep their compatibility path until the owner deliberately disconnects and enrolls again; a temporary outage does not migrate a connection. a-gnt.com and Page are independent services. Page access does not grant a Snail Connect subscription. The engine manages tokens. `snail.connectkey(k)` returns `idle`, `waiting`, `done`, `cancel`, `denied`, `expired` or `error`. Legacy `snail.pair()` and `snail.paircheck()` remain available; new apps use the shared connect screen. Snail Companion pairs with an Android phone over Bluetooth for free. Wi-Fi relay uses Snail Connect. The Mac must stay awake, online and signed in to relay iMessage, Calendar and Reminders. Current prices are at https://snailos.org/connect; preserve existing customers' historical prices. ## Store and books The device fetches https://snailos.org/apps/index.json. Array order controls listing order; categories appear in first-occurrence order. Press RIGHT in Store to refresh a saved catalog. Entries contain flat scalars: `name`, `file`, `desc`, `category`, `also`, `author`, `version`, `size`, `soon`. The current extractor captures nine fields. `also` names one adjacent art file; a nonempty `soon` other than `"0"` prevents installation. Change the version whenever a published file's bytes change. Files are served from https://snailos.org/apps/. Lua and art go to `/apps`; EPUB goes to `/books` and appears in Reader. EPUB downloads allow up to 32 MiB. Store stages downloads before replacement. Reader imports a selected book; browsing the shelf must not generate an unimported book's cover. ## Verification and firmware operations Render apps on X3 and X4 at every text size. Check empty responses, long labels, saved-state invariants and cancel/back behavior. If the matching linter is unavailable, report incomplete validation. Simulator success does not prove physical radio operation, display readability or battery life. Use the signed package's `install.sh` for USB firmware installation. It verifies the board, partition layout and target bank. Keep its recovery backup. Never use `arduino-cli upload`, guess a bank or bypass board verification. Release builds come from a detached worktree with blank Wi-Fi secret headers and pass signature and credential scans. Publishing firmware is separate from preparing an app or simulator preview. ## Reference downloads - https://snailos.org/llms.txt — complete agent reference - https://snailos.org/dev/SKILL.md — installable Lua skill - https://snailos.org/dev/download/CLAUDE.md — Claude project instructions - https://snailos.org/dev/download/AGENTS.md — Codex project instructions - https://connect.snailos.org/downloads — Connect download hub