A Sidedoor plugin is a folder with an index.tsx whose default export is a
definePlugin({...}) call. The app runs every plugin under Bun, each in its
own Worker, and draws what it renders with native GPUI elements. There is no
DOM, HTML or CSS. Saving a file reloads the plugin in the running app.
1. Find or create the plugin folder
Plugins live in the plugins folder of the app's data directory:
| OS | Folder |
|---|---|
| macOS | ~/Library/Application Support/Sidedoor/plugins/<id>/ |
| Linux | ${XDG_DATA_HOME:-~/.local/share}/sidedoor/plugins/<id>/ |
| Windows | %APPDATA%\Sidedoor\plugins\<id>\ |
The folder name is the plugin's id. The quickest way to start is
Settings › Plugins › New Plugin: it creates a working plugin, adds it to
the dock and opens its code. To start by hand, create the folder with
index.tsx (use the template below). The app links @sidedoor/sdk into its
node_modules and writes a tsconfig.json the first time it loads the
plugin. Plugins in a git repository elsewhere work too, as long as that repo
is what users install from (see step 6).
2. Read the API before writing code
The SDK is the source of truth. Read these rather than guessing names:
node_modules/@sidedoor/sdk/README.md(full guide), if present. On Windows, and when it's missing, use https://github.com/lassejlv/sidedoor/blob/main/sdk/README.md.node_modules/@sidedoor/sdk/src/types.ts: every element, component and style prop, with types.node_modules/@sidedoor/sdk/src/data.ts: the shapes of native data feeds.
references/api.md is a condensed cheat sheet of the same API. references/example.md is a complete plugin with a tile, a card, a setting, storage, an action, a window and tests.
3. Write the plugin
Start from this template:
import { Button, Card, Text, definePlugin, useStorage } from "@sidedoor/sdk";
export default definePlugin({
name: "Counter",
icon: "hash",
width: 260,
settings: {
step: { title: "Step", type: "number", default: 1 },
},
card({ settings }) {
const [count, setCount] = useStorage("count", 0);
return (
<Card title="Counter" accessory={`${count} clicks`}>
<Text variant="display">{count}</Text>
<Button variant="primary" label="Add" on_click={() => setCount(count + settings.step)} />
</Card>
);
},
});
Rules that aren't obvious from React experience:
nameandiconcome first, as plain string literals. The app reads them from the file as text, without running it, to list the plugin. It takes the firstname:/icon:afterdefinePlugin(, so a variable, a template with${}, or a setting callednameplaced above it breaks the listing.iconis a Lucide name.- GPUI names, not HTML or CSS. The only elements are
div,svgandimg. Style with boolean or value props named after GPUI'sStyledmethods, in snake_case:<div flex flex_col gap={8} px={12} text_color="secondary">. Events areon_click,on_changeandon_submit. There's noclassName,styleoronClick. Numbers are points. - Prefer native components (
Card,Text,Button,ListRow,Meter,Chart, …) so the widget matches the built-ins. Style props on them apply on top. - Colors are palette tokens that follow light and dark mode:
label,secondary,tertiary,separator,fill,track,accent,blue,green,orange,red,purple, … Use hex (#rrggbb) only when a token doesn't fit. cardandtilealways render, not only while visible. Run a timer in one of them only. Pause work withuseInterval(fn, null)and useuseCardOpen()to refresh data when the card opens.- State:
useStateis per component.createStore(initial)shares state between tile, card and windows (.use()in render,.set()anywhere).useStorage(key, initial)persists JSON across reloads and restarts. Outside render, usesidedoor.storage.get/set/delete. - Windows (
windows: { key: { title, render } }, opened withsidedoor.openWindow(key)) lose component state when closed. Keep what matters in storage or a store. - Native data: declare
data: ["weather" | "stats" | "clipboard"]and read it withuseData(name), which isnulluntil the first update. Inputneeds anid. Give list items akey.- Everything else is plain Bun:
fetch,Bun.file, timers. Find your own files withimport.meta.dir, since the working directory is shared. Write files tosidedoor.dataDir. - Size:
widthis clamped to 120–480 points (280 by default). Leave outheightso the card fits its content, up to 600. - A plugin runs with the app's full access. Don't read or send user data the widget doesn't need.
4. Check it
From the plugin folder:
bunx -p typescript@7.0.2 tsc --noEmit -p .
bun test
Pin TypeScript to the version in node_modules/@sidedoor/sdk/package.json;
a plain bunx tsc fetches an unrelated package, and newer TypeScript
releases can reject the SDK's types. Settings are typed from the settings
you declare, so a wrong key or value is a type error. Write tests with @sidedoor/sdk/testing:
import { expect, test } from "bun:test";
import { mount } from "@sidedoor/sdk/testing";
import plugin from "./index";
test("Add counts by the step", async () => {
const p = mount(plugin, { settings: { step: 2 } });
await p.press("Add");
expect(p.find("Counter").props.accessory).toBe("2 clicks");
p.unmount();
});
For typechecking tests, add "types": ["bun"] to tsconfig.json and run
bun add -d @types/bun. The mount API is summarized in
references/api.md.
5. Run it in the app
Saving reloads the plugin, and useState, useRef and store values carry
over when they're JSON. The first time, turn the plugin on under
Settings › Plugins and confirm the trust prompt. Errors show in its
card. console.log output and the reload button are under the plugin's
name in Settings › Plugins.
If it doesn't show up, check in this order:
- The folder is directly inside
plugins/and holdsindex.tsx,.ts,.jsxor.js. - The file calls
definePlugin(and default-exports it. nameandiconare string literals.bunx -p typescript@7.0.2 tsc --noEmit -p .passes.
6. Share it
Push the folder to a repository or host an archive. Users paste the link into Settings › Plugins › Install from URL. The link can be:
- a GitHub repo (
you/timer) - any Git URL
- a folder inside a repo (
github.com/you/widgets/tree/main/timer) - a
.zipor.tar.gzfile
Runtime dependencies go in package.json dependencies. They're installed
with bun install --production --ignore-scripts, so don't rely on install
scripts. Never list @sidedoor/sdk; the app provides it. Check for
Updates in Settings installs newer commits in place.
Done checklist
-
export default definePlugin({ name: "…", icon: "…", … })with literal name and icon first. - Only
div/svg/imgplus SDK components; snake_case GPUI style props; palette colors. - One timer at most, paused when idle; persistent state in
useStorageorsidedoor.storage. -
bunx -p typescript@7.0.2 tsc --noEmit -p .andbun testpass. - Reloaded in the app without errors in the card or log.