The extension manifest
Every SHERU extension is a data package: a directory holding one manifest.json plus a JS/TS entry module, with no executable of its own. The manifest is the contract the core reads at startup — it declares the extension's identity, what the host's signed runner should load, and the host resources the extension is allowed to touch.
This page is the reference for the manifest. A manifest declares the node an extension serves — a service projection (a GitHub node is the canonical example) or an fs-provider (a device bridge like adb) — and an extension can declare additional host contributions through the contributes bag described in extensions — concepts and in The contributes bag at the end.
Validate your manifest against the published manifest.schema.json, shipped in @sheru-app/extension-sdk. It is the authoritative description of every accepted key, allowed value, and constraint.
Where the manifest lives#
An extension manifest lives at:
~/.sheru/connectors/<id>/manifest.jsonThe host walks each immediate subdirectory of that directory looking for a manifest.json, validates it, and registers each as an extension-backed node. Discovery is forgiving: a bad manifest is logged and skipped, never fatal, and if two directories declare the same id, the later one wins (so a user can shadow a bundled extension).
A complete service-projection manifest looks like this:
{
"id": "github",
"name": "GitHub",
"class": "service",
"icon": "github",
"runtime": {
"type": "sheru-ts",
"entry": "extension.js"
},
"execTools": ["gh"]
}An fs-provider additionally declares a services block (adb, abbreviated):
{
"id": "adb",
"name": "Android devices",
"class": "fs-provider",
"icon": "smartphone",
"runtime": { "type": "sheru-ts", "entry": "extension.js" },
"execTools": ["adb"],
"services": {
"fs": { "flavor": "faithful", "canWrite": true, "canDelete": true, "canPaste": true },
"terminal": { "command": ["adb", "-s", "{node}", "shell"] },
"proc": { "canSignal": true },
"nodes": { "enumerate": true }
}
}Field reference#
Required fields are id, name, class, and runtime. The schema is additionalProperties: false, so unknown top-level keys are rejected. The full set of fields:
| field | required | type | summary |
|---|---|---|---|
id |
yes | string | Stable extension id; must equal the extension's directory name. |
name |
yes | string | Human label shown in the sidebar. |
class |
yes | enum | "service" (read-only projection; legacy "connector") or "fs-provider". |
runtime |
yes | object | What the host runner loads (type + entry). |
services |
no | object | Per-service declaration (fs-provider); clamped to the class ceiling. |
execTools |
no | string[] | Allow-list of binaries host.exec may run. |
secrets |
no | string[] | Allow-list of secret keys host.secret may read. |
allowHttp |
no | boolean | Whether host.http is permitted. |
schemaVersion |
no | number | Sub-protocol major the extension speaks (exact-match). |
icon |
no | string | Sidebar icon token hint. |
token |
no | object | Personal-access-token sign-in descriptor (host-only UI metadata). |
capabilities |
no | object | Legacy pre-services block; write flags forced false. |
id (required)#
The stable extension id. Pattern ^[a-z0-9-]+$, 1–64 characters. It addresses the extension in NodeInfo.extensionId and in commands.list / commands.invoke routing.
The id MUST equal the extension's directory name. This is enforced so the contract can't drift — a node referencing extensionId: "github" must bind to the extension in the github/ directory. A manifest whose id does not match its directory name is rejected.
name (required)#
The human label shown in the sidebar. 1–80 characters. ("GitHub".)
class (required)#
The provider class — "service" (a read-only SaaS projection; the legacy spelling "connector" is still accepted) or "fs-provider" (a faithful filesystem the extension serves). The built-in local node and rclone fs-engine nodes are not manifest-driven, and any other value is refused. The class sets the capability ceiling the core clamps every declaration to; a manifest can never widen its class.
runtime (required)#
What the host's shared, signed extension runner loads. The extension ships no executable of its own; the runner is the single host-signed binary that imports the entry and runs it under a scrubbed environment.
| field | rule |
|---|---|
runtime.type |
const "sheru-ts" — the entry is a JS/TS module the runner imports. Reserved for future kinds; anything else is rejected. |
runtime.entry |
the entry module the runner imports (its default export is a defineExtension(...) definition), resolved relative to the manifest directory, e.g. "extension.js". |
The host validates the shape — runtime.type must be "sheru-ts" and entry must be non-empty.
services (optional; the fs-provider declaration)#
The node's per-service declaration, mirroring the wire NodeServices subset. A key present means the node serves that service; what it actually serves is this declaration clamped to the class ceiling (a service class stays read-only regardless). A service projection omits the block; an fs-provider declares it:
| key | shape | notes |
|---|---|---|
fs |
{ flavor, consistency, watch, atomicRename, caseSensitivity, pathSemantics, canWrite, canTrash, canUndo, canPaste, canDelete, lowLatency } |
Omitted booleans default false; omitted profile fields default to the conservative remote profile. nativePaths is not declarable — no extension maps wire paths onto host paths. |
terminal |
{ command: string[], remoteRoot? } |
An argv template the host runs through a PTY. {node} in any element is replaced with the device id; command[0] must be in execTools (a terminal is an exec grant, not a new one). |
clipboard |
{} |
Presence-only; the node serves clipboard read/write. |
proc |
{ canSignal } |
The node lists (and, when canSignal, signals) its processes. |
nodes |
{ enumerate } |
With enumerate: true the core calls nodes.enumerate and expands each device into a child node. |
execTools (optional, default [])#
The allow-list of binaries the upward host.exec callback may run (e.g. ["gh"], ["adb"]). The extension names a tool; the host resolves and runs it. Any binary not listed here is refused before the request ever reaches the host. An empty or omitted list effectively disables host.exec.
secrets (optional, default [])#
The allow-list of secret keys the extension may fetch via the upward host.secret callback (e.g. ["GITHUB_TOKEN"]). Any key not listed is refused.
allowHttp (optional, default false)#
Whether the extension may make outbound requests via the upward host.http callback. Note the cloud server refuses HTTP regardless.
schemaVersion (optional)#
The sub-protocol major the extension speaks. Defaults to the host's current version (2). The core requires an exact match — the v1→v2 verb rename was a clean break, so a stale version is rejected at registration.
icon (optional)#
A sidebar icon hint — a token name resolved by the theme layer. Advisory only.
token (optional)#
A personal-access-token sign-in descriptor — { secretKey, docsUrl }. The user generates a token in their own account and pastes it into the host's secure field (never into a chat or argv); the extension reads it via ctx.secret(secretKey). The core does not interpret this block; the host reads it to render the connect sheet. secretKey must also appear in secrets.
capabilities (optional, legacy)#
The pre-services capability block. A service projection is read-only, so canWrite / canPaste are const false and a manifest asserting either is rejected; lowLatency maps onto services.fs.lowLatency. New manifests declare services instead.
How the manifest pairs with the entry module#
The manifest carries identity and host allow-lists; the entry module carries logic. The id is not in the entry module — it lives once in the manifest, and the runner supplies it to runExtension. The author writes a default export via defineExtension:
import { defineExtension } from "@sheru-app/extension-sdk";
import { commands, invokeCommand, listPage, read } from "./github";
// Per-node repo allow-list, captured from the describe handshake params.
let repos: string[] = [];
export default defineExtension({
node: {
init(params) {
const list = (params.params as { repos?: unknown }).repos;
repos = Array.isArray(list) ? list.filter((x): x is string => typeof x === "string") : [];
},
fs: {
list: (params, ctx) => listPage(params, repos, ctx),
read: (params, ctx) => read(params.path, params.maxBytes, ctx),
},
commands: {
list: (params) => ({ commands: commands(params.path) }),
invoke: (params, ctx) => invokeCommand(params.command, params.path, params.args, ctx),
},
},
});The ExtensionDefinition carries only the node's handler groups plus static metadata — never the id:
export interface ExtensionDefinition {
/** The node this extension serves, as per-service handler groups. */
node?: NodeDefinition;
/** LEGACY: a service projection's flat handlers (maps onto node.fs/commands). */
connector?: ExtensionHandlers;
/** Advisory; default false. */
lowLatency?: boolean;
}Only fs.list and fs.read are required; the fs mutation verbs and the commands / terminal / proc / clipboard / nodes groups are optional and declared per manifest, and node.describe is fully synthesized by the SDK (id from the manifest, protocol version — no capability block). The manifest's execTools / secrets / allowHttp are exactly what gate the upward ExtensionContext callbacks (host.exec / host.secret / host.http) those handlers reach the host through — see the runner reference for the full runner and trust-tier model.
Validation flow (what happens at registration)#
When an extension is discovered, its manifest is validated before it is registered:
- The host finds
<dir>/manifest.jsonin each extensions directory. - The JSON is parsed and validated — the
class("service"/"connector"/"fs-provider"), the class ceiling (aservicemanifest may not assert write bits),runtime.type, a non-emptyruntime.entry, and the exactschemaVersion. - The manifest
idmust equal the directory name. - On success the extension is registered as an extension-backed node. On any failure the manifest is logged and skipped — never fatal. If two directories share an
id, the later one wins.
The host never reads runtime.entry itself; it hands the manifest's directory to the runner, and the runner resolves the entry from the manifest there.
The contributes bag#
Extensions — concepts describes the manifest's broader contribution surface, where an extension declares a contributes object with keys like nodes, commands, menus, keybindings, openers, launchers, and sidebarItems. This is how a single extension can contribute more than one capability to the host; each nodes[] entry carries the same class + services fields the single-node manifest carries at top level.
The single-node shape documented above is the form manifest.schema.json validates; the filename stays manifest.json either way. Consult manifest.schema.json for the authoritative set of accepted keys before authoring a contributes bag, since the schema is additionalProperties: false and rejects unrecognized top-level keys.