Skip to content

Plugin Development

github-actions[bot] edited this page Jul 18, 2026 · 4 revisions

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.

Before you start

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.

Scaffold

npx trek-plugin-sdk create                # interactive wizard
npx trek-plugin-sdk create my-plugin --type integration|page|widget|trip-page   # or direct
cd my-plugin

The 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.

Run it locally with hot reload

npx trek-plugin-sdk dev        # http://localhost:4317

dev 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 (an auth: true route then returns 401, mirroring the host).
  • Feed the fixtures by dropping a dev-fixtures.json next to the manifest — it takes the same shape as createMockHost options, 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.

Test against a real instance's data (dev-link)

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=1

With 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_LINK on top of admin + the plugin kill-switch, and is off by default. It loads unsigned local code that mutates live between restarts, and under npm run dev the OS permission jail is off — so only ever enable it on a machine you control, pointed at a dev instance.

The plugin types

  • 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 / calendarSource are 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 (sidebar slot), a hero-bar overlay (hero slot), a panel inside the trip planner's place-detail view (place-detail slot — the frame also receives the open placeId in trek:context, so it can show place-specific info like reviews or ratings), a panel inside the day-detail view (day-detail slot — receives the open dayId; 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-detail slot — receives the open reservationId, for things like live check-in status or a seat map). Set the slot in capabilities.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 current tripId in trek:context (so you can scope data to the open trip) and it has no dashboard nav entry. The tab shows on desktop and mobile. Via capabilities.tripPage the 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; plan is never replaceable), and position picks 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.

The SDK package

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-sdk

Writing the server

Your 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.

The ctx object

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.

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.

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).

Writing the client (page / widget)

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).

The design kit (recommended)

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.trek so you never hand-roll postMessage.

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.

The raw bridge (without the kit)

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.

The context payload

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 } | nullnever 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 }

Matching the TREK look by hand (m.tokens)

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.

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 decrypted through ctx.config (server-side only) — never to the iframe. Resolved values arrive in ctx.config.

A custom settings page (capabilities.settingsUi)

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.

Host-brokered OAuth (ctx.oauth)

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 null when 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 https to 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).

GDPR data-subject hooks

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, deleteUserData is 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 exportUserData across every granted plugin. A plugin that is currently inactive but holds hook:user-data, or one whose export errored/timed out, is flagged pending (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.

Provider hooks

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.

Notification channels

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-channel
module.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() returns undefined here, and ctx.trips.* reads are refused.
  • The recipient's credentials arrive as the config argument instead: the host reads your plugin's scope:'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:

  • title names the column in the notification preferences matrix (defaults to your plugin's name). events may 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:outbound does not open any host at runtime — you need http: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=on on 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.

Settings-page actions

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: true renders 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.

Operator-supplied egress hosts (operatorEgress)

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.

Event subscriptions

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:*.

Dependencies

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

"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

"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.

Talking to other plugins

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.

Exports — request / response

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's provides.
  • 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 provides are reachable — routes, jobs and helpers stay private. Because your pluginDependencies range pins B's version, B can refactor internals freely and only breaks you on a major bump.

Events — publish / subscribe

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-forget

A 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 koffi as a satisfied dependency and subscribed to that (plugin, event). Emitting an event not in your emits is 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.

Testing without a running TREK

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.

Driving your plugin's handlers

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.scheduler

A 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.

Settings actions and notification channels

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.

Rules

  • 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 runs npm install on a plugin.
  • Ship built JS in server/index.js and pre-built static files in client/. .ts and .map files are stripped by pack.
  • Declare every outbound host in egress[] whenever you use http:outboundand grant each of them a matching http:outbound:<host> permission. The runtime builds the child process's network allow-list and the iframe's CSP from those permissions only; it never reads egress[], which exists for the admin's consent screen. A host listed in egress[] with no matching permission installs, activates, consents — and is then silently unreachable. validate errors on this.

Manifest reference (trek-plugin.json)

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).

TREK version compatibility (trek)

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.0 it 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 reports TREK_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:* — use ws:broadcast:trip and/or ws:broadcast:user explicitly.

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.

The trek-plugin CLI

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.

Registry & publishing

  • No reserved namespaces — any unique slug id is accepted. (A tiny set of ids like registry/install/rescan is 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 a signature over 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.

Getting Started

Account & Auth

Planning Trips

Travel Management

Photos & Media

Collaboration

Addons

Plugins

AI / MCP

Admin Panel

Operations

Help

Contributing

Clone this wiki locally