# Termix plugin guide SDK 1.1.2 | Plugin API 1.15.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. Run `npm update @termix-ssh/plugin-sdk` to get the newest one. If this guide and the SDK's TypeScript types disagree, the types are right. They live next to this file in `dist/*.d.ts`. The long form of everything here is at https://docs.termix.site/develop, and the raw Markdown of each page is at `https://raw.githubusercontent.com/Termix-SSH/Docs/main/develop/.md` (for example `develop/backend.md`), which is easier for tools to read than the site. ## What a plugin is Termix is self-hosted, plugin-based server management. Core keeps hosts, credentials, users, sharing and the shell. Everything else, even the SSH terminal, is a plugin built with this SDK. A plugin is: - `manifest.json`: what it is, what it adds and what it may do. Termix reads it without running any code. - A backend, `src/backend/index.ts`, run inside the Termix server process. It exports `activate(ctx)`. - A frontend, `src/frontend/index.tsx`, run in the browser in the same page as Termix. It exports `activate(app)`. There is no sandbox. Capabilities make a plugin honest and easy to audit, but code runs with the server's rights. Write it as if an admin will read every line, because reviewers do. ## Hard rules Break one of these and the plugin fails to load, fails validation or gets rejected in review. 1. Import only `@termix-ssh/plugin-sdk/*`, your own files and npm packages. Never import Termix's own code. 2. Create everything inside `activate`. No servers, timers, sockets or connections at the top of a module. Disable then enable runs `activate` again on the same module, and it must work without a restart. 3. Clean up what you make yourself with `ctx.disposables.add(fn)` (backend) or `app.onDispose(fn)` (frontend). Things made through `ctx` and `app` are cleaned up for you. 4. Never call `process.exit` or touch signals. 5. Never open your own SSH connection. Use `ctx.ssh` so jump hosts, proxies, host keys, every auth type and sharing work the way the user set them up. 6. Outbound HTTP goes through `ctx.fetch` (needs `network:outbound`), not a raw `fetch` or axios. 7. Ask for the fewest capabilities you can. A call that needs one you didn't list throws `PluginCapabilityError`. 8. Never take a user id from a request body or a service caller. Use `ctx.currentActor()`. 9. Check permissions on the backend. Frontend checks only hide UI. 10. Call `await ctx.db.persist()` after every database write. On SQLite a write without it can be lost on restart. 11. Never use `.returning()` in a query. MySQL doesn't have it. Read a new row back by a key you chose. 12. Never edit a migration that has shipped. Termix checks a checksum and refuses to start the plugin. Add a new one. 13. Never index a `text()` or `json()` column. Use `varchar(n)` for anything you index. 14. Never hard code UI text. Every string goes in `locales/en.json` and is read with `t()`. 15. The plugin `id` never changes once released. It names your tables, routes and permissions. 16. The version in `manifest.json`, `package.json` and the newest `CHANGELOG.md` section must match. 17. Put an `@openapi` JSDoc block above every route. 18. No obfuscated or minified source, and no code downloaded and run at runtime. 19. No tracking, telemetry or network calls the user didn't ask for. If you need one, make it off by default and explain it in your docs. ## Project layout ``` manifest.json what the plugin is, adds and may do package.json npm scripts and dependencies CHANGELOG.md release notes, one section per version README.md the repo front page AGENTS.md notes for AI tools working on this plugin docs/index.md the plugin's docs page (more pages next to it) src/backend/index.ts activate(ctx), runs in the Termix server src/backend/tables.ts table definitions, exports `tables` src/backend/routes.ts HTTP routes src/frontend/index.tsx activate(app), runs in the browser locales/en.json every string the plugin shows locales/translated/ other languages, like de_DE.json (optional) migrations/ sqlite/, postgres/, mysql/ and snapshot.json, made by npm run migrations tests/backend/ backend tests (Node) tests/frontend/ frontend tests (jsdom) vitest.config.ts uses the SDK preset .github/workflows/ ci.yml and release.yml, calling shared workflows in Termix-Registry dist/ build output, never commit it ``` ## Commands ```bash npm install npm run build # termix-plugin build: dist/backend.js, dist/frontend.js, CSS, openapi.json npm test # termix-plugin test: Vitest npm run typecheck # tsc npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs npm run migrations # termix-plugin migrations: write migrations after changing tables.ts npm run pack # termix-plugin pack: write -.tmxplug (with LICENSE and NOTICE) npm run format # Prettier npm run format:check ``` ## Done checklist Before you call a change finished: - [ ] `npm test` passes - [ ] `npm run typecheck` passes - [ ] `npm run validate` passes with no warnings you can fix - [ ] `npm run build` passes - [ ] `npm run format:check` passes - [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables) - [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest - [ ] New routes have an `@openapi` block and a test, including without the permission - [ ] New env vars are listed in `env` in the manifest - [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does - [ ] User facing changes are in `CHANGELOG.md` under the version they will ship in ## Manifest ```json { "$schema": "./node_modules/@termix-ssh/plugin-sdk/schema/manifest.schema.json", "id": "hello", "name": "Hello", "version": "1.0.0", "description": "Says hello and keeps a few notes.", "author": { "name": "You", "url": "https://example.com" }, "license": "Apache-2.0", "repository": "https://github.com/you/termix-plugin-hello", "docs": "https://github.com/you/termix-plugin-hello/tree/main/docs", "category": "Productivity", "icon": "Hand", "features": ["A tab from the sidebar", "Notes per user"], "engine": { "termix": ">=26.10.0", "api": "^1.2" }, "capabilities": ["db:own", "network:serve", "ui:surface"], "env": [], "contributes": { "tabs": [ { "id": "hello", "titleKey": "tab.title", "icon": "Hand", "openFrom": ["rail"] } ], "permissions": [ { "name": "use", "titleKey": "permissions.use.title", "descriptionKey": "permissions.use.description", "defaultRoles": ["admin", "user"] } ] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] } ``` Unknown fields are refused at every level, so a typo fails validation. The schema at `node_modules/@termix-ssh/plugin-sdk/schema/manifest.schema.json` has every field. | Field | Notes | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `id` | Lowercase, starts with a letter, 2 to 40 characters, letters, numbers and dashes. Can't be an official plugin's id. | | `version` | Semver. Betas are `X.Y.Z-beta.N`. | | `category` | One of: Terminal, Files & Transfer, Infrastructure, Monitoring, Networking, Access & Security, Productivity. | | `icon` | A [Lucide](https://lucide.dev/icons/) icon name, like `Hand`. | | `features` | Up to 20 short lines shown on the plugin's page. | | `docs` | An https link to your docs. | | `engine.termix` | Termix versions you work with, like `>=26.10.0`. Older Termix builds don't list a release they can't run. | | `engine.api` | Plugin API range, like `^1.2`. A major bump of the API is breaking. | | `capabilities` | What your code may do. See the reference at the end. | | `dependencies`, `optionalDependencies` | Other plugins, `{ "snippets": "^1.0.0" }`. You must keep working without optional ones. | | `provides`, `requires` | Services you offer and use. | | `env` | Every environment variable your backend reads. `validate` warns about unlisted `process.env.X`. Prefer settings. | | `desktop` | `mirror` (default), `local` or `server`: how a linked desktop app treats the plugin. | | `nativeDependencies` | npm packages with `.node` bindings. Avoid them: community plugins can't count on them. | `contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`, `settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`, `uiPresets`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first. ### Mobile app `contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the plugin as having nothing to show (official plugins are mapped by the app itself). ```json "mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] } ``` | Field | Notes | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. | | `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. | | `sizes` | `webview` only. `phone`, `tablet` or both (the default). | | `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. | | `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. | | `note` | A locale key or plain text the app shows next to the plugin. | `build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`. How a `webview` plugin runs in the app: - Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's plugin list. - Every request and socket goes through the app, which adds the login, proxy headers and client certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your settings and a short list of core reads (hosts, folders, permissions, current user, version, health, activity). Other core routes answer 403, so call your own backend instead. - Images load straight from the server, without proxy headers or a client certificate. - Nothing in `localStorage` survives closing the view. Keep preferences in settings. - Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()` is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app. - The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API is too old for the range, it asks the user to update the app instead of opening the view. When the range is for an older API major, it asks to update the plugin. Ask only for the API you use. - Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`. Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs `"engine": { "termix": ">=26.10.6", "api": "^1.13" }`. Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your code may reach. A permission (`hello.use`, with a dot) is what a user may do. ## Backend ```ts import type { Router } from "express"; import type { PluginContext } from "@termix-ssh/plugin-sdk/backend"; import { notes } from "./tables.js"; import { registerRoutes } from "./routes.js"; export async function activate(ctx: PluginContext) { const notesTable = await ctx.db.define(notes); registerRoutes(ctx.http.router(), ctx, notesTable); } export async function deactivate() { // Everything registered through ctx is disposed by core. } ``` Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own files use `.js`. ### What ctx has | Member | Capability | What it does | | ---------------------- | -------------------------------- | --------------------------------------------------------------- | | `pluginId`, `manifest` | none | Your id and manifest | | `log` | none | Log lines tagged with your plugin | | `db` | `db:own` | Your own tables | | `kv` | `kv:own` | Small key value state | | `files.dataDir()` | `files:own` | A folder of your own | | `settings` | none | Your settings | | `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables | | `http`, `ws` | `network:serve` | Routes and sockets | | `rbac` | none | Check the actor's permissions | | `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor | | `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline | | `credentials` | varies | The actor's saved keys, and protocol logins | | `services` | per service | Call other plugins and offer your own | | `events` | `events:core` for core topics | Emit and listen for events | | `notify` | `notify:send` | Send alerts to the Alerts inbox | | `fetch` | `network:outbound` | Outbound HTTP with SSRF protection | | `process` | `process:spawn` | Run programs on the server | | `schedule` | none | Timers that stop on disable and never overlap | | `audit.record` | none | Write your own audit line | | `sync` | none | Sync your tables to a linked desktop | | `auth` | `auth:provide` | Login methods, second factors, SSH auth types | | `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user | | `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors | | `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops | | `ssh.transports` | `ssh:transport` | Carries a host's SSH connection over your own stream | | `hosts.folders` | `hosts:write` | Create a folder row with a color and icon, remove it when empty | | `users.getLocale` | `users:read` | A user's UI language | | `system` | `system:tls` | The server's HTTPS certificate | | `plugins.list()` | `plugins:read` | Which plugins are installed | | `asUser(userId, fn)` | always audited | Run background work as a user | | `currentActor()` | none | The user the current call runs for | | `disposables.add(fn)` | none | Extra cleanup on disable | ### The actor In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()` returns it. Background work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies that user's permissions. Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with `ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer `. It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's UI language, like `zh_CN`, or null, for writing to them in it. ### Routes Routes live at `/plugin-api//`, sockets at `/plugin-ws//`, on the main Termix port. You never need your own port or an nginx change. Before your handler runs, Termix checks sign in, sets the actor, answers 503 if the plugin is off, parses JSON up to 2 MB and catches errors. ```ts router.use(ctx.rbac.require("use") as never); /** * @openapi * /plugin-api/hello/notes: * post: * summary: Add a note for the signed-in user * tags: * - Hello * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * text: * type: string * responses: * 201: * description: Note added * 400: * description: Missing or empty text */ router.post("/notes", async (req, res) => { const text = typeof req.body?.text === "string" ? req.body.text.trim() : ""; if (!text) return res.status(400).json({ error: "text is required" }); const db = await ctx.db.client(); await db .insert(notesTable) .values({ userId: ctx.currentActor() as string, text }); await ctx.db.persist(); res.status(201).json({ success: true }); }); ``` - The `@openapi` path is the full path, starting with `/plugin-api//`. `build` turns these into `dist/openapi.json` and the docs site renders them. - `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes have no actor: check their token yourself. Core allows 120 public requests a minute per address; `publicRateLimit` raises that for a proxied page that loads its own assets. - `ctx.http.baseUrl(req)` is the public address, for callback URLs. - `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check `conn.isDataUnlocked()` per message. - `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like `hosts.edit` checks a core permission. - Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`. - Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId, host), open(host) })`, where `host` has `id`, `ip`, `port` and on Termix 26.10.6+ also `userId`, `syncId`, `connectionOrigin` and `localOnly` returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves `/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another. - Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and `allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and `ctx.credentials.list()` finds the acting user's credentials by name. - `ctx.credentials.setSshKeyCertificate(credentialId, certificate)` stores a CA-signed OpenSSH certificate (a `-cert.pub` line, or `null` to clear it) on one of the acting user's SSH key credentials. Core then offers key plus certificate on every connection through that credential, so a plugin that issues certificates can keep the credential current instead of asking the user to paste it. - Connect policy: with `hosts:connect-policy`, `ctx.hosts.connectPolicy.provide({ check(input) })` is asked before every SSH config is built and every remote desktop login is handed out (`input` has `userId`, `hostId`, `protocol`, `purpose`, `profile`, `interactive`, `origin`). Return `{ decision: "allow" }`, `{ decision: "deny", reason }` or `{ decision: "request", reason, requestTab }`. The strictest answer of all providers wins and a provider that throws denies. SSH prepare then gives an error outcome with code `access-denied` or `access-required`, and `ctx.credentials.resolveHostProtocol` throws an error with that `code` and a `toJSON()` body for a 403. Keep `check` fast, it also runs for background polling. ### Errors Errors from routes, sockets, listeners and timers count against an error budget. Five in a minute and Termix turns the plugin off (state `failed`). A 4xx you send on purpose doesn't count. Validate input and send 400s instead of throwing. ### SSH ```ts const { client, dispose } = await ctx.ssh.connect(hostId, { purpose: "hello", profile: "background", }); try { // use the ssh2 client } finally { dispose(); } ``` `ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and sudo commands. `@termix-ssh/plugin-sdk/host-scripts` builds Docker, metrics, systemd and journal commands and parses their output; send a script on stdin to the command `scriptShell()` returns. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`. ### The desktop app Set `"runtime": "desktop"` in the manifest for a plugin that only makes sense on a desktop app. It never loads on a server, ignores server sync and may declare `desktop:local`. Any plugin can declare `desktop:local` and check `ctx.desktop.local.available()` at runtime. `ctx.desktop.local` works on the desktop app's own backend only: - `info()`: home folder, platform, user data folder, backend port and the linked server (url and token) or null - `fs`: `list`, `mkdir`, `createFile`, `rename`, `ensureDir`, `exists`, `walk`, `trash`, `reveal`, `open`, `openRead` and `openDownload`. Downloads stay inside the folder the user picked, links in it are refused and an existing file is never replaced by accident - `pty.spawn()`: a local shell, stopped when the plugin stops - `pluginFetch(origin, subPath, init)`: calls this plugin's own routes on the desktop's backend ("local") or the linked server ("remote") as the acting user. Bodies can be streams, so a plugin can move big files without the renderer Use `ctx.audit.subscribe`, `list` and `exportBatches` (`audit:read`) to read the audit trail. Subscribers are called after a line is accepted and never delay the action that wrote it. ### Events, timers, alerts, services ```ts ctx.events.on("host.deleted", ({ hostId }) => cleanUp(hostId)); // core topics need events:core ctx.events.emit("plugin.hello.greeted", { name }); // your own: plugin.. ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 }); ``` - Alerts go through `ctx.notify.send` (`notify:send`). Never build your own notification system. - Offer a service: declare it in `provides` with a version and a permission, then `ctx.services.provide("hello.greetings", impl)`. The name starts with your id. Methods read the caller from `ctx.currentActor()`. - Use a service: declare it in `requires`, then `ctx.services.get("snippets.access")`. It returns nothing while no provider runs, so always handle that. ### Operations Declare what your plugin can do in `contributes.operations`, and it shows up in Automations, Fleets, the assistant, the CLI, the phone app and the command palette with no other wiring. It also gets per host results, dry runs, run history, approvals and drift checks. ```json "capabilities": ["operations:provide"], "engine": { "termix": ">=26.11.1-0", "api": "^1.13" }, "contributes": { "operations": { "actions": [{ "id": "demo.restart", "titleKey": "ops.restart.title", "scope": "host", "inputs": [{ "key": "name", "type": "string", "labelKey": "ops.restart.name", "required": true }], "risk": "change", "permission": "use", "hostAccess": "edit", "dryRun": "describe" }] } } ``` ```ts ctx.operations.register("demo.restart", { describe: (input) => `Would restart ${input.name}`, run: async (input, { hostId, signal, log }) => { return { summary: `Restarted ${input.name}`, changed: true }; }, }); ``` - Actions: `scope` is `host`, `resource` or `server`. `risk` is `read`, `change` or `destructive`. Host actions declare `hostAccess`. Use `edit` for changes unless people with `connect` already do this today. A destructive action must use `edit` or `manage` and needs a confirm. - Inputs use the settings field types, plus `required`, `pattern`, `optionsFrom` (a read action fills the choices) and type `resource`. Keys you did not declare are refused before your handler runs. `secret` values reach the handler but are never stored or shown. Mark private outputs `sensitive`. - `dryRun`: `native` calls `run` with `dryRun: true` and nothing may change. `describe` calls `describe` instead. - `check` names a read action that proves the result, used for drift. `undo` names the reverse action, and `run` returns `undoInput`. `idempotent` lets a rollout retry it. `exclusive` allows one at a time per host. `when` limits it to hosts by setting or OS. - `surfaces`: the assistant and MCP are off until you set them true. Only do that for actions safe for a model to ask for. The rest are on unless you set them false. - Resources describe a kind of thing, such as a container: a read action on one host returns `{ rows }` as `data`, and core draws the All hosts table and host page section. Attach actions with `scope: "resource"`. - Signals are status tiles: a read action returns `{ value, status, label }` as `data`, with status `ok`, `warn`, `fail` or `unknown`. `fix` names the action offered when it is not ok. Keep these reads cheap, they run for every host. - Events: declare them, then call `ctx.operations.emit(id, payload, { hostId })`. - Handlers must watch `signal` and stop when it fires. Throw on failure; a failed run never counts against your plugin's error budget. Throw an error with `code = "NEEDS_INTERACTION"` when a host needs a person to sign in first. - Handlers have no request and no session. Take what you need from the input and `hostId`. - Keep your REST routes. Inside one, call `ctx.operations.run(id, { hostId, input })` so a button press goes through the same checks and lands in the same history. - To run another plugin's action, add `operations:invoke`, list it in `operations.invokes` and call `ctx.operations.invoke(id, { hostId, input, via: "automation" })`. It acts as the current user and rules still apply. - `risk` is your plugin's claim. Plugins run in-process, so this is not a sandbox. Actions from plugins not signed as official count as `change` when rules decide what needs a person, and stay off the assistant and MCP until an admin allows them. - Set `engine.termix` to at least `>=26.11.1-0`. Keep `engine.api` as it was unless you import something new from `/ui`, or the phone app will ask people to update to see your plugin. ### Shared packages These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`, `cookie-parser`, `axios`, `drizzle-orm`, `sharp` and every Node builtin. List the ones you use in `peerDependencies`. Any other npm package is bundled. ### Workers and WebAssembly Heavy work (decoding, encoding, parsing) belongs in a worker thread so it never blocks the server. Name each worker entry in `package.json`: ```json "termix": { "workers": { "engine": "src/backend/engine/worker.ts" } } ``` `build` bundles it alone into `dist/workers/engine.js`; start it with `new Worker(new URL("./workers/engine.js", import.meta.url))`. A worker bundles everything it imports and can't use the shared packages above, so it must not import `@termix-ssh/plugin-sdk`. Talk to it with `postMessage`. Importing a `.wasm`, `.ttf` or `.otf` file gives its bytes as a `Uint8Array`, in `build` and in tests, so a module ships inside the bundle with no file to find. Compile it once on the main thread with `new WebAssembly.Module` and post that to each worker. Add `declare module "*.wasm" { const bytes: Uint8Array; export default bytes; }` to a `.d.ts` in the plugin. ## Database Needs `db:own`. Define tables once in TypeScript, generate migrations for SQLite, PostgreSQL and MySQL, query with [Drizzle](https://orm.drizzle.team/). ```ts // src/backend/tables.ts import { defineTable, id, refUser, text, timestamp, } from "@termix-ssh/plugin-sdk/db"; export const notes = defineTable( "notes", { id: id(), userId: refUser(), text: text().notNull(), createdAt: timestamp().notNull().defaultNow(), }, { indexes: [{ name: "idx_p_hello_notes_user_id", columns: ["userId"] }] }, ); export const tables = [notes]; // termix-plugin migrations reads this ``` The table is stored as `p__notes`. Index names should use the same prefix. | Builder | Type | | ---------------------------------------------- | --------------------------------------------------------------------------- | | `id()` | Auto increment key | | `text()` | Any length string. Not indexable | | `varchar(n)` | String up to `n` (255 default). Indexable | | `integer()`, `bigint()`, `real()`, `boolean()` | Numbers and booleans | | `timestamp()` | A time, stored as text | | `json()` | JSON stored as text. Not indexable | | `encryptedText()` | Text encrypted with the owning user's key | | `refUser()` | A user id. Rows are deleted with the user and included in their data export | | `refHost()` | A host id. Rows are deleted with the host | Modifiers: `.notNull()`, `.unique()`, `.primaryKey()`, `.default(value)`, `.defaultNow()`. After any change to `tables.ts`, run `npm run migrations` and read the three new files. Renames and type changes are refused rather than guessed: write those by hand, for all three databases. A migration may only touch your own tables. `createTestDb` applies them in tests, so a broken one fails `npm test`. Query through `const db = await ctx.db.client()`, then `await ctx.db.persist()` after writes (`persist({ lazy: true })` for frequent low value writes). `ctx.db.dialect` says which database runs. Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secrets` for secrets, `ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and `contributes.syncEntities`. The entity name is part of the protocol and never changes. A registered table syncs the rows each user owns, both ways. For data you keep for the whole server (a shared library, rows other users share), register a read only entity with `table: null`, `readOnly: true` and `load`, `write` and `erase` (Termix 26.10.6+). `load(userId)` returns the rows that user should get, each with a `syncId`. The desktop keeps them with `write` and drops them with `erase`, and never pushes them back: ```ts ctx.sync.registerEntity({ type: "sharedIcons", table: null, readOnly: true, order: 70, load: (userId) => iconsVisibleTo(userId), write: (userId, row) => storeCopy(userId, row), erase: (userId, syncId) => dropCopy(userId, syncId), }); ``` ## Frontend ```tsx import { useTranslation, type TabProps, type TermixApp, } from "@termix-ssh/plugin-sdk/frontend"; import { EmptyState, PanelShell } from "@termix-ssh/plugin-sdk/ui"; import { Hand } from "lucide-react"; function HelloTab(_props: TabProps) { const { t } = useTranslation(); return ( } title={t("tab.title")}> ); } export function activate(app: TermixApp) { app.registerRailItem({ id: "hello", icon: Hand, titleKey: "tab.title", kind: "tab", permission: "use", }); app.registerTab("hello", HelloTab, { icon: Hand, titleKey: "tab.title", singleton: true, hostless: true, }); } ``` Every `register*` returns an undo function, and everything is undone on disable. Each component runs in your plugin's error boundary and Suspense. | Member | What it does | | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `registerRailItem` | A sidebar rail button | | `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) | | `registerCompanion`, `toggleCompanion` | A panel beside any host tab, bound to its host and handle | | `registerCompanionLayout` | Per host on/off, side, order and hidden panels for a tab type | | `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) | | `registerHostAction` | A host button, palette entry or default connect | | `registerHostEditorSection` | Your own host editor section (host settings get one for free) | | `registerHostProtocol` | A connection protocol next to SSH | | `registerHostBadge`, `registerHostContextMenuItem` | Badges and menu items on host rows | | `registerHostStatusIndicator` | The status dot or stripe on a host row (a layout draws it) | | `registerHostEditorToolbarItem` | A control in the host editor toolbar (a layout draws it) | | `registerPaletteEntry`, `registerPaletteGroup` | Command palette entries | | `registerKeybindingAction`, `registerKeybindingDefault` | Bindable actions | | `registerSettingsComponent` | A component for a `custom` settings field | | `registerAction`, `invokeAction` | Actions across plugins | | `registerSlotContribution`, `declareActionSlot` | Fill or own a UI slot | | `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point | | `registerOnboardingStep` | A first run setup step (`section`, `position`, `replaces`) | | `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI | | `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) | | `api` | Axios client for your `/plugin-api//` routes, auth included | | `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth | | `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers | | `onSettingsChanged`, `onDispose` | Hooks | Badges, status indicators and toolbar items take `{ id, when(host), component, order? }`; lower `order` comes first. An onboarding step sits in a section (`start`, `setup`, `explore`, `security`) or at an exact `position` from 1 to 99. `replaces: ["preset"]` lists older step keys the step took over, so people who saw those are not shown it again. Branding: a plugin that owns the app name, tagline and logo provides `ctx.registry.provide(".brandingProvider", async () => ({ appName, tagline, logo }))` in its backend. Core reads it for `GET /users/branding` (the login page, the browser tab and the sync server name) and falls back to "Termix" with no logo when no plugin provides one. `logo` is a PNG data URL or null. Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`, `useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`, `useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`. Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries your own host settings, under `host.pluginSettings[]`. Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`, `terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`, `hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See https://docs.termix.site/develop/more-extension-points. ## Layouts and themes A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance; admins set the default for everyone. A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register it in `activate`: ```tsx import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend"; import { ShellConfirmCapture, SurfaceScope, TabStage, } from "@termix-ssh/plugin-sdk/ui"; function MyLayout() { const shell = useShell(); if (!shell) return null; return (
{/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */}
); } export function activate(app: TermixApp) { app.registerLayout({ id: "my-layout", label: "layout.name", component: MyLayout, }); } ``` - `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps every session. - `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal keeps its connection. Wrap it in `SurfaceScope kind="tab"` with a `ShellConfirmCapture` inside, so close questions and inline views show over the tab area. Overlays and onboarding stay in core. - The layout draws the command palette. Core keeps whether it is open (`palette.isOpen`, Ctrl+K calls `palette.open()`) and what is in it (`usePaletteEntries`, `usePaletteGroups`, rail items, hosts). - The shell always keeps a home tab with the id and type `dashboard`. `useShell()` leaves it out until some plugin registers a `dashboard` tab type, so a layout without a home screen has no empty tab to draw. - Settings, Manage and the host editor belong to Classic Layout. A layout without them should still render `LookSettings` somewhere so people can switch back. `tabs.openSingleton("plugins")` opens core's own Plugins tab in any layout. Switching layouts closes the tabs the old layout's plugin declared. - `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`. - Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair screen where people can switch, reset, update, turn it off or (for a bundled plugin) restore the copy that shipped. `?safe-layout=1` opens it any time. - Termix has no layout of its own. The default is the bundled Classic Layout plugin (https://github.com/Termix-SSH/Plugin-Classic-Layout), which is also a full example. It owns the rail, sidebar, tab bar, docks, Dashboard, Manage, Settings and command palette, registers the `dashboard`, `host-manager` and `settings` tab types, and offers the host list and dashboard as the components `classic-layout.hostTree` and `classic-layout.dashboard` for other plugins to place with `PluginComponent`. It reads the shell with `useShellController()` from `@termix-ssh/plugin-sdk/ui`, which exposes more than `useShell()` and may change with Termix. A third party layout should start from `useShell()`. A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path, optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on ``, so the file may only hold rules whose selectors start with `.`, `:root.` or `html.`, optionally inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by `termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`, `--foreground`, `--card`, `--border`, `--muted` and the rest). A plugin that only holds themes needs no code. Leave out `src/`: `termix-plugin build` writes an empty backend and `validate` does not ask for a frontend. Keep the CSS in `themes/` and a test that checks it with `validateThemeCss()`. Notes: - A theme with the id of a built-in (`light` or `dark`) replaces core's basic copy while the plugin runs. - The first theme a bundled default-look plugin lists is the default for new installs. - Themes show in the picker in the order core lists its own, then the plugin's order. - A theme someone saved that no longer exists shows as System. It comes back when the plugin does. - Accent color, UI font, font size and density are not part of a theme. Core applies them on top. - Bundled Classic Themes (https://github.com/Termix-SSH/Plugin-Classic-Themes) is a full example. ## Looking native Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of exports with props is `ui.api.txt` in the Termix repo (https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt). - Web pages that break in an iframe: `NativeView` (props `url`, `ignoreCert`, `hidden`, `fallback`) shows the page in a native view in the desktop app. It fills its element, hides while a dialog or menu is open and renders `fallback` in a browser. - Square: panels, cards and containers have no rounded corners. Small controls may stay rounded. - Flat: borders, not shadows. Use theme tokens like `border-border`, `bg-background`, `text-muted-foreground`, `text-destructive`. One accent, `text-accent-brand`. Don't add colors. - Small type: most text 11 to 13px. - No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use `PanePrompt` for small prompts. - Every tab or panel sits in `PanelShell` (inside `PanelFrame` when it is a tab). Lists use `PanelList` and `ListRow`, add with `AddButton`, forms end with `FormFooter`, empty lists show `EmptyState`, loading shows `LoadingState`, and "not installed" or "no access" shows `NoticePanel`. - The tile look: page content sits edge to edge on the page background, with one line between sections and no gaps, padding or card fill around them. The body of a `PanelShell` has no padding. Stack sections with `TileStack` (or `TileColumn` when cards sit deeper), put numbers and small cards in a `TileWall` of `StatTile` or `MetricCard`, and use `TileFrame` for a titled section. Cards drop their own box inside these. Forms keep `PANEL.body` padding inside their section. An `InlineView` form made of `SectionCard`s takes `stacked`. - Page views go in `PanelShell`'s `tabs` band as a `TabStrip`. `Segmented` is for filters and view modes and keeps its natural width, never stretched across the pane. A page with many sections uses `SideNav` on the left. Put the refresh button in the header with `RefreshButton`, and the docs link with the `docs` prop. - Toolbars: search, filters and buttons go in `PanelShell`'s `toolbar` slot, or in a `ToolbarBand` for a second row. Both make every control in the row 32px, so a `size="sm"` button lines up with a search box. Use `ToolbarSelect` for a small select there, never a native `