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

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

  1. The host finds <dir>/manifest.json in each extensions directory.
  2. The JSON is parsed and validated — the class ("service" / "connector" / "fs-provider"), the class ceiling (a service manifest may not assert write bits), runtime.type, a non-empty runtime.entry, and the exact schemaVersion.
  3. The manifest id must equal the directory name.
  4. 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.