Skip to the content.

Writing a plugin

eve-online-tooling can load third-party plugins dropped into a local folder. A plugin adds either pure logic/data (a pricing model, a BOM optimizer, a custom profit engine) or — later — its own UI page.

Plugins are untrusted. They run in a WebAssembly sandbox and get no ambient authority: no filesystem, no network, no keychain, no ESI tokens. The only things a plugin can reach are the host functions the app exposes, and each of those is gated behind a permission the user explicitly grants at install time. If a plugin’s manifest asks for a capability the user hasn’t granted, the plugin can’t even start.

This page documents Phase 1 — logic plugins (WASM).

Where plugins live

<app_data_dir>/plugins/<id>/
  plugin.json        # the manifest (required)
  <id>.wasm          # the compiled logic (for a logic plugin)

<app_data_dir> is the standard per-OS app data directory. For this app it is:

OS <app_data_dir>
Linux ~/.local/share/com.thlange.eve-online-tooling (or $XDG_DATA_HOME/com.thlange.eve-online-tooling)
macOS ~/Library/Application Support/com.thlange.eve-online-tooling
Windows %APPDATA%\com.thlange.eve-online-tooling (i.e. C:\Users\<you>\AppData\Roaming\com.thlange.eve-online-tooling)

So plugins go in <app_data_dir>/plugins/<id>/. The Plugins page shows the exact resolved path for your machine. <id> is the plugin’s id and must match the folder name — you never have to get this right by hand, though: the easiest way in is drag-and-drop.

Installing: drag-and-drop

Drag a plugin’s folder, or a .zip of one, onto the app window (anywhere — not just the Plugins page) and it’s copied into plugins/<id>/ automatically, using the id from its plugin.json regardless of what the dropped folder/zip was named. A .zip may have the plugin’s files at its root, or wrapped one level deep in a single folder — both work, matching the shape of the release zips linked below. Dropping a plugin whose id you already have installed replaces it — that’s also how you update one; the update takes effect on the plugin’s next invocation (any already-running instance of the old version is discarded). A malformed manifest is rejected before anything is published; nothing changes — an existing install of that id stays intact and keeps working.

Prefer doing it by hand? Copy the folder straight into the path shown on the Plugins page, then click Rescan (or restart the app) to pick it up.

Removing

Each installed plugin’s card has a Remove button (a confirm step guards against a stray click) that deactivates it, evicts its running instance, and deletes its folder from disk — gone for good, not just deactivated.

The app enumerates the plugins folder on startup, validates each plugin.json, and lists the valid ones (plugins_list). An invalid manifest is skipped and logged — it never stops the app from booting. Added or removed a plugin outside the app (e.g. editing files directly) while it’s running? Click Rescan to pick it up without a restart. On first run the app also seeds a bundled hello-ui example here so there’s something to try; delete it and it stays gone.

Try a ready-made one

Don’t want to build anything? Grab a prebuilt example from eve-online-tooling-plugins’ latest release:

Drag the downloaded .zip onto the app window and it’s installed — no need to unzip it yourself first. Then activate it from the Plugins page. To build one yourself instead, read on — the full source (and both examples above) lives in eve-online-tooling-plugins.

The manifest: plugin.json

{
  "id": "pricing-model",
  "name": "Pricing Model (example)",
  "version": "0.1.0",
  "minAppVersion": "0.33.0",
  "wasm": "pricing_model.wasm",
  "permissions": ["sde:read", "market:read", "storage:own"]
}
Field Required Meaning
id yes Stable id; must equal the folder name; [A-Za-z0-9_-] only.
name yes Human-readable name.
version yes Plugin version (semver).
minAppVersion yes Minimum app version this plugin supports (semver). Enforced: install and activation are refused on an older app, naming the required version.
wasm one of Path to the WASM entry point (relative to the plugin folder).
ui one of Path to the UI HTML entry point (Phase 2).
permissions no Capabilities requested (see below). Empty = a powerless plugin.
mcpTools no MCP tools this plugin backs (see below). Each needs a wasm entry.
allowedHosts no Hosts the plugin may contact; required (and only valid) with net:fetch.

At least one of wasm / ui must be present. Unknown permission strings, non-semver versions, an id that doesn’t match the folder, or a wasm path that escapes the folder are all rejected.

Permissions

A permission is a string in permissions. Each grants access to specific host functions; nothing else is reachable.

Permission Grants Status
storage:own storage_get / storage_set — a private key/value store available
sde:read sde_type_info — read the Static Data Export available
market:read market_price / appraise via host_call available
assets:read assets / corp_assets via host_call available
orders:read my_orders via host_call available
net:fetch outbound HTTP, but only to the manifest’s allowedHosts available
info:write send_alarm / write_message — post to the Info Panel available
pi:read pi_overview via host_call available
industry:read industry_jobs via host_call available

Enforcement is load-time: the host only links the host functions your granted permissions cover. A plugin that imports an un-granted host function fails to instantiate — there is no runtime path that could slip through.

net:fetch is enforced differently: rather than linking a host function, the host builds the sandbox with an outbound-HTTP allow-list set to exactly the plugin’s allowedHosts. A request to any other host is refused by the runtime, and a plugin without net:fetch has an empty allow-list — no network at all.

Host functions

Declared in the extism:host/user namespace. From Rust (extism PDK):

#[host_fn("extism:host/user")]
extern "ExtismHost" {
    // storage:own — values are scoped to *your* plugin; you cannot see
    // another plugin's data or the app's own storage.
    fn storage_get(key: String) -> String;        // "" if unset
    fn storage_set(key: String, value: String);

    // sde:read — returns the item's TypeInfo as JSON, or "null" if unknown.
    fn sde_type_info(type_id: String) -> String;

    // info:write — post an alarm / message to the app's Info Panel (under
    // Support). Tagged as coming from your plugin.
    fn send_alarm(text: String);
    fn write_message(text: String);
}

The capability registry (host_call)

Beyond the specific functions above, a plugin can reach the app’s shared capability registry — the same read/compute operations scripts and the MCP bridge use, so data is fetched and cached once — through one generic gateway:

#[host_fn("extism:host/user")]
extern "ExtismHost" {
    // Call a registry capability by name; args + result are JSON strings.
    // e.g. host_call("market_price", "{\"typeId\":34}")
    fn host_call(name: String, args_json: String) -> String;
}

Unlike the specific host functions (gated at load time), host_call is always linked and gated per call: each capability declares the permission it needs, and a call is refused unless your manifest was granted it. Capabilities today: market_price, sde_type_info, sde_search, appraise, route (read-only) and assets, corp_assets, my_orders, pi_overview, pi_idle_colonies, industry_jobs, industry_line_status (need the matching assets:read / orders:read / pi:read / industry:read grant + a logged-in character). pi_idle_colonies/industry_line_status are small “who’s idle” summaries of pi_overview/industry_jobs — prefer them when you only need to know what needs attention, not the full colony/job detail.

Being called

The host invokes an exported function by name through a single command:

plugins_invoke(pluginId, fn, argsJson) -> Result<JsonValue, AppError>

argsJson is passed to your exported function as its input, and your return value is handed back as JSON. Runaway plugins are stopped: each instance has a memory cap (~64 MiB) and a per-call timeout.

The reference plugin

A complete, buildable example lives in eve-online-tooling-plugins/pricing-model/. It reads an item’s volume via sde:read and its Jita price via market:read, derives a volume-only density plus a price-aware ISK-per-m³ score, and keeps a call counter in storage:own.

Build it

git clone https://github.com/th-lange/eve-online-tooling-plugins
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown \
  --manifest-path eve-online-tooling-plugins/pricing-model/Cargo.toml
cp eve-online-tooling-plugins/pricing-model/target/wasm32-unknown-unknown/release/pricing_model.wasm \
   eve-online-tooling-plugins/pricing-model/pricing_model.wasm

Install it

Drag the pricing-model/ folder (its plugin.json + pricing_model.wasm) onto the app window, or copy it into <app_data_dir>/plugins/ and click Rescan. Then approve the requested permissions when prompted.

The install-time consent prompt that grants permissions is tracked separately; until it lands, grants are empty, so a plugin that needs a capability (like this one) can be exercised through the test suite but not yet granted live in the running app.

Exposing MCP tools

A plugin can offer tools to an external AI agent through the app’s MCP bridge. Declare them in plugin.json:

"mcpTools": [
  {
    "name": "price_vector",
    "description": "Custom price model for a type.",
    "inputSchema": { "type": "object", "properties": { "typeId": { "type": "integer" } } },
    "function": "price_vector"
  }
]

function is the exported WASM function that implements the tool; it’s called exactly like plugins_invoke (JSON in, JSON out) with your granted capabilities. The host advertises the tool as <pluginId>.<name> — but only while your plugin and the MCP bridge are active. Your plugin never touches the network; the native bridge proxies the call.

UI plugins

A plugin can ship an HTML/JS UI that appears as its own sidebar page once the plugin is activated. Declare it in the manifest and name the entry document index.html at your plugin’s root:

"ui": "index.html"

The UI loads from a distinct plugin:// origin in a sandbox="allow-scripts" iframe with no allow-same-origin — a unique opaque origin that can’t read the app’s DOM or localStorage, call invoke, or reach the network (connect-src 'none'). Its only channel to the app is a postMessage bridge.

The bridge

Your UI reaches its own logic through one call, invoke(fn, args): it runs one of your own plugin’s exported WASM functions through the host (plugins_invoke) and resolves with the JSON return value. The broker still enforces the capabilities your manifest was granted, and the host only ever dispatches to your plugin — a UI can drive nothing but its own logic. To pull foreign data, that logic uses net:fetch in the WASM layer, never the iframe.

args reaches your export exactly as plugins_invoke sends it: the JSON-serialised value. A function taking a bare String (like the reference’s evaluate(type_id: String)) wants a JSON number / unquoted value, not a quoted string; a function taking a struct wants an object.

invoke is a thin wrapper over postMessage. For a single-file UI the simplest thing is to inline it — no import, works on any build:

<script>
  const CHANNEL = "eve-plugin";
  const pending = new Map();
  let nextId = 1;
  window.addEventListener("message", (e) => {
    const m = e.data;
    if (!m || m.channel !== CHANNEL || m.kind !== "result") return;
    const p = pending.get(m.id);
    if (!p) return;
    pending.delete(m.id);
    m.ok ? p.resolve(m.result) : p.reject(new Error(m.error));
  });
  function invoke(fn, args) {
    const id = nextId++;
    return new Promise((resolve, reject) => {
      pending.set(id, { resolve, reject });
      parent.postMessage({ channel: CHANNEL, kind: "invoke", id, fn, args }, "*");
    });
  }
  // const r = await invoke("evaluate", 34);
</script>

Under the hood that’s this wire protocol, if you’d rather implement it yourself:

// your window → parent
{ channel: "eve-plugin", kind: "invoke", id: 1, fn: "evaluate", args: 34 }
// parent → your window
{ channel: "eve-plugin", kind: "result", id: 1, ok: true, result: {} }
// or, on failure
{ channel: "eve-plugin", kind: "result", id: 1, ok: false, error: "" }

For a multi-file / TypeScript UI, copy plugin-ui-sdk.js (and plugin-ui-sdk.d.ts for types) into your UI and import { invoke } from it. The plugin:// host sends Access-Control-Allow-Origin, so a sandboxed frame can ES-module-import its own assets (app builds from v0.40).

Reference

eve-online-tooling-plugins/pricing-model/ is a complete logic + UI plugin: its Rust WASM scores an item by name (search resolves the name via sde_search, evaluate scores its cargo density and price-aware ISK/m³ via sde_type_info + market_price), and index.html drives it through the bridge as a self-contained single-file UI. Drop the folder into your plugins dir, activate it, and it shows up as a Pricing Model page.

Other languages

Rust is the reference, but a logic plugin can be written in any Extism-supported language — Go, TypeScript, AssemblyScript, Zig, or C. The manifest, permissions, and host-function contract are identical; only the guest SDK differs.