Writing a plugin¶
This page takes a developer from a clone of Evey to a plugin running on a local server, then to a signed package other administrators can install. The tutorial in section 3 is the happy path; the rest is reference you will come back to. The calls and fields themselves are in the Plugin API reference.
flowchart LR
subgraph server["Evey server"]
host["HostService<br/>checks every call against<br/>the declared permissions"]
ui["Web interface<br/>plugin slots"]
end
subgraph plugin["Plugin, in its sandbox"]
backend["PluginService<br/>your backend process"]
front["Frontend module<br/>isolated iframe"]
end
backend <-->|"gRPC over a Unix socket"| host
front <-->|"postMessage bridge"| ui
host -->|"Fetch: declared hosts only"| net(("Internet"))
A plugin is a ZIP with a plugin.yaml at its root and a backend process the
server starts in a sandbox. The process serves a small gRPC service on a Unix
socket and calls the server back on another one. It may also ship an interface
module, drawn in a sandboxed frame of the page, and talk to the outside world
only through the server, to the hosts it declared.
1. Prerequisites¶
| Tool | Why |
|---|---|
| Go 1.27, Node.js 22 with pnpm, Docker with Compose | The dev stack (make dev) builds the server and the web app. |
bubblewrap (bwrap) |
Only for the bubblewrap and strict sandbox modes. The default auto mode uses Landlock and needs nothing on a kernel 5.13 or later. |
| cosign | To sign your package before distributing it (section 7). Not needed to develop. |
Pick the language of your plugin from the runtimes the server can start:
python, nodejs, php, ruby, lua, bash and sh need their
interpreter in the image, go ships a compiled binary and needs the
native-binary permission. The default image carries Python, Node.js, PHP, Ruby and Lua; the
-nodejs image keeps Python and Node.js and the -lean image keeps Python only. A
manifest that names a runtime the image lacks is refused at install, with the
list of the runtimes available.
The gRPC contract is one file, proto/plugin/v1/plugin_api.proto. Only
the Python SDK ships generated stubs; the other languages under
sdk/<language>/ hold the .proto. The server copies the files of your
language next to your entrypoint when the plugin starts. The image provides
grpcio to Python plugins, so a Python plugin has nothing to vendor. A
Node.js plugin must vendor its node_modules (@grpc/grpc-js and
@grpc/proto-loader) in the package. For another language, generate stubs
with protoc and ship them in the ZIP:
protoc --proto_path=proto \
--<lang>_out=OUT_DIR --grpc_<lang>_out=OUT_DIR \
proto/plugin/v1/plugin_api.proto
make plugin-sdk regenerates the shipped stubs; each sdk/<lang>/README.md
names the gRPC plugin its language needs.
2. Bring up Evey locally¶
git clone git@github.com:eveyproject/evey.git
cd evey
cp .env.dev.example .env.dev # then set MEDIA_DIR, EVEY_DB_PASSWORD, MEILI_MASTER_KEY
make setup # once: adds evey.test to /etc/hosts
make dev # postgres, meilisearch, the server (Air), the web app (Vite)
Open https://evey.test. The setup wizard creates the administrator account
on first launch, as described in First setup.
Plugin files land in ./evey-data/plugins/<plugin-id>/, mounted as
/data/plugins in the container.
3. Your first plugin, step by step¶
The example examples/plugins/nodejs-example is the smallest complete plugin:
it serves PluginService, dials HostService, and writes a line in its log
when the server starts it. We copy it, change one thing, and install it.
-
Copy the example.
-
Give it a name. Edit
plugin.yaml:plugin.yamlname: hello-plugin # unique, kebab-case kind: plugin version: 1.0.0 # semver: bump it at every upload description: Says hello in its log author: you language: nodejs # the runtime the server must have entrypoint: node main.js # the command line the server runs permissions: - api.library # lets the backend read media itemslanguagetells the server which runtime to check and which stubs to copy;entrypointis what it runs, in the plugin's directory, with the environment of the next step. -
Read
main.js. The server passes three variables to the process:EVEY_PLUGIN_ID,EVEY_PLUGIN_SOCK(the socket your process must listen on, servingPluginService) andEVEY_HOST_SOCK(the socket you dial to callHostService). The example loadsplugin_api.protofrom its own directory, where the server copied it, and implements the five methods ofPluginService. Change the message inInitialize: -
Zip it. The manifest must sit at the root of the archive, not in a sub-directory:
-
Install it. In Admin → Plugins, Install Plugin, choose the ZIP and Preview & Install: the preview lists
api.libraryand the result of the static analysis. Through the API, the same upload is: -
Read the log. On the plugin's card, Logs shows the line written by
Initialize.docker compose logs eveycarries the server side of the same start, under the plugin's id. -
Iterate. Change the code, bump
version, zip again and use Update from source… on the card. An upload with the same or a lower version is refused; a new permission in the manifest triggers the consent step the administrator must approve.
What you have is the whole backend contract. From here, a plugin grows in
three directions: calling the server (HostService, section 4), drawing an
interface (section 5), and reaching the outside world (section 6).
4. Call the server from the backend¶
Every HostService call carries the plugin's identity: the server checks the
process on the socket by its PID, and an interceptor refuses any call whose
permission the manifest does not declare. Three groups need no permission at
all:
Logwrites to the plugin's log, shown on its card;GetConfigreads the values the administrator set in Config (declared inconfig_schema),SetConfiglets the plugin keep its own keys there;GetData/SetData/DeleteData/ListDataKeysis the plugin's private key/value store, wiped at uninstall.
GetMediaItem and ListMediaItems need api.library or media.read;
Fetch needs network. The full table, with the methods declared in the
contract but not served yet, is in the
API reference.
5. Add an interface¶
An interface is a JavaScript module the page loads in a sandboxed frame, in one of the slots the server allows. Declare where the files are and where the module goes:
web_root: dist # the directory served as static assets
frontend:
- slot: home.section # see the slot list
src: dist/index.js # must be under web_root
The module gets a window.evey bridge: evey.onInit hands it the plugin's
config, the locale, the theme and the surface it landed on; evey.resize,
evey.toast and evey.navigate act on the page within fixed limits; and,
when the manifest declares prefs or public, evey.prefs.* and
evey.media.* reach the server through a short-lived token the host holds for
the frame. examples/plugins/weather is a complete widget with a build step;
examples/plugins/data-demo shows prefs, public and the admin.nav slot.
The bridge and the slots are described in the
API reference.
6. Reach the outside world¶
A plugin process can open no socket to the internet, in every mode where the
launcher runs (basic without the launcher only denies execve). The
only way out is the Fetch call of HostService: the server checks the
target against the manifest, then performs the request itself.
permissions:
- network
external_fetch:
- api.open-meteo.com # bare hostnames: no scheme, no wildcard, no IP
network without external_fetch is refused at install. At run time the
broker accepts http and https, GET and HEAD, answers up to 8 MiB
within 20 seconds, and refuses private or reserved addresses even through a
redirect. The frame of the interface gets the same host list as its
connect-src, so a widget may call those hosts directly over https. The
rules are in Permissions and slots.
Hosts are a contract with the administrator
The administrator sees the host list at update and on the Security Dashboard, and must approve any addition at update. Keep it to what the plugin needs, and say in your README what each host receives.
7. Sign your plugin¶
What a signature proves
A signature says the ZIP the administrator installs is byte for byte the
one you published. Your public key travels inside the package
(cosign.pub at the ZIP root), and Evey pins its fingerprint to the
plugin at install: an update signed with another key, or no longer
signed, is refused until the administrator accepts the change. It says
nothing about what the plugin does: the permissions, the hosts and the
analysis of the preview still apply. Evey checks it offline and in
process (ECDSA P-256 over the SHA-256 of the ZIP, the format cosign
sign-blob --key produces): no cosign binary on the server, no network,
no transparency log.
You need cosign on your machine only.
-
Once: generate a key pair.
-
At each release: put your public key at the root of the plugin, zip, then sign the exact file you will distribute.
-
Check it before shipping, with the same verification the server does:
-
Publish the two files together:
hello-plugin.zip(your public key is inside) andhello-plugin.zip.sig, next to each other, so an install by URL finds the signature at<url>.sig. Put the key's fingerprint (sha256:..., shown by Evey on the plugin card) in your README and release notes, so administrators can compare it before trusting the key.
On the instance that installs it, the administrator chooses the ZIP in
Admin → Plugins and, in Signature file (.zip.sig, optional), its
.zip.sig; through the API, the signature is the sig field of the upload.
Evey verifies the package against the key it carries and shows one of:
- Signed by
: the key is in the instance's Trusted publishers list. - Signed, unknown key sha256:...: the signature is good, the key is not trusted yet. One click, Trust this key, adds it to the list under a name.
- Unsigned, or Invalid signature.
The policy decides what happens: EVEY_PLUGINS_COSIGN_POLICY=warn (default)
records the verdict and lets every install through; enforce admits trusted
keys only. Whatever the policy, an update whose key differs from the installed
version, or that drops the signature, is refused until the administrator
ticks The publisher changed, I accept the new key in the update dialog.
EVEY_PLUGINS_COSIGN_PUBLIC_KEY is optional: it seeds the trusted list with
one key at boot (marked as the server operator's).
Things that trip people up:
- Any change to the ZIP breaks the signature, even re-zipping the same files. Sign the file you distribute, and sign again for every version.
- Keep the same key from one version to the next. A new key is a new publisher for every administrator who installed you: they will be asked to accept it. Rotate on purpose, and say so in your release notes.
- Only ECDSA P-256 keys are accepted, which is what
cosign generate-key-pairmakes. Keyless signing and KMS references are not verified. - A
.sigwithoutcosign.pubin the ZIP, or the other way round, counts as Unsigned. - A wrong key, a tampered ZIP and a missing
.sigall read as a failed signature: underenforce, the install or the update is refused.
8. What the installer checks, in order¶
Every step can refuse the package; the administrator sees the reason, and the server log carries the detail.
- Manifest.
plugin.yamlis read straight out of the ZIP: required fields, semver, known permissions and capabilities, slots in the allowed list,external_fetchentries that are bare hostnames,web_rootpresent whenfrontendis declared. The declaredlanguagemust be a runtime of the image. - Version and escalation. For a plugin already installed, the new
version must be higher, and any new permission, capability, slot or host
stops the install here until the administrator approves the difference
(
accept_permission_changes=trueon the upload route). - Signature and hash. The signature is verified when a public key is
configured; a failure blocks only under
enforce. The hash of the ZIP is recorded, and checked again when an update previewed earlier is applied. - Extraction into a staging directory: at most 5,000 entries, 100 MB per file, 500 MB in total, a compression ratio of 100:1, no symbolic link, no path leaving the archive. An update never touches the running version until every later step passes.
- Themes only: the stylesheet is sanitized and written back, and a
theme.jsthat matches a forbidden pattern refuses the package. - Static analysis of every text file: a critical finding refuses the
package; the
deserializationandobfuscationwarnings refuse it too, andnative_code, compiled binaries and embedded archives refuse it unless the manifest declaresnative-binary. - Persist and start. The staging directory is swapped into place, the row is written, and the process is started.
What the analysis looks for is listed in Plugin sandbox and threat model. Comments are stripped before the analysis, so a commented-out line is not reported.
9. What your process runs under¶
The server starts the process under a lower CPU priority, with a memory
ceiling, a limit on processes and threads, and a size limit on its directory;
a monitor stops a plugin that exceeds them. The defaults are 256 MiB, 64
processes and 100 MiB, adjustable by the administrator in Admin → Plugins →
Resource limits. Without the subprocess permission, the process cannot
spawn children, whatever the language; threads work.
In every mode where the launcher runs, the sandbox confines the filesystem to
the plugin's directory and cuts every network socket (basic without the
launcher only denies execve). Which mode is in force depends on the host,
not on the plugin: Plugin sandbox and threat model
describes them, and Plugin sandbox setup tells an
administrator which to pick.
10. Distribute a plugin¶
There is no catalog: an administrator installs a plugin by uploading its ZIP or pasting its URL in the admin panel, from wherever you publish it (a release on a forge, your own site).
- Ship the ZIP, its
.sigand yourcosign.pubtogether (section 7). - State the permissions and the
external_fetchhosts in your README: the administrator sees the permissions at install and the hosts at update and on the Security Dashboard, and has to approve them; a surprise there is the first reason an install is refused. - To have your plugin listed with the bundled examples, open a pull request
against
examples/plugins/with the source and a README. Examples are reviewed and built from source; a listing is not an endorsement and carries no signature from the project.
11. Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
plugin requires "nodejs" runtime which is not installed in this image |
The image variant lacks the interpreter. | Use the default or -nodejs image, or write the plugin in Python. |
manifest: external_fetch entry "*.tmdb.org" is a wildcard - use explicit hostnames |
Wildcards are refused at install, no exceptions. | List every hostname you need (api.tmdb.org, image.tmdb.org). |
manifest: frontend slot "admin.section" not allowed |
The slot is outside the allowed list. | Pick a slot from the slot list; admin.nav is the only admin-area slot. |
manifest: network permission requires non-empty external_fetch |
network declared without hosts. |
Add the hosts, or drop the permission. |
plugin already installed at version 1.0.0, new version must be higher |
The ZIP carries the installed version. | Bump version, or uninstall first. |
signature does not match the plugin or the configured key |
The .sig was made with another key, or the ZIP changed after signing. |
Re-sign the exact ZIP with the right key, or fix EVEY_PLUGINS_COSIGN_PUBLIC_KEY. |
plugin blocked: theme.js contains forbidden pattern |
The theme script scanner matched a banned API. | A theme may only call evey.theme.*: see Themes. |
The log ends with memory quota exceeded (... MB > ... MB) |
The monitor stopped the plugin past its memory limit. | Lower memory usage, or raise Max memory (MiB) in Resource limits. |
bwrap: ... permission denied |
bubblewrap mode inside Docker, blocked by the container's seccomp or AppArmor profile. |
Keep the default auto mode. Otherwise see Plugin sandbox setup. |
| The interface module does not appear | The plugin is disabled, the slot is misspelled, or src is not under web_root. |
Check the card's state, the slot name, and the /api/v1/plugins/<id>/assets/... requests in the browser's network panel. |