-
-
Notifications
You must be signed in to change notification settings - Fork 885
Plugin Development
Build a plugin with the trek-plugin-sdk package. A plugin is a directory with a
manifest (trek-plugin.json), a built server entry, and — for page/widget
plugins — a static client bundle. TREK runs your server code in an isolated
child process and reaches it only over RPC; the browser part runs in a
sandboxed, opaque-origin iframe. There is no other way in or out.
Two resources sit alongside this page:
- Plugin-Skill — an agent skill that teaches Claude Code and other SKILL.md-compatible coding agents how to build, test and publish a TREK plugin. It covers the same ground as this page, but the agent reads it instead of you. Add it to the repo you build your plugin in and it loads automatically whenever the task touches TREK plugins.
- TREK-Plugins — the community registry. It is a static index with no server and no account: you list a plugin by opening a pull request that adds one JSON file. Your plugin's code stays in your own repository; the registry only points at it. See Plugin-Publishing for the submission flow and the CI gates.
Neither is required. You can build, install and run a plugin without touching either — the registry only matters when you want other people's TREK instances to find it.
npx trek-plugin-sdk create # interactive wizard
npx trek-plugin-sdk create my-plugin --type integration|page|widget|trip-page # or direct
cd my-pluginThe wizard (run create with no name) asks for the id, type, author, icon (checked
against lucide — a typo there is invisible locally but rejected by the registry),
permissions, egress hosts and required addons; the direct form takes them as flags.
This emits:
my-plugin/
trek-plugin.json # manifest — including `icon`, and a widget's `slot`
package.json # CommonJS marker + the SDK as a devDependency
server/index.js # your plugin code (built, plain JS)
client/index.html # native UI via the design kit (page / widget / trip-page only)
README.md # fill this in — the registry requires four sections and a screenshot
docs/ # where docs/screenshot.png goes; `trek-plugin shot` writes it
.gitattributes # LF everywhere, so the packed artifact's sha256 is reproducible
The scaffold declares "trek": ">=3.4.0 <4.0.0" — the ctx namespaces it is written
against only exist from 3.4.0, and claiming an older floor would let the plugin install
on a host where they are undefined.
What you get runs and packs immediately, but it does not pass validate yet:
the README is still a template and there is no screenshot. That is deliberate — those
are yours to write — and trek-plugin status says exactly what is missing.
npx trek-plugin-sdk dev # http://localhost:4317dev works straight after create — no npm install needed, because it
injects require('trek-plugin-sdk') from the CLI itself, exactly like TREK
injects it in production. It loads your server/index.js through the same
definePlugin contract the host uses and gives you a real request loop
without a full TREK: a dashboard
listing your routes, the routes served under /api/<path>, your page/widget UI
at /ui, a themed host preview at /preview (a real sandboxed frame with a
theme/accent/appearance toggle, trek.invoke() proxied to your routes), and a reload
on every save. The injected ctx is the full surface — every capability (costs, packing,
files, notify, ai, settings, scheduler, meta, oauth, weather, rates,
journal, db.tx, …), not just routes + db:own — and enforces exactly the
permissions your manifest grants: an ungranted call throws PERMISSION_DENIED, so
you catch a missing grant here rather than after install. db:own is backed by a real
SQLite file (.trek-dev/db.sqlite) when the runtime has node:sqlite; every other
capability is served from your fixtures with the same rules as production.
- Hit a route as an unauthenticated request with
?_anon=1(anauth: trueroute then returns 401, mirroring the host). - Feed the fixtures by dropping a
dev-fixtures.jsonnext to the manifest — it takes the same shape ascreateMockHostoptions, so you can seed trips, users, costs, settings, weather, canned AI results, etc.:{ "actingUserId": 1, "trips": { "1": { "members": [1], "data": { … }, "costs": [ … ] } }, "users": {} }. - Fire a non-route entry point with
GET /__dev/fire/<kind>[/<name>][/<fn>](a JSON body or query params become the payload/args):/__dev/fire/job/refresh,/__dev/fire/scheduled/daily,/__dev/fire/event/place:created,/__dev/fire/hook/tripCardProvider/getCards,/__dev/fire/deleteUserData?userId=1— so jobs, scheduled timers, event subscriptions and provider hooks are all testable in the same hot-reload loop as your routes.
dev above is fast but its host data is synthetic (fixtures / a scratch
db:own). When you need your plugin to run against real trips, places,
reservations, costs and real membership/permissions, link it into a running TREK
instance instead of packing + uploading it every time.
On the server (a local trek-dev or a dev instance — never production), set:
TREK_PLUGINS_DEV_LINK=1With the flag set, the Admin → Plugins tab shows a “Link a local plugin”
field. Paste the absolute path to your built plugin directory (the one holding
trek-plugin.json + server/index.js — build first, the loader runs the compiled
artifact, not TS source) and hit Link. The plugin is symlinked into the plugins
volume and registered INACTIVE, and shows a Dev-link badge in the list.
Prefer scripting? The same thing over HTTP:
curl -XPOST /api/admin/plugins/link -H 'content-type: application/json' \
-d '{ "path": "/abs/path/to/your/plugin" }'Activate it in the admin UI and consent to its permissions as usual. It now runs through the same capability RPC host as any installed plugin: real, membership-gated data, the acting user resolved host-side, no impersonation — code origin never touches the security gate.
Hot-reload: rebuild your plugin (e.g. tsc --watch emitting server/index.js)
and TREK re-forks it automatically (a file-watch on the linked dir). To force it,
POST /api/admin/plugins/:id/reload, or just hit Restart in the admin UI —
both re-fork the child, picking up the new code (a rebuilt manifest that widened
permissions still requires explicit re-consent).
Dev-link is gated behind
TREK_PLUGINS_DEV_LINKon top of admin + the plugin kill-switch, and is off by default. It loads unsigned local code that mutates live between restarts, and undernpm run devthe OS permission jail is off — so only ever enable it on a machine you control, pointed at a dev instance.
-
integration — background logic (jobs, routes) with no UI of its own. Most
provider hooks are live (placeDetailProvider, warningProvider,
tableContributor, mapMarkerProvider, pdfSectionProvider, atlasLayerProvider,
journalEntryProvider); only
photoProvider/calendarSourceare declared in the SDK but not yet wired into the host — see Provider hooks. - page — adds a nav entry that opens a full-page sandboxed iframe.
-
widget — adds a card to the dashboard (
sidebarslot), a hero-bar overlay (heroslot), a panel inside the trip planner's place-detail view (place-detailslot — the frame also receives the openplaceIdintrek:context, so it can show place-specific info like reviews or ratings), a panel inside the day-detail view (day-detailslot — receives the opendayId; the home for per-day content like outfit planning, live flight status or logistics), or a panel at the foot of a booking card in the reservation-detail view (reservation-detailslot — receives the openreservationId, for things like live check-in status or a seat map). Set the slot incapabilities.widget.slot. -
trip-page — adds a tab inside every trip planner, so your UI lives in the
trip alongside Plan / Transports / Files. The frame is the same sandboxed iframe as
a
page, but it receives the currenttripIdintrek:context(so you can scope data to the open trip) and it has no dashboard nav entry. The tab shows on desktop and mobile. Viacapabilities.tripPagethe plugin can also take over core tabs:replaces: ['transports', 'buchungen', …]hides the named tabs while the plugin is active (they return the moment it's deactivated;planis never replaceable), andpositionpicks the 0-based index of your tab in the bar instead of appending it. A plugin that replaces tabs gets a "Replaces planner tabs" chip in the admin list, so the takeover is visible before activation.
trek-plugin-sdk is injected at runtime — the host makes
require('trek-plugin-sdk') resolve inside the child, so do not vendor it
into your artifact. Add it as a devDependency only, so you get types,
createMockHost for tests, and the trek-plugin CLI:
npm i -D trek-plugin-sdkYour server/index.js exports a definePlugin(...) object. Everything reaches
TREK through the ctx argument.
const { definePlugin } = require('trek-plugin-sdk')
module.exports = definePlugin({
// Runs once when the plugin is activated. NOTE: onLoad has no user context —
// ctx.trips.* is refused here (see the ctx table).
async onLoad(ctx) {
await ctx.db.migrate('001_init', 'CREATE TABLE IF NOT EXISTS cache (k TEXT PRIMARY KEY, v TEXT)')
ctx.log.info('loaded')
},
// Runs once on deactivation/stop. Use it to flush or release resources.
async onUnload(ctx) {
ctx.log.info('unloading')
},
// HTTP routes, mounted at /api/plugins/<id><path>.
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 on the schedule.
// Requires the `jobs:run` permission (opt-in: scheduled work runs with NO user,
// so its trip reads are refused — a job can only use ctx.db and declared egress).
// An invalid cron expression is skipped; jobs stop when the plugin is deactivated.
jobs: [
{ id: 'refresh', schedule: '*/15 * * * *', async handler(ctx) { /* … */ } },
],
// Persistent, userless timers you arm at runtime with ctx.scheduler — they
// survive restarts and call this handler when due. Also needs `jobs:run`.
// await ctx.scheduler.in(3_600_000, 'remind', { tripId }) // once, in 1h
// await ctx.scheduler.every(86_400_000, 'digest') // daily
async scheduled({ name, payload }, ctx) {
if (name === 'remind') { /* … one-shot fired; re-arm if you want another */ }
},
// GDPR data-subject rights (needs `hook:user-data`). Userless — you only get the
// userId and act on your OWN db. deleteUserData is delivered DURABLY (queued and
// retried until it succeeds, even across restarts), so make it idempotent.
async deleteUserData({ userId }, ctx) {
await ctx.db.exec('DELETE FROM my_prefs WHERE user_id = ?', userId)
},
async exportUserData({ userId }, ctx) {
return await ctx.db.query('SELECT * FROM my_prefs WHERE user_id = ?', userId)
},
})The routes and job ids you declare here are the authoritative ones: the host
reads them off your loaded definition (a route's array index is its internal id).
The routes block the scaffold writes into trek-plugin.json is only a
declaration for readers — the manifest parser does not consume it.
| Area | Methods | Requires |
|---|---|---|
ctx.db |
query(sql, …args) / exec(sql, …args) / migrate(id, sql) / tx(ops) against your own SQLite file. tx([{sql, args?}, …]) runs up to 100 statements in one transaction (all commit or all roll back; reads see the batch's own earlier writes) → `{ results: [{changes?} |
{rows?}, …] }` |
ctx.trips |
getById / getPlaces / getReservations / getDays / getAccommodations / listMine() — enumerate every trip the acting user can access (membership-checked). getDays includes each day's assignments + notes_items; getReservations includes endpoints + day_positions
|
db:read:trips |
ctx.trips.update(tripId, fields) |
update trip fields (title/dates/currency/reminder_days/…) | db:write:trips |
ctx.trips.create(input) |
create a new trip owned by the acting user (importers) — title required, plus description?/start_date?/end_date?/currency?/reminder_days?/day_count?
|
db:create:trips (+ trip_create) |
ctx.places |
create(tripId, fields) / update(tripId, placeId, fields) / delete(tripId, placeId)
|
db:write:places |
ctx.days |
create(tripId, {date?, notes?}) / update(tripId, dayId, {notes?, title?}) / delete(tripId, dayId)
|
db:write:days |
ctx.itinerary |
assign(tripId, dayId, placeId, notes?) / unassign(tripId, assignmentId) — place↔day |
db:write:itinerary |
ctx.meta |
get / set / list / delete your own namespaced data on a trip/place/day (enrich core entities without forking the schema) |
db:meta |
ctx.packing |
list(tripId) — a trip's packing items (membership-checked, respects private-item visibility) |
db:read:packing |
ctx.files |
list(tripId) — a trip's files, trash excluded (membership-checked) |
db:read:files |
ctx.files.getContent |
getContent(tripId, fileId) → { name, mimetype, size, content_base64 } — a file's bytes as base64 (10MB cap; trashed files refused) |
db:read:files:content |
ctx.journal |
listMine() — the acting user's own travel journals; getEntries(journeyId) — one journey's entries (photos/story/checkins, access-checked) |
db:read:journal (+ Journey addon) |
ctx.atlas |
visited() — the acting user's visited countries + regions; bucketList() — their bucket-list items |
db:read:atlas (+ Atlas addon) |
ctx.vacay |
mine() — the acting user's vacation plan |
db:read:vacay (+ Vacay addon) |
ctx.collections |
listMine() / get(id) — the acting user's saved-place collections |
db:read:collections (+ Collections addon) |
ctx.collections (write) |
create(input) / update(id, input) / savePlace(input) / copyToTrip(input) / deletePlace(placeId) — per-collection role enforced by the service |
db:write:collections (+ Collections addon) |
ctx.atlas (write) |
markCountry(code) / unmarkCountry / markRegion(regionCode, countryCode, regionName?) / unmarkRegion / createBucketItem(input) / deleteBucketItem(itemId) — the acting user's own rows |
db:write:atlas (+ Atlas addon) |
ctx.vacay (write) |
toggleEntry(date) — the acting user's own PTO day on their active plan; toggleCompanyHoliday(date, note?)
|
db:write:vacay (+ Vacay addon) |
ctx.journal (write) |
createEntry(journeyId, {entry_date, ...}) / updateEntry(entryId, fields) / deleteEntry(entryId) — owner/contributor-gated; createJourney({title, subtitle?, trip_ids?}) / deleteJourney(journeyId) — a new journal owned by the acting user (importers bootstrap the journal they then fill) |
db:write:journal (+ Journey addon) |
ctx.files (write) |
create(tripId, {name, content_base64, mimetype?, ...}) (10MB cap, blocked extensions refused) / createLink(tripId, fileId, opts) / update / softDelete — broadcasts file:*
|
db:write:files (+ the acting user's file_upload/file_edit/file_delete) |
ctx.collab |
listNotes(tripId) / listPolls(tripId) / listMessages(tripId, before?) — a trip's notes, polls (with options + voters) and chat (newest 100, oldest first; before = a message id to page back), membership-checked |
db:read:collab (+ Collab addon) |
ctx.collab (write) |
createNote(tripId, {title, ...}) / createPoll(tripId, {question, options}) / votePoll(tripId, pollId, optionIndex) / createMessage(tripId, text, replyTo?) — broadcasts collab:*
|
db:write:collab (+ collab_edit, Collab addon) |
ctx.trips.addMember / .removeMember
|
addMember(tripId, userId) — grants trip access; removeMember(tripId, userId) — revokes it (never the owner), so a directory-sync integration can reconcile departures too |
db:write:members (+ member_manage) |
ctx.notify |
send({title, body, link?, scope, targetId}) — bell inbox + email/ntfy fan-out; recipient forced to the acting user (scope:'user') or a trip they belong to (scope:'trip') |
notify:send |
ctx.ai |
complete(prompt, system?) → { text }; extract(text, jsonSchema, prompt?) → { results } — the admin/user-configured provider; host holds the key; output is DATA (no auto-writes) |
ai:invoke |
ctx.oauth |
getAccessToken() → a short-lived access token for the acting user of a third-party service the host connected on their behalf (Settings → Plugins → Connect); null if not connected / userless. Host holds the refresh token + client secret |
oauth:client |
ctx.scheduler |
at(whenMs, name, payload?) / in(ms, name, payload?) / every(ms, name, payload?) / cancel(name) — persistent, userless timers that survive restarts and fire your scheduled(input, ctx) handler. set is an upsert by name; caps: ≤100 tasks, 8 KB payload, recurring interval ≥ 60 s, ≤ ~1 year out. Same risk class as jobs (no acting user → trip reads refused) |
jobs:run |
ctx.settings |
get(key) — the acting user's own value for one of your scope:'user' settings fields (decrypted host-side). Returns undefined for an unset value or a userless context (job/onLoad) — fall back to ctx.config there. Users fill these in under Settings → Plugins; secrets are stored encrypted and never echoed back |
none (your own settings) |
ctx.daynotes |
list(tripId, dayId) — a day's notes (membership-checked) |
db:read:daynotes |
ctx.daynotes (write) |
create(tripId, dayId, {text, time?, icon?, sort_order?}) / update(tripId, dayId, noteId, fields) / delete(tripId, dayId, noteId) — broadcasts dayNote:*
|
db:write:daynotes |
ctx.packing (write) |
create(tripId, {name, category?, checked?, is_private?, visibility?, recipient_ids?}) / update(tripId, itemId, fields) / delete(tripId, itemId) — broadcasts packing:*, private items (#858) stay owner-scoped |
db:write:packing |
ctx.packing (bags) |
listBags(tripId) / createBag(tripId, {name, color?}) / updateBag / deleteBag / setBagMembers(tripId, bagId, userIds) — bags carry no privacy |
db:write:packing |
ctx.weather |
get(lat, lng, date?) — the host's cached forecast (tenant-free) |
weather:read |
ctx.rates |
get(base) → a map of quote → rate relative to base (e.g. 'EUR' → { USD: 1.08, … }; cached upstream, tenant-free); null on an upstream failure |
rates:read |
ctx.categories |
list() — the global place-category list |
db:read:categories |
ctx.tags |
list() / create({name, color?}) / update(tagId, fields) / delete(tagId) — the acting user's own tags |
db:read:tags / db:write:tags
|
ctx.trips.members |
members(tripId) — the trip roster (id + display fields), membership-checked |
db:read:trips |
ctx.todos |
list(tripId) / create(tripId, {name, ...}) / update(tripId, todoId, fields) / delete(tripId, todoId) — broadcasts todo:*
|
db:read:todos / db:write:todos (+ packing_edit) |
ctx.costs |
getByTrip(tripId) / listMine() — read budget items (membership-checked) |
db:read:costs |
ctx.costs (write) |
create(tripId, input) / update(tripId, itemId, input) / delete(tripId, itemId) — broadcasts budget:*
|
db:write:costs |
ctx.reservations |
listMine() — every booking across the acting user's accessible trips (membership-checked) |
db:read:trips |
ctx.reservations (write) |
create(tripId, input) / update(tripId, reservationId, input) / delete(tripId, reservationId) — full parity with the app (accommodation, budget-sync, notification, broadcasts reservation:*). input.endpoints = array of { role: 'from'|'to'|'stop', name, lat, lng, code?, sequence?, timezone?, local_time?, local_date? }; update semantics: omitted = keep, [] = delete all, array = replace (endpoint ids are not stable) |
db:write:reservations |
ctx.accommodations |
create(tripId, { place_id, start_day_id, end_day_id, check_in?, check_in_end?, check_out?, confirmation?, notes? }) / update(tripId, accommodationId, fields) / delete(tripId, accommodationId) — lodging blocks; create auto-creates the partner hotel reservation, delete cascades it (broadcasts like the app) |
db:write:accommodations (+ day_edit) |
ctx.users |
getById(id) — public profile only (id, username, display_name, avatar) |
db:read:users |
ctx.ws.broadcastToTrip(tripId, event, data) |
broadcast to a trip's members (event forced to plugin:<id>:<event>) |
ws:broadcast:trip |
ctx.ws.broadcastToUser(userId, event, data) |
broadcast to one user | ws:broadcast:user |
ctx.plugins.call(id, fn, args?) |
call a function another plugin exposes and get its result — id must be a declared, satisfied pluginDependency that lists fn in its capabilities.provides
|
a plugin dependency (no permission) |
ctx.events.emit(name, payload?) |
publish an event to dependents that subscribed — name must be in your capabilities.emits
|
— (no permission) |
ctx.config |
your resolved settings (secrets delivered decrypted) | — |
ctx.log |
info / warn / error → your error log |
— |
ctx.id |
your plugin id (string) | — |
Calling a method your manifest didn't grant returns PERMISSION_DENIED; a method
the host doesn't expose at all returns UNKNOWN_METHOD.
ctx.trips only works inside a route handler. The host binds the acting user
from the authenticated request and membership-checks every trip read against it.
onLoad and jobs have no user, so their trip reads are refused with
RESOURCE_FORBIDDEN. The SDK's getById(tripId, asUserId?) signature keeps an
asUserId parameter for source compatibility, but the host ignores it — you
cannot read another user's trips by passing an id.
Writes (ctx.trips.update / ctx.places / ctx.days / ctx.itinerary /
ctx.costs.create) are route-context only too, and doubly gated: the host checks
the acting user can access the trip AND holds the app's edit permission for that
entity (place_edit / day_edit / trip_edit), exactly like the web UI. They run
through the same services and broadcast the same events, so open sessions update
live. Input is validated against TREK's own schemas (a bad payload is BAD_PARAMS),
and every write is recorded in the tamper-evident capability audit log against the
acting user. A plugin can only change what its user could change by hand.
ctx.costs ("costs" = budget items) behaves exactly like ctx.trips: reads are
membership-checked against the request's user and only work inside a route handler
(onLoad/jobs have no user → RESOURCE_FORBIDDEN). getByTrip(tripId) returns one
trip's budget items (hydrated with members/payers); listMine() aggregates budget items
across every trip the acting user can access. create/update/delete(tripId, …) mutate a
trip's budget items — gated exactly like a normal budget write (the same model the planner
write scopes db:write:places/days/itinerary/trips use): the acting user needs the
budget_edit permission on that trip, the input is
validated against TREK's budget schema, and a successful create broadcasts the same
budget:created event the app emits. Every ctx.costs.* call also requires the Costs
(budget) addon to be enabled — if the admin has turned it off, the call is refused with
RESOURCE_FORBIDDEN.
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.
The proxy forwards { method, path, query, body, headers, user }. req.headers
is populated ONLY on auth: false routes (an authenticated route gets {}) and
only an explicit, credential-free allowlist — the common provider signature +
event headers (stripe-signature, x-hub-signature-256, svix-signature,
x-gitlab-event, content-type, user-agent, …). Cookie, Authorization,
X-Socket-Id and every session/forwarded-auth header are stripped and never reach
your code, so a forwarded header can't leak a TREK session. To trust a webhook,
verify the provider's signature over the raw body against a secret you hold in
ctx.config (admin-set instance setting) or ctx.settings (per-user).
The iframe is served same-origin from /plugin-frame/<id>/… but sandboxed
without allow-same-origin, so it runs at an opaque origin: it can't read
cookies or the parent DOM. It talks to TREK only via postMessage (target origin
must be '*' — an opaque frame has no nameable origin).
Your whole client/ directory is served (and shipped by pack) — not just
index.html. The frame CSP allows the plugin its own static files (and nothing
from any other host), so a multi-file build works as-is: point a bundler at
client/ and use relative asset paths (Vite: base: './'), drop the output in,
done. That includes React/Vue/Svelte builds — no more inlining the bundle into
index.html (inline still works, and is what the design-kit marker does).
Because the frame can't load TREK's stylesheet, we ship it. Drop one line in your
client/index.html <head>:
<!-- trek:ui -->dev and pack expand that marker into the inlined TREK design kit — a
token-driven stylesheet plus a window.trek bridge. It costs nothing to keep the
source a one-liner, and a rebuild always ships the current kit. The kit:
- gives you native components — glass panels, cards, buttons, inputs, chips, list rows, hover — that swap correctly between light and dark;
- follows the user's live accent scheme, custom accent and high-contrast (it applies the tokens TREK sends);
- mirrors the host's appearance flags (reduced-motion, no-transparency, density);
-
auto-reports your height (widgets/pages self-size — no manual
trek:resize); - installs
window.trekso you never hand-rollpostMessage.
window.trek also carries trek.ui — tiny DOM builders that emit kit-styled
elements, so you can build UI with no bundler and no CSS:
const { ui } = trek
ui.mount(ui.card([
ui.el('div', { class: 'trek-title', text: 'Nearby' }),
ui.button('Refresh', { variant: 'primary', onClick: refresh }),
ui.chip('open now', 'success'),
]))
// ui.el(tag, props, children) is the general builder; props take class/text/html/on:{event}.The scaffold seeds a working example. A minimal client:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- trek:ui -->
</head>
<body>
<div class="trek-glass trek-stack" style="margin:16px">
<div class="trek-title">Your plugin</div>
<p class="trek-muted" id="hello">…</p>
<button class="trek-btn trek-btn--primary" id="go">Say hello</button>
</div>
<script>
trek.onContext((ctx) => { document.getElementById('hello').textContent = 'theme: ' + ctx.theme })
document.getElementById('go').addEventListener('click', async () => {
try { const data = await trek.invoke('/hello'); document.getElementById('hello').textContent = 'Hello ' + data.hello }
catch (e) { trek.notify('error', e.message) }
})
</script>
</body>
</html>Component classes (the bootstrap adds trek-ui to <body>):
| Class | What |
|---|---|
.trek-glass |
the signature frosted-glass surface |
.trek-card |
a solid card |
.trek-interactive |
add to a glass/card for the native hover-lift |
.trek-btn + --primary / --secondary / --ghost / --danger
|
buttons |
.trek-input / .trek-textarea / .trek-select / .trek-label
|
form controls |
.trek-chip + --accent / --success / --danger / --warning / --info
|
chips / badges |
.trek-row |
a hover-highlight list row |
.trek-title / .trek-muted / .trek-faint
|
text helpers |
.trek-stack / .trek-cluster
|
vertical / horizontal flex with gap |
.trek-menu-enter / .trek-menu-enter-left
|
dropdown enter (scales from its trigger corner) |
.trek-popover-enter / .trek-modal-enter / .trek-backdrop-enter / .trek-toast-enter
|
the host's enter animations (trek-modal-enter becomes a bottom drawer below 640px) |
.trek-page-enter |
subtle fade-up on mount |
.trek-stagger |
children fade up with a 40ms cascade |
.trek-skeleton |
shimmer loading placeholder |
.trek-pie-reveal / .trek-bar-fill
|
chart reveal animations (plus the trek-progress-fill keyframe, driven by --trek-progress-to) |
All animations honour reduced motion: under [data-reduce-motion] or
prefers-reduced-motion they degrade to a gentle 120ms fade, and the skeleton
stops shimmering.
Selects are auto-upgraded. With the kit inlined, every native <select> becomes
a host-styled, keyboard-accessible dropdown that matches TREK — the OS-drawn popup
can't be themed, so the kit replaces it while keeping the real <select> as the
value/form source (it still fires change). Write a plain <select> and it just
works. Add data-trek-native to a field to keep the browser default; multiple and
size selects are always left native.
The window.trek bridge:
| Call | Does |
|---|---|
trek.onContext(cb) |
run cb(context) now (if already received) and on every update; returns an unsubscribe fn |
trek.context |
the last context (or null) |
trek.invoke(sub, { method, body }) |
call your own route; returns a Promise (rejects with an Error, .code = HTTP status) |
trek.notify(level, message, duration?) |
toast (info/success/warning/error); optional duration in ms (clamped 1.5–15s) |
trek.confirm({ title?, message, confirmLabel?, cancelLabel?, danger? }) |
host-rendered native confirm dialog; resolves true/false (one at a time — a second concurrent request resolves false) |
trek.navigate(to) |
in-app navigation (relative paths only) |
trek.openExternal(url) |
open an http(s) URL in a new tab (the sandbox itself can't) |
trek.onEvent(cb) |
cb(event, tripId) for core events on the trip in view — names only, no payloads; refetch via invoke(); returns an unsubscribe fn |
trek.resize(px) |
override the auto height (ignored on full pages — see trek:resize below) |
trek.ready() / trek.requestContext()
|
re-handshake / re-request the context |
Preview it: npx trek-plugin-sdk dev, then open /preview — it renders your UI
in a real sandboxed frame with a theme/accent/appearance toggle and proxies
trek.invoke() to your routes.
If you'd rather not use the kit, talk to the frame yourself. Announce readiness and handle messages:
window.parent.postMessage({ type: 'trek:ready' }, '*') // TREK replies with trek:context
window.addEventListener('message', (e) => {
if (e.source !== window.parent) return // opaque frame: trust the parent window
const m = e.data
if (m.type === 'trek:context') { /* m.theme, m.tokens, m.appearance, … (below) */ }
if (m.type === 'trek:response') { /* m.requestId, m.data */ }
if (m.type === 'trek:error') { /* m.requestId, m.code, m.message */ }
})
window.parent.postMessage({ type: 'trek:invoke', requestId: '1', sub: '/status', method: 'GET' }, '*')Messages you send to TREK:
| Message | Payload | Effect |
|---|---|---|
trek:ready |
— | TREK replies with trek:context
|
trek:context:request |
— | re-request the context |
trek:navigate |
{ to } |
in-app navigation (relative paths only) |
trek:notify |
{ level, message, duration? } |
toast; level = info/success/warning/error; duration in ms, clamped 1500–15000 |
trek:resize |
{ height } |
set the iframe height (capped at 2000px); ignored for full pages (page / trip-page), which always fill their host container |
trek:invoke |
{ requestId, sub, method, body } |
call your own route; resolves as trek:response or trek:error
|
trek:confirm |
{ requestId, title?, message?, confirmLabel?, cancelLabel?, danger? } |
host-rendered ConfirmDialog; answered as trek:confirm:result
|
trek:openExternal |
{ url } |
open an http(s) URL in a new noopener tab; anything else is dropped |
Messages TREK sends you:
| Message | Payload |
|---|---|
trek:context |
{ tripId, placeId, dayId, reservationId, userId, theme, locale, dir, hostOrigin, user, formats, tokens, appearance } (see below) — re-sent whenever the theme, appearance, locale or formats change |
trek:response |
{ requestId, data } — a successful trek:invoke
|
trek:error |
{ requestId, code, message } — a failed trek:invoke (code is the HTTP status or "error") |
trek:confirm:result |
{ requestId, confirmed } — the user's answer to your trek:confirm
|
trek:event |
{ event, tripId } — a core event fired on the trip in view; names only, never payloads — refetch what you need via trek:invoke
|
The frame's CSP is locked down per plugin: default-src 'none', own inline
scripts/styles + the plugin's own /plugin-frame/<id>/ files only (no other
host may serve it script/style/img), connect-src limited to the hosts you were
granted via http:outbound:<host> permissions (not merely the egress[] you
declared), no popups.
| Field | Type |
|---|---|
tripId |
string | null — the trip in view (a trip-page tab, or a widget on a trip), else null. IDs arrive as strings — compare with String(id)
|
placeId |
string | null — the place in view (a place-detail slot), else null
|
dayId |
string | null — the day in view (a day-detail slot), else null
|
reservationId |
string | null — the reservation in view (a reservation-detail slot), else null
|
dir |
'ltr' | 'rtl' — the host's text direction; the kit mirrors it onto your <html> so RTL hosts get RTL plugin UIs |
userId |
string | null |
theme |
'light' | 'dark' |
locale |
e.g. 'en'
|
hostOrigin |
the app origin |
user |
{ name, avatar, isAdmin } | null — never an email; role only as a boolean |
formats |
{ locale, currency, timeFormat, distanceUnit, temperatureUnit, timezone } |
tokens |
TREK's resolved CSS design tokens for the current theme (see below) |
appearance |
{ scheme, density: 'comfortable'|'compact', reducedMotion, noTransparency } |
tokens is the whole global palette resolved for the current theme — surfaces
(--bg-card, --bg-hover, …), text (--text-primary/-secondary/-muted/-faint),
borders, the accent family (--accent, --accent-text, --accent-hover,
--accent-subtle), semantic + soft fills (--success/--danger/--warning/--info
-soft), shadows (--shadow-*), radii (--radius-*) and fonts (--font-system).
Apply them as CSS variables and your UI matches the host exactly — in both themes and
under a custom accent or high-contrast — instead of hard-coding a palette that drifts:
function applyContext(m) {
document.documentElement.dataset.theme = m.theme // for your dark rules
for (const k in m.tokens) document.documentElement.style.setProperty(k, m.tokens[k])
const a = m.appearance || {}
document.documentElement.toggleAttribute('data-reduce-motion', !!a.reducedMotion)
document.documentElement.toggleAttribute('data-no-transparency', !!a.noTransparency)
}
// in your trek:context handler: applyContext(m)tokens/appearance are non-secret display values only, re-sent on every theme or
appearance change so plugins feel native rather than bolted-on. (The glassy tokens the
dashboard uses — --glass-*, --r-*, --sh-* — aren't in tokens; the design kit
bakes those, since they only change with light/dark, not the accent.) Honour
appearance.reducedMotion / noTransparency, and the frame also inherits the OS
prefers-reduced-motion. Dashboard widgets are wrapped in the native glassy tool card
and auto-size to the height you report via trek:resize, so render flush and
transparent — the design kit reports your height for you.
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
decrypted through ctx.config (server-side only) — never to the iframe. Resolved
values arrive in ctx.config.
When declared fields aren't enough — a picker with previews, anything visual —
set "capabilities": { "settingsUi": true } and ship a client/settings.html.
TREK frames it as a card on the user's Settings → Plugins page, in the same
opaque-origin sandbox and postMessage bridge as your widget: it can only reach
your own declared routes (trek:invoke), receives trek:context, and should
report its height via trek:resize. Persist whatever it edits through one of
your own routes (e.g. into your db:own database keyed by req.user.id) — the
settings page has no special storage powers. Hosts older than this capability
simply ignore the flag, so declaring it never breaks an install.
A plugin can act as an OAuth client of a third-party service without ever handling the secrets. The host runs the entire flow — authorize → callback → token exchange → refresh — with PKCE (S256) and a state check, and holds the tokens. The plugin only triggers "connect" and reads a short-lived access token at runtime.
Setup. Declare the provider as scope: "instance" settings the admin fills in: oauth_authorize_url, oauth_token_url, oauth_scopes (optional), plus the two secret: true fields oauth_client_id and oauth_client_secret. (A settings field may also carry an oauth: { initPath, callbackPath } block.)
Connecting. A user connects under Settings → Plugins → Connect, which sends them to the provider's authorize page. The callback returns to …/api/plugin-oauth/<id>/callback; the host verifies the state (single-use, 10-minute TTL, bound to that user — CSRF defence), exchanges the code and stores the tokens per user, encrypted at rest.
Using it. Read the access token in a route handler:
const token = await ctx.oauth.getAccessToken() // needs the `oauth:client` permission
if (!token) return { status: 401, body: 'connect this plugin first' }
// call the third-party API with `Authorization: Bearer ${token}`- Returns
nullwhen the acting user hasn't connected, or in a userless context (a job /onLoad). - The host auto-refreshes a token that is expiring (60-second skew) using the refresh token it holds.
- The plugin never sees the refresh token or the client secret.
- The authorize/token URLs must be
httpsto a non-private host, and the host-side token exchange goes through the SSRF guard (blocks the link-local / cloud-metadata range and pins the resolved IP against DNS-rebind; a self-hosted internal IdP on loopback/LAN still works).
Grant hook:user-data and implement either handler to honour data-subject rights. Both are userless — the plugin only receives the userId and acts on its own ctx.db:
module.exports = definePlugin({
permissions: ['db:own', 'hook:user-data'],
async deleteUserData({ userId }, ctx) { // erasure
await ctx.db.exec('DELETE FROM my_prefs WHERE user_id = ?', userId)
},
async exportUserData({ userId }, ctx) { // portability / access
return await ctx.db.query('SELECT * FROM my_prefs WHERE user_id = ?', userId)
},
})-
Erasure is durable. When a user is deleted,
deleteUserDatais queued and retried until the plugin ACKs, across restarts and even after the plugin is later reactivated — so make it idempotent. -
Export is complete-or-flagged. A data-access request aggregates
exportUserDataacross every granted plugin. A plugin that is currently inactive but holdshook:user-data, or one whose export errored/timed out, is flaggedpending(never silently omitted), so the admin knows to reactivate it to finish the request. - Uninstalling a plugin also purges its stored OAuth tokens and state.
A hook is core calling into your plugin for data (host→plugin). Declare it on
the plugin definition and grant the matching hook:* permission:
module.exports = definePlugin({
hooks: {
placeDetailProvider: {
// Return extra rows TREK renders natively on a place. Runs with the current
// user bound, on a short timeout — a slow/failing call is skipped, never fatal.
async getDetails(placeId, ctx) {
return [{ label: 'Crowd', value: 'Quiet now' }, { label: 'Guide', url: 'https://…' }]
},
},
},
})| Hook | Permission | Status |
|---|---|---|
placeDetailProvider.getDetails(placeId, ctx) → { label, value?, url? }[]
|
hook:place-detail-provider |
live — shown in the place-detail panel; also GET /api/place-details/:placeId
|
warningProvider.getWarnings(tripId, ctx) → { level, message, dayId?, placeId? }[]
|
hook:trip-warning-provider |
live — validation warnings shown as a non-blocking banner in the trip planner; also GET /api/trip-warnings/:tripId
|
tableContributor.getContributions(view, tripId, ctx) → TableContribution[]
|
hook:table-contributor |
live — host-rendered columns/actions keyed by entityId in the reservations, transports, places, day, costs, packing, files and todos views. A column is {kind:'column', entityId, id, label, value?, url?, icon?, tone?} (url is http/https/mailto only); an action is {kind:'action', entityId, id, label, icon?, target} where target opens your sandboxed frame ({kind:'frame', sub}) or calls a route ({kind:'route', method, sub}). All fields are bounded + normalized host-side; also GET /api/view-contributions/:view/:tripId
|
mapMarkerProvider.getMarkers(tripId, ctx) → MapMarkerContribution[]
|
hook:map-marker-provider |
live — bounded markers overlaid on the trip map (#587). Each is {id, lat, lng, label?, popupText?, url?, icon?, tone?}; coordinates are range-checked (−90..90 / −180..180), text length-capped, url http/https/mailto-only, count capped (≤200/plugin). Declarative only — plugin JS never runs on the map canvas. Also GET /api/map-markers/:tripId
|
pdfSectionProvider.getSections(tripId, ctx) → PdfSection[]
|
hook:pdf-section-provider |
live — text-only sections appended to the trip PDF export. Each is {title, paragraphs?, table?}; the host escapes and lays everything out itself (no markup ever reaches the document), caps counts (≤5 sections/plugin, ≤20 paragraphs, ≤8 headers, ≤50 rows) + lengths (title 120, paragraph 2000, header 60, cell 200) and clips rows to the header width. Also GET /api/pdf-sections/:tripId
|
atlasLayerProvider.getLayers(ctx) → AtlasLayer[]
|
hook:atlas-layer-provider |
live — country tint layers drawn over the Atlas world map (wishlists, advisories, …). User-scoped: the host binds the acting user, the hook takes no target parameter. Each layer is {id, name?, countries: [{code, tone?, label?}]}; codes must be ISO-3166 alpha-2 (uppercase-coerced), tone is enum-whitelisted, counts capped (≤3 layers/plugin, ≤300 countries/layer). Declarative only — plugin JS never runs on the map canvas. Also GET /api/atlas-layers
|
journalEntryProvider.getRows(entryId, ctx) → { label, value?, url? }[]
|
hook:journal-entry-provider |
live — extra rows rendered under a journal entry card (needs the Journey addon; the entry's journey is access-checked like the journal detail routes). Same hardening as place details plus server-side normalization: ≤12 rows/plugin, label ≤60, value ≤200, url http/https/mailto-only. Also GET /api/journal-entry-rows/:entryId
|
tripCardProvider.getCards(tripIds, ctx) → TripCardContribution[]
|
hook:trip-card-provider |
live — small badges on the dashboard trip cards. Called ONCE with all visible tripIds (each already access-checked for the acting user), returns { tripId, id, label, value?, icon?, tone?, url? }[]; the host bounds every field (label 64, value 256, tone enum, url http/https/mailto-only), caps the count (≤40/plugin) and drops any badge whose tripId wasn't requested. Declarative only. Also GET /api/trip-card-contributions?tripIds=…
|
photoProvider.search(query, {page, limit}, ctx) / .getById(id, ctx)
|
hook:photo-provider |
live — plugin photo sources aggregated at GET /api/plugin-photos/search (+ /sources, /item) for the picker. Each {id, title?, thumbnailUrl, fullUrl, takenAt?}; thumbnail/full URLs must be http/https, per-source count capped, a failing source skipped |
calendarSource.getName(ctx) / .getEvents(userId, start, end, ctx)
|
hook:calendar-source |
live — plugin calendar events aggregated for the signed-in user at GET /api/plugin-calendar?start=&end=. Each {id, title, start, end, allDay} (ISO dates); count capped, a failing source skipped |
notificationChannel.send(msg, config, ctx) / .test(config, ctx)
|
hook:notification-channel |
live — registers a new notification channel. Userless (see below). See Notification channels |
Each hook method receives its args plus the per-invocation ctx, so any ctx.trips.*
read it makes is membership-checked against the current user (like a route handler) —
with one exception, the notification channel, which has no acting user at all.
hook:notification-channel lets your plugin become a delivery channel alongside TREK's
built-in email / webhook / ntfy — Gotify, Pushover, Telegram, whatever takes a message.
Scaffold one with:
npx create-trek-plugin my-gotify --type integration --template notification-channelmodule.exports = definePlugin({
hooks: {
notificationChannel: {
// msg is ALREADY RENDERED in the recipient's language, with the deep link built.
// config is that recipient's own scope:'user' settings, decrypted by the host.
async send(msg, config, ctx) {
const res = await fetch(`${config.serverUrl}/message`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Gotify-Key': config.appToken },
body: JSON.stringify({ title: msg.title, message: msg.body + '\n\n' + (msg.url ?? '') }),
})
// THROW on failure — the host logs and isolates it, so a dead channel
// can never stop email/in-app/the other channels from being delivered.
if (!res.ok) throw new Error(`Gotify responded ${res.status}`)
},
// Optional — backs the "Send test" button in the user's notification settings.
async test(config, ctx) { /* … */ },
},
},
}){
"type": "integration",
"permissions": ["hook:notification-channel", "http:outbound:gotify.example.com"],
"egress": ["gotify.example.com"],
"capabilities": { "notificationChannel": { "title": "Gotify" } },
"settings": [
{ "key": "serverUrl", "label": "Server URL", "scope": "user", "required": true },
{ "key": "appToken", "label": "App token", "scope": "user", "required": true, "secret": true }
]
}This hook is host-initiated, so it runs with NO acting user. A notification is dispatched to an arbitrary recipient — nobody is "calling" your plugin — so:
-
ctx.settings.get()returnsundefinedhere, andctx.trips.*reads are refused. - The recipient's credentials arrive as the
configargument instead: the host reads your plugin'sscope:'user'settings for that recipient, decrypts them, and hands them over.
That is deliberate. It is what lets a channel plugin be given someone's push token without also being given the right to read their trips as them.
Notes:
-
titlenames the column in the notification preferences matrix (defaults to your plugin's name).eventsmay narrow which events the channel carries; the default is every non-admin event. Admin-scoped events (version_available) are never deliverable to a plugin channel — those go over the admin's own credentials. -
Configured-ness is inferred, not asked. A user's column is "not configured" until every
required,scope:'user'field has a value. A plugin with no required user fields counts as configured for everyone (an instance-wide channel, e.g. a shared workspace webhook). -
Per-host egress is mandatory. Bare
http:outbounddoes not open any host at runtime — you needhttp:outbound:<host>per host (see below). -
For a self-hosted target, set
operatorEgress— see the next section. Your manifest can't name the operator's Gotify; the admin does it after install. -
Reaching a service on your own LAN (a Gotify next to TREK) additionally needs
TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=onon the TREK process — plugins may not reach private addresses by default. It relaxes the policy for every installed plugin, so enable it only if you trust them all.
A plugin can put buttons on its own settings page — "Test connection", "Sync now", "Clear cache". Declare them in the manifest and implement them on the definition:
"actions": [
{ "key": "testConnection", "label": "Test connection", "hint": "Pings the API." },
{ "key": "purge", "label": "Delete my data", "danger": true }
]module.exports = definePlugin({
actions: {
async testConnection(ctx) {
const token = await ctx.settings.get('appToken') // the CLICKING user's own
const res = await fetch('https://api.example.com/ping', { headers: { authorization: token } })
return { ok: res.ok, message: res.ok ? 'Connected' : `Failed: ${res.status}` }
},
},
})An action is user-initiated, which is what makes it different from the
notificationChannel hook: the acting user is whoever clicked the button. So
ctx.settings.get() returns their value and any trip read is membership-checked
against them — exactly what a "test my credentials" button needs.
Notes:
- Return
{ ok, message? }. Throwing is the same as{ ok: false }with the error text. The message is bounded (200 chars) and emoji-stripped host-side before it's shown. -
danger: truerenders it destructively and asks for confirmation first. - The key must be a valid settings key, and the host refuses any key the manifest didn't declare — the key arrives from the URL, so it is never forwarded to your plugin blindly.
- Max 8 actions; label capped at 60 chars, hint at 200.
A plugin's egress list is fixed in its manifest at publish time. That works for a cloud
API (api.pushover.net), but not for a self-hosted service — you cannot know that a user's
Gotify lives at gotify.alice.net. Without a way out, a community plugin for a self-hosted
target could serve nobody.
Declare operatorEgress and the admin supplies the real hosts after install:
{
"permissions": ["hook:notification-channel", "http:outbound:gotify.net"],
"egress": ["gotify.net"], // hosts you DO know (the cloud offering)
"operatorEgress": true // …plus whatever the admin adds
}If there is no host you can name — a plugin for a service that is only ever self-hosted —
operatorEgress is what lets you declare outbound with an empty egress[] (omit the key).
It is the one case where that is legal; without the flag, http:outbound with no egress is a
manifest error:
{
"permissions": ["hook:notification-channel", "http:outbound"],
"operatorEgress": true // every host comes from the admin
}Such a plugin still installs and activates normally — it may have useful non-network
features — but until an admin adds a host it reaches nothing: the child's allow-list is the
union of your http:outbound:<host> grants and the admin's hosts, and both are empty, so every
outbound call fails with egress: <host> is not in the plugin's declared hosts. An empty
egress[] is never an allow-all.
The admin then opens Admin → Plugins → ⋯ → Allowed hosts and adds gotify.alice.net. The
runtime unions that into the child's allow-list and re-spawns the plugin — the egress guard
is installed once at child start and a second init is deliberately refused, so a live child's
allow-list can never be widened in place.
What this deliberately does not do:
-
An end user can never widen egress. Only an admin can, and only for a plugin that
declared
operatorEgress— so the consent given at install still bounds what is possible. A plugin that never asked for it can never be given a host. -
Hosts get the same validation as manifest egress: no bare
*, no whole-TLD*.com, no scheme, no spaces. A wildcard needs a real multi-label suffix (*.mydomain.com). - Uninstalling drops the hosts, so a later plugin reusing the id can't inherit consent granted to a different one.
Users then point their own settings at one of the approved hosts. Anything else is refused by
the guard with egress: <host> is not in the plugin's declared hosts.
React to core activity with events + the events:subscribe permission. Handlers
fire without a user (like a job) and receive
{ event, tripId, entity?, entityId?, snapshot? }: entity is the event family
('reservation', 'place', …), entityId says which entity changed, and
snapshot is a whitelisted field view of the changed entity — delivered only
when your plugin also holds the family's read grant (db:read:trips for
place/day/reservation/accommodation/assignment/trip, db:read:costs for budget,
db:read:packing for packing, db:read:daynotes for dayNote, db:read:files for
file). Without that grant you get exactly the id hint, as before:
module.exports = definePlugin({
permissions: ['events:subscribe', 'db:read:trips'],
events: [
{ on: 'reservation:created', async handler({ event, tripId, entityId, snapshot }, ctx) {
// with db:read:trips the snapshot carries the reservation's fields
// (title, times, type, endpoints, ...) — no refetch needed
await ctx.db.exec('INSERT INTO seen (trip, res, title) VALUES (?, ?, ?)', tripId, entityId, snapshot?.title ?? '')
} },
{ on: '*', handler(e) { /* firehose: every core event */ } },
],
})entityId is absent for bulk/reorder events and for events with no single entity;
snapshot is additionally absent for deletes (nothing left to show) and for a
private packing item (its broadcast is owner-scoped — #858). Never assume either
is set. The snapshot whitelist never carries user ids (owner/paid-by/participants),
trips.feed_token or other secrets — the id is only ever the changed entity's own
id (never a parent or a user id).
Delivery is fire-and-forget on a short timeout, so a slow subscriber never blocks a
core write. Because there's no user, trip reads (ctx.trips.*) are refused inside a
handler — beyond the snapshot, use the plugin's own ctx.db, ctx.ws.*, or an
outbound call. A plugin's own plugin:* broadcasts are never delivered back, so
handlers can't loop. Common events: place:*, day:*, assignment:*, budget:*,
file:*, accommodation:*.
A plugin can declare that it needs certain addons enabled, or other plugins installed, before it will run. Both are top-level manifest arrays, and both are enforced at activation — installing always succeeds, so a missing dependency is a fixable state, never a broken download.
"requiredAddons": ["budget", "journey"]Addon ids (see Addons Overview) that must be enabled for the plugin to activate. If one is off, enabling the plugin is refused and the admin panel names the addon to turn on. Turning a required addon off while the plugin is running auto-disables the plugin (and anything that depends on it) — a plugin never runs against a disabled addon. Ids are validated for shape only, so a plugin may name an addon a given TREK build doesn't have; it just stays un-activatable there.
"pluginDependencies": [
{ "id": "koffi", "version": ">=1.2.0 <2.0.0" }
]Other plugins that must be installed and version-satisfied (a standard semver range) before this one activates. That range is the real contract for anything you call on the dependency (see Talking to other plugins).
Enforcement, all at activation time:
- Missing dependency → activation is blocked and the panel offers a one-click download that fetches the newest registry version satisfying your range (pulling its own dependencies too), then retries.
- Installed but out of range → same block; the panel offers to update it.
- Installed but disabled → enabling your plugin auto-enables the dependency first, transitively (deepest dependency first).
- Disabling a dependency cascades: every plugin that (transitively) depends on it is disabled too.
- A dependency cycle (A → B → A) is refused with a clear error.
Dependencies are also resolved deps-first at boot, so a plugin's dependencies are already up before it starts.
Isolation is the default — plugins can't see each other. To let a plugin be used by
the plugins that depend on it, it opts in by declaring a surface in its manifest
capabilities, and the host routes calls/events between the two child processes.
There is no permission for this: authorization is the dependency edge itself —
plugin A may call or subscribe to plugin B only if A declares B as a satisfied
pluginDependency, and only for the names B publicly declares.
The dependency (B) exposes named functions and lists them in capabilities.provides:
// plugin "koffi"
module.exports = definePlugin({
exports: {
// `args` is whatever the caller passed; `ctx` is a per-call context.
async convert({ amount, from, to }, ctx) {
return { amount: amount * rate(from, to), to }
},
},
})
// manifest: "capabilities": { "provides": ["convert"] }The dependent (A) declares koffi as a dependency and calls it:
// manifest: "pluginDependencies": [{ "id": "koffi", "version": ">=1.0.0 <2.0.0" }]
const out = await ctx.plugins.call('koffi', 'convert', { amount: 10, from: 'USD', to: 'EUR' })- A call is refused (
RESOURCE_FORBIDDEN) if the target isn't a satisfied dependency, isn't currently active, or the function isn't in the target'sprovides. -
The acting user is propagated: B's export runs as A's current user, so any
ctx.trips.*read B makes is membership-checked against that user — B can't be tricked into reading data the calling user couldn't see. - The call is bounded by a timeout and recorded in the capability audit log
(
plugin:<target>#<fn>), attributed to A and the acting user. - B owns its contract: only functions in
providesare reachable — routes, jobs and helpers stay private. Because yourpluginDependenciesrange pins B's version, B can refactor internals freely and only breaks you on a major bump.
The emitter (B) declares event names in capabilities.emits and publishes them:
// manifest: "capabilities": { "emits": ["rate.updated"] }
ctx.events.emit('rate.updated', { pair: 'USD/EUR', rate: 0.92 }) // fire-and-forgetA dependent (A) subscribes by naming the source plugin + event:
module.exports = definePlugin({
subscriptions: [
{ plugin: 'koffi', event: 'rate.updated', async handler(payload, ctx) {
await ctx.db.exec('UPDATE cache SET rate = ?', payload.rate)
} },
],
})- An event reaches A only if A declares
koffias a satisfied dependency and subscribed to that(plugin, event). Emitting an event not in youremitsis refused. - Like core event subscriptions, handlers run without a user — but unlike them they do receive the emitter's payload. Delivery is fire-and-forget on a short timeout; a slow subscriber never blocks the emitter.
createMockHost gives you a ctx that enforces the same permission model, so
a test can prove your plugin degrades gracefully when a grant is missing:
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 ctx.trips.getById(1, 42) // ok — member
await expect(ctx.trips.getById(1, 99)).rejects… // RESOURCE_FORBIDDEN
await expect(ctx.db.query('SELECT 1')).rejects… // PERMISSION_DENIED (no db:own)The mock db is a recorder — set queryResults for canned rows, or use an
integration test for real SQL. To test inter-plugin calls, pass
pluginExports: { koffi: { convert: (args) => … } } and assert on mock.emitted
for anything your plugin publishes via ctx.events.emit.
createMockHost also gives you run(def) — the other half of a test. Where the
ctx recorders (calls, broadcasts, emitted, notifications, scheduled)
capture what your plugin read, run fires each entry point so you can assert
what it did, all against the same mock ctx:
const host = createMockHost({ grants: ['jobs:run', 'hook:trip-card-provider'], actingUserId: 7 })
const app = host.run(myPlugin)
await app.route({ method: 'GET', path: '/ping' }) // → the route's response
await app.job('refresh') // fire a background job (userless)
await app.scheduled('daily', { tripId: 1 }) // fire the `scheduled` handler
await app.event('place:created', { tripId: 1, entityId: 9 })// deliver a core event to `events`
await app.deleteUserData(42) // GDPR erasure handler
const dump = await app.exportUserData(42) // GDPR export handler
const cards = await app.hook('tripCardProvider', 'getCards', [1, 2]) // any provider hook
host.scheduled.get('daily') // timers the plugin armed via ctx.schedulerA handler your plugin didn't declare throws a clear error (not a silent no-op),
so a test catches a missing scheduled/deleteUserData/job before release.
These two have their own driver methods, because the host invokes them in ways a
plain hook() call can't express:
const host = createMockHost({ actingUserId: 7, userSettings: { token: 'abc' } })
const app = host.run(myPlugin)
// A settings-page button. USER-INITIATED, so ctx.settings.get() returns the clicker's
// own value. You get back the result the user would actually see: a handler that
// returns nothing is { ok: true }, and one that THROWS is { ok: false, message } —
// the documented contract, so this never rejects.
await app.action('test') // → { ok: true, message: 'Connected' }
// A notification channel. USERLESS — the host fires it for an arbitrary recipient, so
// ctx.settings.get() returns undefined and trip reads are refused; the recipient's
// decrypted settings arrive as the `config` ARGUMENT instead (defaulting to the
// `userSettings` fixture). That asymmetry is the security property of a channel
// plugin — it is handed someone's push token without the right to read their trips.
await app.channel.send({ event: 'trip_invite', title: 'Hi', body: 'Japan' })
await app.channel.test({ token: 'abc' })An event outside CHANNEL_EVENTS — or outside your
manifest's capabilities.notificationChannel.events, which may only narrow that
set — is refused rather than delivered, so a test can't pass on a notification the
host would never route to you. Pass declaredActions / channelEvents to model
what your manifest declares.
-
No native modules (
.node,binding.gyp,prebuilds/) — rejected at pack and install time. -
Don't vendor
trek-plugin-sdk— it's injected at runtime (devDependency only). Vendor any other runtime deps: TREK never runsnpm installon a plugin. -
Ship built JS in
server/index.jsand pre-built static files inclient/..tsand.mapfiles are stripped bypack. - Declare every outbound host in
egress[]whenever you usehttp:outbound— and grant each of them a matchinghttp:outbound:<host>permission. The runtime builds the child process's network allow-list and the iframe's CSP from those permissions only; it never readsegress[], which exists for the admin's consent screen. A host listed inegress[]with no matching permission installs, activates, consents — and is then silently unreachable.validateerrors on this.
| Field | Type | Notes |
|---|---|---|
id |
string, required | lowercase slug, ^[a-z][a-z0-9-]{2,39}$ (3–40 chars). Must match the directory name. |
name |
string, required | display name; also the page nav label. |
version |
string, required | semver (1.2.3, optional pre-release). |
apiVersion |
number | plugin API version (currently 1; PLUGIN_API_VERSION). Defaults to 1. |
type |
string, required | integration |
trek |
string, required | the TREK versions this plugin supports, as a semver range: ">=3.4.0 <4.0.0" (what the scaffold writes). Must be satisfiable (">=4.0.0 <3.0.0" parses but nothing can satisfy it — rejected). Enforced at install and at activation (see below); it is copied verbatim onto the registry entry, which is the entry's only compatibility field. Keep it bounded — an unbounded range claims support for TREK versions that do not exist yet. |
author |
string | shown in the store. |
description |
string | one-line summary for the store. |
icon |
string | lucide-react icon name (default Blocks); used for the page nav entry. |
homepage |
string | project URL. |
license |
string | shown in the store detail (read from the manifest, not enforced). |
nativeModules |
boolean | must be false/absent — true is rejected. |
permissions |
string[] | see below. |
egress |
string[] | allowed outbound hosts; required (non-empty, no bare *) when any http:outbound permission is present — unless operatorEgress is true, in which case it may be empty/omitted and the admin supplies the hosts. |
capabilities.widget |
object |
{ title, slot, defaultSize } — slot is sidebar (default), hero, place-detail, day-detail, or reservation-detail. |
capabilities.tripPage |
object |
{ replaces?, position? } for trip-page plugins — replaces names core planner tabs to hide while active (transports, buchungen, listen, finanzplan, dateien, collab; never plan), position is the tab's 0-based index in the bar (0–50; omitted = appended). |
actions |
array | Buttons on the plugin's own settings page — { key, label, hint?, danger? } (max 8). Implement each as actions[key](ctx) on the definition. User-initiated, so ctx.settings.get() returns the clicking user's value. See Settings-page actions. |
operatorEgress |
boolean | The plugin talks to a self-hosted service whose hostname only the operator knows. The admin adds the real hosts after install (Admin → Plugins → Allowed hosts) and the runtime unions them into the egress allow-list. Requires an http:outbound permission, and is the only way to declare one with an empty egress[]. See Operator-supplied egress hosts. |
capabilities.notificationChannel |
object |
{ title?, events? } for a plugin implementing the notificationChannel hook — title names the column in the notification preferences matrix (default: the plugin's name), events narrows which events the channel carries (default: every non-admin event; admin-scoped events are never deliverable). Requires the hook:notification-channel permission. See Notification channels. |
capabilities.provides |
string[] | function names this plugin exposes to its dependents via ctx.plugins.call (see Talking to other plugins). |
capabilities.emits |
string[] | event names this plugin publishes to its dependents via ctx.events.emit. |
requiredAddons |
string[] | addon ids that must be enabled for the plugin to activate (see Dependencies). |
pluginDependencies |
{ id, version }[] |
other plugins (semver range) that must be installed + version-satisfied to activate. |
settings |
array | setting fields (below). |
The trek range is a hard contract, enforced in both directions:
- Install is refused when the running TREK falls outside it — on every path: registry install, an explicitly pinned version, an update, a sideloaded archive, and a dev-link. A manifest with no range (or an unsatisfiable one) cannot be installed at all.
-
Activation re-checks it, which is the half install cannot cover: a plugin
legitimately installed on 3.3 is still on disk after an upgrade to 4.0, and if it
declared
<4.0.0it now refuses to start. It stays installed and listed, switched off, with the reason shown (TREK_VERSION_INCOMPATIBLE; a plugin that declares no range at all reportsTREK_VERSION_UNKNOWN). - No admin override. The range is the author's own statement that the plugin does not work there — unlike a rotated signing key, there is nothing an operator could verify out-of-band and wave through.
Two behaviours follow. "Install latest" resolves to the newest version this TREK can run rather than the newest published, so shipping a 2.0.0 that needs TREK 4 doesn't strand 3.x users. And an update that would move a working plugin out of compatibility is refused rather than performed.
One gap, by design: a host whose APP_VERSION is not a semver version — the Docker
build arg defaults to the literal dev — has nothing to compare a range against, so
the check is skipped and an unversioned build installs anything. Plugins should still
guard optional ctx.* namespaces.
Permissions — the commonly-used core subset below; the full list of ~50 (all read/write scopes, the notify/ai/oauth brokers, every provider hook) lives in Plugin Permissions. Unknown values are rejected at activation.
| Permission | Grants |
|---|---|
db:own |
ctx.db — your own SQLite file |
db:read:trips |
ctx.trips.* (membership-checked, route handlers only) |
db:read:packing |
ctx.packing.list(tripId) — a trip's packing items (membership-checked) |
db:read:files |
ctx.files.list(tripId) — a trip's files, trash excluded (membership-checked) |
db:read:costs |
ctx.costs.getByTrip / ctx.costs.listMine (Costs addon, route handlers only) |
db:write:costs |
ctx.costs.create/update/delete (Costs addon + acting user's budget_edit) |
db:write:places |
ctx.places.create/update/delete (acting user's place_edit) |
db:write:days |
ctx.days.create/update/delete (acting user's day_edit) |
db:write:itinerary |
ctx.itinerary.assign/unassign (acting user's day_edit) |
db:write:trips |
ctx.trips.update (acting user's trip_edit) |
db:write:packing |
ctx.packing.create/update/delete (acting user's packing_edit; private items stay owner-scoped) |
db:meta |
ctx.meta.* — your own namespaced data on a trip/place/day/reservation/accommodation |
db:read:users |
ctx.users.getById |
events:subscribe |
receive core activity events via events: [...] (event name + tripId + a { entity, entityId } hint, plus a whitelisted entity snapshot when the plugin also holds the family's db:read:* grant; never a user) |
hook:trip-card-provider |
hooks.tripCardProvider — small badges on the dashboard trip cards |
jobs:run |
run declared background jobs on their cron schedule and ctx.scheduler runtime timers → scheduled handler (opt-in; no user, so trip reads are refused) |
ws:broadcast:trip |
ctx.ws.broadcastToTrip |
ws:broadcast:user |
ctx.ws.broadcastToUser |
http:outbound or http:outbound:<host>
|
outbound HTTP to egress[] hosts |
hook:place-detail-provider |
hooks.placeDetailProvider — extra place rows TREK renders (see Provider hooks) |
hook:trip-warning-provider |
hooks.warningProvider — validation warnings in the planner (see Provider hooks) |
hook:table-contributor |
hooks.tableContributor — host-rendered columns/actions in the reservations, transports, places, day, costs, packing, files and todos views (see Provider hooks) |
hook:map-marker-provider |
hooks.mapMarkerProvider — bounded markers on the trip map |
hook:pdf-section-provider |
hooks.pdfSectionProvider — sections appended to the trip PDF export |
hook:atlas-layer-provider |
hooks.atlasLayerProvider — per-user country tint layers on the Atlas map |
hook:journal-entry-provider |
hooks.journalEntryProvider — extra rows on a journal entry card |
hook:trip-card-provider |
hooks.tripCardProvider — small badges on the dashboard trip cards (getCards(tripIds, ctx) → { tripId, label, value?, icon?, tone?, url? }[]; host bounds every field + access-checks each tripId) |
hook:user-data |
deleteUserData / exportUserData handlers — honour GDPR erasure (durable, retried) and data-export for a deleted/requesting user (userless; own db only) |
hook:photo-provider / hook:calendar-source
|
reserved (see Provider hooks) |
There is no
ws:broadcast:*— usews:broadcast:tripand/orws:broadcast:userexplicitly.
Settings field (settings[]):
| Key | Notes |
|---|---|
key |
required identifier; empty-key entries are dropped. |
label |
form label. |
input_type |
snake_case; e.g. text (default), password, number, select. |
scope |
instance (default) or user. |
required |
boolean. |
secret |
boolean — encrypted at rest, decrypted only into ctx.config. |
placeholder, hint
|
form hints. |
options |
[{ value, label }] for select inputs. |
oauth |
{ initPath, callbackPath } for OAuth flows. |
Page nav: the host builds a page plugin's nav entry from the top-level name
and icon — the installed-manifest parser only consumes capabilities.widget, so
there is nothing else to set. icon must be a real lucide name: TREK resolves it at
render time and silently falls back to Blocks, which makes a typo invisible locally,
so validate rejects one.
See Plugin Permissions for the full permission model.
Run npx trek-plugin-sdk with no command in a terminal and you get an
interactive menu — create / dev / status / shot / publish, with validate, pack,
signing and the registry-entry commands under Advanced… It picks which command to
run and asks which plugin directory to run it against; that command then prompts for
whatever else it needs. Pass a command explicitly to skip the menu (and for scripts and
CI). trek-plugin help <command> — or trek-plugin <command> --help — prints a full
page for any command.
The path is four commands, and the other nine are steps one of them already does:
trek-plugin create [name] [--type integration|page|widget|trip-page]
trek-plugin dev [dir] [--port 4317]
trek-plugin status [dir]
trek-plugin publish [dir] --repo owner/name --tag vX.Y.Z [--sign]status is the one to reach for when you are not sure what to do next. It runs every
gate the TREK-Plugins registry enforces that can be answered without a network —
which is nearly all of them — and prints the whole journey as a checklist grouped by
stage (Manifest, Code, Docs, Release, Repo), with the one command to run next. It never
exits non-zero: it is for orientation, not for gating.
# The gate. The SAME checks as `status`, but it exits 1. This is the form for CI.
trek-plugin validate [dir]
# Build plugin.zip in the installer's exact layout. Prints sha256 + byte size,
# refuses native binaries, enforces the same size limits (25MB/file, 50MB total).
# Ships trek-plugin.json, README.md, LICENSE(.md), package.json + server/ + client/.
# docs/ is intentionally NOT shipped — the store fetches docs/screenshot.png from
# your repo. It refuses a plugin that could not LOAD, but deliberately does not
# enforce the publish gates (an unwritten README, a missing screenshot) — packing is
# how you sideload a plugin to try it locally.
trek-plugin pack [dir] [--out plugin.zip] [--json]
# Boot the dev server, render the plugin in the themed /preview frame, and write a
# 1600x900 docs/screenshot.png. Needs Playwright (not an SDK dependency — it ships a
# browser): npm i -D playwright && npx playwright install chromium
trek-plugin shot [dir] [--port 4317] [--out docs/screenshot.png] [--dark] [--no-serve]
# Ed25519 signing. keygen once; `publish --sign` is the usual way to use the key.
trek-plugin keygen [--key file]
trek-plugin sign [zip] [--key file]
# Emit the ready-to-PR registry entry: commitSha (resolved from the git tag),
# downloadUrl, sha256, size, and the manifest's `trek` range verbatim — plus
# requiredAddons and pluginDependencies, which the registry parity-checks against the
# manifest. --merge prepends a new version onto an existing entry (the update case,
# kept newest-first).
trek-plugin entry --repo owner/name --tag vX.Y.Z [--zip plugin.zip] [--merge entry.json] [--out file]
# The registry checks that genuinely need the network: the tag resolves to the commit
# the entry pins, the released artifact downloads and hashes to the pinned sha256, the
# id is not bound to another GitHub owner, and an update does not drop or rotate a
# signing key you already published under. `publish` runs this for you.
trek-plugin preflight [dir] --repo owner/name --tag vX.Y.Z [--entry file.json] [--all]
# Pack -> cut the GitHub release -> print the entry, without opening the registry PR.
trek-plugin release [dir] --repo owner/name --tag vX.Y.Z [--sign] [--merge entry.json]
# Fork the registry, write registry/plugins/<id>.json, open the PR. Needs `gh`.
trek-plugin submit [dir] --repo owner/name --tag vX.Y.Z [--registry o/n] [--draft]publish chains those last steps in the order that matters: check → pack → release →
preflight → submit. The local gates run before anything is tagged or released,
because a GitHub release is effectively immutable — the registry pins its sha256. See
Publishing a Plugin.
-
No reserved namespaces — any unique slug id is accepted. (A tiny set of ids
like
registry/install/rescanis blocked only because they'd collide with admin API routes.) - Owner-binding still prevents anyone but the original author from repointing an existing id to a different repo.
-
Optional author signing: an entry may carry
authorPublicKey(stable, TOFU-pinned on first install) and each version asignatureover the artifact bytes. Unsigned plugins install on sha256 alone; a plugin that was signed can't later go unsigned or swap its key without an explicit admin re-trust.
Full walkthrough: Publishing a Plugin. Overview: Plugins.
- Home
- Quick Start
- Install: Docker
- Install: Docker Compose
- Install: Helm
- Install: Proxmox VE (LXC)
- Install: Unraid
- Install: Portainer
- Reverse Proxy
- Environment Variables
- Updating
- Login and Registration
- OIDC SSO
- Two-Factor Authentication
- Passkeys
- Password Reset
- User Settings
- General Settings
- Appearance Settings
- Map Settings
- Notifications
- Offline Mode and PWA
- Languages
- My Trips Dashboard
- Creating a Trip
- Trip Members and Sharing
- Trip Planner Overview
- Places and Search
- Day Plans and Notes
- Map Features
- Route Optimization
- Weather Forecasts
- Reservations and Bookings
- AI Booking Import
- Transport: Flights, Trains, Cars
- Accommodations
- Costs
- Currencies
- Packing Lists
- Packing Templates
- Todos and Tasks
- Documents and Files
- Tags and Categories
- Calendar Feeds
- Real-Time Collaboration
- Collab Chat
- Collab Notes
- Collab Polls
- What's Next Widget
- Public Share Links
- Invite Links