Files
TREK/wiki/Plugin-Cookbook.md
T
jubnl b82962f95b Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors

* feat(plugin-sdk): instance-scoped settings actions

* feat(plugins): run instance-scoped actions from the admin settings dialog

* feat(admin): instance-scoped plugin actions in the settings dialog

* fix(plugins): surface instance actions without instance fields and harden the action gate

* docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope

* fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window

* docs(wiki): plugin pages for instance actions and the admin panel changes
2026-09-02 23:06:03 +02:00

28 KiB

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 *_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 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 reads { date?, notes? } and always appends at the end — a position is honoured on the REST route only, not on the plugin path.
// 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 { 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.

events: [
  { on: 'file:created', async handler({ tripId, entityId }, ctx) {
      await notifySlack(`New file on trip ${tripId}`)   // needs http:outbound:<host>
  } },
]

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

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

…and one for the admin. A button that acts on instance configuration rather than one person's credentials takes "scope": "instance":

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

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:<host> 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 on the plugin definition, or a dynamic timer you set at runtime via ctx.scheduler.

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.

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:<solver-host> if you call an external routing service.

"permissions": ["hook:route-provider", "http:outbound:api.example-ev.com"],
"egress": ["api.example-ev.com"],
"capabilities": { "routeProfiles": [{ "id": "ev", "label": "EV (charging)" }] }
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:

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:

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

// 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 <region>Color alongside <region>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:

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

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.

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

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