Lua · Agents
Describe the app. The agent writes the file.
A card app is a small Lua file with hard memory limits — the shape of thing a coding agent gets right when it can check its own work, and almost right when it cannot. Snail OS ships the two pieces that close that gap: a skill that teaches the API, and a linter that runs the app against a stand-in for the device.
The thing to paste§
No setup at all: paste this into Claude Code, Codex, or any agent that can fetch a URL.
Read https://snailos.org/llms.txt, then write me a Lua app for my Snail OS
handheld. I want: <what you want>.
Rules: the panel takes half a second to redraw, so nothing animated. An app
may step itself with snail.tick(ms), but 600ms is the floor. Keep draw() a
pure function of the app's state. Put any key or station number in a local at
the top for me to edit. If the lua-lint tool is available, run it and fix
everything it reports before giving me the file.Or install the skill§
The skill is the same reference packaged the way Claude Code loads instructions, so the agent has it on every future request without being told. One file:
mkdir -p ~/.claude/skills/lua
curl -o ~/.claude/skills/lua/SKILL.md https://snailos.org/dev/SKILL.mdBought the device? The skill is already on the SD card at dev/SKILL.md, with the linter beside it — plug the card into a laptop and hand the folder to your agent.
The loop: write, lint, fix§
You cannot see the device from your laptop, so the linter stands in for it. It runs the app under the firmware's own copy of Lua, with exactly the four libraries the engine opens and no others. A linter that offered io on the laptop and failed on the hardware would be worse than none.
# ...write tally.lua...
tools/lua-lint tally.luaThe first pass plays 4,000 frames of random key presses with nothing loaded. It proves the app never raises an error and never draws an empty screen, and it reaches the states a person testing by hand does not — a cursor walked off the end of a list, a hand played out twice in a row, a key pressed on a screen that was not expecting it. An app with a clock armed is stepped once per frame, so a game that steers in key() and moves in tick() is linted with its main loop running.
The second pass replays those 4,000 frames paired, with a 12,288-byte document staged through the calls the engine uses. GitHub measured 93% of the memory budget unpaired, passed, shipped, and told its owner "not enough memory" the first time it fetched a README into the 7% that was left. Both facts were true at once. The linter fails an app whose measured peak crosses 70% of the 48 KB unpaired or 85% paired.
The third pass replays 30,000 steps and checks what the app saves, which is the only state visible from outside. For an app with money or a score in it, that means the blob reads back and the number it guards never goes negative. An app that saves nothing passes and is told so.
The linter ships on the SD card (dev/lua-lint, macOS) and in the release download. Without it, the skill's closing checklist covers the same ground by hand.
The same linter runs before every release§
Snail OS is built against a simulator that compiles the real firmware — the same translation unit the device is built from, the same kernel and fonts, and the panel driver against a no-op SPI — and writes the framebuffer out as a one-bit PNG. It exists because eight changes once went to the device in one session and eight came back broken, six of them catchable on a laptop.
Every flash and every release package runs tools/sim_check.sh first: the simulator builds, the registered app list matches a golden file, the .lua files are counted against the compile-time slot constant, every app is entered and keyed and left, and every published app is put through the same lua-lint that is on your card. Nothing ships here that your copy of the linter would have rejected.
What it looks like§
A real session, abridged:
> Make me an app that counts three things during the day.
Turn-based, no network, one screen — this fits the panel. Writing tally.lua
with its own cursor over three rows. tools/lua-lint tally.lua
ERROR: attempt to concatenate a nil value (field '?')
UP walks the cursor past the first row, so NAMES[cursor] is nil on the next
draw. The random-key pass found it on the laptop. Clamping both ends.
tools/lua-lint tally.lua
source: 597 B of 24576 (2%)
tally.lua: 4000 frames, 28001 draw calls, 0 ticks, no error
one frame draws: gap=1 row=3 rule=1 small=1 title=1
memory (unpaired): peak 15149 B of 49152 (31%), largest block 1024 B
memory (paired): peak 15129 B of 49152 (31%), largest block 1024 B
30000 steps: no error, and this app saves nothingDone. Copy tally.lua into /apps on the SD card, put the card back, and it
appears in the launcher as "Tally". The three labels are the NAMES table on
line 4. It keeps nothing between sessions yet — say the word and I'll add
snail.save.What to ask for§
Turn-based things. Blackjack, solitaire, minesweeper, sudoku, a tally, a checklist, a reference table, a list fetched from a small flat API. The panel takes about half a second to redraw, so every press has to be worth a frame.
An app can move on its own: snail.tick(ms) steps it on a clock clamped to 600 ms, which is what the games crawl with. Anything faster is a state change nobody saw. Ask for something that writes to the card, draws its own pixels or needs a key the engine does not deliver, and that is a compiled app.