# 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 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|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: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. 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 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` |