# 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. A runnable version of a handful of these recipes — reading a trip's places and bookings, raising validation warnings, contributing a place-detail row, and the entity metadata behind it — 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 reads { date?, notes?, dated? } and appends at the end; a position is honoured on the REST route only, not on the plugin path. // { dated: true } instead adds the day after the trip's last date and extends the trip to it. // 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://…' }, ] }, }, }, } ``` ## Answer place searches from your own index **Needs:** `hook:search-provider` (+ `http:outbound:` and a matching `egress` when the index is somebody else's server) TREK searches its own place index and OpenStreetMap. This hook adds a third one, and its hits are drawn into the same list the user is already looking at. `rating` is the reason to reach for it: open data carries no ratings at all, so a question like "the best rated hotel around here" cannot be answered from OpenStreetMap. Whatever your index knows, this is where it reaches the search list. ```js module.exports = { hooks: { searchProvider: { async search({ query, limit, lang, near }, ctx) { const res = await fetch( `https://your-index.example/search?q=${encodeURIComponent(query)}&n=${limit}`, ) const { results } = await res.json() return results.map(r => ({ id: r.ref, // namespaced host-side to plugin:: name: r.title, lat: r.lat, lng: r.lon, address: r.address, rating: r.stars, // 0..5, clamped host-side website: r.url, // http/https only })) }, }, }, } ``` Called for an explicit search, never per keystroke, so an index with rate limits is safe here. You get two seconds to answer and at most 20 of your places are kept: a person is waiting on the list, and the app stops waiting at two and a half seconds whatever happens, so a provider that misses that window is left out of the list rather than holding it up. `near` is the coordinate the search is biased toward, and it is what tells the four places called "Hase-dera" apart — use it when you have it. The Road trip search along the route reaches the same hook. It calls `search` once per kind the person picked, with two more fields on the request: `category` (`fuel`, `charging`, `rest_area`, `campsite`, `restaurant`, `sights` or `hotel`) and `bounds` (`{ south, west, north, east }`, the rectangle to search, with `near` at its centre). `query` is then a fixed English phrase such as `EV charging station` rather than something typed, so an index that ignores `category` still gets a usable query. Hits outside `bounds` are dropped, and so is a hit whose own `category` names a different one of those seven kinds. Both fields are absent on ordinary searches, so read them as optional. ## 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 `{ event, tripId, entity?, entityId?, snapshot? }` — the `snapshot` (a whitelisted field view of the changed entity) is delivered only when your plugin also holds that family's `db:read:*` grant; deletes/bulk/reorder events carry none. ```js events: [ { on: 'file:created', async handler({ tripId, entityId }, 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), and so are `ctx.ws.*` broadcasts; use `ctx.db` 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#operator-supplied-egress-hosts-operatoregress), plus [Notification channels](Plugin-Development#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). **…and one for the admin.** A button that acts on **instance** configuration rather than one person's credentials takes `"scope": "instance"`: ```json "actions": [ { "key": "purgeCache", "label": "Purge cache", "scope": "instance" } ] ``` It renders in **Admin → Plugins → ⋯ → Instance settings** instead of on anyone's settings page, and runs as the clicking **admin** — so `ctx.config` is the instance settings shown right above it, while `ctx.settings.get()` is still that admin's own value. The button is **disabled until the plugin is active** (an action needs a running child), and an edited form is saved before the action fires, so you never test against configuration the plugin hasn't been given. Needs a manifest `trek` floor of `>=4.2.0` — hosts older than 4.2.0 ignore `scope` and would show the button to every user. 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 on the plugin definition, or a **dynamic timer** you set at runtime via `ctx.scheduler`. ```js module.exports = { // fixed cron — standard 5-field (or 6-field with seconds) expressions 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). `at`, `in` and `every` upsert by name — scheduling the same name again replaces the pending task — and survive restarts. Caps: ≤100 tasks/plugin, ≤128-char name, ≤8 KB payload, recurring interval ≥60s, ≤1 year out. --- ## Contribute native primitives to core views Seven 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|transports|places|day|costs|packing|files|todos 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:map-layer-provider — routes/corridors/zones drawn on the trip map mapLayerProvider: { async getLayers(tripId, ctx) { return [{ id: 'route', name: 'Suggested route', features: [ { type: 'polyline', points: [[35.62, 139.78], [35.66, 139.70]], tone: 'success', width: 4 }, { type: 'circle', center: [35.66, 139.70], radiusM: 1500, dash: 'dash', fill: true, label: 'Reachable on foot' }, ] }] } }, // 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 — and it is the only way to get a glyph in, because every string a hook returns is emoji-stripped at the render boundary before TREK draws it. A table `action` (instead of a `column`) is a labelled button whose target opens your sandboxed frame or calls one of your routes. --- ## Offer a routing profile (EV charging stops) Declare a profile and implement the `routeProvider` hook — your profile appears in the planner's route toggle next to Driving/Walking, and TREK asks *you* to route the day. Needs `hook:route-provider`; add `http:outbound:` if you call an external routing service. ```json "permissions": ["hook:route-provider", "http:outbound:api.example-ev.com"], "egress": ["api.example-ev.com"], "capabilities": { "routeProfiles": [{ "id": "ev", "label": "EV (charging)" }] } ``` ```js hooks: { routeProvider: { async getRoute({ tripId, dayId, profile, waypoints }, ctx) { // waypoints = the day's located stops in visit order (2..30). const plan = await solveEvRoute(waypoints) // your solver / external API return { coordinates: plan.polyline, // [lat,lng][] for the map line distance: plan.meters, duration: plan.seconds, // driving + charging legs: waypoints.slice(1).map((_, i) => ({ distance: plan.legs[i].meters, duration: plan.legs[i].seconds, note: plan.legs[i].chargeMin ? `${plan.legs[i].chargeMin} min charge` : undefined, })), viaPoints: plan.chargers.map((c) => ({ lat: c.lat, lng: c.lng, tone: 'success', label: `${c.name} · to ${c.targetSoc}%`, dwellSeconds: c.chargeMin * 60, })), } }, }, }, ``` The legs must line up 1:1 with the waypoint pairs (TREK rejects the result whole otherwise), `note` shows on the day-plan connector, and via points are drawn as stops on the route line. You have 20 s per request; a throw or timeout simply falls back to straight lines. Pair this with `mapLayerProvider` if you also want corridors/zones, and with `db:write:places` + `db:write:itinerary` if the user should be able to persist charging stops as real places. To also make the *time plan* reflect your stops, add `hook:day-schedule-provider` and return anchored time rows — they render in the day plan on desktop and mobile, and their minutes count into the day's route-footer total: ```js dayScheduleProvider: { async getSchedule(tripId, ctx) { const days = await ctx.trips.getDays(tripId) return days.flatMap((d) => (d.assignments ?? []) .filter((a) => needsCharge(a)) .map((a) => ({ id: `charge-${a.id}`, dayId: d.id, assignmentId: a.id, minutes: 35, label: 'Charge to 80% nearby', tone: 'warn', }))) }, }, ``` ### Colour-code the days of a multi-destination trip `hook:day-schedule-provider` can announce "Kanazawa begins" as a row, but it cannot make day 12 *look* like it belongs to Kanazawa. `hook:day-tint-provider` does — it colours a day card in the Plan sidebar (and that day's mobile chip), with one of the four tones or with a colour of your own: ```js dayTintProvider: { async getDayTints(tripId, ctx) { const legs = await loadLegs(ctx, tripId) // your own db:own rows const days = await ctx.trips.getDays(tripId) return days .map((d) => ({ d, leg: legs.find((l) => covers(l, d)) })) .filter(({ leg }) => leg) // days no leg covers stay untinted .map(({ d, leg }) => ({ dayId: d.id, tone: leg.tone, label: leg.name })) }, }, ``` Return **one entry per day** — the bound here is the trip's day count, not a fixed item cap, so this stays correct on a six-month itinerary where the ≤60-item day-schedule hook would tint only the first 60 days. #### Bring your own colour Four tones cannot keep a twenty-stop trip's legs apart. Send `color` instead — `#rrggbb` only, and nothing else survives (`#abc`, `rgb(...)` and CSS names are all ignored, because the value ends up inside a CSS colour the host builds): ```js // One hue per leg, from your own palette. return days.map((d) => ({ dayId: d.id, color: legColor(d), label: legName(d) })) // Mix freely: your colour on the badge, a shared tone for the rest of the card. return days.map((d) => ({ dayId: d.id, tone: 'default', badgeColor: legColor(d) })) ``` Each region takes `Color` alongside `Tone`. Within one region a colour wins over a tone, and anything you name on a region beats the shorthand — so the second example above tints the badge with your colour and the header and activity list with `default`. You choose the hue; the host still chooses the weight. It sets the alpha per theme and per region and clamps the colour's lightness into a band that reads on both the light and the dark sidebar, so the tint always lands as a wash behind the day rather than a fill, and no colour you send can make a day unreadable. An ordinary mid-range colour comes through as you sent it; only the extremes (near-white, near-black) are pulled in. Colour is decoration, not information — pair it with `label` (and a day-schedule row where the meaning matters) so it still works for anyone who can't tell your legs apart by hue. #### Tint one region instead of the whole card A day card has three separately tintable regions, and `tone` above is just the shorthand that fills all of them. Name them individually when colouring the whole card would fight the content — a packed day is much easier to read with only its badge marked: ```js // Bold on the badge (and the mobile day chip), plain everywhere else. return days.map((d) => ({ dayId: d.id, badgeTone: legTone(d), label: legName(d) })) // Or: a quiet leg colour on the card, with the activity list left completely alone. return days.map((d) => ({ dayId: d.id, badgeTone: legTone(d), headerTone: legTone(d), label: legName(d), })) // Or: reserve the activity list for a per-day signal of your own. return days.map((d) => ({ dayId: d.id, headerTone: legTone(d), activityTone: overbooked(d) ? 'danger' : undefined })) ``` | Field | Region | |---|---| | `badgeTone` / `badgeColor` | the day-number badge — smallest and boldest; also the mobile day chip | | `headerTone` / `headerColor` | the day header row (number, title, date, cost); hovering deepens the tint | | `activityTone` / `activityColor` | the expanded activity list — largest, behind the densest text, tinted faintest | A region you never name is not tinted and renders exactly as it does without your plugin. The regions follow the **desktop** day card. On mobile only the badge renders, as the day chip's tint — header and activity tints do not show on phones. Keep whatever every user must see on the badge (or the shorthands, which include it), and treat the finer regions as desktop refinement. `label` becomes the day's tooltip. A day takes at most one contribution and it is resolved **whole** — within your list the first entry for a day wins, and if another plugin also tints that day the first granted provider wins outright, so days never flicker and two plugins can't each own part of one card. --- ## 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 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, { name: 'Hotel', total_price: 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, { text: '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` |