Repository navigation
Writing extensions
This page is for authors. An extension is a folder (or a .sextant-ext archive of it) with a manifest, sextant-extension.json, and a web page: HTML, JavaScript and CSS. The manifest says what the extension adds to Sextant and what it may do in clusters; the page runs in an isolated frame and talks to Sextant by messages. How users install and control extensions is on Extensions.
The SDK lives in the Sextant repository, in sdk/: the package @sextant/extension-sdk (types, the client for the frame, the theme helper, a test host), the command sextant-ext (build, pack, check), a template and two examples. It is not published to npm; you use it from a checkout of the repository.
Use the SDK build. It bundles your TypeScript and puts the scripts and styles inside the HTML page. A hand-written page may also load its own files with
<script src>and<link rel="stylesheet">— only from its own package.
Contents: Quick start · The package · The manifest · Contributions · Views and the context · The API · Watching objects · Errors · Theme and language · Storage · Permissions and confirmations · Development mode and debugging · Testing · Packaging and publishing · API versions · The wire protocol · Example: hello-page · Example: crd-view
You need a checkout of the Sextant repository and Node.js 22 or newer (see Building from source); npm ci at the root of the checkout installs what the SDK uses (Vite, TypeScript, Vitest).
The template, sdk/template, is an extension with one page:
sdk/template/
├── sextant-extension.json the manifest
├── index.html the page every view opens
├── package.json build, pack, typecheck, test scripts
├── tsconfig.json
└── src/
├── main.ts connects to Sextant and draws the page
├── hello.ts the page itself
└── hello.test.ts a test against the SDK's test host
Build it and load it into Sextant:
npm ci
npm run sdk:build -- sdk/template # writes sdk/template/dist/In Sextant: Settings → Extensions → Load unpacked folder…, choose sdk/template/dist, and Install. A section Hello appears in the cluster's sidebar with the page Hello; it shows the cluster and the theme, and its button shows a notice. Change src/hello.ts, build again, and Sextant reloads the page within a second.
To start a project of your own, copy sdk/template anywhere and point its dependency at the SDK of your checkout:
"devDependencies": {
"@sextant/extension-sdk": "file:/path/to/sextant/sdk",
…
}Then, in your project:
npm install
npm run build # sextant-ext build . → dist/
npm test # vitest with jsdom
npm run typecheck # tsc --noEmit
npm run pack # build, then sextant-ext pack dist → <id>-<version>.sextant-extGive the extension its own id, name, author and description in sextant-extension.json (the template's id is example.hello). The $schema line of the template points to the schema by a path inside the repository; in a copy, fix the path or remove the line (Sextant ignores it).
A package is a folder with sextant-extension.json at its top, the HTML file named by entry, and whatever that page loads:
dist/
├── sextant-extension.json
└── index.html (the SDK build puts the JavaScript and CSS inside it)
Rules (checked by Sextant at install and by sextant-ext pack):
- at most 2000 files and 50 MB in all; the manifest at most 256 KB;
- plain relative paths only: no
.., no absolute paths, no\:*?"<>|, no names ending with.or a space, no Windows device names (nul,con.txt,com1, …), no two names that differ only by case, no symbolic links; - entries whose name starts with
.andnode_modulesfolders are left out of a package and never served; - the files are served by Sextant at
sextant-ext://<id>/<path>(Windows:http://sextant-ext.localhost/<id>/<path>), withContent-Typeby extension (.html,.js,.mjs,.css,.json,.svg,.png,.jpg,.gif,.webp,.avif,.ico,.woff,.woff2,.ttf,.otf,.txt,.md; anything else isapplication/octet-stream) and never cached; - every response carries the content policy
default-src 'none'; script-src <base> 'unsafe-inline'; style-src <base> 'unsafe-inline'; img-src <base> data:; font-src <base> data:; connect-src 'none'; form-action 'none'; sandbox allow-scripts allow-forms allow-popups-to-escape-sandbox, where<base>is the extension's own address (sextant-ext://<id>/, on Windowshttp://sextant-ext.localhost/<id>/): no network at all (fetch,XMLHttpRequestand WebSockets fail), no scripts, styles, images or fonts from elsewhere. Images and fonts may bedata:URLs (the SDK build turns small ones, up to 64 KB, intodata:URLs).
sextant-extension.json is checked strictly against a JSON schema, crates/sextant-core/src/extensions/manifest.schema.json: any unknown field or wrong value refuses the install with the path of the field, for example
sextant-extension.json: contributes.pages[0].id: …
Check a manifest without installing it: node sdk/scripts/cli.mjs check <folder or manifest> (in a project: npx sextant-ext check .). An example with every top-level field:
{
"$schema": "https://raw.githubusercontent.com/FriendsOfKubernetes/sextant/main/crates/sextant-core/src/extensions/manifest.schema.json",
"id": "acme.widgets",
"name": "Widgets",
"version": "1.2.0",
"description": "Pages and actions for Acme's Widget resources.",
"author": "Acme platform team",
"sextantApi": 1,
"entry": "index.html",
"icon": "customResource",
"permissions": {
"clusters": { "type": "all" },
"rules": [
{
"namespaces": { "type": "all" },
"kinds": { "type": "only", "kinds": [{ "group": "acme.example.com", "kind": "Widget" }] },
"actions": ["read", "modify"]
}
]
},
"contributes": {
"pages": [{ "id": "board", "title": "Widget board", "section": "customResources" }]
}
}| Field | Required | Rules |
|---|---|---|
$schema |
no | Any string (up to 2048 characters); lets an editor find the schema; Sextant ignores it. |
id |
yes | 3–64 characters: lowercase letters, digits, . and -; starts with a letter or digit, does not end with ., no .., not a Windows device name. Unique: it names the extension's folder, its data and its contributions. |
name |
yes | 1–80 characters, not only spaces. Shown in the list, the consent window, confirmations and the log. |
version |
yes | A semantic version: 1.2.0, 1.2.0-beta.1, 1.2.0+build.5 (up to 64 characters). |
description |
yes | Up to 500 characters (may be empty). |
author |
yes | 1–100 characters, not only spaces. |
sextantApi |
yes | The major version of the extension API: 1. |
entry |
yes | The HTML file (.html or .htm) every view opens, relative to the package root, such as index.html or app/index.html; up to 255 characters; no part may start with . or be node_modules. Must be in the package. |
icon |
no | A name of Sextant's icon set (letters, digits and -, starting with a letter, up to 40), such as customResource, pod, deployment, workloads, config, network, storage, overview, scale, logs. The names are those of src/components/icons.tsx; an unknown name shows the default icon. |
permissions |
yes | What the extension may do in clusters, see below. |
contributes |
yes | What it adds, see Contributions; may be {}. |
Sextant fills what may be left out: contribution lists default to empty, confirm of a rule to true, danger and multiple of an action to false.
"permissions": {
"clusters": { "type": "only", "names": ["kind-sextant-test", "staging"] },
"rules": [
{
"namespaces": { "type": "only", "namespaces": ["team-a"] },
"kinds": { "type": "only", "kinds": [{ "group": "", "kind": "ConfigMap" }, { "group": "apps", "kind": "Deployment" }] },
"actions": ["read", "modify"],
"confirm": true
},
{
"namespaces": { "type": "all" },
"kinds": { "type": "all" },
"actions": ["read"]
}
]
}| Field | Rules |
|---|---|
clusters |
{"type": "all"}, or {"type": "only", "names": [...]} with up to 100 kubeconfig context names (unique, not blank). A name covers every context with that name from every kubeconfig source of the user. {"type": "only", "names": []} reaches no cluster. |
rules |
Up to 100 rules. An empty list gives no access to any cluster data (the extension can still list its clusters, show pages and use its storage). |
rules[].namespaces |
{"type": "all"} or {"type": "only", "namespaces": [...]} (1–100 names, up to 63 characters each). Objects without a namespace (Nodes, Namespaces, CRDs, …) are covered only by all. |
rules[].kinds |
{"type": "all"} or {"type": "only", "kinds": [{"group", "kind"}, ...]} (1–100). group is the API group without a version, "" for the core group (Pod, ConfigMap, Secret, …); kinds are compared ignoring case. |
rules[].actions |
1 or more of read, modify, delete, logs, secretValues, no repeats. |
rules[].confirm |
Ask the user before every modify and delete the rule allows. Default true; reads, logs and Secret values are never asked about. |
A call is allowed when any rule covers it; it asks when any covering rule has confirm: true. The user grants exactly what the manifest asks for, at install (see Permissions and consent).
All titles are 1–80 characters (not only spaces) and shown as written; ids are 1–40 characters of lowercase letters, digits and -, unique among contributions of the same type. A kind in kinds is {"group": "", "kind": "Pod"} as in rules. Every contribution shows the extension's entry page with ?view=<view>&id=<contribution id> in its URL; the page learns the same from the context. Inside Sextant a contribution's id becomes ext:<extension id>:<id>.
A sidebar item that opens a page filling the main area (view page). Up to 50.
"pages": [
{ "id": "board", "title": "Widget board", "section": "customResources", "icon": "customResource", "order": 5 },
{ "id": "report", "title": "Weekly report", "section": "acme", "sectionTitle": "Acme" }
]| Field | Rules |
|---|---|
id, title
|
As above. |
section |
One of Sextant's sections, cluster, nodes, workloads, config, network, storage, namespaces, events, helm, accessControl, flux, argocd, customResources, or a section of the extension's own (a letter, then letters, digits and -, up to 40). Own sections come after Sextant's. |
sectionTitle |
The header of an own section (the first page that gives one wins); without it the section id is shown. Ignored for Sextant's sections. |
icon |
Icon of the item; default: the extension's icon, else the default icon. |
order |
−10000 to 10000, default 0; smaller comes first among the section's items. |
A section or a tab in the details panel of objects of the named kinds (view details). Up to 50.
"detailsSections": [
{ "id": "summary", "title": "Widget summary", "kinds": [{ "group": "demo.sextant.dev", "kind": "Widget" }], "placement": "section" },
{ "id": "history", "title": "History", "kinds": [{ "group": "apps", "kind": "Deployment" }], "placement": "tab", "order": 10 }
]-
placement: "section": a section with the title as its header, among Sextant's sections (defaultorder50: after the conditions, before the YAML). The frame is as tall as the page asks (the SDK does it for you; 120 px until the firstresize, at most 5000 px). -
placement: "tab": the details panel gets a tab bar, Overview (Sextant's sections) plus one tab per such contribution; the frame fills the tab. -
kinds: 1–100 kinds.order: −10000 to 10000.
The context's selection holds the shown object (an ObjectRef) and resourceVersion its metadata.resourceVersion, which changes whenever the object changes: read the object again with resources.get (or watch it).

A button in the details panel and an item in the table's row menu for objects of the named kinds. Running it opens a window with the extension's page (view action); ui.close() closes it. Up to 50.
"actions": [
{ "id": "scale", "title": "Scale widget", "icon": "scale", "kinds": [{ "group": "demo.sextant.dev", "kind": "Widget" }] },
{ "id": "purge", "title": "Purge", "kinds": [{ "group": "", "kind": "ConfigMap" }], "danger": true, "multiple": true }
]-
danger: trueshows it in red;multiple: trueoffers it when several rows are selected (otherwise only for one object). - The context's
selectionholds the objects it runs on.

A column in the table of one kind, read with a JSONPath from each object; no extension code runs. Up to 50.
"columns": [
{ "kind": { "group": "demo.sextant.dev", "kind": "Widget" }, "id": "generation", "title": "Generation", "jsonPath": ".metadata.generation", "after": "Replicas" },
{ "kind": { "group": "", "kind": "Pod" }, "id": "ready", "title": "Ready cond.", "jsonPath": ".status.conditions[?(@.type==\"Ready\")].status" }
]-
jsonPath(up to 500 characters) is the JSONPath subset of CRD printer columns:.spec.size,.items[0].name,.items[*].name,.metadata.labels['app\.kubernetes\.io/name'],.status.conditions[?(@.type=="Ready")].status;{…}and a leading$are accepted. A path that does not parse refuses the install; a path that finds nothing shows an empty cell; several values are joined. -
after: the title of the column it goes after (compared ignoring case); at the end when it is missing or not found.

A button in the dock bar, after Sextant's own, that opens a dock tab with the extension's page (view dock); pressing it again brings the same tab forward, and ui.close() closes it. Up to 20.
"dockActions": [{ "id": "console", "title": "Widget console", "icon": "logs" }]Without icon the button shows its title. The context's clusterId is the cluster shown in the window.
A section of the extension in Settings → Extensions, under its row (view settings, contribution id settings). The frame is as tall as its content.
"settings": { "title": "Widgets" }clusterId in its context is the cluster shown in the window, or null.
One HTML page serves every view; the context tells it where it is. With the SDK it is sextant.context after connect(), and onContextChange follows it.
| Field | Meaning |
|---|---|
apiVersion |
1 |
extensionId |
The extension's id |
view |
page, details, action, dock or settings
|
contribution |
The manifest id of the contribution shown (settings for the settings section) |
clusterId |
The cluster the frame is about; pass it as clusterId (the SDK does). null when none is open |
namespaces |
The namespaces selected in Sextant's namespace filter for that cluster; null for all namespaces (and when no cluster is open). Read-only: an extension cannot change the filter |
theme |
light or dark
|
language |
The UI language, such as en
|
tokens |
Sextant's CSS variables and their values (see Theme and language) |
selection |
Details: the shown object; action: the objects it runs on; otherwise empty. ObjectRefs only: {kind: {group, version, kind}, namespace, name}
|
resourceVersion |
Details: the shown object's metadata.resourceVersion; otherwise null
|
Sextant sends the context when the page says it is ready, and again whenever any of it changes (theme, language, cluster, namespace filter, selection, a changed object). Sextant never sends object data on its own: read objects through the API, which checks the extension's permissions.
A typical main.ts:
import { connect } from "@sextant/extension-sdk";
const sextant = await connect();
const root = document.getElementById("app")!;
switch (sextant.context.view) {
case "details": showSummary(sextant, root); break;
case "action": showScale(sextant, root); break;
case "settings": showSettings(sextant, root); break;
default: root.textContent = `Nothing to show in the ${sextant.context.view} view.`;
}connect(options?) connects the page to Sextant and resolves with a Sextant object once the first context arrives (it fails with timeout after 10 seconds, or invalid when the page is not inside a Sextant frame). Options: theme (apply Sextant's theme, default true), reportErrors (report uncaught errors, default true), autoResize (default true for details and settings), logFailures (write failed calls to the console, default true), connectTimeoutMs, timeoutMs (answer timeout of reads, default 60 000), changeTimeoutMs (of apply, patch and delete, which may wait for the user, default 180 000). Every call also takes {timeoutMs} as its last argument.
Every call on a cluster takes an optional clusterId (default: the context's cluster). It may be a cluster id from clusters.list or a context name (when only one cluster has it). A cluster that is not connected is connected first, as if the user pressed Connect.
Methods, the permission each needs, and what they return:
| SDK | Method | Needs | Returns |
|---|---|---|---|
clusters.list() |
clusters.list |
nothing (lists the clusters in the extension's clusters) |
[{id, name, context, server, state}] |
kinds({clusterId?}) |
kinds |
the cluster |
ResourceKind[]: the kinds the cluster serves that some rule names |
resources.list(params) |
resources.list |
Read | {items, truncated, namespaces?} |
resources.get({ref}) |
resources.get |
Read | the object |
resources.watch(params, handlers) |
resources.watch |
Read | a Watch (see Watching objects) |
resources.apply({yaml}) |
resources.apply |
Modify, for every object | the ObjectRefs applied |
resources.patch({ref, patch, patchType}) |
resources.patch |
Modify (and Secret values, see below) | the object as saved |
resources.delete({ref}) |
resources.delete |
Delete | nothing |
logs.tail(params) |
logs.tail |
Logs on the Pod | the log text |
secret.value({namespace, name, key}) |
secret.value |
Secret values on the Secret | the decoded value |
storage.get(key) / set(key, value) / remove(key)
|
storage.* |
nothing | the value or null / nothing |
ui.openDetails(ref) |
ui.openDetails |
nothing | nothing |
ui.navigate(page) |
ui.navigate |
nothing | nothing |
ui.notify(text) |
ui.notify |
nothing | nothing |
ui.close() |
ui.close |
nothing | nothing |
refreshContext() |
context.get |
nothing | the context |
call(method, params) |
any of the above, by name |
"Needs" is checked for the cluster, namespace and kind of each object. Every method also needs the extension to be installed, enabled and not paused; otherwise it fails with denied. Parameters are read strictly: an unknown field is invalid (the SDK leaves out undefined fields).
A kind is {group?, version?, kind}: group defaults to "" (the core group); without version the version the cluster serves is used. An object (ref) is {kind, namespace?, name}; namespace is required for a namespaced kind and ignored for others. ObjectRefs from the context fit as they are.
const clusters = await sextant.clusters.list();
// [{ id: "57a83729d8067720", name: "kind-sextant-test", context: "kind-sextant-test",
// server: "https://127.0.0.1:41871", state: { state: "connected" } }]name is the display name the user gave the cluster (its context name otherwise); state is {state: "disconnected" | "connecting" | "connected"} or {state: "error", code, message}. In the activity log this call has no cluster.
const kinds = await sextant.kinds(); // [{ kindRef: {group, version, kind}, plural, namespaced, verbs, printerColumns }]Only kinds some rule of the extension covers (all of them with "kinds": {"type": "all"}). printerColumns is always empty.
const { items, truncated, namespaces } = await sextant.resources.list({
kind: { group: "apps", kind: "Deployment" },
namespace: "team-a", // optional
labelSelector: "app=web", // optional
fieldSelector: "metadata.name=web", // optional
limit: 100, // optional: default 500, at most 5000
});Without namespace, the extension gets every namespace it may read: all of them when a rule for all namespaces covers the kind, otherwise each namespace its rules name, one after another, and namespaces says which. truncated is true when there was more than limit. Secrets come without their values.
const widget = await sextant.resources.get<Widget>({
ref: { kind: { group: "demo.sextant.dev", kind: "Widget" }, namespace: "sextant-demo", name: "demo-widget" },
});A Secret comes without its values (every value is "" and the annotation sextant.dev/values-hidden is set); use secret.value for one value.
const applied = await sextant.resources.apply({ yaml: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: settings\n namespace: team-a\ndata:\n mode: fast\n" });
// [{ kind: { group: "", version: "v1", kind: "ConfigMap" }, namespace: "team-a", name: "settings" }]Server-side apply (field manager sextant) of one or more YAML documents (---). Every document is read and every object checked (Modify) before anything is sent, and the user is asked once for all of them when a rule says so; documents are then applied in order, each within 30 seconds. A Secret read through the API (with the sextant.dev/values-hidden annotation) is refused; pods of a node shell are refused.
await sextant.resources.patch({
ref,
patch: { spec: { replicas: 3 } },
patchType: "merge", // "merge" (an object, RFC 7386) or "json" (a list of operations, RFC 6902)
});A JSON patch must be a list whose operations each have a path (and from, where used) starting with /. On a core Secret, a JSON patch with operations other than add, replace and remove (such as copy, move, test) also needs Secret values, because it could read them. Pods of a node shell are never patched. The change is limited to 30 seconds.
await sextant.resources.delete({ ref });Limited to 30 seconds. Pods of a node shell are never deleted.
const text = await sextant.logs.tail({ namespace: "team-a", pod: "web-1", container: "app", lines: 500, previous: false });The last lines lines (default 200, at most 2000) of a container (container may be left out for a pod with one container); previous: true reads the previous run of the container.
const password = await sextant.secret.value({ namespace: "team-a", name: "db", key: "password" });One key of one Secret, decoded; needs a rule with secretValues covering Secrets in that namespace. Use it only when the user expects it: the consent window warns about this permission.
await sextant.ui.openDetails({ kind: { group: "", version: "v1", kind: "Pod" }, namespace: "team-a", name: "web-1" });
await sextant.ui.navigate("report"); // an own page by its manifest id
await sextant.ui.navigate("kind:apps/Deployment"); // a table: kind:<group>/<Kind>, kind:/Pod for the core group
await sextant.ui.navigate("overview"); // a page of Sextant: overview, events, helm-releases, helm-charts, portforward, settings
await sextant.ui.notify("Scaled demo-widget to 3 replicas");
await sextant.ui.close();-
openDetailsopens the details panel of the object in the cluster shown in the window;versionmay be left out for kinds Sextant knows itself, otherwise it is required. -
navigateopens a page in the cluster shown; an own page id comes first, never another extension's page; an unknown page isnotFound. -
notifyshows a short notice with the extension's name (at most 500 characters; it stays 8 seconds). -
closecloses the action's window or the dock tab; elsewhere it does nothing. - Without an open cluster,
openDetailsandnavigatefail withinvalid.
import { trackObjects } from "@sextant/extension-sdk";
const watch = sextant.resources.watch(
{ kind: { group: "", kind: "Pod" }, namespaces: ["team-a"], labelSelector: "app=web" },
{
onUpdate: trackObjects<Pod>((pods) => render(pods), (other) => console.log(other.type)),
onError: (err) => showError(err.message),
},
);
await watch.started; // rejects when the watch is refused
// … later
await watch.unwatch();Parameters: kind, namespaces (a list; without it, every namespace the extension may read), labelSelector, fieldSelector, metadataOnly (only {apiVersion, kind, metadata} of each object). The updates (WatchUpdate) are:
type |
Fields | Meaning |
|---|---|---|
snapshot |
items |
All current objects; comes first and again after a reconnect |
delta |
upserted, deleted
|
Objects added or changed; uids of objects deleted |
reconnecting |
The connection to the cluster dropped; a new snapshot follows | |
error |
code, message
|
The watch failed |
forbidden |
namespaces |
The cluster refused the listed namespaces (Kubernetes RBAC) |
Updates come in batches at most every 250 ms. trackObjects(onChange, onOther?) keeps the current objects for you (by metadata.uid) and calls onChange with them sorted by namespace and name. A Watch is also an async iterable (for await (const update of watch); leaving the loop stops it). A page may keep 32 watches open at once; a watch stops when the page reloads or goes away, when the cluster is disconnected, and when the extension is disabled, removed or given other permissions.
A failed call rejects with a SextantError (code, message, method); isSextantError(err, "denied") tests for one. The SDK writes failed calls to the console as [sextant] <method> failed: <code>: <message>. The codes:
| Code | When |
|---|---|
denied |
Outside the extension's permissions, the user pressed Deny or did not answer in 2 minutes, the extension is disabled, paused or removed, or the object is a node shell pod. The message says what was refused and where the user can allow it. |
invalid |
Bad parameters (an unknown field, a missing namespace or name, a malformed patch), an unknown method, a 33rd watch, storage over 1 MB, YAML without objects or with a Secret read without its values |
notFound |
No such cluster, kind (the cluster does not serve it), object or page |
forbidden |
The cluster's own access control (RBAC) refused the user's credentials |
unauthorized |
The cluster rejected the user's credentials |
conflict |
The cluster reported a conflict (for example a field another manager owns, in an apply) |
timeout |
No answer in time: Sextant's limit (30 seconds for a read or a change) or the SDK's (60 seconds for reads, 180 for changes) |
unreachable |
The cluster cannot be reached |
authPlugin, kubeconfig
|
The user's kubeconfig or its credential plugin failed |
other |
Anything else |
connect() applies Sextant's look to your page and follows its changes: it sets the context's tokens as CSS variables on <html>, data-theme (light or dark) and the matching color-scheme (so native controls and scrollbars follow the theme), data-view (the view) and lang, and adds a few base styles (body font and colour, buttons, inputs, tables, links; classes primary, danger, muted, error) first in <head>, so your own styles win. connect({theme: false}) turns it off; applyTheme(context, options) and addBaseStyles() do it by hand; sextant.onThemeChange(listener) is called when the theme, language or tokens change.
The variables: --color-bg, --color-rail, --color-sidebar, --color-main, --color-panel, --color-dock, --color-dock-header, --color-text, --color-text-muted, --color-border, --color-hover, --color-selected, --color-accent, --color-accent-text, --color-danger, --color-warning, --color-success, --shadow-panel, --control-height, --control-height-compact, --control-font-size, --icon-size, --font-family, --font-mono.
.card { background: var(--color-panel); border: 1px solid var(--color-border); color: var(--color-text); }
.card .hint { color: var(--color-text-muted); }Give the page a background (the base styles do: --color-main for a page, --color-panel for details, action and settings, --color-dock for the dock); a transparent page shows white. The UI language is context.language (Sextant's interface is in English for now, en); the extension's texts are its own.
Each extension has a small store of its own JSON values on the user's computer, kept by Sextant (extensions-data/<id>.json in the data folder):
await sextant.storage.set("scale", { step: 2, maxReplicas: 20 });
const settings = await sextant.storage.get<{ step: number; maxReplicas: number }>("scale"); // null when not set
await sextant.storage.remove("scale");- keys are 1–256 characters; values are any JSON;
- the whole store, as JSON, holds at most 1 MB; a
setthat would make it larger fails withinvalidand changes nothing; - it needs no permission, works without a cluster, and is not in the activity log;
- it is deleted when the extension is removed, and kept across updates.
Do not keep secrets there: it is a plain file.
From the author's side:
-
Ask for what you use. The user sees every rule before installing, in plain words; a narrow request is easier to accept. Name kinds (
{"type": "only", ...}) and namespaces when you can. -
Objects without a namespace (Nodes, Namespaces, CRDs, PersistentVolumes, …) need a rule with
"namespaces": {"type": "all"}. - Secret values is its own action that nothing else implies; the consent window warns about it. Without it, Secrets come without values everywhere.
-
Changes ask by default. With
confirmleft out (ortrue), everyapply,patchanddeletewaits for the user's Allow in Sextant's window (up to 2 minutes). Your promise stays pending meanwhile: show that something is waiting, and handledenied(the user said no)."confirm": falseruns without asking, and the consent window shows the rule as Runs without asking. - A YAML with several objects is checked whole and confirmed once.
- Granted, not requested. Calls are checked against what the user granted. A development folder whose manifest asks for more is paused until the user agrees; one that asks for less keeps what was granted until it is loaded again.
- Never available: commands in containers, shells, port forwards, Helm, changing or deleting node shell pods.
- Build your project into a folder and load it with Load unpacked folder…; it runs from the folder, marked Development.
- Sextant looks at the folder every second: a changed file reloads every frame of the extension; a changed manifest also registers its contributions again. Rebuild (
npm run build) and look; Reload in its row forces it. - When the manifest asks for more permissions than were granted, the extension pauses and Sextant offers Review permissions. Loading the same folder again grants exactly what the manifest asks for now.
- An uncaught error or unhandled rejection in your page (reported by the SDK), or a page that fails to load, replaces the frame with Extension failed: and a Reload button. Report your own with
sextant.reportError(err). - Failed calls are logged to the console by the SDK. In a development build of Sextant (
npm run tauri dev) the web inspector is available (right-click → Inspect Element); your frame is a separate frame in it. Release builds have no inspector. - The Activity of your extension (in its row) shows every cluster call with its decision and result.
- Your page cannot be opened in a normal browser:
connect()fails outside Sextant. Test the logic with the test host instead.
@sextant/extension-sdk/testing has a stand-in for Sextant's window that the real client talks to (in a DOM environment such as jsdom):
import { startTestHost, type TestHost } from "@sextant/extension-sdk/testing";
import { afterEach, expect, it } from "vitest";
import { startScale } from "./scale";
let host: TestHost;
afterEach(() => host.close());
it("patches the replicas", async () => {
const widget = { metadata: { name: "demo-widget", namespace: "sextant-demo" }, spec: { replicas: 2 } };
host = await startTestHost({
context: { view: "action", selection: [{ kind: { group: "demo.sextant.dev", version: "v1", kind: "Widget" }, namespace: "sextant-demo", name: "demo-widget" }] },
handlers: {
"resources.get": () => widget,
"resources.patch": ({ patch }) => ({ ...widget, ...patch }),
},
});
// … drive the page …
expect(host.calls.at(-1)).toMatchObject({ method: "resources.patch" });
});startTestHost({context, handlers}) answers each method from handlers (throw a SextantError or {code, message} to refuse; a method without a handler fails with notFound; storage, ui and watch have defaults). The host records calls, watches (with emit(update) to send updates), storage, notices, closedByFrame, errors and heights; setContext(patch) sends a new context. The template and the examples have tests to copy.
npm run pack # in a project from the template
npx sextant-ext pack dist --out releases # the same, another output folder
npm run sdk:pack -- path/to/dist # from the Sextant checkout
npm run sdk:pack -- path/to/dist --dry-run # check and list onlysextant-ext pack <folder> checks the folder as Sextant checks a package (manifest, entry, paths, links, file count and size) and writes <id>-<version>.sextant-ext next to the folder (or into --out). The command line of the SDK:
| Command | Does |
|---|---|
sextant-ext build <project> [--out <dir>] [--no-inline] |
Checks the manifest, bundles the project's entry page and what it imports (TypeScript included) with Vite into <project>/dist, puts scripts and styles inside the HTML (unless --no-inline, which keeps them as separate files), turns small assets into data: URLs, copies public/ and the manifest |
sextant-ext pack <folder> [--out <dir>] [--dry-run] |
Checks and packs a folder |
sextant-ext check <folder or manifest> |
Checks a manifest as Sextant does |
In the Sextant checkout the same commands are npm run sdk:build -- …, npm run sdk:pack -- … and node sdk/scripts/cli.mjs check ….
To publish, attach the .sextant-ext file to a GitHub release of a public repository (Sextant downloads without credentials), for example:
gh release create v1.2.0 acme.widgets-1.2.0.sextant-ext --title "Widgets 1.2.0"Users install it with Install from URL… and the asset's link, https://github.com/<owner>/<repo>/releases/download/v1.2.0/acme.widgets-1.2.0.sextant-ext (at most 20 MB), or download the file and use Install from file…. A new version is a new release with the same id and a higher version; if it asks for more permissions, users see New permissions before they update.
-
sextantApiin the manifest is the major version of the extension API the extension is written for; this Sextant supports1and refuses to install anything else ("extension API N is not supported; this Sextant supports 1"). - Every message carries
sextant: 1, and the context hasapiVersion: 1. - A method Sextant does not know fails with
invalid, and so does a parameter it does not know; an extension that wants to use something newer can try the call and fall back. - The SDK in the repository speaks the API of the Sextant next to it; build your extension with the SDK of the Sextant version you target.
The SDK hides this; read it if you write the frame side yourself. The frame talks to window.parent with postMessage(message, "*"); Sextant answers with messages to the frame. Every message has sextant: 1 and doc: an id the page makes up once per document (16–128 characters of letters, digits, _ and -; the SDK uses 128 random bits in hex). Sextant ignores messages without them and messages from any window but the frame's own; the page should ignore messages that are not from window.parent or carry another doc.
| Direction | Message | Meaning |
|---|---|---|
| frame → Sextant | {sextant: 1, doc, type: "ready"} |
The page listens. Send it first, as early as possible; Sextant answers with the context. The SDK repeats it (after 100, 250, 500, 1000 ms, then every 2 s) until a context comes. |
| frame → Sextant | {sextant: 1, doc, id, method, params?} |
A call; id is a string (1–128 characters) or a number chosen by the page. |
| Sextant → frame | {sextant: 1, doc, id, result} |
Its answer. |
| Sextant → frame | {sextant: 1, doc, id, error: {code, message}} |
Or its failure. |
| Sextant → frame | {sextant: 1, doc, stream, event} |
An update (WatchUpdate) of a resources.watch; stream is the id of the watch call, so listen before you send it. The call itself answers {stream: id}; stop it with resources.unwatch and {stream: id}. |
| Sextant → frame | {sextant: 1, doc, type: "context", context} |
The context, on ready, on every load of the frame and whenever it changes. |
| frame → Sextant | {sextant: 1, doc, type: "resize", height} |
The content's height in CSS pixels (details sections and the settings section; at most 5000). |
| frame → Sextant | {sextant: 1, doc, type: "error", message} |
An uncaught error: Sextant shows "Extension failed". |
The method names and parameters are those of The API (clusterId is required on the wire for cluster methods). A new document in the frame (a reload, another page of the package) has its own calls and watches; Sextant closes those of a document that is gone. A frame may have 8 documents going at once; a ninth ends the oldest one that is neither the current document nor the one the last load kept.
doc keeps well-behaved documents apart; it is not a secret between the documents of one frame. Answers and watch updates still on their way to a document that unloads can reach the next document in the same frame until the frame's next load ends the old one. Ignore messages for another doc (the SDK does) and do not rely on doc for anything else.
A page without the SDK, with its script inline:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8" /><title>Clusters</title></head>
<body>
<pre id="out">Connecting…</pre>
<script>
const doc = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
const send = (message) => parent.postMessage({ sextant: 1, doc, ...message }, "*");
const pending = new Map();
let context = null;
let nextId = 1;
const call = (method, params) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
send(params === undefined ? { id, method } : { id, method, params });
});
addEventListener("message", (event) => {
const m = event.data;
if (event.source !== parent || !m || m.sextant !== 1 || m.doc !== doc) return;
if (m.type === "context") {
const first = context === null;
context = m.context;
if (first) start();
return;
}
const waiting = pending.get(m.id);
if (!waiting) return;
pending.delete(m.id);
if ("error" in m) waiting.reject(m.error);
else waiting.resolve(m.result);
});
send({ type: "ready" });
async function start() {
const clusters = await call("clusters.list");
document.getElementById("out").textContent = JSON.stringify(clusters, null, 2);
}
</script>
</body>
</html>sdk/examples/hello-page adds a page Hello pods to the Workloads section: pods of the namespaces selected in Sextant's namespace filter (or of one namespace picked on the page), kept live by a watch, in Sextant's theme.

Its manifest asks to read Pods and Namespaces everywhere and adds one page:
"permissions": {
"clusters": { "type": "all" },
"rules": [
{
"namespaces": { "type": "all" },
"kinds": { "type": "only", "kinds": [{ "group": "", "kind": "Pod" }, { "group": "", "kind": "Namespace" }] },
"actions": ["read"]
}
]
},
"contributes": {
"pages": [{ "id": "pods", "title": "Hello pods", "section": "workloads", "icon": "pod", "order": 100 }]
}src/pods.ts, step by step:
- By default it shows the namespaces of Sextant's filter,
context.namespaces(null: every namespace). A namespace picked on the page overrides the filter; it is read back fromstorage(storage.get("namespace")). - Opens
resources.watch({kind: {group: "", kind: "Pod"}, namespaces})(withoutnamespacesfor all of them) and draws the table withtrackObjects;reconnecting,errorandforbiddenupdates become a status line. - Lists namespaces with
resources.list({kind: {group: "", kind: "Namespace"}})for the selector, whose first entry is "Sextant's filter"; picking a namespace saves it withstorage.set, picking "Sextant's filter" forgets it withstorage.remove, and either starts a new watch (afterunwatch()of the old one). - A pod's name is a button:
ui.openDetails({kind: {group: "", version: "v1", kind: "Pod"}, namespace, name}). -
onContextChangerestarts the watch when the user switches to another cluster, and when the namespace filter changes while no namespace is picked on the page.
src/pods.test.ts drives it with the test host. Build and load it:
npm run sdk:build -- sdk/examples/hello-page # → sdk/examples/hello-page/dist, then Load unpacked folder…sdk/examples/crd-view ("Widgets") works with the Widget custom resource of the test cluster (demo.sextant.dev/v1, the object demo-widget in sextant-demo, created by scripts/test-cluster.sh up). It has no page; it adds a column, a details section, an action and a settings section, all served by one index.html that switches on context.view (src/main.ts).
"permissions": {
"clusters": { "type": "all" },
"rules": [
{ "namespaces": { "type": "all" }, "kinds": { "type": "only", "kinds": [{ "group": "demo.sextant.dev", "kind": "Widget" }] }, "actions": ["read"] },
{ "namespaces": { "type": "all" }, "kinds": { "type": "only", "kinds": [{ "group": "demo.sextant.dev", "kind": "Widget" }] }, "actions": ["modify"], "confirm": true }
]
},
"contributes": {
"columns": [{ "kind": { "group": "demo.sextant.dev", "kind": "Widget" }, "id": "generation", "title": "Generation", "jsonPath": ".metadata.generation", "after": "Replicas" }],
"detailsSections": [{ "id": "summary", "title": "Widget summary", "kinds": [{ "group": "demo.sextant.dev", "kind": "Widget" }], "placement": "section" }],
"actions": [{ "id": "scale", "title": "Scale widget", "icon": "scale", "kinds": [{ "group": "demo.sextant.dev", "kind": "Widget" }] }],
"settings": { "title": "Widgets" }
}-
Column "Generation":
.metadata.generation, after the CRD's own "Replicas" column. No code. -
Details section (
src/summary.ts): reads the widget withresources.get({ref: context.selection[0]})and shows its size, replicas and generation; it reads again whenevercontext.resourceVersionchanges, so it follows edits made anywhere. -
Action "Scale widget" (
src/scale.ts): reads the widget, lets the user pick a number of replicas (step and maximum fromstorage), and sends a merge patch{"spec": {"replicas": n}}. Because the Modify rule hasconfirm: true, Sextant asks the user first:
On Allow the page calls
ui.notify("Scaled demo-widget to 3 replicas")andui.close(); on Deny thedeniederror is shown in the window, which stays open. -
Settings section (
src/settings.ts): the step and maximum of the action, saved withstorage.set("scale", …)and reset withstorage.remove("scale").
src/widgets.test.ts tests every view with the test host. Build it with npm run sdk:build -- sdk/examples/crd-view.
Sextant is licensed under MIT or Apache-2.0. Found a mistake in this wiki? Open an issue.
Sextant
- Home
- Installation
- Getting started
- Using Sextant
- YAML editor
- Terminal
- Views and workspaces
- Kubeconfig management
- All clusters
- Safe apply (Preview changes)
- Upgrade readiness
- Security posture
- Storage
- Right-sizing
- Cost
- Resource map
- Change timeline
- Cleanup
- Autoscaling
- Connectivity
- RBAC: who can do what
- cert-manager and certificates
- Problems and notifications
- Gateway API
- Installing Flux and Argo CD
- MCP server
- Extensions
- Writing extensions
- Settings
- Troubleshooting
For contributors