Skip to content

Plugin API reference

github-actions[bot] edited this page Aug 12, 2026 · 1 revision

Everything a plugin can use. If it is not here, it does not exist.

The manifest

plugin.json, at the top level of the archive.

Field Required Meaning
format yes Always "wmkeyboard-plugin". The one tag that identifies the file.
version no Container format version. 1.
id yes Unique, lowercase, 3–64 characters of a-z 0-9 . _ -, starting alphanumeric. Becomes the plugin's folder on the device. Reverse-DNS by convention.
name yes Shown everywhere. Trimmed to 40 characters, control and direction-override characters removed.
pluginVersion yes Your version, e.g. "1.2.0".
author no Shown on the install screen.
description no One or two sentences.
apiVersion no The API level you need. 1. A higher number than the app knows is refused.
entry no The script inside the archive. Defaults to main.lua.
permissions no See Permissions. Usually [].
Refused, not repaired

A manifest this build does not fully understand is refused. That includes any permission string it does not recognize. Half-understood code does not get to run.

Your two functions

function render()    --> a widget, or a list of widgets
function on_event(e) --  optional

render() is called after loading and after every event. on_event(e) is called when the user does something. Change your variables in on_event, describe the result in render().

Both are plain globals. There is no registration step.

Widgets

Built by ui.*, which are pure-Lua helpers that return tables. You can write the tables yourself if you prefer.

Layout

ui.column { child, child, ... }   -- stacked vertically
ui.row    { child, child, ... }   -- side by side, equal widths
ui.spacer { height = 12 }         -- vertical gap, 0-64
ui.divider()                      -- a horizontal line

Children go in the array part, which is why they have no name = in front.

Text

ui.label { text = "", style = "title" | "body" | "caption" }

body is the default. Labels are not interactive and have no id.

ui.output {
  id = "result",
  text = "",
  mono = false,        -- monospace, for anything with alignment
  insertable = true,   -- show the Insert button (default true)
  copyable = true,     -- show the Copy button (default true)
}

ui.output is how your results reach the user's text. The keyboard draws Insert and Copy buttons under it. A tap on Insert puts the text where they are writing. There is no function that types for them.

Controls

ui.button { id = "go", text = "Go", style = "primary", enabled = true }
ui.toggle { id = "caps", label = "Uppercase", checked = false }
ui.input  { id = "msg", label = "Message", placeholder = "Type here" }

style = "primary" highlights a button. Anything else is plain. enabled = false grays one out.

ui.input carries no value. The keyboard owns what is in the box: a tap points the keys at it, and you are told the contents through an input_changed event. Use wm.ui.set_input(id, text) to write to one. This is why a plugin never sees a keystroke. It is handed the whole contents of its own box after each one, and nothing else.

Tabs

ui.tabs {
  id = "modes",
  ui.page { title = "First", child, child },
  ui.page { title = "Second", child },
}

Top level only, up to 8 pages. Which page is showing is the keyboard's business. You get a tab_selected event if you care.

Progress

ui.progress()   -- an indeterminate bar

Events

on_event(e) receives a table. Always e.type and e.id. Some carry more.

e.type Extra Fired when
"click" none A button was tapped.
"toggle" e.value (boolean) A toggle was flipped.
"input_changed" e.value (string) The contents of one of your boxes changed.
"tab_selected" e.index (number, 0-based) A tab was picked.

tab_selected counts from 0, not from 1 the way the rest of Lua does. The first page is 0.

input_changed fires per keystroke into your own input widget. That is how a live preview works. It is bounded to your panel: you are told what is in your box, never what is typed anywhere else.

wm.*

The complete host API.

Always available

wm.api_version      -- 1
wm.plugin_id        -- your manifest id
wm.plugin_version   -- your manifest pluginVersion
wm.log(message)     -- a line in your log, readable in Settings
wm.ui.set_input(id, text)   -- write to one of your own input widgets

Applied after your handler returns. Capped at 8 KB.

wm.json.decode(text)   --> table, or nil + reason
wm.json.encode(value)  --> string, or nil + reason

A Lua table encodes as a JSON array when its keys are exactly 1..n, and as an object otherwise. Whole numbers stay whole. Depth is capped, which is also what stops a self-referencing table from encoding forever.

With the storage permission

wm.storage.get(key)      --> string, or nil
wm.storage.set(key, val) --> true, or nil + reason
wm.storage.remove(key)
wm.storage.keys()        --> list of strings

Strings only, so use wm.json for anything structured. Local to your plugin, local to the device, deleted when the user uninstalls you. set returns nil and a reason when you are over quota. Say something rather than losing the data silently.

If the manifest did not declare storage, wm.storage is nil.

Not present, and never will be

wm.text, wm.clipboard, wm.http, wm.net, wm.files. None of these exists. There is no API for reading what the user types, reading the field, reading the clipboard, or reaching the network. See Security.

The Lua you get

Lua 5.2 via LuaJ, with these libraries:

Available: string, table, math, bit32, the base functions (assert, error, getmetatable, setmetatable, ipairs, pairs, next, pcall, xpcall, rawget, rawset, rawequal, rawlen, select, tonumber, tostring, type, _G, _VERSION), print, and a reduced os.

os has os.time(), os.clock() and os.date(format, time). os.date takes "*t" and a strftime subset (%Y %y %m %d %H %M %S %j %p %A %a %B %b %c %x %X %%), plus a leading ! for UTC. Nothing else: no getenv, execute, exit, remove, rename or tmpname.

Absent: io, require, package, coroutine, debug, luajava, load, loadstring, loadfile, dofile. You cannot load code at runtime, and precompiled Lua is refused at install.

collectgarbage exists but does nothing and returns 0.

One incompatibility worth knowing

Adding to the string table works for string.mine(s) but not for s:mine(). Method syntax resolves through a process-wide table that is frozen so one plugin cannot rewrite string handling for every other one.

Limits

Exceeding one of these is something you can see: either an error, or a note on the panel saying what had to be dropped. Two are quiet, and the table says which.

Script size 256 KB
Archive size 1 MB, 16 entries
Instructions 30M on load, 20M per event, 4M per render
Time 3 s on load, 2 s per event, 0.5 s per render
Widgets 256 nodes, 12 deep, 8 tabs (dropped, and reported)
Widget text 2 KB per node, 64 KB per tree (truncated, and reported)
string.rep output 256 KB
Pattern subject / pattern 256 KB / 256 bytes
table.concat output 1 MB
Storage 128 keys, 64 chars per key, 8 KB per value, 64 KB total
Log 200 lines of 512 characters (truncated quietly)
Input box 8 KB (truncated quietly)

A plugin that runs out of instructions or time is stopped, the user is told, and it takes a strike. So does one the watchdog has to abandon for going unresponsive. Two strikes and the plugin is switched off until they turn it back on, which also forgives the strikes. That last part is a setting: Switch off a plugin that hangs, on the Plugins screen, on by default. With it off the strikes are still counted and still shown, and only the automatic switch-off goes away.

Clone this wiki locally