Skip to main content

Plugin Manifest

The Plugin table at the top of your Lua file declares metadata, permissions, and capabilities.

Basic Structure​

Plugin = {
-- Required
name = "My Plugin",

-- Optional metadata
version = "1.0.0",
description = "What this plugin does",
author = "Your Name",

-- Event subscriptions
events = {"app:startup", "clip:created"},

-- Network permissions
network = {
["api.example.com"] = {"GET", "POST"},
},

-- Filesystem permissions
filesystem = {
read = true,
write = true,
},

-- Scheduled tasks
schedules = {
{name = "cleanup", interval = 3600},
},

-- User-configurable settings
settings = {
{key = "api_key", type = "password", label = "API Key"},
},
}

Required Fields​

name​

The only required field. Displayed in the plugin list and logs.

Plugin = {
name = "My Plugin",
}

Optional Metadata​

version​

Semantic version string for tracking updates.

version = "1.0.0",

description​

Brief explanation shown in the plugin list.

description = "Automatically backs up clips to the cloud",

author​

Your name or organization.

author = "Jane Developer",

Events​

Subscribe to app events by listing them in the events array. Your plugin will only receive events it explicitly subscribes to.

events = {"app:startup", "clip:created", "clip:deleted"},

Available Events​

EventHandler FunctionData Passed
app:startupon_startup()None
app:shutdownon_shutdown()None
clip:createdon_clip_created(clip){id, content_type, filename}
clip:deletedon_clip_deleted(clip_id)Clip ID (number)
clip:archivedon_clip_archived(data){id}
clip:unarchivedon_clip_unarchived(data){id}
clip:renamedon_clip_renamed(data){id, filename}
watch:file_detectedon_watch_file_detected(data){path, folder_id}
watch:import_completeon_watch_import_complete(data){clip_id, source_path, folder_id}
tag:createdon_tag_created(tag){id, name, color}
tag:updatedon_tag_updated(tag){id, name, color}
tag:deletedon_tag_deleted(tag_id)Tag ID (number)
tag:added_to_clipon_tag_added_to_clip(data){clip_id, tag_id}
tag:removed_from_clipon_tag_removed_from_clip(data){clip_id, tag_id}

See Event Handling for detailed event documentation.

Network Permissions​

Declare which domains your plugin can access. Users see these permissions before installation.

network = {
["api.example.com"] = {"GET", "POST"},
["cdn.example.com"] = {"GET"},
},
  • Domain: The exact domain, or a wildcard subdomain pattern (e.g., *.cdn.example.com)
  • Methods: Array of allowed HTTP methods (GET, POST, PUT, PATCH, DELETE)

Requests to undeclared domains will fail with a permission error.

Example: Multiple APIs​

network = {
["api.openai.com"] = {"POST"},
["api.anthropic.com"] = {"POST"},
["storage.googleapis.com"] = {"GET", "PUT"},
},

Filesystem Permissions​

Request access to read or write files outside the plugin's sandbox.

filesystem = {
read = true, -- Can read files
write = true, -- Can write files
},

Both default to false. Users are prompted to approve filesystem access on first use. Approving a parent directory covers all files and subdirectories within it. Approvals are persisted in the database across restarts.

warning

Filesystem access grants broad permissions. Only request what you need, and document why in your description.

Clipboard Permission​

Request access to write to the system clipboard:

clipboard = true,

Required for utils.clipboard_write(). Displayed in the permissions review during installation.

Scheduled Tasks​

Run functions at regular intervals.

schedules = {
{name = "cleanup", interval = 3600}, -- Every hour
{name = "sync", interval = 300}, -- Every 5 minutes
},
  • name: The name of the Lua function to call at each interval
  • interval: Seconds between executions

Handler Example​

schedules = {
{name = "backup", interval = 1800}, -- Every 30 minutes
},

function backup()
log("Running scheduled backup...")
-- Backup logic here
end

The function name must match the name field exactly. For example, {name = "cleanup", interval = 3600} calls a function named cleanup().

Settings​

Define user-configurable options that appear in the plugin's settings panel.

settings = {
{
key = "api_key",
type = "password",
label = "API Key",
description = "Your API key from example.com",
},
{
key = "auto_sync",
type = "checkbox",
label = "Enable Auto-Sync",
default = true,
},
},

Setting Fields​

FieldRequiredDescription
keyYesUnique identifier for storage
typeYesInput type (see below)
labelYesDisplay label in settings UI
descriptionNoHelp text shown below the input
defaultNoDefault value if not set
optionsFor selectArray of choices
sourceFor searchName passed to the on_search hook
note

Settings support types text, password, checkbox, select, search, and url. The range type is only available for option form fields in UI actions, not for settings.

Setting Types​

text​

Single-line text input.

{
key = "username",
type = "text",
label = "Username",
default = "",
},

password​

Obscured text input for sensitive data.

{
key = "api_key",
type = "password",
label = "API Key",
description = "Get your key at example.com/api",
},

checkbox​

Boolean toggle.

{
key = "enabled",
type = "checkbox",
label = "Enable Feature",
default = true,
},

select​

Dropdown with predefined options.

{
key = "quality",
type = "select",
label = "Export Quality",
options = {"low", "medium", "high"},
default = "medium",
},

Searchable picker whose rows come from the plugin at runtime, via the on_search hook. The field declares a source name; the picker sends the user's query to on_search and lists the returned rows. Selecting a row writes the row's value (a string) into plugin storage under the field's key.

{
key = "owner_id",
type = "search",
source = "groups",
label = "Parent group",
},

A search field without a source is dropped at parse time.

url​

A text input for a server URL whose saved value grants the plugin network access to that host. The field declares the HTTP methods it needs via grants_network; when the user saves the field, mahpastes records a revocable network permission for the parsed hostname (lowercased, port and trailing dot stripped, wildcards rejected). The host appears under the plugin's Permissions on its card, with a Revoke control. Retargeting the URL revokes the old host and grants the new one; clearing the value revokes it.

{
key = "server_url",
type = "url",
label = "Server URL",
grants_network = {"GET", "POST"},
default = "http://localhost:8181",
},

The grant is a snapshot of the value the user saved — the plugin cannot write a url setting from Lua (storage.set refuses url-typed keys), so a plugin can never widen its own network access. A granted host can only use methods listed in the manifest's grants_network fields. A url field without grants_network is dropped at parse time.

If on_search or an action reaches an ungranted host, the request fails with domain not in allowlist: <host>; return that (or a friendlier wrapper) from on_search as nil, "message" so the picker shows the reason instead of "No results".

Reading Settings​

Settings are stored under their raw key. Use storage.get() to read them:

function on_startup()
local api_key = storage.get("api_key")
local auto_sync = storage.get("auto_sync")

if not api_key then
log("Warning: API key not configured")
return
end

if auto_sync then
log("Auto-sync is enabled")
end
end

Implement one on_search(source, query) hook for every search field your plugin declares (in settings and in option form fields). It receives the declared source name and the user's query, and returns an array of rows:

function on_search(source, query)
if source ~= "groups" then return {} end

local resp = http.get("https://api.example.com/v1/groups?Name=" .. utils.url_encode(query))
if not resp or resp.status < 200 or resp.status >= 300 then return {} end

local ok, groups = pcall(json.decode, resp.body or "[]")
if not ok or type(groups) ~= "table" then return {} end

local rows = {}
for _, g in ipairs(groups) do
rows[#rows + 1] = { value = tostring(g.ID), label = g.Name }
end
return rows
end

Rules:

  • Each row is a table with value (stored when selected) and label (displayed); a missing label falls back to value, numbers are coerced to strings, and rows are capped at 50.
  • An unknown source returns an empty list. Sources not declared by a search field in the manifest are rejected before on_search runs.
  • A query is best-effort: while the plugin's sandbox is busy running an action, the picker shows a "busy" hint instead of queueing; the next keystroke retries.
  • on_search runs under a 15-second budget, and HTTP requests made during it respect that budget rather than the usual 5-minute timeout.

UI Actions​

Plugins can add custom buttons to the lightbox, card context menus, and bulk-selection toolbar. When a user clicks a plugin action, the on_ui_action(action_id, clip_ids, options, context) handler is called.

ui = {
lightbox_buttons = {
{id = "enhance", label = "Enhance", icon = "wand", async = true, file_types = {"image/*"}},
{id = "convert", label = "Convert", icon = "refresh", file_types = {"image/*"},
options = {
{id = "format", type = "select", label = "Format",
choices = {
{value = "png", label = "PNG"},
{value = "jpg", label = "JPEG"},
}
},
}
},
},
card_actions = {
{id = "enhance", label = "Enhance", icon = "wand", async = true, file_types = {"image/*"}},
{id = "generate_qr", label = "Generate QR", icon = "qrcode", async = true, file_types = {"text/*", "application/json"}, max_size = 4296},
},

-- Actions shown when one or more clips are selected. The action receives
-- every selected clip ID in one on_ui_action invocation.
bulk_actions = {
{id = "combine", label = "Combine Images", icon = "sparkles", async = true,
file_types = {"image/png", "image/jpeg"}},
},
},

Action Fields​

FieldRequiredDescription
idYesUnique action identifier
labelYesDisplay text for the button
iconNoIcon name (e.g., "wand", "refresh", "pencil")
asyncNoIf true, runs in background (up to 5 minutes timeout)
optionsNoArray of form fields shown in a dialog before execution
file_typesNoMIME type filters. Supports exact match ("text/plain") and wildcard prefixes ("image/*")
max_sizeNoMaximum clip size in bytes. Action is hidden when clip size exceeds this value

For bulk_actions, file_types and max_size must match every selected clip. If any selected clip does not match, the action is hidden.

Option Form Fields​

Options support these types: text, password, checkbox, select, range, search.

FieldRequiredDescription
idYesField identifier (passed in options table)
typeYesInput type
labelYesDisplay label
requiredNoWhether field is required
defaultNoDefault value
choicesFor selectArray of {value, label} objects
sourceFor searchName passed to the on_search hook
min, max, stepFor rangeNumeric range constraints

A search option field renders the same searchable picker as the settings panel (see searchable fields); selecting a row passes the row's value to on_ui_action in the options table under the field's id. A search field without a source is dropped at parse time.

The dialog remembers the last submitted select, checkbox, range, and search values per action and reopens with them instead of default. text and password fields always start at default, so prompts and secrets are never replayed. A remembered value that no longer exists (e.g. a select choice removed by a plugin update) falls back to default.

Handler​

function on_ui_action(action_id, clip_ids, options, context)
if action_id == "enhance" then
-- Process each clip
for _, clip_id in ipairs(clip_ids) do
-- ... processing logic
end
end
return {success = true, result_clip_id = new_id}
end

The handler receives:

  • action_id (string) - Which action was triggered

  • clip_ids (table) - Array of selected clip IDs

  • options (table) - User-provided option values (empty table if no options defined)

  • context (table) - Invocation context (empty table when not applicable). When the action is triggered from folder view while inside a folder, it carries:

    • folder_tag_id (number) - The active folder's tag ID
    • folder_tag_path (string) - The active folder's full tag path (e.g., work/client1)

    Use this to place generated clips into the current folder:

    if context.folder_tag_id and context.folder_tag_id > 0 then
    tags.add_to_clip(context.folder_tag_id, new_id)
    end

It should return a table with:

  • success (boolean) — Whether the action succeeded
  • error (string, optional) — Error message on failure
  • result_clip_id (number, optional) — Navigate to the resulting clip
  • modal (table, optional) — Show a result modal with title, content, format ("markdown", "image", or "text"), plus optional copy_data, paste_data, paste_data_base64, paste_name, paste_content_type

Only one modal can be open at a time across all plugins.

Global Actions​

Plugins can also define actions that appear in the navigation drawer (hamburger menu) and are not tied to any specific clip.

ui = {
global_actions = {
{id = "generate_report", label = "Generate Report", icon = "chart"},
{id = "sync_all", label = "Sync All", icon = "cloud", async = true,
options = {
{id = "mode", type = "select", label = "Mode",
choices = {
{value = "full", label = "Full Sync"},
{value = "incremental", label = "Incremental"},
}
},
}
},
},
},

Global actions use the same UIAction structure as lightbox_buttons and card_actions (same fields: id, label, icon, async, options). The file_types and max_size fields are ignored since no clip is selected.

When a user clicks a global action, the drawer closes and on_ui_action is called with an empty clip_ids table. If the action defines options, the options dialog opens first. When the drawer is opened from folder view, the context argument carries the active folder (see Handler), so a global action that creates clips can drop them into the current folder.

function on_ui_action(action_id, clip_ids, options, context)
if action_id == "generate_report" then
-- clip_ids is empty for global actions
local all = clips.list()
modal.show({
title = "Report",
content = "Total clips: " .. #all,
format = "markdown",
})
return {success = true}
end
end

Complete Example​

Here's a full manifest for a cloud sync plugin:

Plugin = {
name = "Cloud Sync",
version = "2.1.0",
description = "Automatically sync clips to your cloud storage",
author = "Jane Developer",

-- React to clip changes and app lifecycle
events = {
"app:startup",
"app:shutdown",
"clip:created",
"clip:deleted",
},

-- API access for cloud provider
network = {
["api.cloudstorage.com"] = {"GET", "POST", "PUT", "DELETE"},
["auth.cloudstorage.com"] = {"POST"},
},

-- Need to read clip files for upload
filesystem = {
read = true,
write = false,
},

-- Periodic sync check
schedules = {
{name = "sync_check", interval = 300}, -- Every 5 minutes
},

-- User configuration
settings = {
{
key = "api_key",
type = "password",
label = "API Key",
description = "Your Cloud Storage API key",
},
{
key = "auto_sync",
type = "checkbox",
label = "Auto-Sync New Clips",
description = "Automatically upload new clips",
default = true,
},
{
key = "sync_quality",
type = "select",
label = "Image Quality",
options = {"original", "high", "medium", "low"},
default = "high",
},
{
key = "folder_path",
type = "text",
label = "Remote Folder",
description = "Path in cloud storage (e.g., /mahpastes/clips)",
default = "/mahpastes",
},
},
}

-- Handler implementations
function on_startup()
local api_key = storage.get("api_key")
if api_key then
log("Cloud Sync initialized")
else
log("Cloud Sync: Please configure your API key")
end
end

function on_clip_created(clip)
local auto_sync = storage.get("auto_sync")
if auto_sync then
sync_clip(clip)
end
end

function sync_check()
log("Checking for sync conflicts...")
-- Sync logic here
end

function sync_clip(clip)
local api_key = storage.get("api_key")
local quality = storage.get("sync_quality") or "high"
local folder = storage.get("folder_path") or "/mahpastes"

-- Upload implementation
log("Syncing clip: " .. clip.filename)
end

Next Steps​