-
-
Notifications
You must be signed in to change notification settings - Fork 886
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;
for the permission catalogue see 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
*_editpermission. 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
example plugin.
Needs: db:read:trips
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
}Needs: db:read:packing · db:read:files (declare only what you use)
const packing = await ctx.packing.list(tripId) // hydrated bags/assignees
const files = await ctx.files.list(tripId) // trash excludedBoth are membership-checked against the current user — same gate as ctx.trips.*.
Needs: db:write:places · db:write:days · db:write:itinerary (declare only what you use)
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.
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.
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.
Needs: hook:place-detail-provider
Return rows and TREK draws them at the foot of the place panel — no iframe.
module.exports = {
hooks: {
placeDetailProvider: {
async getDetails(placeId, ctx) {
return [
{ label: 'Crowd', value: 'Quiet right now' },
{ label: 'Official site', url: 'https://…' },
]
},
},
},
}Needs: hook:trip-warning-provider
Return problems and they show as a non-blocking banner in the planner.
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.
Needs: ws:broadcast:trip and/or ws:broadcast:user
ctx.ws.broadcastToTrip(tripId, 'doctor:rechecked', { count }) // → plugin:<id>:doctor:rechecked
ctx.ws.broadcastToUser(userId, 'nudge', { text: '…' }) // (userId, event, data) — only that userEvents are automatically namespaced to plugin:<your-id>:… so they can't collide
with core events.
Needs: events:subscribe
Handlers run with no user and get the event name + tripId only (never the payload).
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.
Needs: a pluginDependencies entry for the other plugin (no permission).
Expose a contract from the dependency (declare the names in
capabilities.provides / capabilities.emits):
// 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):
// 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.
Add <!-- trek:ui --> to your widget's <head>. 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. window.trek.ui
gives you bundler-free, kit-styled DOM builders (ui.el/button/card/chip/input/mount).
Needs: notify:send
The plugin supplies target + plain text; the host owns delivery and the user's notification preferences.
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.
Needs: hook:notification-channel + http:outbound:<host> (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.
npx create-trek-plugin my-gotify --type integration --template notification-channelmodule.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 */ },
},
},
})"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,
plus 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.
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:
"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) {
// 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.
Needs: ai:invoke
Uses whatever provider the admin/user set up — your plugin never holds a key.
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).
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.
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}` } })
}Needs: rates:read
const rates = await ctx.rates.get('EUR') // { USD: 1.08, GBP: 0.85, … } — or null on an upstream failure
const usd = 100 * rates.USDTenant-free and cached upstream — no trip access needed.
Needs: jobs:run
Two flavours: a fixed cron job declared in the manifest, or a dynamic timer you set at runtime via ctx.scheduler.
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.
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.
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.
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.
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)
},
}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.
// 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.
These belong to the user (not one trip), each gated on its addon being enabled.
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().
Needs: db:own (for db.tx); ctx.settings needs no permission.
// 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.configctx.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.
| 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
|
- Home
- Quick Start
- Install: Docker
- Install: Docker Compose
- Install: Helm
- Install: Proxmox VE (LXC)
- Install: Unraid
- Install: Portainer
- Reverse Proxy
- Environment Variables
- Updating
- Login and Registration
- OIDC SSO
- Two-Factor Authentication
- Passkeys
- Password Reset
- User Settings
- General Settings
- Appearance Settings
- Map Settings
- Notifications
- Offline Mode and PWA
- Languages
- My Trips Dashboard
- Creating a Trip
- Trip Members and Sharing
- Trip Planner Overview
- Places and Search
- Day Plans and Notes
- Map Features
- Route Optimization
- Weather Forecasts
- Reservations and Bookings
- AI Booking Import
- Transport: Flights, Trains, Cars
- Accommodations
- Costs
- Currencies
- Packing Lists
- Packing Templates
- Todos and Tasks
- Documents and Files
- Tags and Categories
- Calendar Feeds
- Real-Time Collaboration
- Collab Chat
- Collab Notes
- Collab Polls
- What's Next Widget
- Public Share Links
- Invite Links