Sheet R1 · Reference

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]
  • id is 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.
  • version is 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.
  • api must be a version this Hammer supports, or Hammer rejects the plugin. Each kit states the version it targets (HAMMER_API_VERSION in the C kit), and the Kotlin Gradle plugin rejects a manifest whose api doesn't match.
  • description is plain text. Links in it are clickable, written either as bare https:// URLs or as [label](https://...). No other formatting is applied.
  • languages is 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's language, 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 languages has the same primary subtag (en covers en-GB), or when it has no languages.
  • 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 example scene.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.

[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).

  • http lists host names, matched exactly: api.example.com covers neither example.com nor eu.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, over https only.
  • userHosts is 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
  • danger marks a bool that turns on something risky; the form shows a warning under it. A toggle for permissions.userHosts must be one.
  • secret is 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>.toml file 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 secret is stored as plain TOML on disk, so put credentials only in a secret. 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 a secret, 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, not string, and can't be a secret. Actions also have scenes and entity types, 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, then fr), 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's language, 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), and version.
  • The package's size.
  • The plugin's languages, and the languages its locales/ 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:read are 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 by permissions.userHosts, and the services in permissions.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] memory is 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.