Skip to content

Plugin Cookbook

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

Plugin Cookbook

Short, copy-paste recipes for the things plugins can do. Each one names the permission it needs (declare it in trek-plugin.jsonpermissions) 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 *_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 example plugin.


Read a trip's places and bookings

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
}

Read the packing list or files of a 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 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)

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.

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.

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.

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

ctx.ws.broadcastToTrip(tripId, 'doctor:rechecked', { count })   // → plugin:<id>:doctor:rechecked
ctx.ws.broadcastToUser(userId, 'nudge', { text: '…' })          // (userId, event, data) — only that user

Events are automatically namespaced to plugin:<your-id>:… 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).

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

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

Match the TREK look

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


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.

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:<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-channel
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 */ },
    },
  },
})
"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.


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:

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


Ask the configured LLM

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


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.

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

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.

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.

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.

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.

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

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.

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

Getting Started

Account & Auth

Planning Trips

Travel Management

Photos & Media

Collaboration

Addons

Plugins

AI / MCP

Admin Panel

Operations

Help

Contributing

Clone this wiki locally