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
| Event | Handler Function | Data Passed |
|---|---|---|
app:startup | on_startup() | None |
app:shutdown | on_shutdown() | None |
clip:created | on_clip_created(clip) | {id, content_type, filename} |
clip:deleted | on_clip_deleted(clip_id) | Clip ID (number) |
clip:archived | on_clip_archived(data) | {id} |
clip:unarchived | on_clip_unarchived(data) | {id} |
clip:renamed | on_clip_renamed(data) | {id, filename} |
watch:file_detected | on_watch_file_detected(data) | {path, folder_id} |
watch:import_complete | on_watch_import_complete(data) | {clip_id, source_path, folder_id} |
tag:created | on_tag_created(tag) | {id, name, color} |
tag:updated | on_tag_updated(tag) | {id, name, color} |
tag:deleted | on_tag_deleted(tag_id) | Tag ID (number) |
tag:added_to_clip | on_tag_added_to_clip(data) | {clip_id, tag_id} |
tag:removed_from_clip | on_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.
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
| Field | Required | Description |
|---|---|---|
key | Yes | Unique identifier for storage |
type | Yes | Input type (see below) |
label | Yes | Display label in settings UI |
description | No | Help text shown below the input |
default | No | Default value if not set |
options | For select | Array of choices |
source | For search | Name passed to the on_search hook |
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",
},
search
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
Searchable fields: on_search
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) andlabel(displayed); a missinglabelfalls back tovalue, numbers are coerced to strings, and rows are capped at 50. - An unknown
sourcereturns an empty list. Sources not declared by asearchfield in the manifest are rejected beforeon_searchruns. - 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_searchruns 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
| Field | Required | Description |
|---|---|---|
id | Yes | Unique action identifier |
label | Yes | Display text for the button |
icon | No | Icon name (e.g., "wand", "refresh", "pencil") |
async | No | If true, runs in background (up to 5 minutes timeout) |
options | No | Array of form fields shown in a dialog before execution |
file_types | No | MIME type filters. Supports exact match ("text/plain") and wildcard prefixes ("image/*") |
max_size | No | Maximum 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.
| Field | Required | Description |
|---|---|---|
id | Yes | Field identifier (passed in options table) |
type | Yes | Input type |
label | Yes | Display label |
required | No | Whether field is required |
default | No | Default value |
choices | For select | Array of {value, label} objects |
source | For search | Name passed to the on_search hook |
min, max, step | For range | Numeric 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 IDfolder_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 succeedederror(string, optional) — Error message on failureresult_clip_id(number, optional) — Navigate to the resulting clipmodal(table, optional) — Show a result modal withtitle,content,format("markdown","image", or"text"), plus optionalcopy_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
- Event Handling — Detailed event reference
- Settings & Storage — Working with persistent data
- API Reference — All available functions