Sheet R2 · Reference

Actions

How to add menu items that ask the writer for input and show results.

An action is an item your plugin adds to the overflow menus of Hammer's screens. When the writer chooses it, Hammer calls your plugin's action export, and your plugin replies with pages, which Hammer shows one at a time in a dialog. Your plugin can't draw its own UI, so a page describes what to ask and what to show, and Hammer decides how it looks.

Each call sends your plugin a JSON request and expects a JSON reply, using the calling convention described in Host. One use of an action, from the moment the writer chooses it until it ends, is called a run. Hammer may reload your plugin between calls, so keep everything a run needs to remember in its page's state.

The manifest

Each [[actions]] table in the manifest declares one action. See Manifest for the rest of the file.

[[actions]]
name = "interview"
label = "Interview a character"
places = ["project"]
keep = true

[[actions.field]]
key = "name"
type = "text"
label = "Name"
required = true
Key Meaning
name Must be unique within the plugin, lowercase, and safe to use as a directory name, following the same rules as a plugin id. Hammer sends it as the call's action
label The menu item's text. It's also the dialog's title on any page that doesn't set its own
places The screens whose menus show the action. Defaults to ["project"]. Hammer rejects an empty list or an unknown place
keep Whether a run survives the writer closing its dialog part way through. Defaults to false. See Sessions

Hammer rejects a manifest in which an action sets output. Every action replies with pages.

Places

Place Screen context.id
project Project Home null
scene The scene editor The scene's id
note A note The note's id
entry An encyclopedia entry The entry's id
event A timeline event The event's id

The first page

The [[actions.field]] tables make up the action's first page. Hammer shows them with a Run button, so if your action only needs one form before it does its work, you don't have to write any code to build that form. These tables take the same keys as a page's fields; see Fields.

An action with no [[actions.field]] tables is called as soon as the writer chooses it.

For an action without keep, the first page starts with the values the writer used last time, for as long as the project stays open, wherever its fields still accept those values.

Hammer translates the labels, hints, and option labels of the manifest's fields from the package's locales/ directory, just as it does the manifest's other text. Pages your plugin returns are its own, so write them in the language of the call's locale.

The call

Hammer calls your plugin's action export when the writer chooses the action, when they press Run, and when they press any button. Every key in the request is always present.

{"action": "interview", "project": "Storm", "settings": {}, "locale": "en",
 "context": {"place": "project", "id": null},
 "input": {"q1": "Mara, or Captain.", "q2": "", "q3": ""},
 "button": "next", "changed": null,
 "state": {"name": "Mara", "set": "quick", "step": 1}, "resumed": false}
Key Value
action The action's name
project The project's name
settings The current value of every setting your plugin declares
locale The BCP 47 tag of Hammer's interface language, such as fr-CA, or null
context The screen the action was run from, as place, and the id of the item shown on that screen, as id. On Project Home, id is null
input The values on the page the writer was on, one key per field. It's {} for a page with no fields, or for an action without any
button The id of the button the writer pressed. It's null on the first call, whether the action opened without fields or the writer pressed Run on its first page
changed Always null. It's reserved for fields that call the action when they change
state The state of the page the writer was on, passed back unchanged. It's null on the first call
resumed True on the first call made from a page restored from a session

locale is the language of Hammer's interface, not the project's language.

The reply

Your plugin replies with a JSON object in which every key is optional. Returning empty output is the same as returning {}.

{"page": {"title": "Mara: Review", "markdown": "## Who they are\n\n...",
          "buttons": [{"id": "back", "label": "Back", "validate": false},
                      {"id": "create", "label": "Create entry", "primary": true}],
          "state": {"name": "Mara", "set": "quick", "step": 5}}}
Key What Hammer does
page Shows this page next, in place of the one the writer was on. See Pages
message Shows the message briefly, whatever else the reply does. A message longer than 200 characters is cut short
errors Takes a {key: message} object. Hammer keeps the writer on the page they were on, with what they entered and the page's own state, and shows each message under the field with that key. A message whose key is "", or doesn't match any field on the page, applies to the whole page and appears above its fields
open Takes a {"place", "id"} object. Hammer ends the run, closes the dialog, and shows the item. place is one of the places, and id is the scene, note, entry, or event. For project, Hammer shows Project Home, and no id is needed

Precedence

If a reply has more than one of these keys, errors wins over open, and open wins over page. A reply with none of the three ends the run and closes the dialog. Hammer does this silently unless the reply includes a message.

  • If your plugin replies with errors to a call made with no page showing (the first call of an action without fields), there's no page to keep the writer on. The run ends, and Hammer shows the errors as a message.
  • If an open names an item that isn't in the project, or has no id for a place other than project, the run still ends, and Hammer tells the writer.
  • Every call follows something the writer did, so your plugin can't use open to move the writer somewhere they didn't ask to go.
  • A blank title, markdown, or message counts as missing.

Here's a typical reply at the end of a run that created something:

{"message": "Added Mara", "open": {"place": "entry", "id": 12}}

What fails a call

A reply makes the call fail, just as a trap does, in any of these cases:

  • It isn't JSON of this shape (for example, a button has no id).
  • A page's state is larger than 1 MiB once encoded.
  • An open names an unknown place.
  • A page has a field Hammer can't show. That means an unknown type or format, a key used twice on the same page, a choice with no options, an entity with an unknown kind or entry type, or a default the field won't accept.

Hammer ignores unknown keys. When a call fails, the writer sees a short message: the one your plugin set with error_set, if it set one. See Host.

Pages

Every key in a page is optional.

Key Value
title The dialog's title. Without one, Hammer uses the action's label
markdown Shown first, at the top of the page
fields Shown in order, below the markdown. See Fields
buttons See Buttons
state Any JSON. Hammer passes it back as the next call's state

A page's fields always start at their defaults. To show a page again with what the writer entered (when they press Back, for example), pass those values as the defaults.

Documents

A page with markdown and no fields offers a Copy button. A page with no fields and no buttons is a document, and it also offers Save as note. Hammer carries out both itself, so saving doesn't need any permission. If a document is longer than a note allows, Hammer cuts it at a line break, ends it with "…", and tells the writer. Saving ends the run and closes the dialog, which is why a page with buttons doesn't offer it.

Fields

Fields take the same keys whether they're in a manifest's [[actions.field]] table or in a page's fields.

Key Meaning
key The field's key in the next call's input, and in errors. Must be unique on the page
type One of the types below
label Shown with the field
hint Optional help text shown with the field
default Optional. The field's value when its page appears
required Optional, false by default. See below
type Shown as Value in input Extra keys Without a default
bool A toggle true or false false
int A number field A whole number min, max min, or 0
choice A dropdown The chosen option's value options: [{"value", "label"}], at least one The first option
text The editor its format picks A string; markdown for the rich formats format, multiline ""
scenes A button that opens the scene tree to pick from, with a short summary of the selection next to it A list of scene ids; with multiple = false, one id or null multiple, true by default Nothing picked
entity The selection as chips, and a button that opens a search by name {"kind", "id"} or null; with multiple = true, a list of them kinds, entry_types, tags, multiple, false by default Nothing picked
  • default takes the same shape as the field's value. A default the field won't accept makes the call fail.
  • required applies to text, which must not be blank, and to scenes and entity, which must not be empty. Other types ignore it.
  • string is a settings type, and Hammer rejects it here. To ask for text in an action, use text.

Text formats

format Editor
plain, the default An ordinary text field. It's one line, or several with multiline = true
rich The scene editor, with its format bar. It grows from 3 lines to 12, then scrolls
rich-extended The same editor with the extended format bar, which adds highlight, task lists, and tables

A rich value is the markdown the editor stores, so your plugin can pass it straight to entry.create or note.create.

Entity fields

An entity field lets the writer pick items from the project by name.

[[actions.field]]
key = "who"
type = "entity"
label = "Character"
kinds = ["entry"]          # scene, note, entry, event; all four when left out
entry_types = ["person"]   # person, place, thing, event, idea; every type when left out
tags = ["pov"]             # only items carrying one of these; any when left out
required = true
Key Values When left out
kinds scene, note, entry, event All four
entry_types person, place, thing, event, idea. Requires entry among the kinds Every type
tags Tags an item must carry at least one of, matched ignoring case and any leading # Any item
multiple true for a list false: one item

The field's value is {"kind": "entry", "id": 12}, or null when nothing is picked. With multiple = true, the value is a list of these, and it's empty when nothing is picked. default takes the same shape. Picking a single item is the common case, so multiple is false by default, unlike on a scenes field.

Hammer rejects an entity field that has an unknown kind or entry type, an empty kinds, entry_types when kinds doesn't include entry, or a blank tag.

  • The picker searches names and entry aliases, matching anywhere in the text and ignoring case. When the field takes several kinds, the picker has a tab for each. It lists the first 100 matches, then how many more there are, and it doesn't offer archived scenes.
  • Names. Scenes are listed in story order, named as in the scene tree, with their groups beside them. Entries and notes are listed by name, a note named by its first line. Events are listed in timeline order, each named by its date, or by its first line if it has none.
  • Read afresh. Hammer reads the project's items each time a page with an entity field appears, so the picker includes items created, renamed, or deleted since, such as an entry your plugin created on an earlier call.
  • Picks dropped. If a pick on the page names an item that's no longer in the project, or one the field doesn't accept, it shows as nothing picked rather than as an error. This applies to a default (for example, the entry the action was run from, when that entry is a place and the field takes people), to a pick the first page carries over from the last run, and to a pick restored from a session.
  • Checked at the press. If a picked item is gone by the time the writer presses a button, it fails Hammer's checks.
  • Unchecked when unreadable. If Hammer can't read the project's items, it passes picks to your plugin without checking them. So your plugin should still reply with errors when it gets an item it can't read.

Buttons

Key Default Meaning
id Sent as the call's button when the button is pressed. Required
label The button's text
primary false Emphasised, and placed last in the footer
inline false Placed in a wrapping row under the markdown instead of in the footer. Use it for a button that acts on something in the markdown, such as a name to add
validate true Set it to false to skip Hammer's checks, for buttons such as Back or Cancel

The footer shows its buttons in this order:

  1. Start over, on a restored page
  2. Your plugin's other footer buttons
  3. Copy and Save as note
  4. Your plugin's primary buttons
  5. Run, on the first page

Host checks

Before it makes a call from Run, or from a button with validate, Hammer checks the page:

  • A required text field isn't blank.
  • A required scenes or entity field isn't empty.
  • An int is within its min and max.
  • A choice is one of its options.
  • Each picked entity is still in the project, and matches a kind, entry type, and tag its field accepts.

If a check fails, Hammer shows the problem under that field and doesn't call your plugin. Changing the field clears the message. Any other rule is up to your plugin, which reports problems with errors.

State

Hammer never looks inside a page's state. It passes it back to your plugin, unchanged, on the next call. Keep everything your action needs to remember in state rather than in memory: Hammer may reload your plugin between calls, and a kept action can resume days later. If a reply's state is larger than 1 MiB once encoded, the call fails.

Sessions

When an action sets keep = true, Hammer saves a run the writer leaves part way through, so they can come back to it later. Hammer calls these saved runs action sessions. A session records the page that was showing, its state, and what the writer had entered.

Only an action whose manifest entry sets keep = true gets sessions. The setting applies to the whole action, not to individual pages, so a page with no fields is saved like any other. Use keep for an action that asks the writer for something they'd want to keep, such as answers to an interview. A report or a list of names is better generated fresh each time.

Your plugin can't read or write a session directly. It only sees the state that Hammer passes it.

  • Resuming. When the writer chooses the action again from the same place, Hammer shows the page where they left off, with their values and a Start over button, which asks before it clears the session. The next call from that page has resumed: true. Hammer keeps a separate session for each action, project, and place, and for each item when the action is run from a scene, note, entry, or event.
  • Ending. A reply that ends the run removes its session. So do open, Save as note on a document, and closing a page that has no fields and no buttons. If the writer closes the first page just as it opened, without entering anything, Hammer doesn't save a session. errors, a failed call, and Stop all leave the session in place.
  • A new version. If the plugin's version differs from the session's, Hammer doesn't pass the session's state back, since it may mean nothing to your plugin. Instead, it shows the writer's answers, which they can copy or save as a note, along with a Start afresh button. Hammer does the same for a session whose page it can no longer show.
  • A new project id. Sessions are kept by the project's id, so renaming or moving a project keeps them. When the id itself changes, such as when a backup is restored, Hammer copies the sessions to the new id.
  • A renumbered item. Sync can give an item made on this device a new id. Sessions follow it: a run from that item moves to its new id, and picks of it in scenes and entity fields take the new id. Hammer can't rewrite state, though. If a page's state may hold the old id (a number equal to it, the item as {"kind", "id"}, or a string without spaces that ends in it after a separator, such as open:12), Hammer doesn't carry the run on. It shows the writer's answers with Start afresh, as for a new version. Hammer doesn't read keys, or strings with spaces, so a question's number or an answer's isn't taken for an id.
  • Size. A session can be at most 4 MiB.
  • Not synced. A session stays on the device where it was created. Uninstalling a plugin doesn't remove its sessions, so installing it again brings them back.

A run without keep ends when the writer closes its dialog.

Progress and stopping

While a call is running, its page stays on screen and the writer can still type in it, but its buttons are disabled. If the reply is errors, anything typed in the meantime is kept. A call made with no page showing gets a dialog of its own. Each project window runs one action at a time.

If a call takes longer than 400 ms, Hammer replaces the footer with a progress bar, a line of text, and a Stop button. Your plugin sets the bar and the text by calling the hammer_progress import with:

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

Both keys are optional. If your plugin doesn't report progress, the bar is indeterminate. Outside an action, hammer_progress does nothing.

The writer can't close the dialog while a call is running. If they choose another action in the same project window meanwhile, Hammer tells them one is already running.

Stop interrupts your plugin immediately. After the writer stops a run, any call to hammer_progress traps, which ends the action there. If a call traps, returns a reply Hammer rejects, or is stopped, Hammer shows a short message. For an action with keep, the run then goes back to the page it was called from, as the writer left it. Any other run ends.

In each kit

Each kit has types for the call and the reply. Here's the same action in each one: a first page that asks for a name, and a message once the writer presses Greet.

action { request -> ... } is the body of the export. Returning null ends the action without a message. request.input<T>() and request.state<T>() read the call, and stateOf(value) sets a page's state. progress(fraction, message) reports progress. See Kotlin.

import hammer.ActionReply
import hammer.ActionReply.Button
import hammer.ActionReply.Field
import hammer.ActionReply.Page
import hammer.action
import hammer.input
import kotlin.wasm.WasmExport
import kotlinx.serialization.Serializable

@Serializable
class Greet(val name: String)

@WasmExport
fun action(): Int = action { request ->
    when (request.button) {
        null -> ActionReply(
            Page(
                fields = listOf(Field.text("name", "Name", required = true)),
                buttons = listOf(Button("greet", "Greet", primary = true)),
            ),
        )
        "greet" -> ActionReply.message("Hello, ${request.input<Greet>().name}")
        else -> null
    }
}

The hammer::action module provides Request, Reply, Page, Field, Button, Open, and EntityRef. Page::with_state sets a page's state, and Request::state reads it back. hammer::progress(Option<f64>, Option<&str>) reports progress. See Rust.

use hammer::action::{Button, Field, Page, Reply, Request};
use serde::Deserialize;

#[derive(Deserialize)]
struct Greet {
    name: String,
}

#[unsafe(no_mangle)]
pub extern "C" fn action() -> i32 {
    hammer::run(|| {
        let request: Request = hammer::input_json()?;
        match request.button.as_deref() {
            None => Reply::page(Page {
                fields: vec![Field::text("name", "Name").required()],
                buttons: vec![Button::new("greet", "Greet").primary()],
                ..Page::default()
            }),
            Some("greet") => Reply::message(format!("Hello, {}", request.input::<Greet>()?.name)),
            _ => Reply::default(),
        }
        .to_json()
    })
}

hammer_action.h reads the call with hammer_action_read and the hammer_input_ readers. It writes a reply with hammer_put_page, hammer_put_message, hammer_put_document, hammer_put_errors, or hammer_put_open. Strings in the request are the contents of JSON strings exactly as sent, escapes included; hammer_json_unescape decodes one. hammer_progress(fraction, message) reports progress. Pass a negative fraction when you don't know how far along the work is. See C.

#define HAMMER_IMPLEMENTATION
#include "hammer_action.h"

int32_t EXTISM_EXPORTED_FUNCTION(action) {
  hammer_alloc_reset();
  uint64_t size = extism_input_length();
  char *input = hammer_must_alloc(size);
  extism_load_input(0, input, size);
  HammerActionRequest request;
  if (!hammer_action_read(input, size, &request)) hammer_fail("Hammer sent a request this could not read");

  HammerBuffer out = {0};
  if (!request.button.data) {
    HammerField fields[] = {{.type = HAMMER_FIELD_TEXT, .key = "name", .label = "Name", .required = 1}};
    HammerButton buttons[] = {{.id = "greet", .label = "Greet", .primary = 1}};
    HammerPage page = {.fields = fields, .field_count = 1, .buttons = buttons, .button_count = 1};
    hammer_put_page(&out, &page, 0);
  } else if (hammer_text_is(request.button, "greet")) {
    HammerText name = hammer_input_string(&request, "name");
    HammerBuffer message = {0};
    hammer_put_text(&message, "Hello, ");
    hammer_reserve(&message, name.length);
    message.length += hammer_json_unescape(name.data, name.length, message.data + message.length);
    hammer_put_message(&out, hammer_terminated(&message));
  } else {
    hammer_put_text(&out, "{}");
  }
  extism_output_buf(out.data, out.length);
  return 0;
}