Guides
Writing a plugin
A directory, a manifest, a script — and how to get it into the market.
A plugin is how capability from outside Mosael gets in. Writing one does not mean reading framework source: a directory, a manifest, a script — that is all of it.
This page is about writing one; Plugins is about using them, and the manifest reference is the full field list.
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; mcp-everything is exactly that.
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.py
The 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.
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"]
Nothing else reaches you. The child process gets PATH / HOME / LANG plus the keys you
declared — not the app's provider keys, not the database, not its API token. That isn't a
restriction on you; it is what makes people willing to install: the list they see on the Plugins
page is everything you can touch.
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.
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).
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.
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.
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 reference the outputs keys as {{nodeId.asset_id}}.
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.
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",
"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.
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.