-
Notifications
You must be signed in to change notification settings - Fork 4
Addon repository format
Version 1 · status: implemented and shipping in WM Keyboard
WM Keyboard can install extra themes, layouts, dictionaries, emoji keyword packs, snippet packs, sticker packs, icon packs, fonts, emoji fonts, key sounds, sound packs, vocabulary packs and plugins from the web. Anyone can publish these by putting a small manifest and the addon files in a public Git repository (GitHub, GitLab, a raw host, or anything else that serves files over https). Users add a repository by pasting its URL into the app.
A repository is nothing more than an index over files the app already knows how to import. The addon files are the same formats the app produces when you export a theme or a layout from the app itself. There is no packaging, signing or build step.
Start from the sample
The full, forkable sample lives at https://github.com/wasi-master/wmkeyboard-addon-repository. Clone it as a starting point.
- wmkeyboard-repo.json required, the manifest at the repo root
- icon.png optional repo icon
- README.md optional but recommended
- themes/
- midnight.wmtheme.json
- layouts/
- workman-bn.wmlayout.json
- dictionaries/
- fr.txt
- snippets/
- email.wmsnippets.json
- stickers/
- moments.wmstickers
- icons/
- rounded.wmicons
- fonts/
- inter.ttf text faces and emoji faces alike
- sounds/
- thock.mp3
- previews/
- midnight.png optional screenshots
Folder names are a convention, not a rule. The manifest's path fields are what actually locate files.
Must sit at the repository root and validate against
wmkeyboard-repo.schema.json (JSON Schema draft 2020-12).
{
"$schema": "https://raw.githubusercontent.com/wasi-master/wmkeyboard-addon-repository/main/docs/addons/wmkeyboard-repo.schema.json",
"format": "wmkeyboard-repo",
"version": 1,
"repo": {
"id": "com.example.coolpack",
"name": "Cool Addon Pack",
"description": "Dark themes and extra layouts.",
"author": "Some Creator",
"homepage": "https://github.com/user/wmkeyboard-addons",
"icon": "icon.png",
"updatedAt": "2026-07-22"
},
"addons": [ /* AddonEntry[] */ ]
}| Field | Req | Notes |
|---|---|---|
$schema |
Optional URL to wmkeyboard-repo.schema.json (e.g. raw.githubusercontent.com link) for IDE autocompletion & validation. |
|
format |
✔ | Magic tag, exactly "wmkeyboard-repo". The client rejects anything else. |
version |
✔ | Manifest schema version. Currently 1. |
repo.id |
✔ | Stable id, reverse-DNS recommended. Namespaces installed addons. |
repo.name |
✔ | What the repository is called in the app's list. |
repo.{description,author,homepage,icon,updatedAt} |
Optional metadata. icon is relative or absolute (see §4). |
|
addons |
✔ | Array of addon entries (§3). May be empty. |
Unknown fields are ignored, so future versions can add fields without breaking old clients.
The published schema lags the format
The schema's type enum does not list vocabulary yet, so a schema validator
rejects an entry that uses it. requires is not described either, though it
passes because the schema allows extra keys. The app accepts both. Fix the
schema upstream before reading a validator failure on those two as a real
problem with your manifest.
{
"id": "midnight",
"type": "theme",
"name": "Midnight",
"version": "1.2.0",
"author": "Some Creator",
"description": "Deep blue dark theme.",
"tags": ["dark", "blue"],
"path": "themes/midnight.wmtheme.json",
"sha256": "…64 hex chars…",
"sizeBytes": 24680,
"previews": ["previews/midnight.png"],
"minAppVersion": 40,
"langId": "fr",
"langIds": ["en", "ru", "el"],
"license": "OFL-1.1",
"licenseFile": "fonts/OFL-LICENSE.txt",
"requires": ["midnight-font", "midnight-sound"]
}| Field | Req | Notes |
|---|---|---|
id |
✔ | Unique within the repo. |
type |
✔ |
theme | layout | dictionary | emoji_keywords | snippets | espanso | stickers | icon_pack | font | emoji_font | sound | sound_pack | plugin | vocabulary. |
name |
✔ | Display name. |
version |
✔ | Semver string. Bump it to offer an update. |
path |
✔ | Payload location, either relative to the manifest or an absolute https URL (§4). |
author, description, tags
|
Display / search metadata. | |
sha256 |
plugin | Lowercase hex SHA-256 of the payload, verified before install when present. Optional for every type but plugin: an addon without one installs normally and is shown as unverified, while a plugin without one is refused. |
sizeBytes |
Payload size, for the UI and a pre-download guard. Optional; the client caps every download regardless. | |
previews |
Screenshot images (relative or absolute). | |
minAppVersion |
App versionCode floor. An older app still lists the addon and refuses the install with a note saying to update the app. |
|
langId |
dict |
Required for dictionary, emoji_keywords and vocabulary, since all three install into a per-language folder. An optional hint for layout. Must be a registered language id (§5). |
langIds |
Languages this addon covers, when one id isn't enough. See Language coverage. | |
license |
Licence identifier. Use SPDX where one fits (MIT, OFL-1.1, CC0-1.0, CC-BY-4.0), otherwise any short name. Shown on the addon's page. |
|
licenseText |
Full licence text, inline. | |
licenseFile |
Licence text as a file, relative or absolute; fetched when the user opens it. | |
requires |
Ids of other entries in this same manifest the addon was designed with, such as a theme naming its font and sound. Soft: the install screen offers to download them together, and skipping them still installs a working addon that falls back to the user's global choices. Ids that don't resolve in the manifest are ignored. Only the addon the user actually tapped is offered as something to switch to afterwards. Its dependencies install quietly, and the theme is what turns them on. |
None of the three licence fields is required, but state something. An addon
with no licence is one nobody can safely reuse, and for a font or an icon set
it is the first thing anyone asks. tools/validate.py warns when all three are
absent.
Use whichever fits:
-
licensealone is enough for a well-known licence."license": "MIT". -
license+licenseFileis the usual choice. The identifier shows on the addon's page, and a tap on it fetches and displays the full text. Point it at the licence file already sitting beside your payload:"licenseFile": "fonts/OFL-LICENSE.txt". -
licenseTextcarries the whole text inline, for a short custom licence with no identifier worth quoting. It travels in the manifest, so keep it short: every client downloads it whether or not anyone reads it.
The app shows the identifier in the addon's Details, and opens the text in a dialog when there is one. Nothing is enforced. This is metadata for the person deciding whether to install, not a licence check.
langIds says which languages an addon is for. It matters most for fonts:
plenty of faces carry Latin and nothing else, and offering one in the Bengali
font picker offers a keyboard of empty boxes.
{ "id": "caveat", "type": "font", "langIds": ["en"] }
{ "id": "inter", "type": "font", "langIds": ["en", "ru", "el"] }The app groups the pickers by script, so a font is offered wherever any of its
languages is written: en reaches the English picker (which also drives Cyrillic
and Greek), bn the Bengali one. Omitting langIds makes no claim, and the
font is offered everywhere. That is the right default for a face with broad
coverage, and it is what every font published before this field existed gets.
Dictionaries use the singular langId instead, and must: a word list has exactly
one language. langIds is ignored for them.
path, previews[] and repo.icon are each either:
- a path relative to the manifest URL's directory, for example
themes/midnight.wmtheme.json; or - an absolute
https://URL, which can point anywhere (a GitHub Release asset, a CDN, another repo).
The app derives the raw manifest URL from what the user pastes:
| User pastes | Manifest fetched from |
|---|---|
https://github.com/USER/REPO |
https://raw.githubusercontent.com/USER/REPO/HEAD/wmkeyboard-repo.json |
https://github.com/USER/REPO/tree/BRANCH |
…/BRANCH/wmkeyboard-repo.json |
a direct raw.githubusercontent.com/.../wmkeyboard-repo.json
|
used as-is |
any other https URL ending in .json
|
used as-is |
| a repository page on another forge (Codeberg, a GitLab, SourceHut, Bitbucket, a self-hosted Forgejo or GitHub Enterprise) | that forge's own raw-file address, for example GitLab's .../-/raw/REF/wmkeyboard-repo.json or SourceHut's .../blob/REF/wmkeyboard-repo.json
|
any other https URL |
read as a directory, with wmkeyboard-repo.json appended |
A pasted address with no scheme is assumed to be https, because that is how
people write them down. Relative paths resolve against the manifest URL's
directory.
https only
Plain http and non-URL schemes are rejected. That covers the manifest and every
payload, preview and licence file it references.
These are the app's native import/export formats. To make a theme, layout, snippet, sticker or icon payload, export it from the app. Then drop the file into your repo.
type |
File | Format |
|---|---|---|
theme |
*.wmtheme.json |
One ThemeSpec object (the app's theme export). The only JSON payload with no format/version envelope: it is the bare object, so a theme file is identified by its extension rather than a magic tag. Background images travel base64-embedded inside the JSON, so the file is self-contained. Colors are decimal ARGB longs (0xAARRGGBB). |
layout |
*.wmlayout.json |
Envelope { "format":"wmkeyboard-layout", "version":1, "layout": { …LayoutSpec… } }, which is the app's layout export. |
dictionary |
<langId>.txt |
Plain UTF-8, one entry per line: word<space>frequency (frequency optional, default 1). # starts a comment. May be gzipped (.txt.gz) for transport. |
emoji_keywords |
<langId>.tsv |
Plain UTF-8, tab-separated, one row per emoji: emojikeyword,keyword,…name. The name column is optional and becomes the emoji's long-press description. # (hash and space) starts a comment. A bare # does not, because #️⃣ is an emoji. May be gzipped (.tsv.gz) for transport. Packs stack on the bundled English and Bangla keywords rather than replacing them, so search finds an emoji under every language installed for it. tools/emoji/export_keyword_pack.py converts CLDR annotations into this format. |
snippets |
*.wmsnippets.json |
{ "format":"wmkeyboard-snippets", "version":1, "snippets":[ { "id":1, "label":"…", "text":"…", "trigger":"…"?, "triggerPattern":"…"?, "triggerWords":3?, "confirm":true? }, … ] }. Ids are reassigned on import. trigger is one exact word. triggerPattern is an anchored regular expression over the last triggerWords words before the cursor (default 3, max 8), whose captures reach text as $1…$9. See Snippets. A snippet carrying both keeps the word. confirm makes the trigger offer itself as a suggestion-bar chip instead of rewriting what the user typed, which is what a pack of canned replies should do. See Ask before it expands. triggerPattern, triggerWords and confirm are written only when set, so a pack of plain snippets looks exactly as it always did. Installing a pack puts every one of its snippets into a folder named after the addon's name, so the user can silence or find the whole pack in one gesture. The envelope's own optional folders array is honoured on a hand-imported file but flattened into the pack folder on install, because folders are one level deep. Pick a name that reads well as a folder. |
espanso |
*.yml, *.yaml, *.zip
|
A match file or a package archive in Espanso's own format, converted on the way in. A match file is YAML with a top-level matches: list; an archive holds package.yml and _manifest.yml, in either the layout the Espanso Hub publishes or the one you get by zipping a package directory. Serve a Hub package as it stands, with no conversion step. It installs into a folder exactly as snippets does, and uninstalls by the same rules. What the format cannot carry across is listed in Importing from Espanso, and the app shows that list in the preview before the install button rather than after. shell and script variables are dropped and never run. The two types are deliberately separate so a mistyped entry is a clear rejection rather than a guess; a .yml filed as snippets is refused. To a user both browse under Snippets. |
stickers |
*.wmstickers |
ZIP archive containing a pack.json envelope ("format":"wmkeyboard-stickers", "version":1, pack metadata with its stickers[]) and the image files under stickers/. See Sticker packs. |
icon_pack |
*.wmicons |
ZIP archive containing a pack.json envelope ("format":"wmkeyboard-icons", "version":1, pack metadata) and one SVG per replaced icon under icons/, named for its slot. See Icon packs. |
font |
*.ttf, *.otf
|
Standard TrueType or OpenType font file used for keyboard key labels and text typography. Declare langIds when the face only covers some scripts. |
emoji_font |
*.ttf, *.otf
|
A font whose glyphs are emoji, like Twemoji or OpenMoji. Same file format as font, kept a separate type because it is chosen in a different place (Emoji settings, not the key-label pickers) and because a colour emoji font on the key labels is not a choice anyone makes on purpose. Colour builds (COLR/CBDT) draw in colour on Android 8+. A monochrome outline build takes the keyboard's text colour. There is exactly one emoji slot, and the app offers to fill it once the font is installed. |
sound |
*.mp3 |
A single short key-press sound. Keep it under ~300 ms and a few tens of KB: it is loaded into a SoundPool and replayed on every keystroke. |
sound_pack |
*.wmsoundpack |
ZIP archive containing a pack.json envelope ("format":"wmkeyboard-sound-pack", "version":1) and the recordings under sounds/. Many recordings of one keyboard, one picked per key press and optionally another when the finger lifts, with an optional separate set for the spacebar and the other key roles. Set minAppVersion: older builds have no way to install this type. See Sound packs. |
vocabulary |
*.wmvocab.json, *.wmvocab.json.gz
|
A vocabulary pack ("format":"wmkeyboard-vocab") for the Vocabulary tool: lemmas with parts of speech, senses, examples and pronunciation, up to 5 000 words. langId is required. Gzip is detected by magic bytes on every import path. The same file the app exports from Settings › Vocabulary. |
plugin |
*.wmplugin |
ZIP archive containing a plugin.json manifest ("format":"wmkeyboard-plugin") and a Lua script. The only payload that is code rather than data. See Plugins. |
vocabulary |
*.wmvocab.json |
{ "format":"wmkeyboard-vocab", "version":1, "pack":{ "id":"…", "name":"…", "langId":"…" }, "words":[ { "word":"…", "pos":["…"], "senses":[ { "pos":"…", "definition":"…" } ] } ] }. Requires langId, one language per pack. May be gzipped for transport, since the reader inflates it automatically. At most 5,000 words and 8 MB inflated. |
- cipher.wmplugin a ZIP, renamed
- plugin.json
- main.lua
{
"format": "wmkeyboard-plugin",
"version": 1,
"id": "com.example.cipher",
"name": "Cipher Tool",
"pluginVersion": "1.0.0",
"author": "Example Author",
"description": "Caesar and Vigenere ciphers.",
"apiVersion": 1,
"entry": "main.lua",
"permissions": []
}A plugin is a Lua script that draws a small tool panel. It runs in a sandbox with no way to read what the user types, read the text field, read the clipboard, or use the network. See the plugin developer guide and the full security model.
Three things differ from every other type, all because the payload is code:
-
sha256is required. The app refuses to install a plugin it cannot verify.tools/build_index.pyfills it in;tools/validate.pyfails without it. -
The id must be a safe path segment, because it becomes the plugin's
directory on the device. That means lowercase, 3–64 characters of
a-z 0-9 . _ -, starting alphanumeric. -
Unknown
permissionsvalues refuse the install. That is also how an older app version safely declines a plugin built against a newer API: it does not install something whose capabilities it cannot describe to the user.
The script must be plain source. Precompiled Lua is refused. Build archives with
tools/build_plugins.py, which zips plugins-src/<name>/ deterministically so
rebuilds do not churn the index.
- mypack.wmstickers a ZIP, renamed
- pack.json
- stickers/
- airplane.png one file per sticker
{
"format": "wmkeyboard-stickers",
"version": 1,
"appVersion": 41,
"appVersionName": "1.4.0",
"pack": {
"id": "undraw-illustrations",
"name": "unDraw Everyday Moments",
"author": "…",
"description": "…",
"stickers": [
{ "id": "airplane", "fileName": "airplane.png", "mime": "image/png", "name": "Airplane" }
]
}
}Unlike icon packs, sticker entry names are listed explicitly. id keys the sticker,
name is what the user searches, and fileName locates the image inside the archive.
stickers[] belongs inside pack: that is what the app writes on export and what it
reads back. Three near-misses are accepted anyway, because they are what hand-written packs
actually do: stickers[] beside pack rather than inside it, file instead of fileName,
and a path (stickers/airplane.png) instead of a bare name. None of those values is ever
used as a path: every image is written under a name the app derives from a freshly minted
id, so a .. in one finds no image and is reported rather than escaping anywhere.
A pack whose list is empty, or whose every entry names an image the archive doesn't hold, is refused with the reason for each. An empty pack would otherwise install, appear in the list and contain nothing, which reads as the app having lost the images.
mime is informational. Images are re-encoded into the app's own sticker storage on import,
so any common raster format works. Limits: at most 500 entries and 64 MB per archive,
200 stickers per pack and 50 packs installed.
appVersion / appVersionName record the app build that wrote the file. They are
informational and import ignores them. Both are optional in a hand-built pack.
- mypack.wmicons a ZIP, renamed
- pack.json
- icons/
- tool.clipboard.svg one file per replaced slot
{
"format": "wmkeyboard-icons",
"version": 1,
"appVersion": 41,
"appVersionName": "1.4.0",
"pack": {
"id": "rounded",
"name": "Rounded",
"author": "…",
"description": "…",
"version": "1.0.0",
"slots": ["tool.clipboard", "key.enter_send", "…"]
}
}appVersion / appVersionName record the app build that exported the pack. They are
informational and import ignores them. Both are optional in a hand-built pack.
Slot ids are the file names. An icon for tool.clipboard is
icons/tool.clipboard.svg. The four slot groups are:
| Group | Ids | Count |
|---|---|---|
| Tools |
tool.<toolbar tool>, for example tool.clipboard, tool.gif, tool.sticker, tool.voice
|
72 |
| Keys |
key.shift, key.shift_on, key.shift_lock, key.backspace, key.forward_delete, key.globe, key.input_method_picker, key.emoji, and key.enter plus key.enter_{search,send,go,next,previous,done}
|
15 |
| Toolbar chrome |
chrome.toolbox, chrome.panel_back, chrome.suggestions_expand, chrome.emoji_shortcut, chrome.search_close, chrome.incognito, chrome.power_saving
|
7 |
| Emoji tabs |
emoji_tab.search, emoji_tab.recent, emoji_tab.most_used, and one per category (smileys, people, animals, nature, food, travel, activities, objects, symbols, flags) |
13 |
The Tools row is derived from the app's tool list, so its count grows with every new tool. The other three groups are hand-maintained lists in the app. Two of them have grown too: a new key and a new piece of toolbar chrome each picked up a replaceable icon. The Emoji tabs group has not changed.
A pack does not have to be complete: any slot it leaves out keeps the app's
built-in icon. The slots list is advisory. The app walks the archive's
entries and keeps every one whose name matches a slot it knows, so a pack
assembled by hand still works if the manifest is out of date. Files naming a
slot the installed version has no idea about are dropped and reported, never
stored.
Colour. An SVG that declares no colours (fill="none", currentColor, or
nothing at all) is drawn in the theme's colour and picks up the per-tool accent
colours, exactly like a built-in icon. An SVG that sets real colours keeps them
and stops following the theme. Prefer the first unless the pack is deliberately
a colour set.
Supported SVG. <path>, <rect>, <circle>, <ellipse>, <line>,
<polyline>, <polygon>, <g transform> (translate/scale/rotate/matrix/skew),
presentation attributes and inline style, and #rgb/#rrggbb/#rrggbbaa/
rgb()/named colours. Text, embedded images, <use>, gradients, filters, masks
and CSS <style> blocks are skipped. An icon that leans on them renders as
whatever is left rather than failing to import. Each SVG must be under 256 KB,
and the archive under 8 MB with at most 400 entries.
No DOCTYPE declarations
A DOCTYPE causes the file to be rejected outright: packs are untrusted input, and an external entity there would be a file-disclosure hole. Do not ship SVGs with one. Most exporters can be told to omit it.
Dictionaries and layouts attach to a language by langId (e.g. en, fr, bn). The app can only accept a langId that is already built into it. A dictionary for a brand-new language the app has never heard of cannot be added as data alone today. That needs an app update that registers the language. So pick a langId the current app supports.
A repository README, a blog post or a share sheet can link straight into the app:
| Link | Opens |
|---|---|
wmkeyboard://addons |
the Addons screen |
wmkeyboard://repo?url=<repo or manifest URL> |
the Addons screen with the add-repository dialog pre-filled |
wmkeyboard://addon?repo=<repo or manifest URL>&id=<addonId> |
that addon's page |
The URL is percent-encoded and resolved by the same rules as pasted input
(§4). https only.
A link is a shortcut, not an install button
A link can never install anything or add a repository on its own. It navigates to a page showing the repository's host, the addon and its author, and the user taps Install. That tap is also what adds the repository to their list.
- Bump an addon's
version(semver) to publish an update. The app compares it against the installed version and offers Update. - Bump
repo.updatedAtwhen you change the manifest. - Never recycle an
idfor a different addon. Ids are how installs are tracked.
-
Put
wmkeyboard-repo.jsonat the repo root. List every addon in it. -
Add the payload files. Set each
path. -
Optionally fill
sha256andsizeBytes. Neither is required, except that apluginentry must carry asha256. A manifest without them is otherwise valid, and its addons install normally, marked unverified. Do not compute them by hand. Runpython3 tools/build_index.py(in the sample repo) after every payload change, and it keeps them current. -
State a licence on every addon. Set
license, andlicenseFilewhen you have the text. -
Validate the repository with
python3 tools/validate.py. It runs the JSON Schema, plus the file-existence and checksum checks a schema can't express. -
Push to a public
httpshost. Share the repo URL.
See also: Client design for how the app fetches, resolves and installs these.
- Home
- Getting started
- Typing
- Languages
- Suggestions & correction
- Emoji & expression
-
Tools
- Clipboard manager
- Voice typing
- Offline voice (Whisper)
- Handwriting
- Scanner (OCR, QR, documents)
- Camera tool
- Translate
- Search, Wikipedia & dictionary
- Media controls
- AI chat
- AI tools
- Utility tools
- Snippets & text expansion
- Text editing & cursor tools
- Instruments
- Trackpad
- Calendar
- App launcher
- Learn from text
- Vocabulary
- Resize the keyboard
- The toolbar
- Themes & appearance
- Addons
- Plugins
- Privacy & security
- Accessibility
-
Reference
- Gesture cheat sheet
- Typing
- Hardware shortcuts
- Deep links & launcher shortcuts
- Key press
- File formats
- Dictionaries & words
- Importing from other keyboards
- Languages
- Importing from Espanso
- Appearance
- Keyboard themes
- Troubleshooting
- Glossary
- Keyboard font
- Easter eggs
- Icons
- Layout & size
- Key layouts
- Rows & bars
- Keyboard modes
- Emoji
- Phone number formats
- Tools
- Addons & plugins
- Reference - Accessibility
- Fingerprint lock
- Reference - Data saver
- Reference - Permissions
- Privacy
- Reference - Selection actions
- Servers
- Reference - Backup & restore
- About & diagnostics
- Statistics
- Settings A–Z
- Development