diff --git a/plugin-sdk/examples/trip-countdown/README.md b/plugin-sdk/examples/trip-countdown/README.md
new file mode 100644
index 000000000..049c98d13
--- /dev/null
+++ b/plugin-sdk/examples/trip-countdown/README.md
@@ -0,0 +1,39 @@
+# Trip Countdown
+
+> A dashboard widget that counts down the days to your next trip.
+
+
+
+## What it does
+
+Trip Countdown adds a small dashboard widget showing how many days remain until
+your upcoming trip starts. When a trip is under way it shows "in progress"
+instead. It's the reference plugin for the TREK plugin system — a minimal but
+complete widget that reads trip data through the sandbox and renders it in an
+isolated iframe.
+
+## Screenshots
+
+
+
+## Permissions
+
+| Permission | Why this plugin needs it |
+|---|---|
+| `db:read:trips` | To read the current trip's start date so it can compute the countdown. The read is membership-checked by TREK, so the widget only ever sees a trip you already have access to. |
+
+It requests **no** network access and stores **no** data of its own.
+
+## Setup
+
+None. Enable plugins on your instance (`TREK_PLUGINS_ENABLED=true`), install and
+activate Trip Countdown, and the widget appears on your dashboard.
+
+## Building
+
+The widget ships plain JS/HTML — no build step. `server/index.js` is the plugin
+entry; `client/index.html` is the widget iframe.
+
+## License
+
+MIT — see the TREK repository.
diff --git a/plugin-sdk/examples/trip-countdown/client/index.html b/plugin-sdk/examples/trip-countdown/client/index.html
new file mode 100644
index 000000000..b35d45dea
--- /dev/null
+++ b/plugin-sdk/examples/trip-countdown/client/index.html
@@ -0,0 +1,62 @@
+
+
+
+
+ Trip Countdown
+
+
+
+ Loading…
+
+
+
diff --git a/plugin-sdk/examples/trip-countdown/server/index.js b/plugin-sdk/examples/trip-countdown/server/index.js
new file mode 100644
index 000000000..a15335c70
--- /dev/null
+++ b/plugin-sdk/examples/trip-countdown/server/index.js
@@ -0,0 +1,43 @@
+// Trip Countdown — reference widget plugin.
+//
+// Demonstrates the full author path: definePlugin, a permission-gated route that
+// reads trip data through ctx, and a response the client iframe renders. Runs
+// isolated; ctx is the only way to reach TREK.
+const { definePlugin } = require('trek-plugin-sdk')
+
+/** Days (integer, >= 0) between now and an ISO date, or null if past/absent. */
+function daysUntil(iso) {
+ if (!iso) return null
+ const then = new Date(iso).getTime()
+ if (Number.isNaN(then)) return null
+ const diff = Math.ceil((then - Date.now()) / 86_400_000)
+ return diff >= 0 ? diff : null
+}
+
+module.exports = definePlugin({
+ routes: [
+ {
+ method: 'GET',
+ path: '/next',
+ auth: true,
+ async handler(req, ctx) {
+ // The client passes the dashboard's current trip id; the host enforces that
+ // req.user actually has access to it, so a plugin can't read foreign trips.
+ const tripId = Number(req.query.tripId)
+ if (!tripId) return { status: 200, headers: json(), body: JSON.stringify({ trip: null }) }
+
+ const t = await ctx.trips.getById(tripId, req.user.id)
+ const days = daysUntil(t && (t.start_date || t.startDate))
+ return {
+ status: 200,
+ headers: json(),
+ body: JSON.stringify({ trip: t ? { name: t.name, days } : null }),
+ }
+ },
+ },
+ ],
+})
+
+function json() {
+ return { 'content-type': 'application/json' }
+}
diff --git a/plugin-sdk/examples/trip-countdown/trek-plugin.json b/plugin-sdk/examples/trip-countdown/trek-plugin.json
new file mode 100644
index 000000000..f84fa5e46
--- /dev/null
+++ b/plugin-sdk/examples/trip-countdown/trek-plugin.json
@@ -0,0 +1,21 @@
+{
+ "id": "trip-countdown",
+ "name": "Trip Countdown",
+ "version": "1.0.0",
+ "apiVersion": 1,
+ "author": "TREK",
+ "description": "A dashboard widget counting down the days to your next trip.",
+ "homepage": "https://github.com/mauriceboe/TREK",
+ "license": "MIT",
+ "icon": "CalendarClock",
+ "type": "widget",
+ "trek": ">=3.2.0 <4.0.0",
+ "nativeModules": false,
+ "permissions": ["db:read:trips"],
+ "capabilities": {
+ "widget": { "title": "Trip Countdown", "defaultSize": "small" }
+ },
+ "routes": [
+ { "method": "GET", "path": "/next", "auth": true }
+ ]
+}
diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md
new file mode 100644
index 000000000..3b43c45e4
--- /dev/null
+++ b/wiki/Plugin-Development.md
@@ -0,0 +1,136 @@
+# Plugin Development
+
+Build a plugin with the `trek-plugin-sdk` package. A plugin is a directory with a
+manifest, a built server entry, and (for page/widget plugins) a client bundle.
+
+## Scaffold
+
+```bash
+npx create-trek-plugin my-plugin --type integration|page|widget
+cd my-plugin
+```
+
+This emits:
+
+```
+my-plugin/
+ trek-plugin.json # manifest
+ server/index.js # your plugin code (built, plain JS)
+ client/index.html # page/widget iframe (page/widget only)
+ README.md # fill this in — the registry requires it
+```
+
+## The three plugin types
+
+- **integration** — extends an existing feature (photo provider, calendar source,
+ notification target). No UI of its own.
+- **page** — adds a nav entry that opens a full-page sandboxed iframe.
+- **widget** — adds a card to the dashboard.
+
+## Writing the server
+
+Your `server/index.js` exports a `definePlugin(...)` object. Everything reaches
+TREK through the `ctx` argument — there is no other way out of the sandbox.
+
+```js
+const { definePlugin } = require('trek-plugin-sdk')
+
+module.exports = definePlugin({
+ // Runs once when the plugin is activated.
+ async onLoad(ctx) {
+ await ctx.db.migrate('001_init', 'CREATE TABLE cache (k TEXT PRIMARY KEY, v TEXT)')
+ ctx.log.info('loaded')
+ },
+
+ // HTTP routes, mounted at /api/plugins/.
+ routes: [
+ { method: 'GET', path: '/status', auth: true, async handler(req, ctx) {
+ const rows = await ctx.db.query('SELECT COUNT(*) AS n FROM cache')
+ return { status: 200, headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ n: rows[0].n, user: req.user?.username }) }
+ }},
+ ],
+
+ // Scheduled jobs — TREK owns the cron and calls your handler.
+ jobs: [
+ { id: 'refresh', schedule: '*/15 * * * *', async handler(ctx) { /* … */ } },
+ ],
+})
+```
+
+### The `ctx` object
+
+| Area | Methods | Requires |
+|---|---|---|
+| `ctx.db` | `query` / `exec` / `migrate` against your **own** SQLite file | `db:own` |
+| `ctx.trips` | `getById` / `getPlaces` / `getReservations` (membership-checked) | `db:read:trips` |
+| `ctx.users` | `getById` (public profile only — never secrets) | `db:read:users` |
+| `ctx.ws` | `broadcastToTrip` / `broadcastToUser` (events namespaced `plugin::…`) | `ws:broadcast:*` |
+| `ctx.config` | your instance settings (secrets delivered decrypted) | — |
+| `ctx.log` | `info` / `warn` / `error` → your error log | — |
+
+Calling a capability your manifest didn't declare throws `PERMISSION_DENIED`.
+
+### Route auth
+
+Routes are authenticated by default (`req.user` is the logged-in user). Set
+`auth: false` for OAuth callbacks or webhooks that can't carry a session.
+
+## Writing the client (page / widget)
+
+The iframe is sandboxed at an **opaque origin** — it can't read cookies or the
+parent page. It talks to TREK only via `postMessage`:
+
+```js
+window.parent.postMessage({ type: 'trek:ready' }, '*')
+
+window.addEventListener('message', (e) => {
+ const m = e.data
+ if (m.type === 'trek:context') { /* m.theme, m.locale, m.tripId, m.userId */ }
+ if (m.type === 'trek:response' && m.requestId === '1') { /* … */ }
+})
+
+// Call one of your own server routes (TREK proxies it with the user's session):
+window.parent.postMessage({ type: 'trek:invoke', requestId: '1', sub: '/status', method: 'GET' }, '*')
+```
+
+Bridge messages you can send: `trek:ready`, `trek:context:request`,
+`trek:navigate {to}`, `trek:notify {level,message}`, `trek:resize {height}`,
+`trek:invoke {requestId,sub,method,body}`.
+
+## Settings
+
+Declare settings in the manifest; TREK renders the form (you write no settings
+UI). `scope: "instance"` settings are set once by the admin; `scope: "user"`
+settings are per-user. `secret: true` fields are stored encrypted and delivered
+to your plugin decrypted through `ctx.config` (instance) — never to the iframe.
+
+## Testing without a running TREK
+
+`createMockHost` gives you a `ctx` that enforces the **same** permission model:
+
+```js
+import { createMockHost } from 'trek-plugin-sdk/testing'
+
+const { ctx, broadcasts } = createMockHost({
+ grants: ['db:read:trips'],
+ trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
+})
+await plugin.onLoad(ctx) // run your load logic
+await expect(ctx.trips.getById(1, 99)).rejects… // proves your access checks
+```
+
+## Rules
+
+- **No native modules** (`.node`, `binding.gyp`, prebuilds) — they're rejected.
+- **Vendor your dependencies** — TREK never runs `npm install` on a plugin.
+- **Ship built JS** in `server/index.js` and pre-built static files in `client/`.
+- Declare every outbound host in `egress[]` when you use `http:outbound`.
+
+## Validate before publishing
+
+```bash
+npx trek-plugin validate .
+```
+
+Runs the same checks the registry CI runs. See [[Publishing a Plugin|Plugin-Publishing]].
diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md
new file mode 100644
index 000000000..b14b62953
--- /dev/null
+++ b/wiki/Plugin-Permissions.md
@@ -0,0 +1,42 @@
+# Plugin Permissions
+
+A plugin declares the permissions it needs in `trek-plugin.json`. At activation
+the admin sees the list and approves it — and because plugins run in an isolated
+process, **an unapproved capability is physically unreachable**, not just
+disallowed.
+
+## Reference
+
+| Permission | Grants | Notes |
+|---|---|---|
+| `db:own` | Read/write the plugin's **own** SQLite file via `ctx.db` | A separate file per plugin — never `trek.db`. `ATTACH`/`PRAGMA` are refused. |
+| `db:read:trips` | Read-only trip data via `ctx.trips` (`getById`, `getPlaces`, `getReservations`) | Every call is **membership-checked** against the acting user — a plugin can't read a trip that user can't see. |
+| `db:read:users` | Read-only public profile via `ctx.users.getById` | Returns id, username, display name, avatar only — **never** password hashes, tokens, or secrets. |
+| `ws:broadcast:trip` | Push a real-time event to a trip room via `ctx.ws.broadcastToTrip` | Event types are force-namespaced `plugin::` — a plugin can't forge a core event. |
+| `ws:broadcast:user` | Push a real-time event to a user's connections | Same namespacing. |
+| `hook:photo-provider` | Register as a photo provider in Memories | Implement the `PhotoProvider` interface. |
+| `hook:calendar-source` | Register as a calendar source | Implement the `CalendarSource` interface. |
+| `http:outbound` / `http:outbound:` | Make outbound network requests | **Requires** a non-empty `egress[]` list of hosts. The plugin's client CSP and the runtime egress guard are limited to those hosts. |
+
+## Declaring them
+
+```jsonc
+{
+ "permissions": ["db:own", "db:read:trips", "http:outbound:api.example.com"],
+ "egress": ["api.example.com"] // required whenever http:outbound is declared
+}
+```
+
+Rules the manifest validator enforces:
+
+- Only the permissions above are accepted; an unknown string fails validation.
+- `http:outbound` (with or without a host) requires a non-empty `egress[]`.
+- `egress[]` may not contain a bare `*`.
+
+## What is NOT covered
+
+Isolation bounds *what* a plugin can touch, not its intent within a grant. A
+plugin you allow to read trip data **and** reach `api.example.com` could send
+that trip data there. That is the point of the consent screen — grant only what
+you'd trust the plugin to do with your data. Prefer **Reviewed** plugins and
+authors you trust.
diff --git a/wiki/Plugin-Publishing.md b/wiki/Plugin-Publishing.md
new file mode 100644
index 000000000..8f625f378
--- /dev/null
+++ b/wiki/Plugin-Publishing.md
@@ -0,0 +1,84 @@
+# Publishing a Plugin
+
+Plugins are distributed from a static registry — the
+[TREK-Plugins](https://github.com/mauriceboe/TREK-Plugins) GitHub repo. There is
+no upload server and no account: you host your plugin in your own GitHub repo and
+list it with a pull request.
+
+## 1. Host your plugin
+
+Put your plugin in a **public GitHub repo** (convention: `trek-plugin-`). It
+must contain a `trek-plugin.json` at the root, a built `server/index.js`, a
+filled-in `README.md`, and (page/widget) a built `client/`.
+
+## 2. Tag a release
+
+Tag `vX.Y.Z` where `X.Y.Z` **equals** `version` in your manifest. Either attach a
+`plugin.zip` release asset, or rely on the GitHub source archive at that commit.
+
+## 3. Validate locally
+
+```bash
+npx trek-plugin validate .
+```
+
+This runs the exact checks the registry CI runs — a local pass predicts a CI
+pass.
+
+## 4. Open a PR against the registry
+
+Add one file, `registry/plugins/.json`:
+
+```jsonc
+{
+ "id": "flight-tracker",
+ "name": "Flight Tracker",
+ "author": "Your Name",
+ "description": "Live flight status widget.",
+ "repo": "you/trek-plugin-flight-tracker",
+ "type": "widget",
+ "versions": [{
+ "version": "1.0.0",
+ "gitTag": "v1.0.0",
+ "commitSha": "<40-hex commit the tag points at>",
+ "downloadUrl": "https://github.com/you/trek-plugin-flight-tracker/releases/download/v1.0.0/plugin.zip",
+ "sha256": "",
+ "minTrekVersion": "3.2.0",
+ "apiVersion": 1,
+ "nativeModules": false
+ }]
+}
+```
+
+CI validates the entry and the artifact, and the maintainer reviews the source at
+the pinned commit before merging.
+
+## What CI enforces
+
+**Entry:** valid schema · `id` matches the filename and is a valid slug ·
+reserved namespaces (`trek-`, `official-`) blocked · your `id` is bound to your
+GitHub owner on first registration (nobody can repoint it later) · homoglyph
+check · the release tag exists and points at the pinned commit · manifest parity
+· **the artifact's SHA-256 matches the pin** · **no native binaries** ·
+`egress[]` present when `http:outbound` is declared.
+
+**README:** must exist, have the required sections (*What it does / Screenshots /
+Permissions / Setup*), contain **at least one screenshot that actually resolves**,
+have real content (not the unfilled template), and explain every declared
+permission.
+
+## Provenance & integrity
+
+- `commitSha` pins the exact source the maintainer reviewed (git tags are movable).
+- `sha256` pins the exact artifact bytes TREK will run (release assets are mutable).
+
+TREK verifies the downloaded bytes against `sha256` and refuses to install on a
+mismatch. A `reviewedAt` date on your entry means a maintainer looked at that
+exact commit — it is **not** an ongoing guarantee.
+
+## Updating
+
+Add a new version entry (new `version`/`gitTag`/`commitSha`/`sha256`) to your
+plugin file and PR it. Instances see the update on their next registry poll;
+updating is always an explicit admin action, and if a new version requests **more**
+permissions the admin must re-approve.
diff --git a/wiki/Plugins.md b/wiki/Plugins.md
new file mode 100644
index 000000000..0077c4dc6
--- /dev/null
+++ b/wiki/Plugins.md
@@ -0,0 +1,78 @@
+# Plugins
+
+Plugins let anyone extend a self-hosted TREK instance with new features — a
+dashboard widget, a full page, a photo/calendar integration — **without touching
+TREK's source code**. They are developed by third parties, distributed from a
+public registry, and installed at the instance owner's discretion.
+
+> [!IMPORTANT]
+> **Plugins run arbitrary code, isolated but real.** Every plugin runs in its own
+> sandboxed child process with only the permissions you approve. TREK does not
+> maintain, audit, or take responsibility for community plugins. Grant a plugin
+> only the access you'd trust it to have with your data, and prefer plugins
+> marked **Reviewed**.
+
+## Enabling plugins
+
+The plugin runtime is **off by default**. To turn it on, set the environment
+variable on your instance and restart:
+
+```yaml
+environment:
+ - TREK_PLUGINS_ENABLED=true
+```
+
+When it's off, the **Admin → Plugins** tab shows a "disabled by server
+configuration" banner and nothing runs.
+
+## The isolation model — what a plugin can and can't do
+
+Each active plugin runs as a **separate OS process** with a deliberately empty
+environment:
+
+- It has **no** access to `JWT_SECRET`, the database connection, or any TREK
+ secret — those are simply not in its process.
+- It **cannot** open `trek.db`. Its own data lives in a separate SQLite file it
+ reaches only through TREK.
+- It talks to TREK exclusively over an internal RPC channel, and TREK only
+ answers the capabilities the plugin's manifest **declares and you approve**.
+ An ungranted call is refused, not merely ignored.
+- If a plugin crashes, hangs, or runs out of memory, only *its* process dies —
+ TREK keeps running and restarts or disables it.
+
+This means the permission list you approve at activation is a **real boundary**,
+not a label. See [[Plugin Permissions|Plugin-Permissions]] for exactly what each
+permission grants.
+
+## Installing a plugin
+
+Two ways, both from **Admin → Plugins**:
+
+1. **From the registry.** Click *Browse plugins*, pick one, and *Install*. TREK
+ downloads the pinned version from GitHub, verifies its SHA-256 against the
+ registry, safely unpacks it, and registers it — **inactive**. Nothing runs
+ yet.
+2. **From disk.** Drop a plugin directory into the `/plugins` volume and click
+ *Rescan* (or restart). TREK discovers it and registers it inactive.
+
+## Activating a plugin
+
+Activation is a separate, deliberate step. You review the plugin's requested
+permissions and outbound hosts on a consent screen, then click **Activate** —
+which grants those permissions and spawns the isolated process. A page plugin
+then appears in the top navigation; a widget appears on the dashboard.
+
+## Managing plugins
+
+- **Disable** stops the process immediately; the plugin keeps its data.
+- **Uninstall** removes the code and lets you keep or delete the plugin's data.
+- **View errors** shows the plugin's own error log (crashes, failed requests).
+
+## Building your own
+
+See [[Plugin Development|Plugin-Development]] and the `trek-plugin-sdk` package.
+
+## Kill switch
+
+Set `TREK_PLUGINS_ENABLED=false` to disable the entire subsystem instantly —
+installed plugins stay on disk, deactivated and harmless.
diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md
index 9dd5a4f3a..02335181e 100644
--- a/wiki/_Sidebar.md
+++ b/wiki/_Sidebar.md
@@ -66,6 +66,12 @@
- [[Collections]]
- [[Dashboard Widgets|Dashboard-Widgets]]
+## Plugins
+- [[Plugins Overview|Plugins]]
+- [[Plugin Development|Plugin-Development]]
+- [[Plugin Permissions|Plugin-Permissions]]
+- [[Publishing a Plugin|Plugin-Publishing]]
+
## AI / MCP
- [[MCP Overview|MCP-Overview]]
- [[MCP Setup|MCP-Setup]]