NanoTorrent
Developer reference

Writing a NanoTorrent plugin

A plugin is one .rhai file that defines handler functions. NanoTorrent calls them when things happen to your torrents, and the file says on its first line what it is allowed to reach. Everything the host offers is on this page — search it.

01Quick start

Plugins are written in Rhai — a small, pure-Rust scripting language. This is a complete, working plugin:

// notify-me.rhai — say something when a download finishes.
//! permissions: read, notify

let done = 0;

fn on_torrent_completed(hash, name) {
    done += 1;
    notify("Download finished", name);
    log("that is " + done + " this run");
}

Drop it in your plugins folder, then in Preferences ▸ Plugins switch the host on, tick the plugin, and approve what it asks for. Ok applies it — there is no restart.

Two switches, both off by default: the host (nanotorrent --plugins on, or the checkbox) and each plugin individually. A plugin switched off is never compiled or run at all.

02Where plugins live

PlatformFolder
Windows%LOCALAPPDATA%\NanoTorrent\plugins
Linux~/.local/share/nanotorrent/plugins (or $XDG_DATA_HOME)
macOS~/Library/Application Support/NanoTorrent/plugins
PortableA plugins folder beside the executable (NANOTORRENT_PORTABLE, or portable.txt)

Preferences ▸ Plugins ▸ Open plugins folder opens the right one. A file added, edited or deleted while NanoTorrent runs is picked up the next time plugin settings are applied — press Ok and the folder is read again. A reloaded plugin starts fresh: it loses whatever it kept in its top-level scope, exactly as a restart would have done.

Two examples are placed there for you, switched off and unapproved: example.rhai, which only watches and reports, and rss.rhai, a working feed reader that uses every subsystem the host has. Each is offered once by name, so a new example in a later version reaches a profile that already has the folder, and one you delete stays deleted.

Plugins work in headless builds too — arguably where they are most useful, since there is no window to watch. Use --plugins on, and approve permissions from a desktop build sharing the same profile.

03Permissions

Every plugin declares what it may reach, on one line, before any of it runs:

//! permissions: read, control, notify

NanoTorrent reads that line from the source text, without executing the script — the whole point is knowing what a script wants before any of it runs. Only the leading comment block is scanned, so a permissions: line further down, or inside a string, cannot quietly widen the request. An unknown word is reported rather than ignored, because a typo silently dropping a permission looks like the host is broken.

PermissionGrants
readSee your torrents and transfer rates: torrents, torrent, exists, session_rates
controlPause, resume and re-check: pause, resume, recheck
addAdd new torrents: add_magnet
labelsChange torrent labels: set_label, clear_label
storageMove downloaded files: move_storage
removeremoveincluding deleting the downloaded files
notifyDesktop notifications: notify
networkReach servers: http_get, and add_torrent_url together with add
dataA key/value store of its own: data_get, data_set, data_remove, data_keys
uiA window, a menu and a cog: ui_window, ui_rows, ui_groups, ui_buttons, ui_input, ui_status, ui_show, ui_menu, ui_configurable
network is the one that changes what the others mean. A plugin holding read and network together can send everything it can see to anyone, and nothing in the host can tell a feed request from an upload of your torrent list. That is why approval shows the whole set at once rather than a line at a time — the combination is the decision, not the parts.

How it is enforced

Each plugin gets its own Rhai engine, holding only the functions it was granted. A function you did not ask for is not registered, so calling it fails with “function not found” rather than being refused at runtime. There is no way to probe for what sits behind a permission you do not hold, and no shared engine that could leak one plugin's grant to another.

log needs no permission. A script that declares nothing gets log and nothing else, and needs no approval — there is nothing to consent to. Ask for the least you need: over-asking is not free, it is shown to the user in plain language next to your plugin's name.

Approval, and what invalidates it

A plugin asking for anything is held — discovered, listed, but not loaded — until it is approved in Preferences ▸ Plugins:

WARN plugin tidy-up is waiting for approval of: read, storage, remove

Consent is stored as the set you asked for, not a yes/no flag. Edit the header to want more and the stored grant no longer matches, so the plugin is held again until the new set is approved — an updated plugin cannot quietly widen its own reach. Asking for less also re-prompts, which is the harmless direction.

04Handlers

Define any of these. All are optional; a plugin that defines none does nothing. Handlers are called on the plugin thread, in alphabetical order by filename.

on_session_start()#

The plugin host has finished loading. The place to draw a first window or restore state from data_get.

on_session_stop()#

Shutting down — best effort. NanoTorrent does not wait for the plugin thread before exiting, so a slow handler may be cut off mid-run. Do not use it to save anything you cannot lose; write state out as you go instead.

on_torrent_added(hash, name)#

A torrent appeared in the session. Lifecycle events are detected by the session itself rather than by the UI, so they fire the same way whether the torrent came from the window, the web interface, a magnet link handed to a second instance, or another plugin.

on_torrent_completed(hash, name)#

A torrent finished downloading, on a genuine transition. Torrents that were already complete when NanoTorrent started do not fire it, and a recheck that un-finishes a torrent lets it fire again when it re-completes.

on_torrent_removed(hash, name)#

A torrent left the session, with or without its files.

on_error(message)#

Background work failed. message is the text that went to the log.

on_tick()#

Once a minute, for as long as the session runs, on a wall-clock deadline so a busy session cannot starve it. One minute is fixed: a plugin that wants an hour counts sixty ticks, which is cheaper than a scheduler nobody asked for. Poll things here rather than in a loop — nothing else fires it.

on_ui_open()ui#

Your window was opened. Redraw its lists here rather than keeping them warm.

on_ui_row(id)ui#

A row in the main list was clicked. id is the id you gave that row in ui_rows.

on_ui_group(id)ui#

A row in the optional upper list was clicked — the feed, category or account whose contents the main list shows.

on_ui_button(id, input)ui#

A button was pressed. input is whatever the text field held at that moment, or an empty string if there is no field.

on_ui_menu(id)ui#

An item in your menu-bar dropdown was chosen.

on_ui_configure()ui#

The Configure cog on your row in Preferences ▸ Plugins was pressed. Only fires if you called ui_configurable(true).

05Reading the session

torrents()read#

returns [ #{ … } ] — an array of maps, one per torrent in the session.

torrent(hash)read#

returns #{ … }, or () if that torrent is gone.

exists(hash)read#

returns bool — cheaper than fetching the map when all you want is presence.

session_rates()read#

returns #{ download: int, upload: int } — bytes per second, session-wide.

06Torrent fields

Every map from torrents() and torrent(hash) carries these. The names match the web API's JSON, so a plugin and a web client describe the same torrent the same way.

FieldTypeMeaning
hashstringInfo hash — the key every other function takes
namestringTorrent name as shown in the list
save_pathstringFolder the data is being written to
labelstringCurrent label, empty if none
progressfloat0.0 to 1.0 — not a percentage
ratiofloatUploaded over downloaded
pausedboolPaused by you, a plugin or the queue
errorstringLast error, empty when healthy
sizeintTotal bytes of the selected files
remainingintBytes still to fetch
downloadedintBytes downloaded this torrent's lifetime
uploadedintBytes uploaded
download_rateintBytes per second, down
upload_rateintBytes per second, up
peersintConnected peers
seedsintConnected seeds
queue_positionintPosition in the queue
statestringDownloading, seeding, checking, paused, error …

07Acting on torrents

These are the same verbs the web API exposes, deliberately: a plugin cannot reach anything an authenticated web client could not.

pause(hash)control#

Pause one torrent. A torrent a plugin pauses reads as paused in the window and the web interface too — all three read the same session.

resume(hash)control#

Resume one torrent.

recheck(hash)control#

Force a recheck of the data on disk.

remove(hash)remove#

Remove the torrent, keeping its files.

remove(hash, delete_files)remove#

Remove the torrent, deleting the downloaded data when delete_files is true. Two arities rather than a default argument: Rhai has no optional parameters, and remove(hash) deleting files by accident is a mistake a plugin author only makes once.

move_storage(hash, folder)storage#

Move the downloaded files to another folder, the way the context menu's Move storage does.

set_label(hash, id)labels#

Apply a label by its numeric id — the ids Preferences ▸ Labels assigns.

clear_label(hash)labels#

Take the label off.

add_magnet(uri)add#

Add a magnet link, into the default save path.

add_magnet(uri, save_path)add#

Add a magnet link into a folder you name.

notify(title, body)notify#

Raise a native desktop notification. Windows and macOS always can; on Linux it goes to the D-Bus notification daemon, so a session without one logs a warning and shows nothing.

08Network

http_get(url)network#

returns #{ ok: bool, status: int, body: string, error: string }

Only http and https; file:// is refused before the request is made, so the network permission cannot be turned into a filesystem read. Capped at 4 MB and 30 seconds. An unreachable server is not an error that kills your handler — it comes back as ok: false with error set, because a plugin polling the internet on a timer will meet one sooner or later.

add_torrent_url(url)networkadd#

returns bool

Takes a magnet link as-is and fetches anything else as a .torrent, which is what feeds actually contain. Needs both permissions.

add_torrent_url(url, save_path)networkadd#

returns bool — the same, into a folder you name.

09Parsing

Neither needs a permission: they are arithmetic on a string you already hold.

parse_json(text)no permission#

returns maps, arrays and scalars — or () if it will not parse.

parse_xml(text)no permission#

returns #{ tag, attrs, text, children }, or ().

Not a full document model — no namespaces, no comments — but enough to walk an RSS or Atom feed, which is the job it exists for. Entities and CDATA are decoded, and each element's text is trimmed. Without it, writing a feed reader would have meant writing an XML parser in Rhai.

10Stored data

Strings only, namespaced per plugin, so one plugin cannot read or overwrite another's. There is a 64 KB ceiling per plugin: it lives in the settings database, which is not the place for a cache of every item a feed ever published.

data_get(key)data#

returns the stored string, or () if there is none. Test for () before using it.

data_set(key, value)data#

returns false if the store is full — check it, prune and retry rather than ignoring the result.

data_remove(key)data#

Forget one key.

data_keys()data#

returns an array of every key this plugin has stored.

11Window, menu and cog

Nothing appears anywhere unless the script asks for it. The shape is fixed — an optional upper list, a main list, a text field, some buttons, a status line — and there is no layout language. That is deliberate: a plugin says what goes in the window and NanoTorrent decides how it looks, so a script cannot draw something that passes for part of the client asking for a password.

Because the window is state held by the plugin host rather than by the desktop window, the web interface renders the same surface from the same data — a plugin written for the desktop shows up in the browser unchanged, and needs no extra permission for it. In a headless build every ui_* call does nothing and the host says so once in the log, rather than failing.

ui_window(title)ui#

Name the window. A plugin with no name has no window — declaring one is what lists it in the menu.

ui_input(placeholder)ui#

Show a single text field with this placeholder. An empty string means no field. Its contents arrive as the second argument to on_ui_button.

ui_buttons([#{ id, label }])ui#

A row of buttons. Clicks call on_ui_button(id, input).

ui_rows([#{ id, title, subtitle, selected }])ui#

The main list. Clicks call on_ui_row(id). Which row is current is yours to decide, not the window's — set selected: true on it when you redraw, so the two can never end up disagreeing.

ui_groups([#{ id, title, subtitle, selected }])ui#

An optional second list above the main one, for when the main list shows the contents of something the user picks: feeds, categories, accounts. Clicks call on_ui_group(id). Leave it empty and the window is the single-list one it was before.

ui_status(text)ui#

One line under the list. This is where a failure belongs: a window stuck on “Checking…” with a healthy log file somewhere else is indistinguishable from a hang.

ui_show()ui#

Put the window on screen now. Separate from ui_window so a plugin can prepare one at load without a window appearing unasked.

ui_menu(title, [#{ id, label }])ui#

Your own dropdown in the main window's menu bar, after File, View and Help. An empty title falls back to the plugin's name; choosing an item calls on_ui_menu(id). This is where a plugin puts the things a person does with it — “Feeds…”, “Check now”.

One dropdown per plugin, structurally rather than by a rule the host checks: a plugin holds a single menu title and item list, so calling ui_menu twice replaces the menu instead of adding another. Capped at 20 items; the rest are dropped with a line in the log.

ui_configurable(true)ui#

Put a cog on your row in Preferences ▸ Plugins, which calls on_ui_configure(). For a plugin that will not work until it is set up — the RSS reader has no feeds until you give it one. It is not a second way to open your window: declare it only if there is genuinely something to configure, or the cog becomes noise on every row.

12Logging

log(message)no permission#

Write a line to the NanoTorrent log, tagged plugin. Always available — a script that declares no permissions can still do this, which is why declaring nothing needs no approval.

13State and scope

Top-level statements run once, when the plugin loads, and the variables they create are visible to your handlers — and mutable from them. The scope belongs to that one plugin: another plugin's top-level seen is a different variable, and neither can see the other's.

Only handlers can see it. Rhai functions are pure: the scope is given to the function NanoTorrent calls, and not to anything that function calls in turn. This is the single thing most likely to catch you out when a plugin grows past one handler.
let items = [];

fn draw()     { ui_rows(items); }   // WRONG — "variable not found: items"
fn draw(list) { ui_rows(list);  }   // right — pass it down

fn on_ui_open() {
    items = fetch();                 // fine, a handler sees the scope
    draw(items);
}

Keep state in the handlers and pass it to helpers as arguments. The scope is gone at restart, and gone again whenever plugin settings are applied — anything that has to outlive that belongs in data_set.

14Limits

LimitValue
Operations per handler call500,000 — a script that loops forever is killed here and logged, it cannot hang the client
Call depth64 levels
Expression depth64, or 32 inside a function
Arrays and maps50,000 elements
Strings8 MB — twice the HTTP ceiling, deliberately
http_get response4 MB, 30 seconds
Stored data64 KB per plugin
Menu items20 per plugin
Tickson_tick once a minute, fixed

The string and HTTP ceilings are one number and not two on purpose: http_get hands the response body back as a string, so the string limit has to hold a whole response with room to build something from it. Otherwise a fetch inside the documented limit produces a value the engine refuses to hold, and every feed above the smaller number fails with an error its author cannot act on.

Plugins run on their own thread, so a slow one delays other plugins but never the UI, the session or a web request.

15When it breaks

A plugin that fails to compile, or throws from a handler, is logged and skipped. It does not stop other plugins running, and it does not stop NanoTorrent starting. Preferences ▸ Plugins shows a compile error next to the plugin, with its line and column, and a broken plugin stays ticked — that it is broken is a fact about the script, not a setting to be undone on your behalf.

INFO  loaded plugin ratio-keeper with: read, control
WARN  plugin tidy-up is waiting for approval of: read, storage, remove
ERROR plugin broken-thing: Syntax error: ... (line 2, position 32)
INFO  plugin: hello from my script

Note that the Preferences tab checks whether a plugin compiles; it does not run its top-level statements, because opening a settings dialog must not have side effects. A script that compiles and then fails on its first line shows up in the log, not in the dialog.

16Distributing a plugin

As source. There is no compiled form. Rhai has no serialised-AST or bytecode format to ship, so a plugin is exactly one .rhai file that someone copies into their plugins folder. For this design that is the right way round rather than a limitation: the permission header is only trustworthy because it is read from the same text that will run. A pre-compiled blob would make the declaration unverifiable and put the user's decision on something they cannot read.

  • One file, one plugin. There is no import or module system, so keep it self-contained.
  • The file name is the plugin's identity — what Preferences shows, what the log lines say, and the key the approval is stored under. Renaming re-asks for approval. Pick something specific: example.rhai and rss.rhai are already taken.
  • Put the permission line where a reader will see it, and say in a comment why you need each one. It is the first thing anyone installing your plugin reads.

17What plugins deliberately cannot do

  • No run(), no file reading, no file writing — not behind a permission, not at all. Those are the difference between a script that manages torrents and one that owns the machine, and no prompt makes “execute arbitrary programs” a decision a user can sensibly consent to. Post-download processing that has to launch something needs another channel for now.
  • No arbitrary UI. A plugin's window is the one described above and nothing else. It cannot add to the main window or the details panel, and it cannot draw its own controls.
  • No reach past the web API. A plugin gets the same verbs an authenticated web client has, and no more.
  • No approving itself. The web interface can list plugins and switch them on and off, but consent to what a script may reach is given at the machine it runs on.

What a plugin changes in the session does show up everywhere, because every surface reads the same session — a torrent a plugin pauses reads as paused in the window and in the browser.

18Worked example

A plugin with a window, a menu, stored state and a timer — the pieces most plugins need, in one file. The shipped rss.rhai is the full version of this.

// watchlist.rhai — keep a list of search terms, and label anything matching.
//
//   read     see torrent names as they are added
//   labels   put a label on the ones that match
//   data     remember the watchlist across restarts
//   ui       a window to edit it in, and a menu to open the window
//! permissions: read, labels, data, ui

const LABEL_ID = 1;

fn terms() {
    let raw = data_get("terms");
    if raw == () { return []; }
    let out = [];
    for line in raw.split("\n") {
        // Rhai's trim edits in place and returns (), so it is its own statement.
        let t = line;
        t.trim();
        if t != "" { out.push(t); }
    }
    out
}

fn draw(list) {
    let rows = [];
    for t in list { rows.push(#{ id: t, title: t }); }
    ui_rows(rows);
    ui_status("" + list.len() + " terms — click one to remove it");
}

fn on_session_start() {
    ui_window("Watchlist");
    ui_input("a word to watch for");
    ui_buttons([#{ id: "add", label: "Add" }]);
    ui_menu("Watchlist", [#{ id: "open", label: "Terms…" }]);
    ui_configurable(true);
    draw(terms());
}

fn on_ui_menu(id)      { ui_show(); }
fn on_ui_configure()   { ui_show(); }
fn on_ui_open()        { draw(terms()); }

fn on_ui_button(id, input) {
    if id != "add" || input == "" { return; }
    let list = terms();
    list.push(input);
    if !data_set("terms", list.reduce(|a, t| if a == () { t } else { a + "\n" + t })) {
        ui_status("the store is full — remove a term first");
        return;
    }
    draw(list);
}

fn on_ui_row(id) {
    let kept = [];
    for t in terms() { if t != id { kept.push(t); } }
    data_set("terms", kept.reduce(|a, t| if a == () { t } else { a + "\n" + t }));
    draw(kept);
}

fn on_torrent_added(hash, name) {
    let lower = name;
    lower.to_lower();
    for t in terms() {
        let needle = t;
        needle.to_lower();
        if lower.contains(needle) {
            set_label(hash, LABEL_ID);
            log("watchlist matched " + t + " in " + name);
            return;
        }
    }
}

The canonical version of everything on this page is docs/PLUGINS.md in the repository. Found something this page gets wrong, or want a function that is not here? Open an issue or come to the Discord.