Lua library

Plugins and :lua chunks run in a sandboxed VM (no os/io/debug, no filesystem) with a deadline kill. The library surface is the full set of globals the VM provides, split by where they exist:

  • load-time globals - available in every plugin file while it loads, and in the hook functions it declares (body_render, categorize, refresh), which run on the same loaded VM: re_match, get_attachments, date_str, json, http (gated), log, register_attach_command, register_action, bind_key, translate.
  • invocation globals - available in every action body and :lua chunk: ctx, attach_add, tag_add, tag_remove, picker_argv, prompt, ai_chat, translate, print, plus json (and http when gated). The two sets overlap but neither is a subset of the other: re_match/get_attachments/date_str are load-time only (their data does not exist at invocation), and the client-effect bindings are invocation only.

The usage page covers how plugins load, the network gate, and the categorization contract; this page is the function reference.

Load-time globals

Every plugin file sees these while it loads.

re_match(pattern, str)

RE2 regex helper - Lua string patterns have no alternation, so Go’s regexp syntax is exposed instead. Returns two values: match, err. A bad pattern is false plus the error text, never a raise; the common single-value use if re_match(...) keeps working.

local ok, err = re_match("trip\\.com", msg.from)  -- literal dot: \\.
if not ok and err then return nil end              -- a pattern error is fatal

RE2 escapes with backslash, so a literal dot is \. and the Lua literal needs \\ - a single backslash is swallowed by the Lua parser.

get_attachments(handle)

Fetches the attachment list of the message the save pass is categorizing. The handle is the opaque string passed to categorize(handle, msg); the plugin never opens files - this returns what the client already parsed. Returns a table of {name, ext, mime, size, ordinal} rows; ext is the filename extension without the dot, lowercased (the sender’s own naming, immune to parser-reported mime quirks), ordinal is the 1-based position in the message (the key the categorize return table uses). An unknown handle raises.

for i, att in ipairs(get_attachments(handle)) do
  if att.ext == "pdf" or att.ext == "docx" then out[i] = category .. "/" .. slug(att.name) end
end

date_str(sec, pattern)

Formats a unix timestamp by the same YYYY/MM/DD token pattern as the [attachments] layout config - the calendar lives in the client, not the plugin. Literal text passes through; the default pattern is YYYY/MM.

date_str(msg.date, "YYYY/MM")      -- "2026/08"
date_str(msg.date)                 -- "2026/08" (default)
date_str(msg.date, "YYYY-MM")      -- "2026-08"
date_str(msg.date, "MM/YYYY")      -- "08/2026"

json

json.encode(v) and json.decode(s), depth- and size-capped (a cyclic Lua table hits the depth cap, a fan-out table cannot build a JSON bomb). encode returns the string, or nil, err; decode returns the value, or nil, err.

local ok, err = json.decode(json.encode({a = 1}))

http

The REST binding - exists only when the plugin has a [lua.network.<name>] section (deny-by-default). http.request(method, url, opts) returns {status, headers, body} or nil, err; opts may carry headers and body. Every request (redirect hops included) must match the configured targets AND one "METHOD /path" rule; the body is capped at 256 KiB; the VM deadline aborts in-flight requests. A network-enabled plugin never sees mail content (its ctx is the metadata surface only, see below).

local res, err = http.request("GET", "https://api.hubspot.com/crm/v3/objects/contacts", {
  headers = { authorization = "Bearer " .. token },
})
if not res then error("api: " .. err) end
local data = json.decode(res.body)

register_attach_command(name, argv)

Adds an attach command to the compose prompt (? lists it, @name runs it). Runs during load, the reverse of the read-after pattern - the plugin file calls it directly.

register_attach_command("yazi", {"yazi", "--chooser-file"})

register_action(name, fn)

Registers a named action (:name). fn runs in a fresh invocation VM on every call, with the invocation globals below. The name must be non-empty; a duplicate just overwrites.

register_action("triage", function(ctx)
  for _, line in ipairs(ctx.mail_lines()) do print(line) end
end)

bind_key(context, key, action, fn)

Binds a key in a binding context; the action name is the keybinding’s fun. fn receives the same invocation context as register_action.

bind_key("index", "g t", "triage", function(ctx) ... end)

translate(id)

The i18n lookup - the same embedded catalog the client UI uses, selected by the [ui] language setting, never plugin config.

print(translate("save attachment to: "))

log(msg, err)

Appends to the session log - the same surface the ~ overlay and the status line show, shared with the client’s own events. log(msg) is a normal entry, log(msg, true) an error line. Available in every plugin-declared hook (body_render, categorize, refresh); :lua chunks and action bodies have print instead, which captures into the run’s output rather than the log. Never log mail content (F6) - the text is the plugin’s own data, not message bodies.

function refresh(ctx)
  log("sync " .. ctx.account_name)
end

Invocation globals

Available in action bodies and :lua chunks, on a fresh VM per call (the plugin file re-runs, so an edit since load is what the call sees). :lua chunks and action invocations are user-typed or plugin-authored code, never mail content. The json and gated http modules above exist here too; register_action and bind_key re-run scoped to the call (a plugin may register from inside an action).

ctx

The invocation context table.

  • ctx.thread_id - the id of the thread the action ran on.
  • ctx.mail_lines() - the full thread plain text as a table of strings. Absent on a network-enabled plugin’s invocation - the data policy replaces it with the metadata surface, so a body cannot cross the network allowlist.

A network-enabled plugin sees the metadata surface instead:

  • ctx.thread_info(thread_id) - one thread’s messages as {thread_id, count, messages = {id, thread_id, timestamp, author, subject, tags, references}}.
  • ctx.search(query, limit) - message rows for a notmuch query (same row shape; limit 1-500, default 50).
  • ctx.count(query) - the message count of a query.

The surface is filtered per message by the account’s [ai-data] grant (docs/usage.md “Per-account data grants”, deny by default): a message whose account grants participants/subjects/dates projects its author/subject/timestamp, and thread_info’s count appears only when the newest message’s grant allows it. The structural identity (id, thread_id, tags, references) always renders.

attach_add(path)

Adds an attachment to the compose dialogue’s attachment list.

tag_add(tag) / tag_remove(tag)

Stage a tag op into the current folder’s R14 buffer - the same buffer the t/a/d keys fill. The script classifies, the APPLY key flushes; Lua never writes notmuch directly.

picker_argv(argv)

Runs a chooser (the attach-command exec path) and blocks the VM on the TUI’s reply, under the action deadline. Returns the selection as a table of paths. This is the only picker primitive; the client-specific wrapper is your plugin (see lua-picker.md).

prompt(…)

Opens the native text dialogue and blocks for its reply: committed text returns as the string, an esc cancel returns nil. The wait is deadline-bounded - a never-answered prompt cannot wedge the plugin.

ai_chat(name, opts)

Streams one completion to a configured [ai.<name>] provider. opts carries model, system, and text (required); the streamed deltas publish as AiChunk (the pager-inline summary) and the full text returns. On failure it raises.

ai_chat is network egress, so it exists only on a plugin with a [lua.network.<name>] section - a no-network plugin or a :lua chunk calling it gets “attempt to call a nil value”. A network plugin’s ctx is already the metadata surface (no mail_lines), so a body can never reach the provider this way.

print(…)

Captures into the run’s output (rides LuaResult, shown to the user) - never a log line.

Pickers

No picker is bundled: the core exposes only picker_argv, and the chooser wrapper lives in your plugin. See lua-picker.md for the attach-choose action pattern and a chooser.lua example.