Developers

Build an add-on

An ExileCompass add-on is a small TypeScript package that draws a panel inside the overlay. It runs in a sandbox, asks only for the permissions it needs, and installs from a public registry.

# How they work

You write TypeScript in src/. Your release pipeline bundles it with esbuild into a single browser-runnable module at dist/panel.js, zips the package, and attaches it to a GitHub release. ExileCompass downloads that asset, checks it, and runs your bundle in a sandboxed iframe on an opaque origin. The bundle cannot reach the app’s DOM, the app’s storage, or any Tauri API.

The host bridge passed to your mount function is the only way out. Every method on it needs a permission that your manifest declares. If you did not ask for the permission, the call fails.

# Quick start

The scaffold writes a complete, buildable package, including the manifest, the tsconfig and the release workflow.

terminal
git clone https://github.com/juddisjudd/exilecompass
cd exilecompass
node tools/addon-scaffold/create-addon.mjs "My Addon" ../my-addon \
  --id dev.you.my-addon --author "You" \
  --homepage https://github.com/you/my-addon

Your entry point has one default export:

src/panel.ts
import type { MountFn } from './types';

// The host calls this once, inside a sandboxed iframe. `root` is yours to
// render into; `host` is the only way out.
const mount: MountFn = async ({ root, host }) => {
  const saved = await host.storage.get('state');
  root.textContent = saved ?? 'Hello from my add-on.';
};

export default mount;
terminal
bun install
bun run check
bun run build

To test it before you publish, open the app’s Add-ons hub, choose Install from file, and pick your plugin.manifest.json. The app loads the add-on straight from the folder, so you do not need a release.

# The package

A built add-on is a flat zip. The installer rejects anything missing:

plugin.manifest.json Exactly this filename. required
README.md The installer rejects a package with no README. required
dist/panel.js The bundle that entry.panel points at. required
data/default.json Anything that entry.data points at. optional

entry.panel must sit under ./dist/ and end in .js or .mjs. The app never runs your src/. A repository link is required, so anyone can read the source of an installed add-on.

plugin.manifest.json
{
  "schemaVersion": "1.0",
  "id": "dev.you.my-addon",
  "name": "My Addon",
  "description": "What it does, in one line.",
  "author": "You",
  "homepage": "https://github.com/you/my-addon",
  "version": "0.1.0",
  "kind": "addon",
  "entry": {
    "panel": "./dist/panel.js",
    "data": "./data/default.json"
  },
  "compatibility": { "app": ">=1.5.0", "pluginApi": "^1.1.0" },
  "permissions": ["storage.read", "storage.write", "ui.panel"],
  "contributions": {
    "view.panels": [
      { "id": "my-addon", "title": "My Addon", "icon": "panel", "pinDefault": false }
    ]
  }
}

# Host API

Older hosts may not have every method. Test for a method before you call it, and state what you need in compatibility.app. The typed copy of this contract lives in the scaffold’s src/types.ts.

Storage

Key/value strings, scoped to your add-on so no other add-on can read them. The host stores them, not the browser. The sandbox has no usable localStorage.

storage.get(key) → Promise<string | null>
storage.read 0.3.0+

Returns null when the key was never set.

storage.set(key, value) → Promise<void>
storage.write 0.3.0+

Values are strings, so call JSON.stringify on anything larger. Keep it small. Add-on storage shares one file with the app’s own settings, so put game data in net.fetchCached instead.

Network

The panel runs on an opaque origin. It cannot call an API that lacks CORS headers, and the browser caches nothing it loads. The host makes every request for you, and only to the hosts your manifest names.

net.fetch(url) → Promise<{ status, body }>
network.fetch:<host> 1.4.0+

HTTPS GET. Redirects are blocked, so an allowed host cannot send you somewhere else.

net.fetchImage(url) → Promise<string>
network.fetch:<host> 1.4.1+

Returns a data: URL and caches it on disk for a week. Use it for every image. Without it, the panel downloads every icon again each time it opens.

net.fetchCached(url, maxAgeSeconds?) → Promise<{ status, body }>
network.fetch:<host> 1.5.0+

GET, cached on disk for a day by default and 30 days at most. Use it for large payloads that change once a patch, not once a session. It serves the stale copy when the network is down.

net.request({ url, method, headers, body }) → Promise<{ status, headers, body }>
network.request:<host> 1.5.0+

GET or POST, and the only way to read response headers. Both directions are allowlisted: you may set Accept and Content-Type, and you get back x-rate-limit-*, retry-after and content-type. The host owns User-Agent, so traffic stays attributable and cookies never cross. Grants network.fetch for the same host.

App state

Read-only views of what the overlay is doing. Both push changes to an open panel, so you never need to poll.

game.get() → Promise<"poe1" | "poe2">
game.read 1.4.0+

Which game the overlay targets. game.onChange(cb) fires when the player flips the footer switch.

builds.getActive() → Promise<Build | null>
builds.read 0.3.1+

A snapshot of the player’s imported Path of Building build. builds.onChange(cb) fires on import.

Shell

Hands something off to the rest of the machine.

shell.openExternal(url) → Promise<void>
shell.open:<host> 1.5.0+

Opens an https:// URL in the default browser.

# Permissions

The app prints every permission your add-on declares on its card under Add-ons → Installed. Ask for the narrowest set that works. A host pattern covers that host and its subdomains, and nothing wider.

ui.panel Adds a panel. Every add-on with a UI needs this.
storage.read Reads its own key/value store.
storage.write Writes to that store.
network.fetch:<host> HTTPS GET to that host and its subdomains.
network.request:<host> POST and response headers for that host. Grants network.fetch for it too.
shell.open:<host> Opens that host in the default browser.
game.read Which game the overlay is pointed at.
builds.read The active imported build.

# Publishing

  1. Tag a release

    Set the same version in package.json and plugin.manifest.json, then push a matching vX.Y.Z tag. The scaffolded workflow type-checks your code, builds it, and publishes exilecompass-addon.zip on the release. Keep that asset name. The app uses it to find your package without calling the GitHub API.

  2. Add it to the registry

    Add your entry to registry.v1.json and open a pull request against exilecompass-registry. Run npm run validate:registry and npm run sync:registry, then commit both changed files.

  3. Ship updates

    After the pull request merges, your add-on appears under Add-ons → Discover in the app. An update takes the same two steps: push a new tag, then raise latestVersion in the registry.

# Examples

If you get stuck on the host bridge, ask in the Discord. The API grows to fit what add-ons actually need.