Lua · Fetching
Reading something off the network.
snail.fetch(url) does an HTTPS GET and hands back the body as one string. Everything hard about a networked card app follows from that, and from the 48 KB the app has to hold the result in.
The data has to be small and flat§
There is no JSON library in Lua on this device. String patterns are the whole parser for a one-argument fetch, and the body has to fit in the app's memory budget beside everything else it is holding. A 400 KB document with nested objects is not readable that way at any level of cleverness.
The engine has a reader of its own — pass a field list and a sink and it walks the reply outside your budget, which is the section below. It captures the fields it was named, at one level of nesting, so the question to settle before writing a line of the app is still what the endpoint returns. Lines of comma- or tab-separated fields are ideal, and a flat JSON array is fine. Objects inside array elements are a sign that the app is the wrong place to be doing the work.
local rows, note = {}, "Press OK to load."
local function reload()
snail.status("Fetching...")
local body, why = snail.fetch(URL)
snail.status("")
if not body then
note = why or "Could not reach the server."
return
end
rows, note = {}, nil
for line in body:gmatch("[^\r\n]+") do
local when, feet, kind = line:match("^([^,]+),([^,]+),([^,]+)$")
if when and tonumber(feet) then
rows[#rows + 1] = { when = when, feet = feet, kind = kind }
end
end
if #rows == 0 then note = "Nothing in today's reading." end
end
Never fetch from start()§
start() runs before the kernel has painted a single frame of your app, so a fetch there leaves the previous app on the panel through a Wi-Fi join, a TLS handshake and a request. The user pressed OK and nothing happened for four seconds.
Load the cache in start() and hand the network to snail.after(fn), which runs once the first frame is on the glass. The busy snail crawls while the request runs, and a failed request leaves the cached rows where they were.
function start()
recall() -- cached rows, drawn at once
if snail.paired() then
snail.after(load_agenda) -- the network, once the screen is up
end
end
When the API is big, move the work off the device§
The answer to a nested API is an endpoint that flattens it, not a cleverer app. Snail OS uses a-gnt for that, and the contract is written down: one array, every field a flat scalar, already in the timezone and the wording the screen wants.
// Google Calendar events.list — unreadable on the device
{ "items": [ { "summary": "Dentist",
"start": { "dateTime": "2026-08-11T14:00:00-04:00" },
"end": { "dateTime": "2026-08-11T14:30:00-04:00" } } ] }
// what a-gnt returns instead
{ "events": [ { "title": "Dentist",
"when": "Tue 11 Aug 2:00 PM",
"rel": "tomorrow",
"where": "Dr. Patel, 2nd floor",
"allday": "0",
"mins": "1290" } ] }Booleans arrive as "0" and "1" and numbers arrive as strings, because the device captures the text that was on the wire. A JSON type here would be a type nothing reads.
A card app must never hold a credential§
Anyone holding the SD card can read every file on it, and any card app can call snail.fetch on any URL. A Google or Reddit refresh token reachable from Lua is a token that leaks to the next app installed. So the device holds a token that is useless anywhere except a-gnt, revocable in one click and scoped to one device:
device --(device token, a-gnt only)--> a-gnt --(OAuth)--> Google, RedditLosing the device costs one revocation. It does not cost a Google account, which is what putting OAuth on a six-button device with no lock screen would risk.
Pairing, in two calls§
snail.paired() says whether this device holds a token. snail.connect(name), called inside draw(), is the entire login screen. snail.connectkey(k), called inside key(), runs the flow one press at a time and returns "idle", "waiting", "done", "cancel", "denied", "expired" or "error".
function draw()
if not snail.paired() then
snail.connect("Google") -- the engine draws the whole screen
return
end
...
end
function key(k)
if not snail.paired() then
if snail.connectkey(k) == "done" then load_agenda() end
return
end
...
end
The engine draws it, not the app, for two reasons. An app that drew its own login screen would repeat the same twenty-five lines with only the service's name changed — 1.5 KB of compiled chunk inside a budget already spent. The reason that settles it: the screen tells somebody that the thing they are about to approve is a-gnt, and an app that composes that sentence can compose a convincing one around a different address.
Reading the account§
snail.agnt(path) is an authenticated fetch. The engine attaches the device's credential; the script cannot read it, print it, or send it anywhere else.
Give it a field list and a sink and the engine walks the reply where it lies, calling the sink once per row with those fields as positional strings. The document never becomes a Lua value, so it is never charged to the app's 48 KB. It answers head, count — head being the reply's own top-level fields — or nil, why. Up to eight field names, and a sink returning false stops the walk.
local head, count = snail.agnt("/api/calendar/agenda?days=7&max=20",
"title,when,rel,where", function(title, when, rel, where)
rows[#rows + 1] = { title = title, when = when, rel = rel, where = where }
return #rows < 20 -- false stops the walk
end)
Do not write a JSON reader in Lua. The eight apps that did were each paying about 2.7 KB of their budget for it, which is why the reader moved into the engine.
Every endpoint answers with the same failure shape — an error key holding a sentence a person can act on — so the app has one branch for the whole class.
GitHub, Google Drive, Analytics and Search Console are compiled into the firmware: a-gnt composes those screens server-side and the device paints the rows it is sent. A card app reaches the same account on the same pairing through snail.agnt. stocks.lua in the Store is the worked example of a card app that fetches, readable in full there.