Skip to content
TildeMailv0.2.1

Scripting with Lua

Lua scripts let tmail react to new mail and other events, run background jobs, bind keys and define rules. This page covers where scripts live, what they can call, and the limits of the sandbox.

Scripts live in ~/.config/tildemail/scripts/, starting from init.lua:

events.on("new_mail", function(msg)
if msg.headers["List-Unsubscribe"] then
mail.move(msg, "Newsletter")
else
sys.notify(msg.from, msg.subject)
end
end)
keys.bind("N", function() sys.notify("tmail", "key N pressed") end)

Run tmail watch to see it work (see tmail watch).

The script engine is in the library and tested. The shipped tmail runs init.lua in three places:

  • tmail watch starts the engine together with the event hub, the job scheduler and the new-mail watcher, so the new_mail handlers, the other events and the jobs all work there.
  • tmail rules run starts the engine only, so that the rules are defined. Events are not delivered and jobs do not run in that command.
  • tmail in a terminal, the terminal interface, starts the same services as tmail watch, so the handlers, the rules and the jobs run while it is open, and the key bindings and the commands you define work in it. A Lua error in init.lua does not stop the interface: it appears as an error message and in the log. When another tmail already runs the services, the interface starts in reading mode and runs no script.

No other command runs a script.

Every function below exists on develop; the engine renders the same list into definitions.lua.

Module Functions and constants
events events.on(name, fn): runs fn every time the event happens. Built-in events are listed below; native plugins may register additional names. An unregistered name is an error. Handlers run in subscription order.
keys keys.bind(lhs, rhs): binds a key in the global context. keys.bind(context, lhs, rhs): binds a key in global, sidebar, list or reader. rhs is a function or an action name such as "message.delete". A function can take a description as the last argument, keys.bind(lhs, fn, "text"), which the which-key panel shows. keys.unbind(context, lhs): removes that binding, preset bindings included. See Binding from Lua.
mail mail.move(msg, folder): moves the message to another folder of its account. mail.flag(msg, "flagged" | "seen"): sets a flag. mail.mark_read(msg): marks it read.
gpg gpg.has_key(address): true when the keyring holds a usable public key for the address, false when it does not. See gpg.has_key. gpg.search_key(address): looks public keys up for an address without importing them. gpg.import_key(fingerprint [, address]): imports one public key into the keyring. See gpg.search_key and gpg.import_key.
sys sys.notify(title, body): shows a desktop notification with notify-send, without waiting. &, < and > in the body are escaped, so markup in a subject is shown as text. sys.date(format): the local time as text; see sys.date.
log log.debug, log.info, log.warning, log.error(message, ...): write to the tmail log. The message is a Serilog template whose properties the extra arguments fill. log.LEVEL_DEBUG, log.LEVEL_INFO, log.LEVEL_WARNING and log.LEVEL_ERROR hold the level numbers.
jobs jobs.enqueue(fn), jobs.every(name, minutes, fn), jobs.remove(name), jobs.list(). See Jobs from Lua.
rules rules.on(name, conditions, actions): defines a rule, or replaces the rule of that name. rules.off(name): removes it. rules.list(): the names, in order of definition. See Rules.
bar bar.top(fn) and bar.bottom(fn) build the top bar and the status line from the state of the screen. bar.interval(seconds) also builds them every N seconds. bar.symbol(name) returns a symbol of the theme. bar.hints(keys, style) turns the key hints of the pane into styled groups. See bar.
commands commands.add(name, fn [, description]): defines a command for the : line, or replaces the command of that name. commands.remove(name): removes it. commands.list(): the names, in order of definition. See commands.

commands.add(name, fn [, description]) defines a command that is meant to be typed after : in the terminal interface:

commands.add("archive-old", function(args)
-- args is a table with the words typed after the name
return "archived with " .. (args[1] or "no argument")
end, "Archive the old messages")
  • name has 1 to 64 characters: lowercase letters, digits, _ and -, starting with a letter. A name with a dot belongs to the input actions, and quit, q, help, folder, theme, about and jobs are built in; any of these is an error.
  • fn receives a fresh table of strings, one per word typed after the name, with quotes and escapes already resolved. If it returns a string, that string is the message shown to the user; any other return value is ignored. The message is cut to 300 characters and control characters are replaced.
  • description is optional, must be a string and is cut to 120 characters.
  • A command runs like a hook: one at a time with the other Lua work and under the same instruction budget. A command that fails or runs out of budget is reported to the script_error event and shows its error to the user as NAME: <message>.
  • Defining a name again replaces the command where it stands, so the order of commands.list() does not change. A reload of the scripts forgets every command.

Type a command after : in the terminal interface. A command that returns a string shows it on the last line of the screen; one that returns several lines shows them in a window.

The terminal interface has two bars, and your scripts build both of them: the top bar (a row above the panes, hidden while it has nothing to say) and the status line (the last row). tmail ships a script that draws the status line, so the interface looks right without any script of yours; you replace it by calling bar.bottom again. A bar is a function: it gets the state of the screen and returns what to draw, piece by piece.

bar.bottom(function(ctx)
local separator = { bar.symbol("separator"), fg = "border" }
local left = { { ctx.place, bold = true } }
-- No counts and no connection before the first account exists.
if ctx.counts ~= "" then
table.insert(left, separator)
table.insert(left, { ctx.counts })
end
if ctx.conn.state ~= "none" then
table.insert(left, separator)
table.insert(left, { ctx.conn.symbol .. " " .. ctx.conn.text, fg = ctx.conn.role })
end
return {
left = left,
right = bar.hints(ctx.keys, { key = { bold = true }, desc = { fg = "muted" } }),
}
end)

That is the shape of the built-in bar: the place in bold, dim separators from the theme, the counts, the connection in the color the program picks for its state, and the keys of the pane with the key in bold and its meaning in the muted color. The built-in bar also draws ctx.notes between the counts and the connection. The top bar takes the same kind of function. This one is a clock on a colored row, and it uses bar.interval because nothing on the screen changes when a second goes by:

bar.interval(1)
bar.top(function(ctx)
return {
left = { { " " .. sys.date("HH:mm:ss"), bold = true } },
right = { ctx.place .. " " },
bg = "#7A3E9D",
}
end)

The Lua of tmail has no os library (see the sandbox), so the clock is sys.date.

A function returns a table with left and right, each a list of items, and optionally bg, the color of the whole row. Both sides are optional; return {} is an empty bar.

  • An item is a bare string, a piece or a group.
  • A piece is { text, fg = color, bg = color, bold = true }. text is a string and comes first; fg, bg and bold are optional, and a piece without them takes the colors of the row. A bare string is a piece with no style.
  • A group is a list of strings and pieces, such as { { "3 " }, { "unread", fg = "unread" } }. The program keeps a group whole: when the bar is too narrow it drops the group or keeps it, and never keeps half of one. Items that are not groups are dropped one by one. A table is a group when its first element is a table or it has more than one element; a table with one string first is a piece. A group holds strings and pieces, not other groups, and the fg, bg and bold that you write on the group table itself are ignored: style the pieces inside it.
  • A color is the name of a role of the theme ("muted", "success", "border", see Themes) or "#RRGGBB". A role follows the theme; a hex color is the same on every theme, so a bar that only uses roles looks right on all of them. bg on the returned table fills the whole row; without it the status line sits on the background of the theme, and the top bar on top_bar_bg with top_bar_fg for the text.
  • Bold is the only attribute.
  • A text is cleaned for the terminal (a line break or another control character becomes a space and invisible characters are removed) and cut at 200 characters, never inside an emoji. A side holds at most 64 pieces, counting the pieces inside groups.

An unknown role, a color that is neither a role nor #RRGGBB, a text that is not a string, a bold that is not a boolean, or a side that is not a list is an error that names the item, for example left item 2: fg: unknown role 'mutd'; use a role of the theme, such as muted, or '#RRGGBB'.

  • bar.top(fn) and bar.bottom(fn) set the function of a bar. Calling one again replaces the function, which is how you replace the built-in status line; nil removes it. The top bar is hidden while both its sides are empty, and the panes use its row while it is hidden. The built-in script does not set the top bar.
  • bar.interval(seconds) builds the bars again every seconds (a number from 1 to 86400), on top of every change of the screen. bar.interval(nil) goes back to building them only when something changes. Use it for something that moves by itself, such as a clock. It is one interval for both bars.
  • bar.symbol(name) returns the symbol of the theme in use for a key of its [symbols] table ("separator", "online", "offline", "error_mark", and the others in Themes). An unknown name is an error that lists the known ones. Call it inside the function: the symbol then follows the theme that is in use when the bar is built.
  • bar.hints(keys, style) turns ctx.keys into a list of groups, one per hint: the key, a space and what it does, with two spaces before every group after the first. style is optional: { key = { fg = ..., bg = ..., bold = true }, desc = { ... } }. Use the result as right.

ctx is a snapshot of the screen; changing it does nothing to the interface.

Field Value
ctx.pane "mail". The chat and “needs me” screens will add "chat" and "needs_me".
ctx.focus "sidebar", "list" or "reader" in the mail screens ("stream" and "input" are for the chat).
ctx.place where you are, in the language of the interface: work ▸ INBOX, work for an account with no folder open, tmail before the first account exists.
ctx.counts the counts as one text: 4 messages, 3 unread. Empty in the reader, during a search (the search is in ctx.notes) and before an account exists.
ctx.conn the connection of the account you are on, always a table: { state, text, role, symbol }. See below.
ctx.notes a list of { kind, text, role } for what else the middle of the bar tells, in this order: position (message 2 of 4, in the reader), signature (in the reader, only for a signed message; role is signature_good for a good signature, signature_bad for a bad one, signature_unknown for an unknown key or an untrusted one), search (the query and how many messages match), busy (the folder is updating) and jobs (running jobs). role is nil when the note has none.
ctx.keys a list of { key, desc } for the pane that has the focus, most important first, in the language of the interface: { key = "j/k", desc = "move" }. They come from the hint texts of the language files, so they can differ from a key you rebound.
ctx.width the columns of the bar.
ctx.mail { account, folder, total, unread, loaded_more } in the mail screens once a folder is open, otherwise nil. total and unread count the messages that are loaded, and loaded_more is true when more may exist than are loaded.
ctx.chat { account, name, kind, people, new, mentions } for a chat screen, otherwise nil. There is no chat screen yet, so it is always nil today.

ctx.conn.state is "online", "offline", "error", "connecting", "disconnecting" or "none". The program fills role and symbol with the defaults below, so a bar can draw the connection in one piece; text is already in the language of the interface.

state role symbol
online success the online symbol
offline muted the offline symbol
error error the error_mark symbol
connecting, disconnecting warning the offline symbol
none muted empty, and text is empty too

A mail account is online, offline or error; the other two states are for the chat. none means there is no account yet.

The bars are built again when the screen changes (the folder, the counts, the focus, the reader, the jobs, the state of an account) and, if you set bar.interval, on that interval. Changes that come close together are folded into one build (about 50 ms), and the function runs on the script thread, off the thread that draws: the screen draws the last result and never waits for Lua. While a build runs, changes make exactly one more follow. A function runs like a hook, one at a time with the other Lua work and under the same instruction budget.

What the program keeps for itself:

  • A message (a notice, an error, a success) replaces the left side for a few seconds, in the color of its severity.
  • The keys of a sequence that you are typing are drawn before the right side.
  • When a bar is wider than the screen, the program drops items from the end of right first, and only when right is empty does it cut left and end it with the ellipsis of the theme.

A function that fails, or returns something invalid, is reported like any script error when it starts to fail; while it keeps failing it is not reported again at every build (it goes to the log), and it is reported again if it works once and then fails. The last good result stays on the screen. When there has been no good result yet, the screen shows the plain bar described below, so a mistake in init.lua never leaves you with no status line.

In reading mode (another tmail holds the services, see Where scripts run) no script runs, and the status line is a plain bar made by the program: the place, the counts, the notes, the read-only note and the connection, then the keys, with no styles. The same plain bar, without the read-only note, is drawn until the first result of your script arrives, and it stays on the screen if your script removes the status line with bar.bottom(nil). The top bar stays hidden in that mode.

The default status line is a script that ships inside the program, runs before your init.lua and is updated with the program. It uses only roles and symbols of the theme, so it follows every theme. tmail bar dump prints it (see tmail bar dump), so you can start from it:

tmail bar dump > ~/.config/tildemail/scripts/bars.lua

Change the file and require("bars") it from init.lua, or paste the parts you want. Your own bar.bottom(...) replaces the built-in one. A reload of the scripts forgets every function and the interval and runs the built-in script again. The top_bar_fg and top_bar_bg roles are the default colors of the top bar.

The top_status module is gone, a breaking change. Its entries were a flat list of texts with one color for the whole bar; a bar is a function now. Where you had

top_status.add(" c Compose", nil)
top_status.add(function() return " " .. sys.date("HH:mm:ss") end, 1)
top_status.set_colors("#FFFFFF", "#7A3E9D")

write one function, give it the interval of the clock, and put the colors on the piece and on the row:

bar.interval(1)
bar.top(function(ctx)
return {
left = { " c Compose", { " " .. sys.date("HH:mm:ss"), fg = "#FFFFFF" } },
bg = "#7A3E9D",
}
end)

top_status.add had an interval for each entry; bar.interval is one interval for the bars. The entries no longer show in :jobs and in jobs.list() as top_status #<id>, because a bar is not a job. An entry that failed showed (error); a bar that fails keeps its last good result.

sys.date(format) returns the local time as text. format is a .NET custom date and time format of 1 to 64 characters: "HH:mm:ss" gives 15:05:07, "ddd dd MMM yyyy" gives Thu 29 Oct 2026, "'at' HH:mm" copies the text in quotes (there is no week number). The names of days and months are always in English, whatever the language of the machine. A format that is not a string, is empty or longer than 64 characters, or is not valid is a Lua error. A format of a single letter is a standard format ("d" is the short date); use two characters or a % in front ("%d" is the day of the month) to mean a custom one. This is not the format of os.date: "%H:%M" would give the month where it means the minutes, so a % outside quotes, except in the two-character form "%d", is an error; write "HH:mm".

A new_mail handler receives a msg table with account, folder, uid, from, subject, date (ISO 8601) and headers.

headers keeps the original header names as keys, and a lookup with any other casing (msg.headers["list-unsubscribe"]) finds the same header. The mail functions read account, folder and uid from the table, so they act on the message the handler was called for.

gpg.has_key only reads the keyring. A script can also look keys up and import them (see below), but it cannot sign, encrypt or decrypt.

  • Argument. An address, a key id or a fingerprint (hex of 8, 16, 40 or 64 digits, optionally with 0x). A bare address matches exactly (<address>, never a substring). Any other text is refused.
  • Meaning. A key that encryption cannot use counts as no key, because encrypting to it fails. An expired or revoked key, a sign-only key, a key whose encryption subkeys are all expired or revoked, a key whose user ids for that address are all revoked, and, when the key is looked up by address, a disabled key all make it false. A disabled key named by key id or fingerprint is still found, and gpg encrypts to it. It does not say that the key is trusted: an untrusted key still counts as present, and gpg’s trust model decides whether encrypting to it works.
  • Side effects. It never asks for a passphrase or a pinentry and never adds, removes or changes keys. In an empty home, gpg only creates its pubring.kbx and trustdb.gpg.
  • Errors. It raises a Lua error, never false, when:
    • gpg lists no key and reports an error of its own while looking (a broken keyring, or a stale keyring line in gpg.conf, for example);
    • gpg is missing, fails, or does not answer after about 10 seconds;
    • the argument is empty, starts with -, holds a control character or is longer than 320 characters.

gpg.search_key(address) looks for public keys of an e-mail address and returns them. gpg.import_key(fingerprint [, address]) adds one public key to your keyring. Neither can sign, encrypt or decrypt, and neither runs on its own: nothing calls them unless a script does.

  • A script can import keys on its own. gpg.import_key needs no confirmation, so a script that calls it, for example from a new_mail handler, adds public keys to your keyring without asking you. Write such a script only if that is what you want.

  • An imported key is not trusted. The import never changes ownertrust, never certifies the key and never trusts it. Check the fingerprint with its owner and certify the key with gpg yourself before you rely on it. Until then, gpg may refuse to encrypt to it, as for any untrusted key. If your gpg.conf sets trust-model always, gpg encrypts to any key in the keyring, so an imported key can be used for encryption at once: the import keeps your gpg options.

  • What a lookup tells the outside. The address is sent to the web key directory host of its domain and to the key server, which learn that you asked about it. The key server is the one in dirmngr.conf, or the default of your dirmngr (hkps://keys.openpgp.org); a keyserver line in gpg.conf does not apply. The settings of dirmngr.conf (a Tor proxy, for example) apply. A lookup of a sender’s address, for example from a new_mail handler, works as a read receipt: the web key directory host of the sender’s domain, which the sender controls, learns that the mail was processed and when, and your IP address unless dirmngr goes through Tor. There is no rate limit; each call blocks the single script thread for up to about 30 seconds (60 for an import with an address). A key server decides the order of its index and only the first 10 entries are looked at, so it can push the real key out of the results; keys.openpgp.org only returns user ids whose e-mail address was verified.

  • gpg.search_key(address) asks the web key directory first and then the key server. It downloads into a temporary keyring that is deleted afterwards and never changes yours. A key is returned only if one of its user ids has exactly that address (any case). No key gives an empty array. Each element is a table:

    Field Value
    fingerprint The primary key fingerprint, upper-case hex without spaces.
    user_ids An array of strings, as gpg prints them. They are claims of the key holder, not verified identities, and may hold control characters.
    created ISO 8601 string.
    expires ISO 8601 string, or nil when the key does not expire.
    expired, revoked Booleans. Expired and revoked keys are returned, flagged, not hidden.
    source "wkd" or "keyserver".
  • gpg.import_key(fingerprint [, address]) takes a full fingerprint: exactly 40 or 64 hex digits, in any case, with no spaces, no 0x, no short or long key id and no e-mail address. Without address it asks the key server for that fingerprint (a key that only a web key directory has needs the address). With address it first runs the same lookup for that address and requires a key with that fingerprint among the results. The key is downloaded into a temporary keyring first; its fingerprint must be exactly the requested one, the data to import must hold that one key and no other (another key can carry the same fingerprint as a subkey; such data is refused), and the key must not exceed 1 MiB. Only then is it imported, without the signatures that other people made on it. It returns the table of the imported key, the same as one element of gpg.search_key. The import is logged with the fingerprint.

  • Address argument. Not blank, no leading -, no control character, at most 320 characters, and it must contain @: a key id is not accepted here. It must be a plain address (bob@example.org), not a name with angle brackets.

  • Errors. Both raise a Lua error, never an empty result or false, when:

    • an argument breaks the rules above (gpg is not run);
    • gpg or dirmngr is missing or fails, the network or the key server fails, or the answer is not a key;
    • the key server returns a key with another fingerprint than the requested one, or data that holds another key besides it, or a key that is too large, or the address lookup does not return the fingerprint (the keyring is left as it was);
    • the call does not finish in about 30 seconds (gpg.search_key, and gpg.import_key without an address; with an address, about 60).

events.on(name, fn) calls fn with one table each time the event happens:

events.on("message_sent", function(e)
sys.notify("Sent", e.subject)
end)
events.on("offline", function(e)
sys.notify(e.account, "working from the cache")
end)
events.on("job_failed", function(e)
log.warning("job {A} failed: {B}", e.name, e.error)
end)

A handler is fire-and-forget:

  • It runs when its turn comes, one at a time with every other Lua unit.
  • It cannot change or stop what happened; there is no before_send.
  • A handler that fails is reported as script_error, and the next handler still runs.

Events are about what tmail does itself: a flag changed or a message moved by another program is not reported, and neither is a tag (keyword) that a rule puts on a message. No payload carries a message body.

Event Table fields When
new_mail the message table a new message arrived in a watched folder
message_sent account, from, to (list), cc (list), subject, saved SMTP accepted a message; saved is false when the copy in the sent folder could not be stored
send_failed account, from, subject, reason SMTP failed; not for a cancelled message, a refused confirmation or an editor error
flags_changed account, folder, uid, added (list), removed (list) tmail asked for a flag change (seen, flagged, answered, deleted), also through mail.flag and mail.mark_read; it does not compare with the old flags, so setting a flag that was already set still raises it
message_moved account, folder, uid, target tmail moved a message; folder and uid are where it was, so the table no longer points at the message (the mail functions would act on a UID that is gone)
offline account the server became unreachable and tmail serves its cache
online account the server answered again after offline
account_error account, folder, error, failures watching a folder for new mail began to fail (once per run of failures; a run ends when the watch delivers a message or has run 30 seconds without failing, and the next failure starts a new one)
startup none init.lua ran at the first start
config_reloaded none the scripts were reloaded
shutdown none the host asked the scripts to wind down (IScriptEngine.ShutdownAsync, called before the engine is disposed)
script_error source, message a script failed (source is init.lua, an event name, a key, a command or a job); a failure inside a script_error handler does not raise it again
job_failed name, kind (queued or recurring), error a background job failed; for a Lua job the error is generic and script_error carries the real message
signature_checked status (good, bad, unknown_key, untrusted, none), key, signer a signature was verified, alone or inside a decrypted message
key_imported fingerprint, source (wkd or keyserver), user_ids (list, at most 10; the claims of the key holder, not verified), address (the address the key was looked up for, or nil) a public key was imported into the keyring, from the interface, a send or gpg.import_key; it does not mean the key is trusted
decrypt_failed reason gpg could not decrypt a message (the first try of the interface, which runs gpg so that it cannot ask for a passphrase, does not raise it)

Take care with a handler that does what it listens for. A flags_changed handler that calls mail.flag (even mail.mark_read on a message that is already read) raises flags_changed again, and so on without end, one server round trip each time. The same goes for a script_error handler that starts a job which fails, and for a key_imported handler that calls gpg.import_key (importing a key that is already in the keyring raises the event again). tmail does not break such loops.

Native plugins may expose custom events through AddScriptEvent. Subscribe with the same events.on(name, fn) API; the plugin documents its payload fields. Names are registered before the engine first starts and appear in definitions.lua. Reload clears handlers while preserving available event names.

The events travel through an in-process queue (IEventHub) that keeps at most 10,000 waiting events and drops the oldest. The service lifecycle starts the queue before the script engine; events published before the queue starts wait for it.

jobs.enqueue(function() sys.notify("done", "ran once, in the background") end)
jobs.every("check", 5, function() log.info("every five minutes") end) -- name, minutes, function
jobs.remove("check")
for _, job in ipairs(jobs.list()) do log.info("{Name} is {State}", job.name, job.state) end
  • jobs.every needs a positive number of minutes (up to a little under 50 days) and a name no other recurring job uses. The first run is one interval from now.
  • jobs.remove stops a recurring job a script created and ignores an unknown name. Called from inside the job’s own body, it stops that run at once.
  • jobs.list returns tables with name, kind (queued, recurring or tracked), state (pending, running or idle), interval_minutes (recurring jobs), last_error (when the last run failed) and progress (when the job reported how far it is). It lists every job of the scheduler, also the ones tmail runs for itself, such as a search or the cache download of the terminal interface (kinds tracked and queued).

Nothing runs until the scheduler starts: tmail watch and the terminal interface start it, and no other command does. Jobs written in Lua run one at a time, because scripts share one Lua state, and a Lua job that waits for its turn still occupies one of the workers. A job that fails is reported like any script error, and a recurring one runs again at its next tick. A reload of the scripts removes the recurring jobs they created and cancels the queued ones that have not started. The scheduler itself is described in Background jobs.

Lua runs on LuaCSharp through Moongate’s module binder.

  • Libraries. The state opens the base, string, table, math, coroutine and module libraries only. dofile, loadfile and rawset are removed, as are coroutine.create, coroutine.wrap and coroutine.resume (a coroutine would escape the instruction budget) and the package members that reach the filesystem (path, cpath, searchpath, loadlib).
  • Modules. require("lib.util") loads scripts/lib/util.lua and nothing outside the scripts directory, also through symbolic links.
  • No outside access. There is no wait, no sleep and no access to the network or the filesystem.
  • Limits. string.rep refuses a result over 16 MiB.
  • Output. print writes to the tmail log and, like Lua’s own, honours __tostring.

Every unit of Lua work (init.lua, one handler call, one key press, one job body) runs on a thread-pool thread, one unit at a time, under a budget of 10,000,000 VM instructions. A unit that exceeds it is stopped and reported.

A mail.* call blocks the script thread while the backend works, for at most 60 seconds. A gpg.* call does the same and gives up after about 10 seconds (gpg.has_key) or about 30 seconds (gpg.search_key, and gpg.import_key without an address; with an address, about 60). A call also stops when the unit of Lua work that made it is cancelled.

Script errors never escape. They are logged and raised through ScriptError with their source (init.lua, the event name, key X, or the job), and the other handlers still run.

init.lua may be a symbolic link (as dotfile managers create) to a regular file owned by you and not writable by group or others. Any other link is not run, and the reason is logged and reported. A missing init.lua is not an error.

At every start the engine writes definitions.lua into the scripts directory, with an annotation for every module, function, constant and event name. It also creates .luarc.json next to it when that file does not exist. With the Lua language server, an editor opened on the scripts directory then completes mail., log. and events.on(".

  • definitions.lua is rewritten each time, so do not edit it.
  • .luarc.json is yours and is never overwritten.
  • A scripts directory that cannot be written is logged, and the scripts still run.

The engine can also reload everything (ReloadAsync): it drops the Lua state, the event handlers, the key bindings, the commands and the jobs the scripts created, and runs init.lua again. A reload also forgets every rule (see Rules).