* Tell a plugin where it is mounted A plugin sits in one of six host surfaces and cannot see which: a trip tab and the plugin page fill their container and scroll themselves, a dashboard widget and the settings frame report their height, a detail slot is a narrow column in someone else's panel. The contracts differ, and on a filling surface the height report is discarded, so a plugin cannot even discover the difference by trying. trek:context now carries viewport: the surface by name, phone or desktop, the fill flag, and the insets the host already keeps clear. The insets describe space that is held free, not padding to add — the mobile trip tab is the only surface with chrome floating over it, and the numbers come from the same variables the native tabs use so the two cannot drift. * Seat the plugin tab like a native one The mobile plugin tab kept clearance at the bottom and none at the top, so the plugin's first row sat under the status bar and behind the floating back button. The bottom value was a copied 84 that ignored the safe area on the devices that have one, and the padding sat on the container rather than in the scroller. Both clearances now come from the variables TabScroller uses, so the tab cannot drift away from the native ones. The frame also states its surface, and pins color-scheme for the reason both settings mounts already pin it: Chromium paints a white canvas behind a transparent frame otherwise. The tab also had no name anywhere on screen. It is not in the dock, so nothing lit up there either, and the header had a branch for every other tab. It gets the same treatment, built from the tab entry the planner already assembles. * Keep the widget card intact on the phone The card is built from the dashboard's glass vocabulary, which is scoped to .trek-dash. The phone dashboard is a different route and mounts these widgets outside that subtree, so background, border, shadow and blur all resolved empty and the widget stood on the gradient as a bare title over an iframe. Each value now falls back to the mobile token the surrounding cards are made of. * Give the design kit a mobile layer The kit was written for the desktop and stayed there: a filled card with a drop shadow where the mobile design is translucent and border-led, inputs at 13px which make iOS Safari zoom on focus and never zoom back, feedback on :hover which never fires on a touchscreen, and one media query in the whole stylesheet. It reads the new viewport field now and sets data-form-factor, data-surface and data-fill on the document, plus the insets as variables. Everything mobile hangs off data-form-factor="phone", so nothing changes on the desktop: the card loses its shadow and takes the mobile surface tokens, targets grow to 44px, inputs to 16px, and pressing something scales it instead of waiting for a hover that will not come. A filling surface anchors the body and scrolls an inner element, because its height report is discarded by the host. Plus the pieces a v4 screen is actually made of, so an author does not have to reverse-engineer them: eyebrow, count pill, section header, status dot, round icon button, field, segmented control. Each falls back to a global token where the mobile family is absent, so they work on both. * Document what a plugin needs on a phone The guide had nothing on the subject: no mention of the second token family, of who scrolls, of what sizes to expect, or that the kit brings almost nothing responsive. It also promised more than it delivered, claiming the palette was complete and that applying it matches the host "exactly" — fonts are named, not shipped, so that was never true. The new section covers the six surfaces and which of them fill versus report a height, what insets mean (space already held free, not padding to add), the mobile palette with the branch-on-absence pattern, and the four rules that decide whether it feels native. It also says plainly that the dev preview cannot show a phone width yet.
trek-plugin-sdk
The SDK for building TREK plugins.
The path is four commands: create → dev → status → publish. Everything
else the CLI can do is a step one of those already does for you.
Scaffold a plugin
npx trek-plugin-sdk # no command? a guided menu of everything below
npx trek-plugin-sdk create # interactive wizard (id, type, icon, permissions, egress)
npx trek-plugin-sdk create my-plugin --type widget # or non-interactive
cd my-plugin
The wizard asks for the id, type, icon (validated against lucide), permissions,
egress hosts and required addons, and offers to initialize a git repo and install
dependencies for you. In a non-interactive shell (CI, pipes) every command stays
flag-driven with plain output — no prompts, and machine output (entry JSON,
pack --json, PR URLs) stays on stdout.
What you get runs and packs immediately — but it is not publishable yet: the
README is a template and there is no screenshot. That is deliberate. Writing those
is your job, and status tells you exactly what is left.
Develop with a live reload loop
dev runs your plugin locally — no full TREK needed. It injects a ctx that
enforces exactly the permissions your manifest grants (an ungranted call throws
PERMISSION_DENIED, so you catch missing grants), backs db:own with a real
SQLite file, serves your routes and your page/widget UI, and reloads on save.
npx trek-plugin-sdk dev # http://localhost:4317 — dashboard, routes, UI
Open /preview to see a page/widget rendered in a real sandboxed frame with a
theme/accent/appearance toggle (trek.invoke() is proxied to your routes). Hit a route
as an unauthenticated request with ?_anon=1. Drop a dev-fixtures.json (trips, users,
config) next to your manifest to feed ctx.trips / ctx.users.
Know where you are
npx trek-plugin-sdk status # never fails — it's a map, not a gate
status runs every check the TREK-Plugins registry enforces that can be answered
without a network — which is nearly all of them — and prints the whole journey as a
checklist grouped by stage (Manifest, Code, Docs, Release, Repo): what passes, what
does not, and the one command to run next. Run it whenever you are not sure what
to do; it deliberately never exits non-zero.
validate is the same checks with an exit code. It is the form for scripts and CI;
status is the form for humans. A plugin that passes validate will pass every
registry gate that does not require the tag and the release to exist — those four
run in preflight, which publish does for you.
Build a native UI (page / widget)
The UI is a sandboxed, opaque-origin iframe that can't load TREK's stylesheet — so the
SDK ships it. Put one line in your client/index.html <head>:
<!-- trek:ui -->
dev and pack expand it into the inlined design kit: token-driven styles that
follow the host's theme and accent (glass, cards, .trek-btn, .trek-input,
.trek-chip, .trek-row, hover), plus a window.trek bridge:
trek.onContext((ctx) => { /* ctx.theme, ctx.tokens, ctx.appearance, ctx.user, ctx.tripId */ })
const data = await trek.invoke('/status') // calls your own route, host-proxied
trek.notify('success', 'Saved')
Keep UI state for this browser tab
The opaque frame cannot use browser storage directly. The bridge provides host-managed, JSON-only session state instead. It survives an iframe remount and page reload in the same browser tab, but is not durable data. Do not use it to store secrets.
// Default: this user + this plugin + this browser tab.
await trek.session.set('dismissed-onboarding', true)
const dismissed = await trek.session.get('dismissed-onboarding')
// Explicitly partition state by the trip in view (fails without a trip context).
await trek.session.set('filters', ['flight', 'hotel'], { scope: 'trip' })
get() returns undefined for a missing key. set, get, remove, and
clear accept { scope: 'plugin' | 'trip' }; plugin is the default. Use your
plugin's own logical key (for example reservation:88:expanded) for any finer
partitioning. Each plugin or individual trip scope is limited to 32 keys,
64 characters per key, and 1 KiB per JSON value.
The kit applies the theme, mirrors the appearance flags (reduced-motion,
no-transparency) and auto-reports your height. It also upgrades any native
<select> into a host-styled, keyboard-accessible dropdown that matches TREK —
the OS-drawn popup never could. Write a plain <select> and it just works; add
data-trek-native to opt a field out. See the
Plugin Development wiki
for the full component + token reference.
Write a plugin
const { definePlugin } = require('trek-plugin-sdk')
module.exports = definePlugin({
async onLoad(ctx) {
await ctx.db.migrate('001', 'CREATE TABLE cache (k TEXT PRIMARY KEY, v TEXT)')
},
routes: [
{ method: 'GET', path: '/status', auth: true, async handler(req, ctx) {
return { status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":true}' }
}},
],
})
Your plugin runs in an isolated child process. ctx is the only way to reach
TREK, and it grants exactly the permissions your trek-plugin.json declares — an
ungranted call throws PERMISSION_DENIED.
Test without a running TREK
import { createMockHost } from 'trek-plugin-sdk/testing'
const { ctx, broadcasts } = createMockHost({
grants: ['db:read:trips', 'ws:broadcast:trip'],
trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
})
// the mock enforces the SAME permission model, so you can prove your plugin
// degrades gracefully when a permission is missing.
Capture the screenshot
The registry requires a screenshot that resolves to a real image, and the store card
shows it. shot boots the dev server, renders your plugin in the same themed frame
TREK uses, and writes a 1600×900 docs/screenshot.png:
npm i -D playwright && npx playwright install chromium # once
npx trek-plugin-sdk shot # --dark for the dark theme
Playwright is deliberately not a dependency of this SDK — it ships a browser, and
most authors never need one. An integration plugin has no UI to render, so shot
cannot help; screenshot the TREK surface your plugin changes instead.
Publish — one command
Commit and push your plugin to its public GitHub repo, then:
npx trek-plugin-sdk publish --repo you/repo --tag v1.0.0
publish is the whole release, in five steps and in this order:
- check — every registry gate that can be checked locally
- pack — build
plugin.zip - release — git tag, push, and cut the GitHub release with the artifact
- preflight — the gates that need the tag and the release to exist
- submit — open the PR against the TREK-Plugins registry
Step 1 comes first for a reason: a GitHub release is effectively immutable, because
the registry pins its sha256. If a check fails, nothing is packed, tagged, pushed
or released — so you can fix it and re-run against the same version. (--no-checks
skips step 1; it is an escape hatch for a re-run, never right on a first publish.)
Needs git and an authenticated gh. It prints the PR URL at the end.
Updating a listed plugin: bump version in the manifest, commit, and run
publish again with the new tag — it detects the existing entry and prepends the
new version, newest-first.
Prefer to drive the steps yourself? They still exist individually — pack,
release (pack → GitHub release → entry), preflight, submit (opens the PR),
and entry (just prints the JSON).
Signing (recommended — publish offers it)
In a terminal, publish asks whether to sign, and creates the key for you if you
have none — you don't have to know --sign or keygen exist. Scripts and CI are never
prompted: pass --sign.
A signature proves the artifact came from you, not just that its bytes match what the registry saw. Signing is dependency-free Ed25519 over the artifact bytes.
It is a one-way door you may walk through late:
- Unsigned throughout is fine — the sha256 pin is the only guarantee, and TREK accepts it.
- Unsigned → signed later breaks nobody. Nothing is pinned until a signed version installs, so adding a key at v1.4.0 is a real option, not a lost cause.
- Signed → unsigned is refused forever, on every instance that already has the plugin.
publishrefuses that at step 1, before anything is tagged or released.
So back the key up (~/.trek-plugin/signing.key). Losing it means you cannot ship an
update to your own plugin without a registry maintainer override.
npx trek-plugin-sdk publish --repo you/repo --tag v1.1.0 --sign # or just answer the prompt
Exports
definePlugin(def)+ all the plugin types (PluginContext,PluginRoute,PluginJob,PhotoProvider,CalendarSource).PLUGIN_API_VERSION— embed asapiVersionin your manifest.validateManifest(json)— the manifest rules the server loader uses.createMockHost(opts)(fromtrek-plugin-sdk/testing).TREK_UI_CSS,TREK_THEME_JS,TREK_UI_MARKER,injectTrekUi(html)— the design kit, for authors who inline it themselves (a bundler, a custom build). Most plugins just use the<!-- trek:ui -->marker instead.
Commands
Run any of these with npx trek-plugin-sdk <command> (or the short trek-plugin
bin if you install the package). trek-plugin help <command> — or
trek-plugin <command> --help — prints a full page for any of them.
The path:
create [name] [--type t] [--template blank|notification-channel] [--interactive]— scaffold a plugin; a wizard if you omit the name.dev [dir] [--port 4317]— run locally with a real request loop, SQLitedb:own, and hot reload.status [dir]— where am I? what's left? Every offline registry gate as a checklist, plus the next command. Never fails.publish [dir] --repo o/n --tag vX [--sign [key]] [--no-checks] [--no-preflight]— the lot: check → pack → release → preflight → open the PR. In a terminal it offers to sign (and makes you a key); scripts pass--sign.
Also:
validate [dir]— the gate: the same checks asstatus, but it exits non-zero.pack [dir] [--out plugin.zip] [--json]— build the artifact, printsha256+size. Refuses a plugin that could not load; does not enforce the publish gates, because packing is how you sideload a plugin to try it.shot [dir] [--port 4317] [--out docs/screenshot.png] [--dark] [--no-serve]— capturedocs/screenshot.png. Needs Playwright.keygen [--key file]— create an Ed25519 signing key.sign [zip] [--key file]— print a signature + public key for an artifact.entry --repo o/n --tag vX [--merge f] [--sign [key]] [--out f]— emit the registry entry JSON.preflight [dir] --repo o/n --tag vX [--entry f] [--all]— the registry checks that need the network: the tag resolves to the pinned commit, the released artifact downloads and hashes, the id is not bound to another owner, and an update does not drop or rotate a published signing key.submit [dir] --repo o/n --tag vX [--registry o/n] [--draft]— open the registry PR for you.release [dir] --repo o/n --tag vX [--sign [key]] [--merge f]— pack → GitHub release → entry, without opening the PR.
Update notice
Both CLIs (trek-plugin and create-trek-plugin) tell you when a newer SDK has been
published. This matters more than the usual "you're on an old version" nag: the
registry entry format, the manifest rules and the permission catalog all move with the
TREK host, so a stale SDK can pack and submit an entry that today's registry CI
rejects.
It is powered by update-notifier,
the standard for npm CLIs:
- At most once every 24 hours, a detached background process asks
registry.npmjs.orgfor this package'slatestversion and caches the answer under$XDG_CONFIG_HOME/configstore/(or~/.config/…). - Your command never waits for it — the notice is printed from that cache, so a fresh install learns about an update on a later run.
- The notice goes to stderr, so
pack --jsonandentrykeep piping clean JSON.
The request is an unauthenticated GET for a public package — the same one npm install
makes (npm's servers see your IP, as for any download). TREK has no telemetry, and this
isn't any. To turn it off:
export NO_UPDATE_NOTIFIER=1
It is already silent in CI (any CI env var), under NODE_ENV=test, and whenever
stdout isn't a terminal (i.e. when piped or redirected).
The SDK tooling in this repo is MIT. Your plugin is your own code under your own license.