# 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](https://github.com/liketrek/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](https://github.com/liketrek/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](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 ```bash 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": ">=4.0.0 <5.0.0"` — the host surface it is written against is the TREK 4 surface, and claiming an older floor would let the plugin install on a host where parts of it are missing. Picking `oauth:client` in the wizard also scaffolds the five instance-scope settings the OAuth broker reads (see [Host-brokered OAuth](#host-brokered-oauth-ctxoauth)). 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 ```bash 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/`, 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/[/][/]` (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: ```bash 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: ```bash 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. Every provider hook is **live** — from placeDetailProvider and warningProvider through photoProvider (Memories) and calendarSource — see [Provider hooks](#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: ```bash npm i -D trek-plugin-sdk ``` ## Writing the server Your `server/index.js` exports a `definePlugin(...)` object. Everything reaches TREK through the `ctx` argument. ```js 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/. 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 scaffold writes no `routes` block into `trek-plugin.json`, and the manifest parser would ignore one if you added 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?}, …] }`. Your file is capped at **256 MB** (a write past it fails `SQLITE_FULL`, contained to your plugin) and a single result set at **100,000 rows** — page your reads instead of materialising a cartesian product | `db:own` | | `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`/`reservation`/`accommodation` (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), or the field's manifest `default` when they never set it. Returns `undefined` for a field with neither, and in 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::`) | `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 `scope:'instance'` settings (secrets delivered decrypted, a field nobody set resolves to its manifest `default`), frozen at activation | — | | `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, rawBodyBase64, 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. **`req.rawBodyBase64` is webhook-only in the same way** — the exact request bytes, base64-encoded, on `auth: false` routes, and absent on an authenticated route, which never needs them. To trust a webhook, verify the provider's signature over *those* bytes against a secret you hold in `ctx.config` (admin-set instance setting) or `ctx.settings` (per-user). Never re-serialize `req.body` for the check: `JSON.stringify` won't reproduce the key order, whitespace and unicode escaping the sender signed, so the HMAC won't match. The snippet below handles two things, and yours has to as well. TREK only keeps the raw bytes when a body parser ran, i.e. for `application/json` and `application/x-www-form-urlencoded` — a webhook posted as `text/plain` or a GET callback with no body arrives with `rawBodyBase64: null`, so **fail closed** instead of running the HMAC over an empty buffer, which is a constant anyone can forge. And the request body ceiling is 100 kB; a larger payload is rejected before your route runs. ```js const crypto = require('node:crypto') if (typeof req.rawBodyBase64 !== 'string') return { status: 400, body: { error: 'no raw body' } } const raw = Buffer.from(req.rawBodyBase64, 'base64') const mac = crypto.createHmac('sha256', ctx.config.webhookSecret).update(raw).digest() const sent = Buffer.from(String(req.headers['x-hub-signature-256'] || '').replace(/^sha256=/, ''), 'hex') if (sent.length !== mac.length || !crypto.timingSafeEqual(mac, sent)) return { status: 401, body: { error: 'bad signature' } } ``` ### Runtime limits The host enforces these on every plugin. They are generous for real work and only bite a runaway one, but they are exactly what a plugin that loops over `ctx.*` or stores blobs in `ctx.db` runs into, so build against them. | Area | Limit | |---|---| | every `ctx.*` call | burst 60, sustained 20/s, 16 in-flight per plugin; a throttled call is refused with `HOST_ERROR: rate limit exceeded — slow down ctx.* calls`. Tunable with `TREK_PLUGIN_RPC_BURST` / `TREK_PLUGIN_RPC_PER_SEC` / `TREK_PLUGIN_RPC_INFLIGHT` (see [Environment-Variables](Environment-Variables#plugins)) | | `ctx.db` | 256 MB per plugin, one result set capped at 100,000 rows | | event subscriptions | 200 events buffered per plugin while it restarts, dropped unreplayed after 15 minutes | | the plugin process | 300 MB RSS (`TREK_PLUGIN_MAX_RSS_MB`) — the child is killed past it; auto-disabled with status `error` after 5 crashes inside a 5-minute window | The daily `ctx.ai` / `ctx.notify` budgets and the `ctx.meta` quotas are in [Testing without a running TREK](#testing-without-a-running-trek), because the mock host enforces those too. ## Writing the client (page / widget) The iframe is served same-origin from `/plugin-frame//…` 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` ``: ```html ``` `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: ```js 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: ```html
Your plugin

…

``` **Component classes** (the bootstrap adds `trek-ui` to ``): | 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 `` as the value/form source (it still fires `change`). Write a plain `