Sheet R6 · Reference

Host functions

The functions Hammer gives your plugin, and the limits it runs within.

Your plugin is a WebAssembly module that Hammer runs in an interpreter. Your plugin can reach Hammer, and the hosts its manifest names, only through the imports on this page, and Hammer reaches your plugin only through its exports: export, action, diagnose, and command (see Export formats, Actions, Text checks and Commands). Everything passes between them as JSON or bytes, never as a file path.

The Extism convention

Hammer follows Extism's convention for plugins, so Extism's plugin libraries (its PDKs, for C, Rust, Go, Zig, AssemblyScript, and more) work with it. Hammer's own kits are built on the same convention. Here's how it works:

  • Exports take no arguments and return an i32: 0 for success, anything else for failure.
  • Memory shared with Hammer lives in the host, not in your module. Your plugin asks for a block of memory with alloc and gets back a handle (a u64, where 0 means none). It then copies bytes in and out with the load and store functions. Everything allocated during a call is freed when the call ends.
  • The call's input is read with input_length and either input_load_u8 or input_load_u64. Every input is a JSON object.
  • The call's output is set with output_set, from a block your plugin has filled.
  • A failure's message is set with error_set.

Hammer provides these extism:host/env functions:

Function In Hammer
alloc, free, length, length_unsafe As in Extism
load_u8, load_u64, store_u8, store_u64 As in Extism. An access outside a live block traps
input_offset, input_length, input_load_u8, input_load_u64 Read the call's request
output_set Sets the call's reply
error_set Sets the message the writer sees when the call fails; see Errors
log_trace, log_debug, log_info, log_warn, log_error Write a line to Hammer's log, marked with your plugin's id. A line over 4 KiB is cut short, and past 64 KiB in one call the rest of the call's log is dropped
get_log_level Always returns info
config_get Always returns 0. Your plugin's settings arrive in every request instead
var_get, var_set Values last as long as the module stays loaded, which Hammer doesn't guarantee. At most 1,024 variables and 1 MiB in all; a var_set past either traps
http_request, http_status_code Send a request to a host your plugin may reach, and return its body and status; see HTTP
http_headers Always returns 0: Hammer keeps no response headers

Hammer's imports

Hammer adds six functions of its own in extism:host/user. Each one takes and returns handles. Where this page describes a request or a reply, the handle's bytes are JSON. For cache values, resources, and request bodies, they're raw bytes.

Import Signature
hammer_dispatch (request: u64) -> u64
hammer_cache_get (key: u64) -> u64
hammer_cache_set (key: u64, value: u64)
hammer_resource (name: u64) -> u64
hammer_progress (report: u64)
hammer_server_request (request: u64, body: u64) -> u64

hammer_dispatch

Runs one of Hammer's operations, exactly as the CLI or an in-process plugin would. The request names the operation and gives its input:

{"operation": "scene.read", "input": {"project": "Storm", "id": 12}}

The reply has exactly one key, either output or error:

{"output": {"markdown": "Rain fell hard."}}
{"error": {"kind": "NotFound", "message": "No scene with id 12"}}

An error's kind tells you what went wrong:

kind Means
NotFound, InvalidInput The operation rejected the call, and this is the kind of its OperationException. Calling an operation that doesn't exist gives NotFound. Unknown input fields, missing fields, and wrong types give InvalidInput
PermissionDenied Your plugin has no grant that covers the operation; see Permissions
Failed Anything else, such as Hammer being busy

Because a reply never has any other key, a kit can take a large output's text as it is, without parsing it. Binary content, such as an export's bytes or an entry's image, is base64-encoded in the JSON.

A dispatch blocks your plugin until the operation finishes. The operation's output counts against the call's data budget.

hammer_cache_get and hammer_cache_set

These read and write your plugin's cache. hammer_cache_get returns the value stored under a key, or 0 if there isn't one. hammer_cache_set stores a value under a key. Setting a value of 0, or an empty value, removes the key.

Keys and values are bytes. A key larger than 1 KiB, or a value larger than 1 MiB, traps the call.

hammer_resource

Returns a file from your package's resources/ directory, looked up by its path within that directory (such as words/en.txt). If there's no file with that name, it returns 0. An empty file counts as missing. See Resources.

hammer_progress

Reports how far an action has got, so Hammer can show it while the action runs:

{"fraction": 0.5, "message": "Scene 3 of 6"}

You can leave out either key. Once the writer has pressed Stop, a progress report traps and the action ends there, though Stop interrupts your plugin anyway. Outside an action, hammer_progress does nothing. See Actions.

hammer_server_request

Sends a request to a service on the writer's own sync server, such as LanguageTool, as the signed-in writer. Your plugin never holds their credentials, and may reach only the services its manifest lists in permissions.hammerServer (see the manifest).

The first argument is the request, and the second is its body, or 0 for none:

{"service": "languagetool", "method": "POST", "path": "/v2/check", "headers": {"Content-Type": "application/x-www-form-urlencoded"}}
Key Means
service The service's name, as the manifest lists it
method GET (the default) or POST
path The path under the service, such as /v2/check. It starts with / and is made of plain segments of letters, digits, ., _, and -, with no . or .. segment and no query
headers Optional. Only Content-Type and Accept are sent; Hammer drops the rest and sets its own, Authorization included

Hammer sends it to /api/services/{userId}/<service><path> on the sync server of the writer's workspace, attaching their token and refreshing it when it has expired. A request has 30 seconds in all and is never retried.

The reply is the response, with its body as text:

{"status": 200, "body": "{\"matches\": []}"}

A response of any status is a reply like this one. When there's no response to give, the reply is an error instead:

{"error": "not_signed_in", "message": "..."}
error Means
not_signed_in The workspace has no sync account, or its session has ended and couldn't be refreshed
not_permitted The manifest doesn't list the service
invalid Hammer refused the method or path, the request wasn't JSON of this shape, or the response wasn't valid UTF-8
unavailable There's no sync server, or it doesn't offer the service (its admin hasn't turned it on, or it predates services)
network The sync server couldn't be reached

Errors come back in the reply rather than trapping, so your plugin can tell the writer what to do, such as sign in.

Errors

A call can fail in two ways: the export returns non-zero, or the module traps. Either way, if your plugin set a message with error_set, that message is what the writer sees, and the log has the rest of the details. AssemblyScript's abort sets a message and then traps.

Your plugin traps on its own errors, such as an unreachable instruction or an access outside its memory. It also traps when:

  • it reads or writes a host block out of bounds;
  • it passes the cache a key over 1 KiB or a value over 1 MiB;
  • it sets more variables than var_set allows;
  • an HTTP request is refused, or gets no response;
  • it reports progress after the writer has stopped the action;
  • it runs out of fuel, stack, Kotlin/Wasm heap, or the call's data budget;
  • the call is interrupted, because the writer stopped an action or the editor no longer wants a text check's result.

A failed operation isn't a trap. hammer_dispatch replies with error, and your plugin decides what to do. It can give up and pass on the message, or, in an action, reply with errors to keep the writer on the page. A failed hammer_server_request isn't a trap either; it replies with error too.

What happens after a failure depends on the export:

  • An export format shows the message.
  • An action shows a short message. If the action keeps its runs (keep = true), the run goes back to its page.
  • A text check leaves the text unmarked.
  • A command writes the message to standard error and moves on to the next line.

Permissions

The [permissions] table in your manifest lists the operations your plugin may dispatch:

[permissions]
operations = ["content:read", "entry.create", "scene.delete"]

Each entry is either an operation's name or a scope and access: content:read, content:write, account:read, or plugins:read. A scope and access covers every operation with that scope and access. A scope never covers a Prose or Destructive operation, though: if your plugin needs one, list it by name. See the manifest.

No plugin may log in, log out, sync, or install or remove plugins. Hammer rejects a manifest that names account.login, account.logout, sync.run, plugin.install, or plugin.uninstall, or asks for account:write, and a dispatch of any of them fails with PermissionDenied whatever the plugin was granted.

When the writer installs your plugin, Hammer shows them what it asks for, with reads, changes, changes to the text of their scenes, and deletes listed separately, along with the hosts and services it may reach and any commands it adds. Accepting grants everything listed. Installing a new version grants whatever its manifest lists.

Hammer checks the grants on every dispatch, not only at install. If no grant covers an operation, the dispatch gets PermissionDenied and the operation never runs.

Hammer's plugin host handles ops.list itself, so it works even while Hammer is busy. It lists only the operations your plugin may call, each with its description, access, scope, and JSON schemas for its input and output. ops.list counts as a content read, so to call it your plugin needs either content:read or ops.list by name. The mcp plugin in hammer-plugins builds its tools from it.

HTTP

Your plugin reaches the network only through Extism's http_request, and only the hosts its manifest names (see the manifest). The request is JSON, and the body is a second handle, or 0 for none:

{"url": "https://api.example.com/v2/check", "method": "POST", "headers": {"Accept": "application/json"}}

method is GET if you leave it out. http_request returns the response's body, or 0 when it's empty, and http_status_code then returns its status. A response of any status counts as a response.

  • Declared hosts. A host in permissions.http is reachable on any path, port, and method, over https only.
  • An entered host. The host of the URL in the setting permissions.userHosts names is reachable over http as well, while its toggle, if it has one, is on. Hammer reads both on every request, and a value equal to the setting's declared default opens nothing.
  • Never. Other schemes, and URLs with user info, which could disguise their host. Redirects aren't followed; they're returned to your plugin like any other response.
  • Refused or failed. A request Hammer refuses, or one that gets no response, traps the call. So does any request from a plugin whose manifest names no hosts, or from the module's initializer at install.
  • Limits. On desktop and Android, 10 seconds to connect and 30 to read; on iOS, 30 seconds idle and 60 in all. A response over 8 MiB fails. The request blocks your plugin, and an interrupt waits for it to return, so these timeouts bound how long a stopped call takes to end.

The cache

Each plugin has one key-value store of bytes. Use it for results you want to save between calls and runs, such as a scene's counts, keyed by a hash of its text.

  • One per plugin, not one per project. If your plugin wants entries per project, put the project in the key. A plugin that keys entries by content shares them across projects.
  • Local and disposable. The cache lives in the platform's cache directory, so it's never synced or backed up, and the OS may clear it.
  • Limits. Keys can be at most 1 KiB and values at most 1 MiB; anything larger traps the call. When a plugin's cache grows past 32 MiB, Hammer drops the oldest-written entries until it's down to three quarters of that.
  • Cleared on install and uninstall. Installing a plugin again, including a new version, clears its cache, because the new version may store different values under the same keys. A call still running on the old version can write after the cache is cleared, so include a format version in your keys.
  • Best effort. A failed read counts as a miss, and a failed write is dropped. Your plugin must work with an empty cache.
  • Shared between processes. A CLI command and the app may use the same plugin's cache at the same time. Each write is atomic, and the last write wins.

Resources

Your package's resources/ directory contains files your plugin reads at run time, such as word lists and dictionaries, so you don't have to compile them into the module.

  • The directory can have at most 1,000 files and 64 MiB in total. Hammer checks this whenever it reads the package.
  • Your plugin can read only files under resources/, by their path within it.
  • hammer_resource reads the file out of the package every time and hands your plugin all of its bytes, so the file has to fit in your plugin's memory. If you use a file often, hold on to it rather than reading it again.
  • Each read counts the file's size against the call's data budget, until the call ends.
  • An empty file counts as missing.

Memory

Limit Value
Linear memory Set by the manifest's [limits] memory, in MiB: 1 to 1024, 64 by default. The install dialog tells the writer when a plugin asks for more than the default
Module size The memory limit plus 32 MiB, because a pre-initialized module carries its memory's data
Tables 100,000 entries
Data passed to and from Hammer 256 MiB per call, or a sixteenth of the JVM's heap if that's less (16 MiB on a 256 MB Android heap). It covers everything that crosses during the call: the request, the reply, operations' requests and replies, HTTP and sync server replies, cache values, and resources
Kotlin/Wasm heap 1 GiB, or half the JVM's heap if that's less
Call stack 16 MiB on Android. A call past it fails with "ran out of stack"
Variables 1,024, and 1 MiB in all
Log output 4 KiB a line, and 64 KiB a call

You set the memory limit in your manifest:

[limits]
memory = 256

Hammer caps each linear memory and table whatever maximum the module declares. A module whose memory or table starts larger than its cap fails to load, and growing one past its cap returns -1. Hammer rejects modules with shared or 64-bit memories.

No manifest key raises the data budget. An export that sends its plugin the story's images is the likeliest to reach it; see Export formats.

If Hammer runs out of memory anyway, in a call, at load, or in a host function, that call or load fails with "ran out of memory", never the app. Hammer drops the plugin's instance, freeing what it held, and loads the module afresh for the next call.

Kotlin/Wasm. Kotlin/Wasm's objects live in the interpreter's garbage-collected heap, not in the module's linear memory, so [limits] memory doesn't limit them. The heap cap in the table above does. A call that goes past it fails with "ran out of memory".

Garbage in the heap is freed when the call returns, not during it, so a call holds on to everything it allocates. For example, Kotlin's style report on a 300,000-word novel needs 768 MiB. The heap never gives memory back, so if a plugin is still holding more than 64 MiB after a call, Hammer drops it and loads it again from scratch the next time it's used. On iOS, the interpreter caps the heap at 1 GiB itself, and Hammer can't measure it, so it never drops a plugin there for what it holds. The data budget there is a fixed 256 MiB.

The module

  • Features. Hammer supports WebAssembly 3.0, including garbage collection, exception handling, and typed function references, which Kotlin/Wasm needs. It doesn't support SIMD or Memory64.
  • Imports. A module may import only Extism's extism:host/env functions, Hammer's extism:host/user functions, and two WASI functions from wasi_snapshot_preview1: random_get and clock_time_get. Kotlin/Wasm and kotlinx.serialization need those two, and they don't give access to anything. A module that imports any other WASI function, or anything else, won't load. Your plugin has no access to the filesystem, sockets, environment, or arguments.
  • Network. Your plugin can reach only the hosts its manifest names, through http_request (see HTTP), and the services on the writer's sync server that it names, through hammer_server_request.
  • No re-entry. A module can't call itself again from inside a call.

The Rust kit includes rust/tools/finish.py, which makes a cargo-built module loadable. It keeps only your plugin's own exports and strips out what crates written for the browser leave behind: wasm-bindgen's exports, and any of its imports the module still uses, each replaced with a trap. It also fails the build if the module imports anything Hammer doesn't offer.

Start-up

Hammer instantiates a module when it's installed, which checks its imports and runs its initializer. It instantiates the module again whenever it loads the plugin. After instantiating, Hammer runs _initialize once (or __wasm_call_ctors, if the module has no _initialize), as other Extism hosts do. Start-up has its own fuel budget. At install, start-up runs without network, before the writer has agreed to anything, so an http_request from it traps and Hammer rejects the package.

Hammer may load your module again from scratch between any two calls, so your plugin shouldn't keep anything it can't rebuild. An action keeps its run in its page's state, and anything else you want to save goes in the cache.

Wizer. If your plugin's start-up is slow, you can run it at build time under Wizer. Wizer saves the memory that start-up leaves behind into the module, so Hammer loads it ready to use. The harper-grammar plugin in hammer-plugins builds its linter this way. Its start-up takes 36 seconds under Hammer; with Wizer, it becomes a 104 MB module (18 MB zipped) that loads in about 1.5 seconds and declares memory = 256.

Loading a pre-initialized module takes a JVM heap several times its size: the 104 MB harper-grammar module loads in a 400 MB heap but not in 392 MB, and holds about 90 MB once loaded. Short of that, the load fails as out of memory.

The module must fit within the size limit above. With the Rust kit, hammer::ZeroingAllocator zeroes memory as it's freed, so the saved module carries what start-up kept, not what it threw away:

#[global_allocator]
static ALLOCATOR: hammer::ZeroingAllocator = hammer::ZeroingAllocator;

#[unsafe(export_name = "wizer.initialize")]
pub extern "C" fn initialize() {
    // Build what every call needs.
}
wizer --allow-wasi --wasm-bulk-memory true --wasm-reference-types true -o plugin.wasm finished.wasm

Running

  • Off the UI thread. Hammer runs your plugin on a background thread. When your plugin dispatches an operation, that thread blocks until the operation finishes.
  • Fuel. Each call has a budget of work, measured in fuel. Every function entry and loop iteration uses one unit, and a call that uses up its fuel traps. Instantiation and the initializer get a separate budget.
  • Interrupts. Hammer interrupts a call when the writer stops an action, or when the editor no longer wants a text check's result. The call stops at its next function entry or loop iteration, or as soon as the Hammer function it's in returns.

Running at once

  • One call at a time. A loaded plugin runs only one call at a time.
  • The app and the CLI. A CLI command runs its plugin in its own process, alongside the app's. The two share the plugin's cache and settings, but not its memory.
  • Busy. If the app is running, a command's dispatches go to it. If not, they run headless against the project files. Hammer lets only one process write at a time: if a headless dispatch finds another headless Hammer holding the writer lock, it waits briefly, then fails with Failed. ops.list still works when this happens. Your plugin should report such a failure rather than trap on it.
  • No state between dispatches. Each headless dispatch starts Hammer, runs the operation, and stops Hammer again, so nothing loaded for one dispatch carries over to the next.

Which kit function wraps each import

Each kit wraps the raw imports in its own functions:

Import Kotlin Rust C
hammer_dispatch The typed functions on Hammer, such as Hammer.scene.read(project, id), or by name with call<T>(operation, input), callJson and callJsonText; an error throws OperationException(kind, message) The typed functions in hammer::ops, such as ops::scene::read(&project, id), or by name with hammer::call::<T>(operation, &input); an error's message starts with its kind hammer_call(operation, input_json), returning the reply's handle
hammer_cache_get cacheGet(key) hammer::cache_get(key) hammer_cache_read(key, key_length)
hammer_cache_set cacheSet(key, value) hammer::cache_set(key, value) hammer_cache_write(key, key_length, value, length)
hammer_resource resource(name), resourceText(name) hammer::resource(name) hammer_resource_load(name, &length), hammer_resource_lines(name, &count)
hammer_progress progress(fraction, message) hammer::progress(fraction, message) hammer_progress(fraction, message)
hammer_server_request serverRequest(service, method, path, headers, body), returning a ServerResponse; an error throws ServerException(kind, message). hammer.languagetool types the languagetool service hammer::server::request(service, method, path, headers, body); hammer::server::languagetool types the languagetool service None
http_request, http_status_code None hammer::http::request(method, url, headers, body) extism_http_request(request, body), extism_http_status_code()
input_* The request passed to exporter, action, diagnose, command; input(), inputText() hammer::input(), hammer::input_json() extism_input_length(), extism_load_input()
output_set The value the export's body returns Ok from hammer::run extism_output_buf(data, length)
error_set An exception escaping the export's body Err from hammer::run, or a panic hammer_fail(message), extism_error_set_buf_from_sz(message)
log_* Log.info, Log.warn, Log.error hammer::log::debug, info, warn, error extism_log_sz(message, level)

The kits' cache functions check the limits themselves instead of trapping. Given a key over 1 KiB or a value over 1 MiB, a set stores nothing and returns false (0 in C), and a get returns none. The C kit also declares the raw imports (hammer_dispatch, hammer_cache_get, hammer_cache_set, hammer_resource, and hammer_progress_report) for plugins that want to work with handles directly. See Kotlin, Rust, and C.