Manifest and package
How a plugin is packaged, and everything its manifest and settings can declare.
The package
A .hammerplugin file is a zip archive. Hammer looks for these paths at its root:
| Path | Required | Contains |
|---|---|---|
manifest.toml |
Yes | The plugin's id, version, permissions, and what it adds. See The manifest |
plugin.wasm |
Yes | The compiled module |
settings.toml |
No | Declared settings, which Hammer shows as a form. See settings.toml |
locales/<tag>.toml |
No | Translations of the text in the manifest and settings. See Translations |
resources/ |
No | Files the plugin reads at run time, such as word lists. See Resources |
You don't have to build the package by hand; each kit does it for you. In Kotlin, use the Gradle plugin's hammerPlugin task. For Rust and C, use the SDK's tools/package.sh:
tools/package.sh <plugin directory> <module.wasm>
# writes <plugin directory>/build/<directory name>.hammerplugin
package.sh copies manifest.toml, settings.toml, locales/, and resources/ from the plugin directory if they exist, and adds your module as plugin.wasm.
Size limits
| Part | Limit |
|---|---|
plugin.wasm |
The memory limit plus 32 MiB, so 96 MiB at the default 64 MiB. A pre-initialized module carries its memory's data, which is why it may be this large |
manifest.toml, settings.toml, each locale file |
256 KiB each |
locales/ |
At most 200 translation files |
resources/ |
At most 1,000 files and 64 MiB in total |
Hammer checks these limits every time it reads the package: at install, and again every time it starts.
Install checks
When Hammer installs a package, it reads it and checks the manifest, the settings declarations, and the translations. It then instantiates the module once. Instantiating does two things: it checks the module's imports, and it runs the module's initializer. Your module may import only what Hammer offers (see /reference/host). The initializer runs without network, before the writer has agreed to anything, so an http_request from it traps and Hammer rejects the package.
If any check fails, Hammer rejects the package and changes nothing. If a plugin with the same id is already installed, the new package replaces it.
You can run the same checks yourself:
hammer plugin check myplugin.hammerplugin
The manifest
manifest.toml is a TOML file. Hammer ignores keys it doesn't recognize, with one exception: if an action sets output, Hammer rejects the manifest (see Actions).
Top-level keys
| Key | Type | Required | Meaning |
|---|---|---|---|
id |
string | Yes | The plugin's id. Lowercase letters, digits, -, and _, starting with a letter or digit |
name |
string | Yes | The plugin's name, as the writer sees it |
version |
string | Yes | The plugin's version, such as "1.0.0" |
api |
integer | Yes | The host API version the plugin was built against. Must be 1 |
description |
string | No | What the plugin does. Up to 1,000 characters |
languages |
array of strings | No | BCP 47 tags of the project languages the plugin works with, such as ["en", "fr"] |
kind |
table | No | The job the plugin shares with other plugins. See [kind] |
idis used to name everything the plugin owns: its settings file, its cache, and its export formats (which start with<id>.). Because it has to be safe to use as a directory name, it can't start with_or contain.or/. No two plugins can share an id.versionis shown to the writer. Hammer also saves it with each action session that's kept after the dialog closes, and won't hand a session back to your plugin if a different version saved it (see /reference/actions). Change the version with every release.apimust be a version this Hammer supports, or Hammer rejects the plugin. Each kit states the version it targets (HAMMER_API_VERSIONin the C kit), and the Kotlin Gradle plugin rejects a manifest whoseapidoesn't match.descriptionis plain text. Links in it are clickable, written either as barehttps://URLs or as[label](https://...). No other formatting is applied.languagesis shown at install, and decides which plugin of a kind a project runs. Otherwise it doesn't limit your plugin: a project in another language still gets its checks and actions. Every call includes the project'slanguage, so your plugin decides how to handle it. Leave this key out if your plugin doesn't depend on the language. Each entry must be a language tag.
There's no field for platforms: a module runs anywhere Hammer does.
[kind]
Plugins that do the same job, such as grammar checkers, declare the same kind. A project runs at most one plugin of each kind.
| Key | Type | Required | Meaning |
|---|---|---|---|
id |
string | Yes | The kind's id, agreed on by the plugins that share it, such as "grammar". Lowercase and directory-safe like a plugin's id |
label |
string | Yes | The kind's name, such as "Grammar checker", shown where the writer picks one of its plugins. Not blank |
[kind]
id = "grammar"
label = "Grammar checker"
- Which one runs. Of the plugins of a kind that are on for a project, Hammer runs the first installed that works in the project's language, or the first installed if none does, and turns the rest off for that project. A plugin works in a language when one of its
languageshas the same primary subtag (encoversen-GB), or when it has nolanguages. - The writer picks. When two or more installed plugins share a kind, the project's settings show a dropdown for it, where the writer picks one of them or none.
- The label. The dropdown shows the first translated label among the kind's plugins, taken in order of plugin id, or the first label if none is translated. Translate it with
[kind]in a locale file (see Translations).
Leave the table out if no other plugin does your plugin's job.
[permissions]
| Key | Type | Required | Meaning |
|---|---|---|---|
operations |
array of strings | No | The operations the plugin may call. Empty by default, so a plugin without the table may call none |
http |
array of strings | No | Hosts the plugin may send HTTP requests to, over https only. Empty by default |
userHosts |
table | No | A setting holding an address the writer enters, whose host the plugin may also reach |
hammerServer |
array of strings | No | Services on the writer's sync server the plugin may reach, such as "languagetool". Empty by default |
Operations
Each entry in operations is one of these:
- An operation's name, such as
"scene.read"or"entry.create". /reference/operations lists all 79, for examplescene.read. - A scope and an access level, written
<scope>:<access>. This covers every operation with that scope and access:
| Entry | Covers |
|---|---|
content:read |
Every Read operation on projects and their contents |
content:write |
Every Write operation on projects and their contents. This doesn't include the Read ones, so ask for content:read as well |
account:read |
Every Read operation on the account and the sync server: account.status and sync.status |
plugins:read |
Every Read operation on the installed plugins: plugin.list |
A scope never covers a Prose or Destructive operation. If your plugin needs one, list it by name.
- Prose operations put text into a scene's prose:
project.import,scene.write,scene.append, anddraft.apply. - Destructive operations delete or overwrite something in a way a draft can't undo:
project.delete,scene.delete,draft.delete,note.delete,entry.delete,image.delete,timeline.delete,timeline.def.delete, andidea.delete.
[permissions]
operations = [
"content:read", # every content Read operation
"entry.create", # one Write operation
"scene.append", # a Prose operation, only ever by name
"entry.delete", # a Destructive operation, only ever by name
]
No plugin may change the sync account, sync, or install or remove plugins, whatever it asks for. Hammer never grants account.login, account.logout, sync.run, plugin.install, or plugin.uninstall, and /reference/operations doesn't list them. If a manifest names one of them, or asks for account:write, Hammer rejects the plugin at install. An installed package that asks for one doesn't load, and Settings lists it as unreadable.
If an entry is neither an operation name nor a scope with read or write (content:destructive, for example), Hammer rejects the plugin at install. Hammer checks permissions on every call. If no grant covers the operation, the call fails with PermissionDenied (see /reference/host). ops.list lists only the operations your plugin is allowed to call.
Hosts
Your plugin reaches the network only through Extism's http_request, and only the hosts its manifest names (see /reference/host).
httplists host names, matched exactly:api.example.comcovers neitherexample.comnoreu.api.example.com, and there are no wildcards. Each is a lowercase host name with at least one dot, and no scheme, port, or path; Hammer rejects anything else at install. Any path, port, and method on a listed host is allowed, overhttpsonly.userHostsis for an address only the writer knows, such as a self-hosted server's. It names a setting the writer fills in with a URL, and your plugin may reach that URL's host.
[permissions]
http = ["api.languagetool.org"]
[permissions.userHosts]
setting = "server" # a string or secret setting, with no default
toggle = "allow_server" # optional: a bool setting with danger = true, off by default
| Key | Type | Required | Meaning |
|---|---|---|---|
setting |
string | Yes | The key of a string or secret setting that holds a URL. The setting can't have a default |
toggle |
string | No | The key of a bool setting with danger = true and a default of false. The host is reachable only while it's on |
The entered host is reachable over http as well as https, since a server on the writer's own network often has no certificate. Hammer reads the setting and its toggle on every request, so a change applies at once. Only a value the writer chose counts: a value equal to the declared default opens nothing. The install dialog names only the setting, not a host, which is why the setting can't have a default. A host you know in advance belongs in http, where the install dialog lists it. Hammer rejects the manifest at install if setting or toggle doesn't name a setting of the right type.
Sync server services
hammerServer lists services on the writer's own sync server, such as languagetool. Each is lowercase and directory-safe like a plugin's id. Your plugin reaches a listed service through hammer_server_request, as the signed-in writer, without ever holding their credentials (see /reference/host). A sync server offers a service only if its admin turns it on.
[permissions]
hammerServer = ["languagetool"]
Ask only for what your plugin uses. The writer sees the list before installing. On the plugin index, a person reviews any release that asks for more than the previous one, or for any Destructive operation.
Extension tables
These tables declare what your plugin adds to Hammer. A plugin can use any combination of them. Each one has its own page with the details; this section lists the keys.
[[exporters]]
Adds a format to the Export dialog. Your plugin's export export renders it. See Export formats.
| Key | Type | Required | Meaning |
|---|---|---|---|
format |
string | Yes | The format's id, starting with <id>., such as "wordfreq.csv". Unique within the plugin |
extension |
string | Yes | The file extension, without the dot |
mime |
string | Yes | The file's MIME type, used by Android's save picker |
label |
string | Yes | The format's name in the Export dialog |
input |
string | No | The form each scene arrives in: markdown (the default), or prose, which is Hammer's parse of the scene into blocks |
images |
boolean | No | Whether the export is sent the files of the images the story shows. false by default |
cover |
boolean | No | Whether the project's cover is sent with them. Needs images; false by default |
output |
string | No | What the export replies with: file (the default), the export file's bytes, or archive, a list of files that Hammer zips into the export file |
Hammer rejects the manifest at install if a format is declared twice, if input or output has any other value, or if cover is set without images.
[[actions]] and [[actions.field]]
Adds an item to the overflow menu on each screen listed in places. When the writer chooses it, your plugin's action export shows pages in a dialog. See /reference/actions.
| Key | Type | Required | Meaning |
|---|---|---|---|
name |
string | Yes | Unique within the plugin, and lowercase and directory-safe like an id. Sent as the call's action |
label |
string | Yes | The menu item's text. Also used as the dialog's title on any page that doesn't set one |
places |
array of strings | No | Where the menu item appears: project (Project Home), scene, note, entry, event. Defaults to ["project"]. Hammer rejects an empty list |
keep |
boolean | No | Whether a run continues after the writer closes the dialog part way through. false by default |
Actions reply with pages, so an action has no output key. If a manifest sets one, Hammer rejects it.
Each [[actions.field]] belongs to the [[actions]] table above it. Together, an action's fields make up its first page, which Hammer shows with a Run button. If an action has no fields, Hammer calls it as soon as the writer chooses it.
| Key | Type | Required | Meaning |
|---|---|---|---|
key |
string | Yes | The value's key in the call's input. Unique on the page |
type |
string | Yes | bool, int, choice, text, scenes, or entity |
label |
string | Yes | Shown next to the field |
hint |
string | No | Shown under the field |
default |
by type | No | The field's starting value |
required |
boolean | No | For text, scenes, and entity: the field can't be left blank or empty |
min, max |
integer | No | For int |
options |
array of { value, label } |
For choice |
At least one |
format, multiline |
string, boolean | No | For text: format is plain, rich, or rich-extended, and multiline allows several lines for plain |
multiple |
boolean | No | For scenes (true by default) and entity (false by default) |
kinds, entry_types, tags |
arrays of strings | No | For entity: which items the writer can pick |
If Hammer can't show a field, the manifest is invalid. That happens with an unknown type, a key used twice, a choice without options, or a default the field wouldn't accept. Hammer also rejects the string type here, because it's a settings type; to ask for text in an action, use text. It rejects secret too, which only a setting can be.
[[diagnostics]]
Adds a text check that the scene editor runs as the writer types. Your plugin's diagnose export performs the check. See Text checks.
| Key | Type | Required | Meaning |
|---|---|---|---|
name |
string | Yes | Unique within the plugin, lowercase and directory-safe. Sent as the call's diagnostics |
label |
string | Yes | The check's name, as the writer sees it |
scope |
string | No | paragraph (the default) to check only paragraphs the check hasn't seen yet, or scene to check every paragraph in the scene each time |
[[commands]]
Adds a top-level CLI command, on desktop only. Your plugin's command export runs it, one line of standard input at a time. See Commands.
| Key | Type | Required | Meaning |
|---|---|---|---|
name |
string | Yes | The command, run as hammer <name>. Lowercase and directory-safe. Unique within the plugin |
help |
string | Yes | One line, shown by hammer help |
A command can't be named help, or the first word of an operation (scene, project, sync, and so on); Hammer rejects it at install. If another installed plugin already has a command with the same name, your plugin doesn't load.
[limits]
| Key | Type | Required | Meaning |
|---|---|---|---|
memory |
integer | No | How many MiB of linear memory the module may grow to, from 1 to 1024. 64 by default |
Your module's memory can't grow past this limit. The limit also sets the largest plugin.wasm Hammer accepts (see Size limits). If your plugin asks for more than the default, the install dialog tells the writer, so raise it only when your plugin needs it. Kotlin/Wasm objects live in a separate heap that has its own cap; see /reference/host.
[limits]
memory = 256 # Harper's grammar rules need about 100 MiB
A complete example
This word frequency plugin adds an export format, a report action, a text check, and a CLI command, and raises the memory limit.
# Identity. id is lowercase and names the settings file, the cache, and export formats.
id = "wordfreq"
name = "Word Frequency"
version = "1.0.0"
api = 1 # the host API version this was built against
description = "Counts how often each word appears, as a spreadsheet or a report. Source: https://example.com/wordfreq"
languages = ["en", "fr"] # shown only; other projects still get the plugin
[permissions]
# Reads by name. "content:read" would cover these and every other content read.
operations = ["project.info", "scene.tree", "scene.read"]
[[exporters]]
format = "wordfreq.csv" # starts with the id and a dot
extension = "csv"
mime = "text/csv"
label = "Word frequency (CSV)"
input = "prose" # blocks of styled spans, not markdown
[[actions]]
name = "report" # the module tells its actions apart by this
label = "Word frequency report"
places = ["project", "scene"] # Project Home and the scene editor
keep = false # a report is cheap to run again
[[actions.field]] # the action's first page
key = "scenes"
type = "scenes"
label = "Scenes"
hint = "Leave empty for the whole story, or the scene you are in."
[[actions.field]]
key = "top"
type = "int"
label = "Words to list"
default = 20
min = 1
max = 200
[[diagnostics]]
name = "echoes"
label = "Echoed words"
scope = "scene" # an echo spans paragraphs, so it needs them all
[[commands]]
name = "wordfreq" # hammer wordfreq
help = "Counts the words in each line of standard input."
[limits]
memory = 128 # MiB; the install dialog mentions anything over 64
settings.toml
Your plugin's settings are typed values that you declare in settings.toml. Hammer shows them as a form, which the writer opens with the plugin's Settings button under Settings, Plugins. Hammer sends the current value of every setting with every call, as settings, so your plugin never has to read them itself. A CLI command gets fresh values for each line.
The file is an array of [[setting]] tables:
| Key | Type | Required | Meaning |
|---|---|---|---|
key |
string | Yes | The setting's key in settings and in the stored file. Unique, not blank |
type |
string | Yes | bool, int, string, secret, or choice |
label |
string | Yes | Shown next to the control |
hint |
string | No | Shown under the control |
default |
by type | No | The value until the writer changes it. It must be valid for the setting, or Hammer rejects the file at install. A secret can't have one |
Setting types
type |
Shown as | Value | Extra keys | Without a default |
|---|---|---|---|---|
bool |
A toggle | true or false |
danger, false by default |
false |
int |
A number field | A whole number | min, max, both optional |
min, or 0 |
string |
A text field | A string | multiline, false by default |
"" |
secret |
A masked password field | A string | "", its only value until the writer sets one |
|
choice |
A dropdown | The chosen option's value |
options: [{ value, label }], at least one |
The first option |
dangermarks aboolthat turns on something risky; the form shows a warning under it. Atoggleforpermissions.userHostsmust be one.secretis for a password or API key. Hammer masks it in the form and keeps it in the platform's encrypted store, never in the settings file. Your plugin gets it as a string, empty when unset.
[[setting]]
key = "scene_break"
type = "choice"
label = "Scene break"
default = "hash"
options = [
{ value = "hash", label = "#" },
{ value = "asterisks", label = "* * *" },
{ value = "blank", label = "Blank line" },
]
[[setting]]
key = "chapter_headings"
type = "bool"
label = "Chapter headings"
hint = "Only when top-level scenes are exported as chapters."
default = true
[[setting]]
key = "min_length"
type = "int"
label = "Shortest word counted"
default = 3
min = 1
max = 20
[[setting]]
key = "stop_words"
type = "string"
label = "Words to skip"
multiline = true
With these settings, each call includes:
{"settings": {"scene_break": "hash", "chapter_headings": true, "min_length": 3, "stop_words": ""}}
- Storage. Hammer stores the values in the plugin's own
plugins/<id>.tomlfile in Hammer's settings directory, one key per setting, apart from secrets. Only values the writer set are written, so a default stays yours: if a new version changes a default, it changes for every writer who never set that setting. When Hammer loads the file, any value that's missing, the wrong type, or out of range falls back to its default. Keys that are no longer declared are dropped the next time the file is written. This means a new version of your plugin can add or remove settings without a migration. - Secrets. Every setting but a
secretis stored as plain TOML on disk, so put credentials only in asecret. On desktop the store is an encrypted file in Hammer's config directory, on Android it's encrypted preferences under a Keystore key, and on iOS it's the Keychain. It keeps secrets from disk scraping, backups, and copies to other machines, not from malware running as the writer. If a new version turns a setting into asecret, Hammer moves its stored value into the store. Uninstalling the plugin removes its secrets. - Actions are different. An action field asks for text with
text, notstring, and can't be asecret. Actions also havescenesandentitytypes, which settings don't.
Translations
You can translate the text your plugin declares by adding locales/<tag>.toml files, one per language. <tag> is a BCP 47 language tag, such as fr or fr-CA. Each file uses the same names the manifest does to identify things: the format, action name, field key, check name, command name, and setting key.
| Key in the locale file | Translates |
|---|---|
name |
The manifest's name |
description |
The manifest's description. Up to 1,000 characters |
[exporters."<format>"] label |
An exporter's label. The format contains a dot, so quote it |
[actions.<name>] label |
An action's label |
[actions.<name>.fields.<key>] label, hint, options |
A field's text. options maps each option's value to its label |
[diagnostics.<name>] label |
A text check's label |
[commands.<name>] help |
A command's help line |
[settings.<key>] label, hint, options |
A setting's text. options maps each option's value to its label |
[kind] label |
The kind's label |
# locales/fr.toml
name = "Fréquence des mots"
description = "Compte la fréquence de chaque mot, en tableur ou en rapport."
[exporters."wordfreq.csv"]
label = "Fréquence des mots (CSV)"
[actions.report]
label = "Rapport de fréquence"
[actions.report.fields.scenes]
label = "Scènes"
hint = "Laissez vide pour toute l’histoire, ou la scène où vous êtes."
[diagnostics.echoes]
label = "Mots répétés"
[commands.wordfreq]
help = "Compte les mots de chaque ligne de l’entrée standard."
[settings.scene_break]
label = "Séparateur de scènes"
options = { hash = "#", asterisks = "* * *", blank = "Ligne vide" }
- Fallback. For each string, Hammer uses the most specific file that matches the UI's language (
fr-CA, thenfr), and falls back to the manifest's own text. Leave out anything that doesn't need translating. Tags match regardless of case, and_is treated as-. - Only display text. Ids, keys, option values, and all other values stay exactly as the manifest has them.
- When Hammer picks the text. The UI's language is the system's language, so Hammer picks the text once when it loads the plugin, and again for the install dialog.
- Checks. Hammer rejects a locale file at install if it doesn't parse or isn't named after a language tag. Unknown keys are ignored.
- Text at run time is up to your plugin. Your plugin writes its own messages, action pages, and check messages. Every call includes
locale, the UI's BCP 47 tag (or null), so you know which language to write them in. This is different from the project'slanguage, which is the language the story is written in.
Resources
Files under resources/ go into the package unchanged. Your plugin reads one with hammer_resource, using its path inside that directory, such as words/en.txt (see /reference/host).
- You can include at most 1,000 files and 64 MiB in total. Hammer checks this every time it reads the package.
- Your plugin can read only files under
resources/. An empty file reads as missing. - Each read gives your plugin the whole file, so any file it reads has to fit within its memory limit. Each read also counts toward what a call can use for passing data to and from Hammer: 256 MiB, or a sixteenth of the JVM's heap if that's less (see /reference/host).
- Hammer reads the resource from the package every time your plugin asks for it. If your plugin uses a resource often, keep it in memory rather than reading it again.
What the writer sees at install
The install dialog, and the Info button next to each installed plugin, show:
- The
name,description(with clickable links), andversion. - The package's size.
- The plugin's
languages, and the languages itslocales/files translate it into. - What the plugin may use, by level: reads, changes, changes to the text of the writer's scenes (Prose operations), and deletes, which can't be undone. Scope grants such as
content:readare described in words. A named operation that this version of Hammer doesn't have is listed with the changes. - The hosts in
permissions.http, the setting named bypermissions.userHosts, and the services inpermissions.hammerServer, each shown as sending what the writer writes. - What the plugin adds: export formats, actions, text checks, and CLI commands.
- A note if
[limits] memoryis above the default 64 MiB.
When the writer accepts, they grant everything in [permissions]; there's no way to grant only part of it. When a package replaces an installed one, it gets the grants in its new manifest, which the writer has just approved. If an update from the plugin index asks for more than the installed version, Hammer asks the writer again.
A package installed from a file is unsigned. Packages from the plugin index are pinned by hash in a signed index.
The plugin index
Hammer's public plugin index checks every release of a listed plugin. The package must pass hammer plugin check, its id must match the listing, its version must be higher than the previous release, and its api must be one that a released version of Hammer supports. A person approves new listings, releases that ask for more permissions than the previous one, and any Destructive operation. The permissions, commands, actions, and exporters shown in the index come from the package's manifest, never from the listing.