# Plugin Cookbook Short, copy-paste recipes for the things plugins can do. Each one names the permission it needs (declare it in `trek-plugin.json` → `permissions`) and the capability it uses. For the full API see [[Plugin Development|Plugin-Development]]; for the permission catalogue see [[Plugin Permissions|Plugin-Permissions]]. > Every trip/place/day operation is **membership-checked by the host** against the > user bound to the invocation — your plugin never passes a user id. Writes also > need that user's `*_edit` permission. A read/write you're not allowed to do fails > loudly; it never silently escalates. The complete, runnable version of these recipes is the [`trip-doctor`](https://github.com/liketrek/TREK/tree/main/plugin-sdk/examples/trip-doctor) example plugin. --- ## Read a trip's places and bookings **Needs:** `db:read:trips` ```js async handler(req, ctx) { const places = await ctx.trips.getPlaces(Number(req.query.tripId)) const bookings = await ctx.trips.getReservations(Number(req.query.tripId)) // …use them; the host already checked req.user can see this trip } ``` ## Read the packing list or files of a trip **Needs:** `db:read:packing` · `db:read:files` (declare only what you use) ```js const packing = await ctx.packing.list(tripId) // hydrated bags/assignees const files = await ctx.files.list(tripId) // trash excluded ``` Both are membership-checked against the current user — same gate as `ctx.trips.*`. ## Add / move something on the itinerary **Needs:** `db:write:places` · `db:write:days` · `db:write:itinerary` (declare only what you use) ```js const place = await ctx.places.create(tripId, { name: 'Teamlab', lat: 35.62, lng: 139.78 }) const day = await ctx.days.create(tripId, { date: '2027-04-02', notes: 'Odaiba' }) await ctx.itinerary.assign(tripId, day.id, place.id, 'buy tickets first') // days.create accepts { date?, notes?, position? }; set a day title later with ctx.days.update(tripId, day.id, { title: 'Odaiba' }). ``` Updates and deletes mirror the REST app exactly (`ctx.places.update/delete`, `ctx.days.update/delete`, `ctx.itinerary.unassign`). They broadcast the same live event, so open web sessions update instantly. ## Tag a core entity — no schema fork **Needs:** `db:meta` Store your own JSON-serialisable data on a trip, place or day. Rows are namespaced to **your plugin id** — no other plugin can read or overwrite them. ```js await ctx.meta.set('place', placeId, 'lastCheckedAt', Date.now()) const when = await ctx.meta.get('place', placeId, 'lastCheckedAt') const all = await ctx.meta.list('place', placeId) // { lastCheckedAt: 172… } await ctx.meta.delete('place', placeId, 'lastCheckedAt') ``` Reads need trip access; writes additionally need the entity's edit permission. ## Contribute extra info to a place (rendered natively) **Needs:** `hook:place-detail-provider` Return rows and TREK draws them at the foot of the place panel — no iframe. ```js module.exports = { hooks: { placeDetailProvider: { async getDetails(placeId, ctx) { return [ { label: 'Crowd', value: 'Quiet right now' }, { label: 'Official site', url: 'https://…' }, ] }, }, }, } ``` ## Raise validation warnings on a trip **Needs:** `hook:trip-warning-provider` Return problems and they show as a non-blocking banner in the planner. ```js hooks: { warningProvider: { async getWarnings(tripId, ctx) { const places = await ctx.trips.getPlaces(tripId) return places .filter((p) => p.lat == null) .map((p) => ({ level: 'warning', message: `"${p.name}" has no location`, placeId: p.id })) }, }, } ``` `level` is `'info' | 'warning' | 'error'`; `dayId`/`placeId` are optional anchors. ## Push a live update to a trip / a user **Needs:** `ws:broadcast:trip` and/or `ws:broadcast:user` ```js ctx.ws.broadcastToTrip(tripId, 'doctor:rechecked', { count }) // → plugin::doctor:rechecked ctx.ws.broadcastToUser(userId, 'nudge', { text: '…' }) // (userId, event, data) — only that user ``` Events are automatically namespaced to `plugin::…` so they can't collide with core events. ## React to core activity **Needs:** `events:subscribe` Handlers run with no user and get the event name + tripId only (never the payload). ```js events: [ { on: 'file:created', async handler({ tripId }, ctx) { await notifySlack(`New file on trip ${tripId}`) // needs http:outbound } }, ] ``` Fire-and-forget on a short timeout — never blocks a core write. Trip reads are refused (no user); use `ctx.db`, `ctx.ws.*`, or an outbound call. Your own `plugin:*` broadcasts are never re-delivered, so handlers can't loop. ## Depend on another plugin — call it and hear its events **Needs:** a `pluginDependencies` entry for the other plugin (no permission). Expose a contract from the **dependency** (declare the names in `capabilities.provides` / `capabilities.emits`): ```js // plugin "koffi" · manifest: "capabilities": { "provides": ["convert"], "emits": ["rate.updated"] } exports: { async convert({ amount, from, to }) { return { amount: amount * rate(from, to), to } }, }, async onLoad(ctx) { ctx.events.emit('rate.updated', { pair: 'USD/EUR' }) }, ``` Consume it from the **dependent** (declare koffi as a dependency): ```js // manifest: "pluginDependencies": [{ "id": "koffi", "version": ">=1.0.0 <2.0.0" }] routes: [ { method: 'GET', path: '/price', async handler(_req, ctx) { const out = await ctx.plugins.call('koffi', 'convert', { amount: 10, from: 'USD', to: 'EUR' }) return { status: 200, body: JSON.stringify(out) } } }, ], subscriptions: [ { plugin: 'koffi', event: 'rate.updated', async handler(payload, ctx) { ctx.log.info('rates changed', payload) } }, ], ``` TREK auto-enables koffi before your plugin, routes the call (as your acting user), and refuses it if koffi isn't a satisfied dependency or doesn't export `convert`. See [[Plugin Development#talking-to-other-plugins|Plugin-Development]]. ## Match the TREK look Add `` to your widget's ``. The dev server and `pack` inline TREK's token-driven kit (glass surfaces, buttons, inputs, dark-mode) and a `window.trek` bridge with the live theme + tokens. See [[Plugin Development#the-design-kit-recommended|Plugin-Development]]. `window.trek.ui` gives you bundler-free, kit-styled DOM builders (`ui.el/button/card/chip/input/mount`). --- ## Notify a user or a trip **Needs:** `notify:send` The plugin supplies target + plain text; the host owns delivery and the user's notification preferences. ```js await ctx.notify.send({ title: 'Trip rechecked', body: '3 places still need a location', scope: 'trip', targetId: tripId, // or scope:'user', targetId: the acting user link: '/trips/' + tripId, }) ``` The recipient is **forced** by the host: `scope:'user'` can only reach the acting user, `scope:'trip'` only a trip they belong to. You can't notify anyone else. --- ## Become a notification channel (Gotify, Pushover, …) **Needs:** `hook:notification-channel` + `http:outbound:` (and a matching `egress`) The recipe above *produces* a notification. This one **delivers** one — your plugin becomes a channel next to email / webhook / ntfy in the user's notification preferences. ```bash npx create-trek-plugin my-gotify --type integration --template notification-channel ``` ```js module.exports = definePlugin({ hooks: { notificationChannel: { async send(msg, config, ctx) { // msg is ALREADY rendered in the recipient's language, deep link included. // config is that recipient's own scope:'user' settings, decrypted for you. 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 }), }) if (!res.ok) throw new Error(`Gotify responded ${res.status}`) // host logs + isolates }, async test(config) { /* backs the "Send test" button */ }, }, }, }) ``` ```json "permissions": ["hook:notification-channel", "http:outbound:gotify.example.com"], "egress": ["gotify.example.com"], "capabilities": { "notificationChannel": { "title": "Gotify" } }, "settings": [ { "key": "serverUrl", "scope": "user", "required": true }, { "key": "appToken", "scope": "user", "required": true, "secret": true } ] ``` The gotcha worth knowing up front: this hook is **host-initiated for an arbitrary recipient**, so unlike every other hook it runs with **no acting user**. `ctx.settings.get()` returns `undefined` and trip reads are refused — the recipient's credentials come in as `config` instead. That's the whole point: you can be handed someone's push token without being handed their trips. Targeting a **self-hosted** Gotify? Your manifest can't name the user's hostname, so add `"operatorEgress": true` and let the admin add the real host after install (Admin → Plugins → Allowed hosts). See [Plugin-Development → Operator-supplied egress hosts](Plugin-Development.md#operator-supplied-egress-hosts-operatoregress), plus [Notification channels](Plugin-Development.md#notification-channels) for the event list, the "configured" rule, and the `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS` flag you need if the service runs on your own LAN. --- ## Put a "Test connection" button on your settings page **Needs:** nothing — an action is your own code, run for the user who clicked it. Declare the buttons in the manifest, implement them on the definition: ```json "actions": [ { "key": "testConnection", "label": "Test connection", "hint": "Pings the API." }, { "key": "purge", "label": "Delete my data", "danger": true } ] ``` ```js module.exports = definePlugin({ actions: { async testConnection(ctx) { // USER-INITIATED: the acting user is whoever clicked, so ctx.settings.get() // returns THEIR value — which is what makes "test MY credentials" possible. const token = await ctx.settings.get('appToken') const res = await fetch('https://api.example.com/ping', { headers: { authorization: token } }) return { ok: res.ok, message: res.ok ? 'Connected' : `Failed: ${res.status}` } }, }, }) ``` The buttons render under your fields in **Settings → Plugins**, with the result shown beside them. Return `{ ok, message? }`; throwing is the same as `{ ok: false }` carrying the error text. `danger: true` asks for confirmation first. The host refuses any key your manifest didn't declare, and bounds the message it shows (200 chars, emoji stripped). Contrast with the `notificationChannel` hook, which is **host**-initiated and therefore has *no* acting user — there, `ctx.settings.get()` returns `undefined` and the recipient's credentials arrive as an argument instead. --- ## Ask the configured LLM **Needs:** `ai:invoke` Uses whatever provider the admin/user set up — your plugin never holds a key. ```js const { text } = await ctx.ai.complete('Summarise this trip in one line', 'You are a concise travel assistant') const { results } = await ctx.ai.extract( pastedBookingText, { type: 'object', properties: { hotel: { type: 'string' }, checkIn: { type: 'string' } } }, ) ``` Output is **data**. To store it, push it through a gated write yourself (e.g. `ctx.reservations.create`). --- ## Call a third-party API the user connected **Needs:** `oauth:client` (and `http:outbound` for the fetch) The user connects the service under Settings → Plugins → Connect. The host keeps the refresh token and client secret; you only ever get a short-lived access token for the acting user. ```js const token = await ctx.oauth.getAccessToken() // null if not connected, or in a userless context if (token) { const res = await fetch('https://api.example.com/me', { headers: { Authorization: `Bearer ${token}` } }) } ``` --- ## Convert currencies **Needs:** `rates:read` ```js const rates = await ctx.rates.get('EUR') // { USD: 1.08, GBP: 0.85, … } — or null on an upstream failure const usd = 100 * rates.USD ``` Tenant-free and cached upstream — no trip access needed. --- ## Run work on a schedule **Needs:** `jobs:run` Two flavours: a fixed **cron job** declared in the manifest, or a **dynamic timer** you set at runtime via `ctx.scheduler`. ```js module.exports = { // fixed cron — runs on node-cron jobs: [ { id: 'nightly', schedule: '0 3 * * *', async handler(ctx) { /* … */ } }, ], // dynamic timers fire back into `scheduled` scheduled(input, ctx) { ctx.log.info('fired', { name: input.name, payload: input.payload }) }, routes: [ { method: 'POST', path: '/remind', async handler(req, ctx) { await ctx.scheduler.in(60_000, 'remind', { tripId: 1 }) // also: .at(epochMs, name, payload) · .every(ms, name, payload) · .cancel(name) return { status: 200 } } }, ], } ``` Both jobs and scheduled tasks run with **no acting user** (trip reads are refused; only your own db + declared egress). `ctx.scheduler.set` is an upsert by name and survives restarts. Caps: ≤100 tasks/plugin, ≤128-char name, ≤8 KB payload, recurring interval ≥60s, ≤1 year out. --- ## Contribute native primitives to core views Six declarative hooks let a plugin push data into TREK's own screens — the host renders and sanitizes everything, so no iframe and no plugin JS on the canvas. Each needs its own `hook:*` permission, runs with the current user bound on a short timeout, and a slow or failing call is skipped (never fatal). The host caps counts and lengths. ```js hooks: { // hook:table-contributor — cells/buttons on a row. view ∈ reservations|places|day|costs|packing|files tableContributor: { async getContributions(view, tripId, ctx) { return [{ kind: 'column', entityId: 42, id: 'crowd', label: 'Crowd', value: 'Quiet', tone: 'success' }] } }, // hook:map-marker-provider — pins on the trip map (#587) mapMarkerProvider: { async getMarkers(tripId, ctx) { return [{ id: 'm1', lat: 35.62, lng: 139.78, label: 'Teamlab', popupText: 'Opens 10:00' }] } }, // hook:pdf-section-provider — text sections appended to the trip PDF export pdfSectionProvider: { async getSections(tripId, ctx) { return [{ title: 'Notes', paragraphs: ['Bring cash.'], table: { headers: ['Day', 'Plan'], rows: [['1', 'Odaiba']] } }] } }, // hook:atlas-layer-provider — country tint layers on the Atlas map (user-scoped, no target arg) atlasLayerProvider: { async getLayers(ctx) { return [{ id: 'wishlist', name: 'Wishlist', countries: [{ code: 'JP', tone: 'warn' }] }] } }, // hook:journal-entry-provider — extra rows under a journal entry journalEntryProvider: { async getRows(entryId, ctx) { return [{ label: 'Weather', value: 'Sunny 22°C' }] } }, // hook:trip-card-provider — badges on dashboard trip cards (tripIds = the cards on screen) tripCardProvider: { async getCards(tripIds, ctx) { return tripIds.map((id) => ({ tripId: id, id: 'days', label: 'Days left', value: '12' })) } }, } ``` `tone` is `'default' | 'success' | 'warn' | 'danger'`; any `url` must be http/https/mailto; `icon` is a lucide icon name. A table `action` (instead of a `column`) is a labelled button whose target opens your sandboxed frame or calls one of your routes. --- ## Honour account deletion and data export (GDPR) **Needs:** `hook:user-data` The host calls these when a TREK account is erased or its data is exported. Both are **userless** — you get only a `userId` and act on your OWN db, so the grant leaks no read into core data. ```js module.exports = { // Erasure — called durably (queued, retried until it succeeds), so make it idempotent. async deleteUserData({ userId }, ctx) { await ctx.db.exec('DELETE FROM my_rows WHERE user_id = ?', userId) }, // Portability — return a JSON-serialisable value the host aggregates into the export. async exportUserData({ userId }, ctx) { return await ctx.db.query('SELECT * FROM my_rows WHERE user_id = ?', userId) }, } ``` --- ## Write across every subsystem Beyond places/days/itinerary, TREK 3.3.0 opens create/update/delete on almost every trip subsystem. Each is membership-checked against the acting user and needs its `db:write:*` scope **plus** the app's edit permission — declare only what you use. ```js // Bookings & lodging — needs 'reservation_edit' / 'day_edit' await ctx.reservations.create(tripId, { type: 'flight', title: 'NRT → HND', endpoints: [/* legs */] }) await ctx.accommodations.create(tripId, { place_id, start_day_id, end_day_id, check_in: '15:00' }) // Packing & to-dos — needs 'packing_edit' await ctx.packing.create(tripId, { name: 'Passport', visibility: 'personal' }) await ctx.todos.create(tripId, { name: 'Buy JR pass', due_date: '2027-04-01' }) // Costs (budget) — needs 'budget_edit' + the Costs addon await ctx.costs.create(tripId, { title: 'Hotel', amount: 120, currency: 'EUR' }) // Collab notes/polls/chat — needs 'collab_edit' + the Collab addon await ctx.collab.createNote(tripId, { title: 'Meeting point' }) // Day notes — needs 'day_edit' await ctx.daynotes.create(tripId, dayId, { content: 'Rainy — swap plans' }) // Tags (the acting user's own) const tag = await ctx.tags.create({ name: 'foodie', color: '#4F46E5' }) ``` The matching reads mirror the app 1:1: `ctx.trips.getReservations/getAccommodations/getDays`, `ctx.packing.list`, `ctx.costs.getByTrip`, `ctx.collab.listNotes/listPolls/listMessages`, `ctx.daynotes.list`, `ctx.todos.list`, `ctx.tags.list`, `ctx.categories.list`. --- ## Write the acting user's own Atlas / Vacay / Journal / Collections These belong to the **user** (not one trip), each gated on its addon being enabled. ```js await ctx.atlas.markCountry('JP') // db:write:atlas await ctx.atlas.createBucketItem({ name: 'See Mt Fuji', country_code: 'JP' }) await ctx.vacay.toggleEntry('2027-04-02') // db:write:vacay — toggles one PTO day const j = await ctx.journal.createJourney({ title: 'Japan 2027' }) // db:write:journal await ctx.journal.createEntry(j.id, { entry_date: '2027-04-02' }) const c = await ctx.collections.create({ name: 'Tokyo eats' }) // db:write:collections await ctx.collections.savePlace({ collection_id: c.id, name: 'Ramen shop', lat: 35.6, lng: 139.7 }) ``` Reads: `ctx.atlas.visited()/bucketList()`, `ctx.vacay.mine()`, `ctx.journal.listMine()/getEntries()`, `ctx.collections.listMine()/get()`. --- ## Batch writes atomically, and read per-user settings **Needs:** `db:own` (for `db.tx`); `ctx.settings` needs no permission. ```js // db.tx — all commit or all roll back, on your OWN db (≤100 statements; reads see the batch's earlier writes) await ctx.db.tx([ { sql: 'INSERT INTO cache(k, v) VALUES(?, ?)', args: ['a', 1] }, { sql: 'UPDATE cache SET v = v + 1 WHERE k = ?', args: ['a'] }, ]) // ctx.settings.get — the ACTING USER's own value for a scope:'user' settings field (decrypted host-side) const apiKey = await ctx.settings.get('apiKey') // undefined when unset or userless → fall back to ctx.config ``` `ctx.config` is the admin-owned instance settings; `ctx.settings` is that user's private values for the `scope:'user'` fields you declared in the manifest. --- ## Where things run | Surface | Runs | Gets | |---|---|---| | `routes` | forked server child | `ctx` bound to the HTTP request's user | | `jobs` | forked server child, on a schedule | `ctx` with **no** user (can't read user-scoped data) | | `hooks` | forked server child, when core asks | `ctx` bound to the user who triggered the read, short timeout | | `widget` / `page` | sandboxed iframe (no same-origin) | `postMessage` bridge; calls its own routes via `trek:invoke` |