Authoring an extension
A SHERU extension adds function — a data source, agent commands, a device bridge — while a theme adds presentation. The two axes are orthogonal and never mix. This page is the author guide for the node + commands seam: how to write the entry module for an extension that serves a node, using @sheru-app/extension-sdk, packaged as a single-node manifest.json and loaded by the signed sheru-extension-runner. Most of this page uses a read-only service projection (GitHub) as the running example; an fs-provider (a writable device bridge like adb) is the other class, and Provider class covers the difference.
The two-noun model#
There are exactly two user-facing nouns, and it helps to keep them straight while authoring:
- An Extension is the package you ship — a directory with one
manifest.jsonplus a JS/TS entry module. It carries no executable of its own; it is data the runner imports. Its identity is theidin the manifest. - The instance is the configured node a user adds (presented to the user as connecting a data source, projected as a browsable file tree). One extension can back many instances.
An extension serves a node by exporting handler groups. A service projection is read-only by construction (see the class ceiling); an fs-provider may serve writes up to its class ceiling. Either way it reaches host resources only through a host-gated context object.
The author entry point#
Author an extension by default-exporting a defineExtension(...) call from your entry module. The function is identity at runtime — its only job is to type-check the definition:
export function defineExtension(def: ExtensionDefinition): ExtensionDefinition {
return def;
}The definition you pass is small. Note that the id is not here — it lives once in the manifest, and the runner supplies it to the runtime:
/**
* An extension's default export: the node it serves plus static metadata. The
* id is NOT here — it lives once in the manifest and the runner supplies it.
*/
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;
}So the smallest valid shape is export default defineExtension({ node: { fs: { list, read } } }). Exactly one of node (current) or connector (the legacy read-only service-projection shape) must be present.
Handlers#
The handlers live under node, as groups keyed by service. Only fs.list and fs.read are required. Everything else is optional and declared per manifest:
export interface NodeDefinition {
/** Optional one-time hook: receives the node's backend params under
* `params.params` so the extension can stash per-node state. Its return
* value is ignored — the describe reply is synthesized, not authored. */
init?(params: InitParams, ctx: ExtensionContext): Promise<void> | void;
fs?: FsHandlers; // list + read required; mutation/transfer verbs optional
commands?: CommandsHandlers;
terminal?: TerminalHandlers;
proc?: ProcHandlers;
clipboard?: ClipboardHandlers;
nodes?: NodesHandlers; // device enumeration (fs-provider)
}
export interface FsHandlers {
list(params: ListPageParams, ctx: ExtensionContext): Promise<ListPageResult> | ListPageResult;
read(params: ReadParams, ctx: ExtensionContext): Promise<ReadResult> | ReadResult;
// fs-provider only, gated by the served fs bits:
mkdir?(…); createFile?(…); rename?(…); duplicate?(…);
move?(…); copy?(…); delete?(…); upload?(…); download?(…);
}What the SDK does when a handler is omitted:
describeis never authored. Thenode.describereply is fully synthesized by the runtime: theidcomes from the manifest and the protocol is the SDK's version. What the node serves comes from the validated manifest, so there is no capability block on the wire — nothing to author. The optionalinithandler runs first, only so you can capture the node's backend params.commands.listdefaults to "no commands" — an empty descriptor list.- any verb whose group is not served answers with a typed error (e.g. invoking a command with no
commands.invokehandler).
fs.list — list a directory page#
fs.list projects a path into a page of FsEntry rows. FsEntry is structurally identical to the webui protocol's FsEntry: directories keep a trailing slash in path, and modified is Unix epoch seconds.
export interface ListPageParams {
path: string;
showHidden: boolean;
/** Opaque page cursor from the previous reply's `nextCursor`, or null. */
cursor: string | null;
/** Max entries the core wants this page (may be null = provider's choice). */
limit: number | null;
}
export interface ListPageResult {
entries: FsEntry[];
/** Cursor for the next page, or null when the directory is exhausted. */
nextCursor: string | null;
/** Total entry count if cheaply known, else null. */
total: number | null;
}fs.read — read a file#
fs.read returns a file's bytes capped at maxBytes. The result is a tagged union so binary is safe across the line-JSON boundary: return utf8 when the slice is valid UTF-8, otherwise base64.
export interface ReadParams {
path: string;
/** The core's read cap; never return more than this many decoded bytes. */
maxBytes: number;
}
export type ReadResult =
| { encoding: "utf8"; data: string; truncated: boolean }
| { encoding: "base64"; data: string; truncated: boolean };commands.list / commands.invoke — the agent + write seam#
A service projection is read-only as a filesystem, but it can still act through commands. commands.list(path) returns the CommandDescriptor[] available at a node, and commands.invoke performs the action — typically a host-mediated mutation — and returns a changedPath so views re-list:
export interface InvokeResult {
ok: boolean;
message?: string;
/** A path the views should re-list after the action, or null. */
changedPath?: string | null;
}The ExtensionContext host SPI#
Every handler receives ctx: ExtensionContext, the sanctioned, host-gated way an extension reaches host resources. Each method sends one upward callback frame (host.exec / host.http / host.secret) to the core and awaits its reply; the core gates these by your manifest's execTools / secrets / allowHttp allow-lists before anything runs. A rejected promise carries the core's error string (an undeclared secret key, a disallowed binary, or a host that refuses the callback — the cloud server refuses exec/secret/http by default).
export interface ExtensionContext {
/** Run a manifest-allowed binary; base64 in/out exactly as on the wire. */
exec(params: ExecParams): Promise<ExecResult>;
/** Run a binary with byte/string ergonomics; decodes stdout/stderr for you. */
execBytes(
bin: string,
args: string[],
opts?: { stdin?: Uint8Array | string },
): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }>;
/** Host-mediated HTTP; base64 body exactly as on the wire. */
http(params: HttpParams): Promise<HttpResult>;
/** HTTP with byte/string ergonomics; decodes the response body for you. */
httpBytes(
method: string,
url: string,
opts?: { headers?: Record<string, string>; body?: Uint8Array | string },
): Promise<{ status: number; body: Uint8Array }>;
/** Fetch a manifest-declared secret. */
secret(key: string): Promise<string>;
}Prefer execBytes and httpBytes — they handle the base64 ergonomics for you, decoding stdout/stderr/body into bytes. The raw exec and http mirror the wire exactly (base64 in, base64 out) for cases where you want the unwrapped frame.
These callbacks are an interface and review surface, not a sandbox. The local runner loads arbitrary code, so a local extension keeps full host access like any installed CLI. Extensions are therefore trusted (first-party / vetted).
A real example: the GitHub extension#
GitHub is the reference service projection, written exactly as a third party would write it. Here is an example entry module — it is small, wiring the handler groups and capturing the per-node repo allow-list in init:
import { defineExtension } from "@sheru-app/extension-sdk";
import { commands, invokeCommand, listPage, read } from "./github";
// Per-node repo allow-list, captured from the init 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 projection logic lives in a separate module. The single point where it touches the host is a gh runner built over ctx.execBytes:
/** A `gh` JSON runner over the SDK's host-mediated exec callback. */
type Gh = (args: string[]) => Promise<unknown>;
export function makeGh(ctx: ExtensionContext): Gh {
return async (args: string[]) => {
const r = await ctx.execBytes("gh", args);
const stdout = new TextDecoder().decode(r.stdout);
if (r.code !== 0) {
const stderr = new TextDecoder().decode(r.stderr).trim();
throw new Error(stderr.length > 0 ? `github: ${stderr}` : `github: gh exited with ${r.code}`);
}
try {
return JSON.parse(stdout);
} catch (e) {
throw new Error(`github: gh returned non-JSON: ${(e as Error).message}`);
}
};
}Note that "gh" must appear in the manifest's execTools — the core refuses any binary not on that list before the host ever runs it.
The connector maps a path scheme to a read-only tree:
"" → one entry per repo: "owner/repo/"
"owner/repo/" → "Issues/", "Pull Requests/"
"owner/repo/Issues/" → paged "<number>/" resource folders
"owner/repo/Issues/<n>/" → "issue.md", "comments/"
".../<n>/comments/" → "01 author.md" per comment
read(".../<n>/issue.md") → body + YAML frontmatter (markdown)Commands appear only at resource and issueMd nodes, and invokeCommand performs the gh api mutation, returning a changedPath so the views re-list:
export function commands(path: string): CommandDescriptor[] {
const node = parsePath(path);
if (node && (node.kind === "resource" || node.kind === "issueMd")) {
return [
{
id: "github.comment",
title: "Add comment",
args: { type: "object", required: ["body"], properties: { body: { type: "string" } } },
},
{ id: "github.close", title: "Close", args: { type: "object", properties: {} } },
];
}
return [];
}The github.comment command, when invoked, posts via the host-gated gh runner and points views back at the comments folder:
case "github.comment": {
const body = args.body;
if (typeof body !== "string") throw new Error("github.comment: body is required");
await gh(["api", `repos/${owner}/${repo}/issues/${number}/comments`, "-f", `body=${body}`]);
return {
ok: true,
message: "comment posted",
changedPath: `${owner}/${repo}/${coll}/${number}/comments/`,
};
}Provider class: service vs fs-provider#
An extension you author with @sheru-app/extension-sdk serves one of two provider classes (set by the manifest class):
service— a service projection: an out-of-process sidecar (GitHub, Notion, Linear), host-gated and credentialed. Ceiling: a read-only projection. Omits the manifestservicesblock.fs-provider— a device bridge (adb): an out-of-process sidecar serving a faithful filesystem. Ceiling: it may declarefswrite/delete/paste, aterminal,proc,clipboard, and devicenodes.enumeratein its manifestservicesblock — clamped by the core.
(The built-in fs-engine class — rclone presets for S3-compatible object storage, WebDAV, SMB, SFTP, FTP — is in the app, not something you author. The local disk is not a connector at all — it is the built-in local node with full write access.)
The class ceiling#
Every class has a fixed capability ceiling the core enforces; a manifest can never widen its class.
A service projection is read-only by construction, enforced at three layers:
- The JSON schema — for a
serviceclass thefswrite bits (canWrite/canPaste/canDelete) areconst false. - Manifest validation — a
servicemanifest that declares a write bit is rejected at registration (logged and skipped, never fatal). - Routing — the core refuses to route any mutation verb to a projection-flavored fs, regardless of what the child answers. What the node serves comes from the validated manifest, so there is nothing for an author to declare in code.
A service projection therefore mutates only through host-mediated commands.invoke callbacks, never as a filesystem write, and is never a paste/upload target.
An fs-provider may serve real writes, but its declared services are clamped to the fs-provider ceiling (a declaration can lower a bit, never raise it), it can never declare nativePaths, and every mutation still runs through a host-gated tool (e.g. adb) named in execTools.
How it runs#
You never call the runtime loop yourself. The SDK exports runExtension(id, def) — a line-delimited JSON stdio loop — but the host's single signed sheru-extension-runner drives it. The runner reads <dir>/manifest.json for the id and runtime.entry, await import(entry)s your default export, validates that it carries a node (or the legacy connector), then calls runExtension(id, def). See the runner for the spawn model and stdio sub-protocol.
Next steps#
- The manifest — the
manifest.jsonfields the core validates (id,name,class,runtime,services,execTools,secrets,allowHttp). - The runner — how
sheru-extension-runnerloads extensions as data and speaks the stdio sub-protocol.