API reference
Both engines expose the same API. In JavaScript, the network calls (http.*, rss.fetch,
rss.fresh) and sleep return promises, so await them. Everything else is synchronous in both
languages. Lua calls simply block, since scripts never run on the UI thread.
Tables in Lua and objects in JS are interchangeable in the descriptions below: { status, ok }
means a table with those keys in Lua and an object with those properties in JS.
Quick list
Section titled “Quick list”| Call | Returns |
|---|---|
log(text, ctx?) |
nothing; appends a line |
notify(title, body, ctx?) |
true if a notification was posted |
status(text) |
nothing; pins or clears a header line |
http.get(url, headers?) |
{ status, ok, body, headers } |
http.request(opts) |
same as http.get |
http.cached(url, seconds?) |
same, plus cached |
rss.fetch(url) / rss.parse(xml) |
{ title, link, items } |
rss.fresh(url) |
same, only unseen items |
store.get(key) / store.set(key, value) |
string or nil |
json.encode(v) / json.decode(s) |
Lua only |
clipboard.get() / clipboard.set(text) |
string or nil |
device.battery() |
{ level, charging } |
device.network() |
"wifi", "cell", "eth", "net" or "offline" |
device.unlocks() |
today’s unlock count |
apps.list() |
[{ label, pkg, profile }] |
notifications.recent(n?) |
[{ pkg, app, title, text, time }] |
launcher.run(command) |
true if sent |
sleep(ms) |
nothing |
args, argline |
arguments of a command run |
event new |
what triggered an event run |
Output
Section titled “Output”log(text, ctx?)
Section titled “log(text, ctx?)”Appends a line to the log under the run’s ── name · time ── header, with the script’s name at
the end, muted (… · weather).
ctx is an optional table / object of strings that decides what tapping and long-pressing do:
| Key | Effect |
|---|---|
url |
tap opens the link; long-press offers open, share and copy |
copy |
text that “Copy text” copies instead of the visible line |
phone |
tap dials the number; long-press offers call, message and copy |
image new |
an image URL (http or https): the picture shows under the line, as wide as the log; long-press offers “Open image” |
Other keys are kept with the line. Keys the launcher uses internally (commands, script identity, app references and the like) are dropped, so a script can’t plant a line that runs a command when tapped.
log("Kolahal on GitHub", { url = "https://github.com/mtwabpin/kolahal" })log("Your code is 4821", { copy = "4821" })log("xkcd #327", { image = "https://imgs.xkcd.com/comics/exploits_of_a_mom.png" })print("print() also logs", 42) -- arguments joined with tabslog("Kolahal on GitHub", { url: "https://github.com/mtwabpin/kolahal" });log("Your code is 4821", { copy: "4821" });log("xkcd #327", { image: "https://imgs.xkcd.com/comics/exploits_of_a_mom.png" });console.log("console.log also logs", 42); // arguments joined with spacesconsole.error("logged as an error line");notify(title, body, ctx?)
Section titled “notify(title, body, ctx?)”Posts an Android notification, if notifications are enabled for this script (script notify <name> on or the switch in the script manager) and allowed for Kolahal. ctx.url is opened when
the notification is tapped. Returns true if a notification was posted.
notify("Build finished", "main is green", { url = "https://ci.example.org" })notify("Build finished", "main is green", { url: "https://ci.example.org" });status(text)
Section titled “status(text)”Pins one line for this script into the header (first line of text, at most 80 characters).
status(nil) / status(null) or an empty string clears it. It only works in saved scripts, since
it is keyed by the script’s name. Tapping the status line runs the script.
status("HN: " .. count .. " new")status(nil)status(`HN: ${count} new`);status(null);Return value
Section titled “Return value”Whatever the script returns is printed as an output line (tables and objects as JSON; long-press
copies it). A one-liner run with lua / js that produces nothing prints nil.
Responses are { status, ok, body, headers }: the numeric status, ok for 2xx, the body as a string
(read up to 4 MB) and response headers with lowercase names. A User-Agent of
Kolahal-Launcher/0.1 is sent unless you set your own. Network errors raise an error (Lua) or
reject the promise (JS). An HTTP error status does not, so check ok. Plain http:// URLs are
allowed. new Requests to this phone or your local network (a router, a home
server, a VPN address like Tailscale’s 100.x) need @uses lan; without it they fail, even after a
redirect. A JavaScript run makes at most 4 requests at a time; more wait their turn.
http.get(url, headers?)
Section titled “http.get(url, headers?)”local r = http.get("https://api.github.com/repos/neovim/neovim", { Accept = "application/json" })if not r.ok then error("HTTP " .. r.status) endlocal repo = json.decode(r.body)return repo.stargazers_countconst r = await http.get("https://api.github.com/repos/neovim/neovim", { Accept: "application/json" });if (!r.ok) throw new Error("HTTP " + r.status);return JSON.parse(r.body).stargazers_count;http.request(opts)
Section titled “http.request(opts)”Any method. opts = { url, method, body, contentType, headers }; only url is required and
method defaults to GET. A string body is sent as is; a table / object body is encoded as
JSON. contentType defaults to text/plain; charset=utf-8, so set it for JSON APIs.
local r = http.request{ url = "https://ntfy.sh/my-topic", method = "POST", body = "backup done",}
local r2 = http.request{ url = "https://httpbin.org/post", method = "POST", body = { hello = "world" }, contentType = "application/json", headers = { Authorization = "Bearer " .. store.get("token") },}await http.request({ url: "https://ntfy.sh/my-topic", method: "POST", body: "backup done" });
const r2 = await http.request({ url: "https://httpbin.org/post", method: "POST", body: { hello: "world" }, contentType: "application/json", headers: { Authorization: "Bearer " + store.get("token") },});http.cached(url, seconds?)
Section titled “http.cached(url, seconds?)”A GET that reuses this script’s stored response while it is younger than seconds (default 600).
The result has an extra cached field (true when it came from the cache). Only successful
responses are cached.
local r = http.cached("https://open.er-api.com/v6/latest/USD", 6 * 3600)const r = await http.cached("https://open.er-api.com/v6/latest/USD", 6 * 3600);RSS and Atom
Section titled “RSS and Atom”Feeds come back as { title, link, items }, where each item is
{ title, link, id, date, summary }. rss.fetch and rss.fresh raise an error on an HTTP failure,
so wrap them in pcall / try if you want a friendly message.
rss.fetch(url)
Section titled “rss.fetch(url)”Downloads and parses a feed.
rss.parse(xml)
Section titled “rss.parse(xml)”Parses feed XML you already have. Synchronous in both languages.
rss.fresh(url)
Section titled “rss.fresh(url)”Like rss.fetch, but returns only items this script hasn’t seen before (by item id; the last 500
ids per URL are remembered). Items are only marked as seen if the run succeeds, so a script that
fails halfway sees the same items next time.
local feed = rss.fresh("https://lobste.rs/rss")for i, item in ipairs(feed.items) do if i > 5 then break end log(item.title, { url = item.link })endconst feed = await rss.fresh("https://lobste.rs/rss");for (const item of feed.items.slice(0, 5)) log(item.title, { url: item.link });Storage
Section titled “Storage”store.get(key) / store.set(key, value)
Section titled “store.get(key) / store.set(key, value)”Persistent strings, private to each script. store.get returns nil / null for a missing key;
store.set(key, nil) deletes it. Store anything structured as JSON. Deleting a script deletes its
store; one-liners typed at the prompt share one scratch store. A value holds up to 1 MB, and a
script’s store up to 1,000 keys and 16 MB; past that store.set raises “store full”.
local n = tonumber(store.get("runs") or "0") + 1store.set("runs", tostring(n))store.set("list", json.encode({ "a", "b" }))const n = Number(store.get("runs") ?? 0) + 1;store.set("runs", String(n));store.set("list", JSON.stringify(["a", "b"]));json.encode(v) / json.decode(s)
Section titled “json.encode(v) / json.decode(s)”Lua only; JavaScript has the built-in JSON. json.decode turns objects into tables and arrays
into 1-based sequences.
local data = json.decode('{"ip":"203.0.113.7"}')return json.encode({ ip = data.ip, ok = true })Device
Section titled “Device”clipboard.get() / clipboard.set(text)
Section titled “clipboard.get() / clipboard.set(text)”Needs @uses clipboard (what a script may use).
Android only lets the focused app read the clipboard, so clipboard.get() usually returns nil in
timer runs. clipboard.set works any time.
-- @uses clipboardclipboard.set("copied by a script")device.battery()
Section titled “device.battery()”{ level, charging }: level is 0–100 (or nil if unknown), charging is true when plugged in.
device.network()
Section titled “device.network()”The active network: "wifi", "cell", "eth", "net" (something else) or "offline".
device.unlocks()
Section titled “device.unlocks()”Today’s unlock count, as in the header.
local b = device.battery()return string.format("%d%% %s · %s · %d unlocks", b.level or -1, b.charging and "charging" or "", device.network(), device.unlocks())const b = device.battery();return `${b.level}% ${b.charging ? "charging" : ""} · ${device.network()} · ${device.unlocks()} unlocks`;apps.list()
Section titled “apps.list()”Launchable apps in every profile, sorted by label: [{ label, pkg, profile }], where profile is
"personal" or "other" (work, private, clone). Needs @uses apps.
// @uses appsconst work = apps.list().filter(a => a.profile !== "personal");return `${work.length} apps outside the personal profile`;notifications.recent(n?)
Section titled “notifications.recent(n?)”The most recent notifications, newest first: [{ pkg, app, title, text, time }] (time in
milliseconds since the epoch). n defaults to 20, at most 50. Needs
notification access; returns an empty list without it. Needs
@uses notifications.
-- @uses notificationsfor _, n in ipairs(notifications.recent(5)) do log(n.app .. ": " .. (n.title or "") .. " " .. (n.text or ""))endLauncher
Section titled “Launcher”launcher.run(command)
Section titled “launcher.run(command)”Runs a terminal command, as if typed. Only when the script was started from the prompt (a
command script, script run, or a one-liner); in timer and event runs it logs an error and
returns false.
-- @command-- @desc work mode: pause notifications, open the work maillauncher.run("work on")launcher.run("open gmail -w")sleep(ms)
Section titled “sleep(ms)”Pauses for up to 5000 ms per call. In JS: await sleep(1000).
args and argline
Section titled “args and argline”When a script runs as a command, args holds the words after its name (a table in Lua, an array in
JS; quotes group words) and argline the same words joined by spaces. Both are empty in timer runs.
// @command// @desc greet: greet <name>return `hello, ${argline || "world"} (${args.length} args)`;new In a run started by an @on trigger, event describes what
happened. It always carries the event’s type (one of unlock, charger, unplug, wifi,
offline, notification, boot, headset); events such as notification add details about the
notification. Outside event runs, don’t rely on it being set. The editor’s inline docs list the
fields for each event.