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
The pattern in full: fetch, say so on the status band, parse line by line, and leave a sentence on screen for every way it can fail.

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
The pending call is dropped when the app closes, so a fetch cannot outlive the screen that started it.

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" } ] }
Deciding what “in 2 hours” means belongs on the machine that has a date library and knows what time it is.

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, Reddit

Losing 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 device shows a code and a QR, you claim it in a browser you are already signed into, and OK checks whether it has been granted.

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, counthead 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)
snail.fetch(url, fields, sink) is the same form 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 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.

Next: The limits