Skip to main content

Plugin Lua API Reference

The mah module is available to all enabled plugins and provides database read/write access, HTTP requests, JSON encoding, key-value storage, settings, logging, job control, and operation management.

VM Sandboxing

Each plugin runs in an isolated Lua VM.

Allowed libraries: base, table, string, math, coroutine

Blocked libraries: os, io, debug, package

Removed base functions: dofile, loadfile, load

Each VM has a mutex. All calls (hooks, actions, page handlers, HTTP callbacks) acquire this mutex, ensuring single-threaded execution within a single plugin. Different plugins run in separate VMs and can execute concurrently.

mah.db -- Database API

Full CRUD access to all entity types, plus relationship management and resource file operations.

Single Entity Getters

FunctionReturns
mah.db.get_note(id)Note table or nil
mah.db.get_resource(id)Resource table or nil
mah.db.get_group(id)Group table or nil
mah.db.get_tag(id)Tag table or nil
mah.db.get_category(id)Category table or nil

All IDs are numbers (float64 in Lua). Returns nil on error or not found.

Note Fields

FieldTypeDescription
idnumberNote ID
namestringNote name
descriptionstringNote description
metastringJSON-encoded metadata string
note_typestringNote Type name (if set)
owner_idnumberOwner Group ID (if set)
tagstableArray of { id, name }

Resource Fields

FieldTypeDescription
idnumberResource ID
namestringResource name
descriptionstringDescription
metastringJSON-encoded metadata string
content_typestringMIME type
original_filenamestringOriginal upload filename
hashstringSHA1 content hash
owner_idnumberOwner Group ID (if set)
tagstableArray of { id, name }

Group Fields

FieldTypeDescription
idnumberGroup ID
namestringGroup name
descriptionstringDescription
metastringJSON-encoded metadata string
owner_idnumberOwner Group ID (if set)
categorystringCategory name (if set)
tagstableArray of { id, name }

Tag Fields

id (number), name (string)

Category Fields

id (number), name (string), description (string)

Query Functions

FunctionFilter FieldsResult Fields
mah.db.query_notes(filter)name, limit, offsetid, name, description
mah.db.query_resources(filter)name, content_type, limit, offsetid, name, content_type
mah.db.query_groups(filter)name, limit, offsetid, name, description

Limits: Default 20, maximum 100. Offset: Default 0, maximum 10,000.

local images = mah.db.query_resources({
content_type = "image/jpeg",
limit = 50,
offset = 0
})

for _, img in ipairs(images) do
print(img.id, img.name)
end

Resource File Access

local base64_data, mime_type = mah.db.get_resource_data(id)

Returns base64-encoded file content and MIME type string. Maximum file size: 50 MB. Returns nil on error or if the file exceeds the size limit.

Resource Creation

From URL

local resource, err = mah.db.create_resource_from_url(url, options)
ParameterTypeDescription
urlstringMust use http:// or https:// scheme
options.namestringOverride the default URL-based filename
options.descriptionstringResource description
options.owner_idnumberOwner Group ID
options.tagstableArray of Tag IDs
options.groupstableArray of Group IDs

Returns a Resource table (id, name, description, content_type, original_filename, hash, owner_id) on success. Returns nil, error_string on failure.

local resource, err = mah.db.create_resource_from_url(
"https://example.com/image.jpg",
{ name = "Downloaded Image", owner_id = 5, tags = {1, 3} }
)
if not resource then
print("Error: " .. err)
end

From Base64 Data

local resource, err = mah.db.create_resource_from_data(base64_string, options)

Same options and return format as create_resource_from_url. Default filename is "plugin_upload" if no name is provided.

Resource Deletion

local ok, err = mah.db.delete_resource(id)

Returns true on success, or nil, error_string on failure.

Group CRUD

-- Create
local group, err = mah.db.create_group({
name = "My Group",
description = "A new group",
owner_id = 1,
category_id = 2
})

-- Full update (replaces all fields)
local group, err = mah.db.update_group(group.id, {
name = "Updated Name",
description = "Updated description"
})

-- Partial update (preserves unspecified fields)
local group, err = mah.db.patch_group(group.id, {
description = "Only this field changes"
})

-- Delete
local ok, err = mah.db.delete_group(group.id)

All create/update/patch functions return a table on success or nil, error_string on failure. Delete returns true on success or nil, error_string on failure.

Note CRUD

local note, err = mah.db.create_note({ name = "Meeting Notes", description = "Q1 planning" })
local note, err = mah.db.update_note(note.id, { name = "Updated Notes" })
local note, err = mah.db.patch_note(note.id, { description = "Revised" })
local ok, err = mah.db.delete_note(note.id)

Tag CRUD

local tag, err = mah.db.create_tag({ name = "important" })
local tag, err = mah.db.update_tag(tag.id, { name = "critical" })
local tag, err = mah.db.patch_tag(tag.id, { name = "high-priority" })
local ok, err = mah.db.delete_tag(tag.id)

Category CRUD

local cat, err = mah.db.create_category({ name = "Project", description = "Project groups" })
local cat, err = mah.db.update_category(cat.id, { name = "Active Project" })
local cat, err = mah.db.patch_category(cat.id, { description = "Updated" })
local ok, err = mah.db.delete_category(cat.id)

Resource Category CRUD

local rc, err = mah.db.create_resource_category({ name = "Photo" })
local rc, err = mah.db.update_resource_category(rc.id, { name = "Photograph" })
local rc, err = mah.db.patch_resource_category(rc.id, { name = "Image" })
local ok, err = mah.db.delete_resource_category(rc.id)

Note Type CRUD

local nt, err = mah.db.create_note_type({ name = "Meeting" })
local nt, err = mah.db.update_note_type(nt.id, { name = "Meeting Minutes" })
local nt, err = mah.db.patch_note_type(nt.id, { name = "Minutes" })
local ok, err = mah.db.delete_note_type(nt.id)

Group Relation CRUD

local rel, err = mah.db.create_group_relation({
from_group_id = 1,
to_group_id = 2,
relation_type_id = 3
})
local rel, err = mah.db.update_group_relation({ id = rel.id, name = "updated" })
local rel, err = mah.db.patch_group_relation({ id = rel.id, name = "patched" })
local ok, err = mah.db.delete_group_relation(rel.id)

Relation Type CRUD

local rt, err = mah.db.create_relation_type({ name = "depends-on" })
local rt, err = mah.db.update_relation_type({ id = rt.id, name = "blocks" })
local rt, err = mah.db.patch_relation_type({ id = rt.id, name = "blocked-by" })
local ok, err = mah.db.delete_relation_type(rt.id)

CRUD Summary

Most entity types follow the (id, opts) pattern for update/patch:

Function PatternReturnsDescription
mah.db.create_{entity}(opts)table or nil, errorCreate a new entity
mah.db.update_{entity}(id, opts)table or nil, errorFull update (replaces all fields)
mah.db.patch_{entity}(id, opts)table or nil, errorPartial update (preserves unspecified fields)
mah.db.delete_{entity}(id)true or nil, errorDelete an entity

Exceptions: group_relation and relation_type use (opts) for update/patch with id embedded in opts (e.g., mah.db.update_group_relation({ id = 1, name = "new" })).

Supported entity types: group, note, tag, category, resource_category, note_type, group_relation, relation_type, resource (delete only).

Relationship Management

Tag Operations

Add or remove tags from resources, notes, or groups:

-- Add tags to a resource
local ok, err = mah.db.add_tags("resource", 42, {1, 3, 5})

-- Remove tags from a note
local ok, err = mah.db.remove_tags("note", 10, {2, 4})

-- Add tags to a group
local ok, err = mah.db.add_tags("group", 7, {1})
FunctionParametersReturns
mah.db.add_tags(entity_type, id, tag_ids)entity type string, entity ID, array of tag IDstrue or nil, error
mah.db.remove_tags(entity_type, id, tag_ids)entity type string, entity ID, array of tag IDstrue or nil, error

Valid entity_type values: "resource", "note", "group".

Group Operations

Add or remove group associations from resources or notes:

-- Add groups to a resource
local ok, err = mah.db.add_groups("resource", 42, {1, 2})

-- Remove groups from a note
local ok, err = mah.db.remove_groups("note", 10, {3})
FunctionParametersReturns
mah.db.add_groups(entity_type, id, group_ids)entity type string, entity ID, array of group IDstrue or nil, error
mah.db.remove_groups(entity_type, id, group_ids)entity type string, entity ID, array of group IDstrue or nil, error

Valid entity_type values: "resource", "note".

Resource-Note Associations

Attach or detach resources from notes:

-- Attach resources to a note
local ok, err = mah.db.add_resources_to_note(10, {42, 43, 44})

-- Detach resources from a note
local ok, err = mah.db.remove_resources_from_note(10, {42})
FunctionParametersReturns
mah.db.add_resources_to_note(note_id, resource_ids)note ID, array of resource IDstrue or nil, error
mah.db.remove_resources_from_note(note_id, resource_ids)note ID, array of resource IDstrue or nil, error

mah.kv -- Key-Value Storage

Persistent key-value storage scoped to the calling plugin. Values are JSON-serialized before storage and JSON-deserialized on read, so Lua tables, strings, numbers, and booleans are all supported.

FunctionReturnsDescription
mah.kv.get(key)value or nilRead a stored value
mah.kv.set(key, value)nilWrite a value (overwrites existing)
mah.kv.delete(key)nilDelete a stored key
mah.kv.list([prefix])table of stringsList keys, optionally filtered by prefix
-- Store a table
mah.kv.set("config", { threshold = 0.8, model = "fast" })

-- Read it back
local config = mah.kv.get("config")
print(config.threshold) -- 0.8

-- List keys with a prefix
local keys = mah.kv.list("cache_")
for _, key in ipairs(keys) do
print(key)
end

-- Delete a key
mah.kv.delete("config")

Data is scoped by plugin name -- plugins cannot access another plugin's keys. To purge all KV data for a disabled plugin, use the POST /v1/plugin/purge-data endpoint.

mah.log -- Logging

mah.log(level, message, [details])

Writes a log entry to the application activity log.

ParameterTypeDescription
levelstring"info", "warning", or "error"
messagestringLog message
detailstableOptional: additional context (JSON-serialized)
mah.log("info", "Processing started", { resource_id = 42 })
mah.log("warning", "Rate limit approaching")
mah.log("error", "External API failed", { status = 500, url = "https://api.example.com" })

Log entries appear in the activity log with the plugin name as the entity name.

mah.start_job -- Background Jobs

local job_id = mah.start_job(label, fn)

Creates an async job and runs fn(job_id) in a background goroutine. Returns the job ID string immediately. Use this for long-running work outside of action handlers.

ParameterTypeDescription
labelstringDisplay label for the job
fnfunctionCallback receiving job_id as its argument
local job_id = mah.start_job("Import data", function(jid)
mah.job_progress(jid, 10, "Reading file...")
-- do work...
mah.job_progress(jid, 50, "Processing records...")
-- more work...
mah.job_complete(jid, { imported = 150 })
end)

The job appears in the job system and is tracked via SSE events.

mah.http -- HTTP API

Supports both async (callback-based) and sync (blocking) requests.

Constants

ConstantValue
Default timeout10 seconds
Maximum timeout120 seconds
Maximum response body5 MB
Maximum redirects10
Maximum concurrent requests16
User agentmahresources-plugin/1.0

Async Functions

Async functions return immediately. The callback fires later when the response arrives. Only http:// and https:// URLs are allowed.

mah.http.get(url, [options,] callback)

mah.http.get("https://api.example.com/data", function(response)
if response.error then
print("Error: " .. response.error)
return
end
local data = mah.json.decode(response.body)
-- process data...
end)

mah.http.post(url, body, [options,] callback)

mah.http.post("https://api.example.com/process",
mah.json.encode({ input = "test" }),
{ headers = { ["Content-Type"] = "application/json" } },
function(response)
print(response.status_code, response.body)
end
)

mah.http.request(method, url, options, callback)

mah.http.request("PUT", "https://api.example.com/item/1", {
headers = { ["Content-Type"] = "application/json", ["Authorization"] = "Bearer token" },
body = mah.json.encode({ status = "done" }),
timeout = 30
}, function(response)
print(response.status_code)
end)

Options Table

FieldTypeDescription
headerstableKey-value pairs of HTTP headers
timeoutnumberRequest timeout in seconds (max 120)
bodystringRequest body (for request() only)

Response Table

FieldTypeDescription
status_codenumberHTTP status code
statusstringFull status text
bodystringResponse body (truncated at 5 MB)
headerstableLowercase header names, comma-joined values
urlstringRequest URL
methodstringRequest method

On network error, the response contains error (string), url, and method instead.

Callbacks are queued and executed on the plugin's VM thread with a 5-second deadline per callback.

Sync Functions

Action handlers MUST use sync HTTP functions. Async callbacks cannot fire while the VM lock is held by the handler, so async requests will silently never complete.

Sync functions block the Lua execution until the response arrives.

mah.http.get_sync(url, [options])

local response = mah.http.get_sync("https://api.example.com/data")
if response.status_code == 200 then
local data = mah.json.decode(response.body)
end

mah.http.post_sync(url, body, [options])

local response = mah.http.post_sync(
"https://api.example.com/process",
mah.json.encode({ input = "test" }),
{ headers = { ["Content-Type"] = "application/json" } }
)

Returns the same response table format as async functions.

mah.json -- JSON API

mah.json.encode(value)

Converts a Lua value to a JSON string. Returns the string on success, or nil, error on failure.

Array detection: A Lua table is treated as a JSON array if it has consecutive integer keys starting from 1 with no gaps and no string keys. All other tables are encoded as JSON objects.

mah.json.encode({1, 2, 3})           -- '[1,2,3]'
mah.json.encode({a = 1, b = 2}) -- '{"a":1,"b":2}'
mah.json.encode({1, 2, a = 3}) -- '{"1":1,"2":2,"a":3}' (mixed = object)

mah.json.decode(string)

Parses a JSON string into Lua values. Returns the value on success, or nil, error on failure.

JSON TypeLua Type
objecttable (string keys)
arraytable (integer keys starting at 1)
numbernumber (float64)
booleanboolean
nullnil
local data, err = mah.json.decode('{"name": "test", "count": 42}')
if data then
print(data.name, data.count)
end

mah.api -- JSON API Endpoints

Register custom JSON API endpoints accessible at /v1/plugins/{pluginName}/{path}.

mah.api(method, path, handler, [opts])

ParameterTypeDescription
methodstringHTTP method: "GET", "POST", "PUT", or "DELETE"
pathstringEndpoint path (alphanumeric, hyphens, underscores, slashes)
handlerfunctionReceives a context table with request data and response helpers
optstableOptional. { timeout = 30 } -- seconds (default 30, max 120)

Handler Context

The handler receives a single ctx table:

FieldTypeDescription
ctx.pathstringFull request URL path
ctx.methodstringHTTP method
ctx.querytableURL query parameters
ctx.paramstableForm-decoded parameters (empty for non-form requests)
ctx.headerstableRequest headers (lowercase keys)
ctx.bodystringRaw request body
ctx.json(data)functionSet the JSON response body
ctx.status(code)functionSet the HTTP status code (default: 200)

Response Behavior

ScenarioStatusBody
ctx.json() called200 (or custom via ctx.status())JSON-encoded data
ctx.json() not called204 No ContentEmpty
Handler error500{"error": "internal plugin error"}
Handler timeout504{"error": "handler timed out"}
mah.abort() called400{"error": "reason"}
Path not found404{"error": "endpoint not found"}
Wrong HTTP method405{"error": "method not allowed"}

Example

function init()
-- GET endpoint returning JSON
mah.api("GET", "stats", function(ctx)
local notes = mah.db.query_notes({ limit = 0 })
ctx.json({ total_notes = #notes, query = ctx.query })
end)

-- POST endpoint with custom status
mah.api("POST", "webhook", function(ctx)
local payload = mah.json.decode(ctx.body)
mah.kv.set("last_webhook", payload)
ctx.status(201)
ctx.json({ received = true })
end, { timeout = 60 })

-- DELETE with no body
mah.api("DELETE", "cache", function(ctx)
mah.kv.delete("cached_data")
ctx.status(204)
end)
end

Duplicate registrations for the same method + path overwrite the previous handler.

mah.block_type -- Plugin Block Types

Register a custom block type for the note block editor. Call during init().

mah.block_type(config)

ParameterTypeRequiredDescription
config.typestringYesBlock type name (lowercase, alphanumeric and hyphens, max 50 chars). Automatically prefixed as plugin:<pluginName>:<type>
config.labelstringYesDisplay label in the block type picker
config.render_viewfunctionYesLua function that returns an HTML string for view mode
config.render_editfunctionYesLua function that returns an HTML string for edit mode
config.iconstringNoIcon for the block type picker
config.descriptionstringNoDescription of the block type
config.content_schematableNoJSON Schema (as Lua table) for content validation
config.state_schematableNoJSON Schema (as Lua table) for state validation
config.default_contenttableNoDefault content for new blocks
config.default_statetableNoDefault state for new blocks
config.filterstableNoRestrict availability by note_type_ids and/or category_ids

Render Functions

Both render_view and render_edit receive a context table:

FieldTypeDescription
ctx.block.idnumberBlock ID
ctx.block.contenttableBlock content (parsed from JSON)
ctx.block.statetableBlock state (parsed from JSON)
ctx.block.positionstringLexicographic ordering key
ctx.note.idnumberParent note ID
ctx.note.namestringParent note name
ctx.note.note_type_idnumberParent note's note type ID
ctx.settingstablePlugin settings key-value pairs

Each function must return an HTML string. Use mah.html_escape(str) to escape user-provided content.

The rendered HTML is served via GET /v1/plugins/{pluginName}/block/render?blockId={id}&mode=view|edit (see Custom Block Types).

Example

function init()
mah.block_type({
type = "quote",
label = "Quote",
icon = "Q",
description = "A styled quotation block",
content_schema = {
type = "object",
properties = {
text = { type = "string" },
author = { type = "string" }
},
required = {"text"}
},
default_content = { text = "", author = "" },
default_state = {},
render_view = function(ctx)
local html = '<blockquote class="border-l-4 pl-4 italic">'
html = html .. '<p>' .. mah.html_escape(ctx.block.content.text or "") .. '</p>'
if ctx.block.content.author then
html = html .. '<footer>— ' .. mah.html_escape(ctx.block.content.author) .. '</footer>'
end
return html .. '</blockquote>'
end,
render_edit = function(ctx)
return '<div>'
.. '<textarea name="text">' .. mah.html_escape(ctx.block.content.text or "") .. '</textarea>'
.. '<input name="author" value="' .. mah.html_escape(ctx.block.content.author or "") .. '">'
.. '</div>'
end,
filters = {
note_type_ids = {1, 2}
}
})
end

mah.get_setting(key)

Returns the value of a plugin setting, or nil if not set.

local api_key = mah.get_setting("api_key")  -- string
local max_size = mah.get_setting("max_size") -- number
local enabled = mah.get_setting("enabled") -- boolean

Values are returned with their correct Lua type based on the setting definition.

mah.abort(reason)

Aborts the current operation (hook or action) with a message. Works in before hooks and action handlers.

mah.abort("Invalid input: name is required")

In before hooks, this cancels the entity operation. In action handlers, this returns { success = false, message = reason }.

mah.html_escape(str)

Escapes a string for safe HTML output. Replaces &, <, >, ", and ' with their HTML entity equivalents.

ParameterTypeDescription
strstringThe string to escape

Returns the escaped string.

local safe = mah.html_escape('<script>alert("xss")</script>')
-- Result: &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;

Use this in render_view and render_edit functions to prevent XSS when rendering user-provided content.

Job Progress Functions

Available in async action handlers and mah.start_job callbacks. See Plugin Actions for full details.

FunctionDescription
mah.job_progress(job_id, percent, message)Report progress (0-100). SSE updates throttled to 200ms.
mah.job_complete(job_id, result_table)Mark job completed. Sets progress to 100.
mah.job_fail(job_id, error_message)Mark job failed.

Complete Example

A plugin that uses database CRUD, KV storage, logging, and HTTP:

plugin = {
name = "data-sync",
version = "1.0.0",
description = "Sync group data to an external service",
settings = {
{ name = "api_url", type = "string", label = "API URL", required = true },
{ name = "api_key", type = "password", label = "API Key", required = true }
}
}

function init()
mah.action({
id = "sync-group",
label = "Sync to External",
entity = "group",
async = true,
handler = function(ctx)
local group = mah.db.get_group(ctx.entity_id)
if not group then
mah.job_fail(ctx.job_id, "Group not found")
return
end

mah.job_progress(ctx.job_id, 20, "Preparing data...")

local api_url = mah.get_setting("api_url")
local api_key = mah.get_setting("api_key")
local payload = mah.json.encode({
name = group.name,
description = group.description,
meta = group.meta
})

mah.job_progress(ctx.job_id, 50, "Sending to API...")

local response = mah.http.post_sync(
api_url .. "/groups",
payload,
{
headers = {
["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. api_key
}
}
)

if response.status_code ~= 200 then
mah.log("error", "Sync failed", { status = response.status_code })
mah.job_fail(ctx.job_id, "API returned " .. response.status_code)
return
end

local result = mah.json.decode(response.body)
mah.kv.set("last_sync_" .. ctx.entity_id, {
synced = true,
external_id = result.id
})

mah.log("info", "Group synced", { group_id = ctx.entity_id })
mah.job_complete(ctx.job_id, { message = "Synced", external_id = result.id })
end
})
end