Extend & deploy
Writing a plugin
Write a manifest, declare tools and permissions, and test file inputs and outputs.
Interface version 1.9.8 · 2026-10-05
A plugin directory contains a manifest and either a local script or an MCP connection configuration. Declare tools, inputs, outputs, permissions and credentials, test locally, then package for distribution.
For existing packages, see Using plugins. The manifest reference lists all fields.
First decide: script or MCP#
One question: does the thing already have an MCP server?
If it does, don't write a script. Adding a layer that translates stdin JSON into one HTTP call and the result back into stdout re-implements something that exists — and every new endpoint means changing code. Wiring up MCP takes a manifest and zero lines of code; TikHub is wired up exactly that way: its manifest only names the MCP server and its credentials.
If it doesn't — or what you want is local computation (reading files, computing something, calling a private HTTP API) — write a script. That's what the rest of this page covers.
Five minutes: a plugin that runs#
One directory, two files:
my-plugin/
mosael.plugin.json
main.pyThe manifest:
{
"id": "dev.yourname.my-plugin",
"name": "My plugin",
"version": "0.1.0",
"runtime": { "kind": "process", "entry": "main.py" },
"tools": {
"expose": "all",
"declare": [
{ "name": "shout", "description": "Uppercase a piece of text.", "read_only": true }
]
}
}The script — one JSON in on stdin, one JSON out on stdout:
import json, sys
def shout(payload):
return {"text": str(payload.get("text", "")).upper()}
TOOLS = {"shout": shout}
request = json.loads(sys.stdin.read()) # {"tool": "shout", "input": {...}}
try:
output = TOOLS[request["tool"]](request.get("input") or {})
json.dump({"ok": True, "output": output}, sys.stdout, ensure_ascii=False)
except Exception as exc:
json.dump({"ok": False, "error": str(exc)}, sys.stdout, ensure_ascii=False)Drop the directory into the plugins folder — the Plugins page tells you where it is;
don't go looking for ~/.mosael/plugins, that path does not exist on Windows —
then hit Scan. It shows up in the list.
Use a reverse-domain id. It is the unique key on this machine: the installed directory is
named after it, and a collision is treated as the same plugin.
The rules#
- 60 second process timeout, 1MB stdout cap,
outputmust be an object. - Crashed, timed out, printed non-JSON — what fails is that one invocation record, not the app.
- Every call is logged (input, output, duration) and visible on the Plugins page.
- On failure, the first line of
erroris the sentence meant for people (what happened and what to do next); the exception, errno or the other side's response goes on the following lines. The interface shows the first line as the message and keeps the rest under Details or in a hover hint — for example "Can't reach this server. Make sure it is running and the URL is right", with the address and[Errno 61] Connection refusedon the next line. Failure reasons are stored (the error on a connection card is what you said at the last refresh, which may have happened in another interface language or in the background), so give both languages when you can:"error": {"zh": "…", "en": "…"}; the host stores it as is and picks the reader's language when showing it. A plain string is still shown as it is.
Taking settings and credentials#
Declare them in the manifest, the user fills them on the Plugins page, and they arrive as
environment variables. credentials are stored encrypted, config in the clear:
"instance": {
"credentials": [
{ "key": "MY_API_KEY", "label": "API key", "required": true, "help": "Generated in the xxx console." }
],
"config": [
{ "key": "MY_REGION", "label": "Region", "type": "enum", "required": false,
"options": [{ "value": "cn", "label": "China" }, { "value": "us", "label": "US" }] }
]
}key = os.environ["MY_API_KEY"]A config field's type can be string (the default), enum, number, boolean, or one of two kinds of code:
json (a code editor, validated before saving — it points at the line and column of a mistake) and code (a code
editor highlighted by language). Don't declare a JSON snippet as a multiline text box: users would only see its
first line and learn about a missing comma when the plugin runs. The value is still stored as a string for you to
parse. Write placeholders in help as inline code with backticks.
Environment injection is not a sandbox. Mosael passes a minimal environment and the declared credentials. Local plugins still run with the current user’s operating-system permissions and may read accessible files. Declare the permissions your tools need and keep credentials out of logs.
Don't declare network or mirror settings yourself. Which proxy to use is each connection's Network row; the host injects
HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY into your process, so just let your libraries read the environment as usual. A
plugin that installs dependencies writes "package_sources": ["pypi"] (or "npm") at the top level, and the host injects
PIP_INDEX_URL, UV_DEFAULT_INDEX or npm_config_registry from the connection's PyPI mirror / npm registry choice. Those names,
PATH and anything MOSAEL_* belong to the host: config or credential keys with the same name fail at install.
Handing over a file#
The path above moves JSON, capped at 1MB — a 2GB mp4 does not fit. To hand a file to the media
library, put artifact in output. Two ways:
# 1. You downloaded it yourself. It must live in the directory you were given
out = os.environ["MOSAEL_PLUGIN_OUTPUT_DIR"]
path = os.path.join(out, "video.mp4")
download_to(path)
return {"artifact": {"path": "video.mp4"}}
# 2. You only obtained download credentials — let the host fetch it
return {"artifact": {
"url": "https://.../dlink?sign=...",
"headers": {"User-Agent": "..."}, # some endpoints 403 without a specific header
"filename": "video.mp4",
}}The second is usually better. The plugin obtains credentials, the host moves the bytes — progress, cancel, retry, failure isolation are all already there and you write none of it. And your side only gets one 60-second call: downloading something large yourself will time out, and even if it didn't, the user sees no progress and cancel does nothing.
Once accepted, artifact is replaced by asset_id, so callers get a media id just like any
other asset-producing tool. The Baidu Netdisk plugin uses the second form.
To hand over several files, return artifacts (a list; each item is written like artifact, up to 64): the host
imports them all and replaces the list with assets (each file's asset_id, name and any scalar notes you put on
that item) and asset_ids; the first one also becomes asset_id.
Taking a file#
The other direction: your tool needs to work on an existing asset — upload it to a netdisk,
send it off for transcoding. Mark that field "format": "asset" in the input_schema:
{ "name": "upload",
"input_schema": {
"type": "object",
"properties": {
"asset_id": { "type": "string", "format": "asset" },
"path": { "type": "string" }
},
"required": ["asset_id", "path"]
} }The caller passes an asset id; what you receive is a local absolute path:
local = payload["asset_id"] # already a path, not an id
upload_to_somewhere(local)You don't know the media library exists, and you don't need to.
You get a copy, not the library's own file — breaking or deleting it cannot touch the user's
asset. It is removed when the call ends, so don't write anything there you want to keep (hand
that back with artifact instead).
To take several at once, use {"type": "array", "items": {"type": "string", "format": "asset"}} — you receive a
list of paths.
Why you don't fetch it yourself: your environment has no database, no API token, no media directory — that is the isolation boundary, not an oversight.
See pan_upload in the Baidu Netdisk plugin.
Two hints on that field let the host do half the work:
"x-media": "document"or["audio", "video"]limits which kinds of media it takes; the form lists only those, and a wrong kind is explained on the spot."x-audio": "original"has the host extract the sound as a wav at the original sample rate first (for denoising, separation and other things that go back into a cut);"speech"gives 16 kHz mono wav (what recognition models want). You don't need to ship ffmpeg.
When a field holds an id from another system (a ComfyUI task id, a netdisk fs_id, an object-storage path, a paging cursor), mark it
"format": "external_id". Nothing changes at run time; it is a declaration: such tools stay off boards — a creator can't type a task id
there — and work as usual in workflows and chat.
Consequences and confirmation: effects#
Whether the agent asks the user before calling your tool directly is decided by the tool's effects:
effects | Meaning | When the agent calls it |
|---|---|---|
none | Costs nothing, stays inside: read-only, or only writes into Mosael (into the library, say) | Runs directly |
paid | Costs money or paid compute | Confirmation card first (AI cost tier) |
external | Consequences outside Mosael that can't be undone: uploading to someone's server, sending things out | Confirmation card first (Outside this app tier) |
local-code | Runs caller-written code on this computer | Confirmation card first, saying it runs code on your computer |
The default is external — a tool that isn't read-only and declares nothing always asks. read_only: true means none, and
declaring another effects alongside it fails at install. tools.default_effects sets a package-wide default; MCP plugins write theirs by
name under tools.overrides. Things a person starts themselves (Try it on the Plugins page, a plugin node in a workflow they run, running
an item on a board) never raise a card. Tools with consequences carry an Asks first badge on the Plugins page and in the market.
Remembering something#
A plugin process is stateless: environment in, JSON out, then it is gone — you have no way to write anything back yourself. Fine for pure computation, a dead end for credentials that expire — swapping a refresh token for a fresh access token is easy; having somewhere to put the new one is not.
Put state in the response, beside output:
json.dump({
"ok": True,
"output": {"files": [...]}, # goes back to the caller
"state": {"MY_ACCESS_TOKEN": "the new one"}, # the host remembers it for you
}, sys.stdout, ensure_ascii=False)Next call it is simply in the environment — still os.environ["MY_ACCESS_TOKEN"]; you never
need to know the value came from your own last run. Where it is kept follows the manifest: keys
declared under credentials go to the encrypted store, keys under config to plain settings.
Beside, not inside, on purpose: output reaches the caller and the model, and a freshly
minted token has no business being there.
Only keys declared in the manifest can be written. Writing an undeclared key fails outright rather than being ignored — ignoring it means you think it was saved, you get the old value next time, and the error surfaces somewhere else half an hour later.
Say so when the tokens are dead. A plugin that declares oauth and cannot get a fresh token (the
refresh_token is refused, or the call still says "expired" after one renewal) adds "reauthorize": true
to its failure response: the connection is then marked Needs re-authorization on the Plugins page and
its button reads Re-authorize. The error message only lands in the call log; the user looks at the
connection card. Leave it off for failures unrelated to tokens, such as a missing file or rate limiting.
Running longer, keeping big things#
One call runs for 60 seconds by default. A tool that genuinely needs longer (rendering a
video) declares timeout_seconds on its declare entry, capped at 1800; anything that is not a
positive number is ignored. The agent waits at most 180 seconds for a single tool call, so keep
agent-facing tools under that and split heavy preparation into its own step.
The plugin directory is not storage: updating a plugin replaces the whole directory. Keep
anything expensive (hundreds of MB of dependencies, downloaded models) in
MOSAEL_PLUGIN_DATA_DIR — one per plugin, kept across calls and updates, removed with the plugin.
{ "name": "render", "timeout_seconds": 170, "input_schema": { … } }See the Remotion plugin: dependencies live in the data directory and the render tool declares its own budget.
A tool that runs for minutes can also declare "stream": true: stdout becomes one JSON object per line, progress is
written as {"event": "progress", "progress": 0.4, "message": "step 8/20"}, and the last line is the result as
usual. Inside a workflow the run panel shows that progress; when the task is cancelled Mosael first creates a cancel
file (MOSAEL_PLUGIN_CANCEL_FILE) so you can stop the remote work before exiting, instead of being killed outright.
For long work the agent may call without confirmation, offer a “submit and return a task id” tool plus a “fetch by
task id” tool; tools that need confirmation (paid, external, local code) open a card and run in the background once
approved, so they don't count against the agent's wait for a single call.
Working for Mosael: generation and capabilities#
A plugin can also be a generation provider: declare "provides": ["generation"] in the manifest and let one
tool claim it. Mosael calls that tool with two operations — models (which models this connection has, the
parameters each one takes and its reference image slots) and generate (do one generation: take the prompt,
parameters and reference image files, report progress and the remote task id one event per line, and hand back the
output files). To cancel, Mosael creates a cancel file (its path is in MOSAEL_PLUGIN_CANCEL_FILE); when it appears,
stop the remote work.
That tool is host-only — it never shows up for the agent or in workflows. Its budget is 1 hour unless declared, up to
6 hours. Add a fingerprint to the model list and support a cheap op: "fingerprint", and Mosael asks once a minute
and only re-reads the list when it changed — models the user adds on your side appear without a manual refresh.
Parameter titles may be written per language ({"zh": "步数", "en": "Steps"}); Mosael picks one when it shows
them. The full protocol and fields are in docs/PLUGIN_MANIFEST.md (“Generating for the host”) in the repository;
the ComfyUI plugin that ships with Mosael (plugins/bundled/comfyui) is a complete example. Each model can also declare
prompt: "required" | "optional" | "none": upscalers and background removers that take no prompt say none, and the interface
drops the prompt box.
Reporting tools at run time: with "provides": ["tools"] the plugin reports its own tools while running (that's how ComfyUI gets one
tool per workflow); they join the tool table, node panel and agent like declared ones and refresh when the catalogue changes. A reported
tool can declare replaces (the old tool it supersedes — saved nodes are rewritten to it) and mirrors (it is the same thing as a
generation model, so boards keep only the Generate entry).
Registering in the host's capability table. Several of the host's jobs can be handed to a plugin, which then sits beside the local engines in Settings → Capability providers, node engine dropdowns, the editor, the media library and the agent:
provides | Does | Inputs follow a contract |
|---|---|---|
document_parse | Parse a document into Markdown (stored as another parse of that document) | file: format: asset, x-media: document |
audio_denoise / audio_separation | Denoise / split into voice and background | file: audio or video, x-audio: original |
transcription | Speech to a timed transcript | file: audio or video, x-audio: speech |
translation | Translate a batch of texts | texts (array) and target |
speech | Read text with a voice | op: voices lists voices, speak reads |
public_url | Turn a local asset into a time-limited public link | Takes asset_id, expires; returns url |
A few parameter conventions worth knowing for generation models:
- When sizes are only suggestions and any "width x height" works, give
sizeexamples(the suggested sizes),minimum(the smallest side) andmultipleOf(what you round to); the interface becomes a size list you can type into. Useenumonly when the list is all there is. - A field that picks a model file carries
x-model-folder(checkpoints,loras…): when the connection also claims the model library, the form shows thumbnails, base model and trigger words from it. - A
defaulton the parameters the host has its own controls for (size, resolution, duration, audio) is sent when the caller gives none; other parameters the user hasn't touched are not sent. - When the outputs of one request used different parameters (a new seed per image, say), put
parameterson each output and the host records them as that asset's generation parameters; add anote("2 of 3 images were made; image 2 failed because …") and it becomes the job's last word.
Write the same provides on the package and on exactly one tool (process plugins only). A tool that claims one of these is still an
ordinary tool: it sits in the Plugins page's tool table, can be ticked, and the agent and workflows can call it directly, with a capability
badge and a Used in list. The host never picks a plugin on its own — users make it the default under Capability providers or name it
where it's used. Outputs and details for each are in docs/PLUGIN_MANIFEST.md ("Declaring what you can do for the host"); the bundled
plugins/bundled/mineru (document parsing) and plugins/bundled/object-storage (asset links) are full examples.
Giving a connection libraries. When a plugin connects to a server that holds things, it can also claim two directory capabilities (the claiming tool is only called by the host and stays out of the tool table):
model_library: lists the model files on that server (folder, size, base model, trigger words, which workflows use them, which ones workflows are missing), resolves download links and downloads models onto it. The connection's title row gains Open model library.workflow_library: lists the saved workflows (node-graph summary, inputs, models used, missing nodes), copies, renames, moves them to a trash folder and restores them, imports workflows from elsewhere, and installs missing node packs and restarts through a node manager. The connection's title row gains Open workflow library.
Neither may overwrite or hard-delete anything: a name collision answers with a suggested name, and deleting moves to a trash folder.
The protocol is in the model_library and workflow_library sections of docs/PLUGIN_MANIFEST.md, and the bundled ComfyUI plugin is the example.
Making it a workflow node#
Tools work in the agent by default. To make one a workflow node too, add node to it:
{ "name": "pull", "description": "Pull one file",
"node": { "label": "Import from netdisk", "outputs": ["asset_id", "asset_name"] } }Downstream nodes wire straight from the ports named by the outputs keys. Outputs meant only for wiring workflows (asset id lists,
summaries, task ids) go into wiring_outputs, so boards only receive the finished results.
When a parameter is an array whose items are structured (each step of an explainer video, say), declare properties under items and the
node form shows one card per item with a field for each — add, remove, move up and down — instead of a box of hand-written JSON. Parameter
and tool names can be written as {"zh": "…", "en": "…"} and follow the interface language.
Writing the readme#
Put a README.md in the plugin directory. It is rendered as-is on the plugin's detail page
(see Baidu Netdisk), so write it for people, not as an internal note.
Two traps: relative links can follow the repository layout (they get rewritten to point back at
the repo), but don't point at files that don't exist; and while <https://…> is fine on
GitHub, [link](https://…) is safer here.
The manifest can also carry author (a name, per language if you like, and a link) and docs (your usage docs' address): the Plugins
page and the market show who wrote it, and Docs opens your page instead of the generic plugin guide. Add a summary too — one
plain-text sentence saying what it is for, per language if you like, up to 140 characters: market cards, the plugin's header and the install
confirmation lead with it, while the longer introduction stays in the first skill's description, folded in the detail page behind Show more.
Shipping it#
The market index is plain JSON that anyone can host — including on a company intranet:
{"plugins": [{
"id": "dev.yourname.my-plugin", "name": "My plugin", "version": "0.1.0",
"summary": "One line", "description": "…", "author": "You", "homepage": "https://…",
"download": "https://…/my-plugin.zip",
"permissions": []
}]}download points at a zip containing your directory — one wrapping folder is fine too, since
archives from GitHub always have one. Point the app at your index address and it can install.
The official index has one more kind of entry: "bundled": true with no download — a plugin
that ships with Mosael (ComfyUI). It is listed so people can find it, not so they can install it:
the app marks it Built in, and it updates with the app.
Get permissions right. People decide whether to install based on that list, and installing
a plugin puts code that will be executed on their machine. Declare what you really do: when an update asks for more permissions,
the user's existing connections are switched off until they grant the new ones from the connection card — so list the sites you
connect to and the folders you write from the first version, rather than quietly adding them later.
