Skip to content

Making a plugin

This is the build-it-yourself walkthrough. It takes one extension from an empty directory to a verified revision a reader can enable. The Plugin SDK is the API reference and Developing a .tuesh extension is the packaging contract; both override this page when they disagree with it.

The short version

<source-directory>/manifest.json + main.js
              │
              ▼
   npm run pack:plugin        (sandbox)
   npm run build:native-plugin (native)
              │
              ▼
        <id>-<version>.tuesh
              │
              ▼
  owner upload → permission review → publish
              │
              ▼
   reader enables it, everywhere or per course

An extension is prebuilt browser assets plus a manifest. Nothing is ever extracted into the application filesystem, and no application code imports your package.

1. Choose a runtime

sandbox (default) native
Where it runs Opaque-origin iframe The hydrated Study Hub page, as trusted code
Host access Only through window.studyHub Everything the signed-in browser session has
UI Your own DOM and CSS Host React through the exported ui catalog
Background worker entry.worker supported Not allowed
Source format Plain browser JS TSX bundled by the build script
Approval Capability review Capability review and native-runtime approval, every revision

Pick sandbox unless you need host components or host-realm behavior. Capability grants are not a security boundary for native code.

2. Plan the package

Decide before writing code:

  • A lowercase dashed id. It becomes a route segment (/tools/<id>/<segment>) and your storage namespace, so renaming it later abandons every reader's stored data.
  • Which surfaces you contribute — pages, panels, blocks, commands, editor actions, search, review, transfers. See the contribution-kind table in the SDK.
  • Which capabilities each contribution requires. Contributing a kind without requesting its capability is rejected at parse time, and every capability you request is shown to the owner at approval. Request the smallest set that works, and mark the rest optional: true.
  • Whether your surfaces are workspace-scope or course-scope. course.read is refused on a workspace surface; a workspace package that points at course content stores the in-application href and opens it through workspace.layout.

3. Create the source directory

A sandbox package is served directly, so keep development sources under public/plugin-fixtures/<id>/:

public/plugin-fixtures/<id>/
  manifest.json
  main.js          # entry.ui
  worker.js        # optional entry.worker
  styles.css       # optional, guest-only
  assets/icon.svg  # optional

A native package is compiled, so its source lives outside public/:

examples/native-plugins/<id>/
  manifest.json
  main.tsx         # entry.ui, bundled into main.js by the build script

Only json, js, mjs, css, svg, png, jpg, jpeg, webp, woff2, txt, and md files are accepted into an archive, and the host derives content type from the extension, not from manifest metadata.

4. Write the manifest

Sandbox, minimal:

{
  "id": "hello-hub",
  "name": "Hello Hub",
  "version": "1.0.0",
  "pluginApiMajor": 1,
  "courseOsCompatibility": ">=0.1.0",
  "entry": { "ui": "main.js", "styles": ["styles.css"] },
  "requestedCapabilities": [
    { "id": "ui.page", "version": 1 },
    { "id": "storage.namespaced", "version": 1 }
  ],
  "contributes": {
    "globalPages": [
      {
        "id": "hello",
        "segment": "hello",
        "title": "Hello",
        "navigation": { "label": "Hello", "placement": "workspace" }
      }
    ]
  },
  "storageSchemaVersion": 1,
  "provenance": { "author": "You", "license": "MIT", "sourceUrl": "https://example.com/hello-hub" }
}

Native adds two fields and forbids one:

{
  "runtime": "native",
  "nativeApiMajor": 1,
  "entry": { "ui": "main.tsx" }
}

Manifest rules worth restating, because each one fails an upload:

  • id is lowercase alphanumeric with dashes.
  • Every entry path is package-relative, points at a file that exists in the archive, and contains no dot segments or absolute paths. At least one of entry.ui and entry.worker is required; native packages may declare only entry.ui.
  • contributes holds at most 50 entries across all kinds.
  • A source manifest spells the API major as the number pluginApiMajor. The archive spells it pluginApiVersion as a string. npm run pack:plugin translates while archiving, and each parser rejects the other spelling, so never write both by hand.
  • storageSchemaVersion is your own number, handed back to you on every read. Use it to migrate.

5. Write the entry

Sandbox

The guest document already contains #plugin-root. The same main.js is loaded for every surface you contribute, so branch on studyHub.context.surface (page, panel, block, background).

(() => {
  const root = document.getElementById("plugin-root");
  const { surface } = studyHub.context;

  studyHub.onTheme(({ tokens }) => {
    for (const [name, value] of Object.entries(tokens ?? {})) {
      document.documentElement.style.setProperty(name, value);
    }
  });

  async function render() {
    const stored = await studyHub.storage.get("visits").catch(() => undefined);
    const visits = (stored?.value ?? 0) + 1;
    await studyHub.storage.set("visits", visits, stored?.version);

    root.replaceChildren();
    const heading = document.createElement("h1");
    heading.textContent = surface === "page" ? `Hello, visit ${visits}` : "Hello";
    root.append(heading);
  }

  void render();
})();

document.cookie and localStorage throw inside the guest; they do not return empty values. All persistence goes through studyHub.storage, which is namespaced to your extension and uses optimistic concurrency: pass the version you read back, and handle the refusal rather than retrying blindly. A surface that should show nothing must render nothing at all — the host then drops the panel title and border instead of drawing an empty card.

Native

Native entries import the virtual @study-hub/plugin-api module, which the build script resolves to the host shim. React comes from the host; do not add your own copy.

import { definePlugin, ui, useState } from "@study-hub/plugin-api";

definePlugin({
  id: "hello-hub",
  nativeApiMajor: 1,
  activate() {
    function Page({ studyHub }: { studyHub: { invoke(method: string, payload?: unknown): Promise<unknown> } }) {
      const [count, setCount] = useState(0);
      return (
        <ui.PluginPage data-native-plugin="hello-hub">
          <ui.PageHeader>
            <ui.PageHeaderTitle>Hello Hub</ui.PageHeaderTitle>
          </ui.PageHeader>
          <ui.Card>
            <ui.CardContent>
              <ui.Badge>Count {count}</ui.Badge>
              <ui.Button
                onClick={() => {
                  const next = count + 1;
                  setCount(next);
                  void studyHub.invoke("storage.set", { key: "count", value: next });
                }}
              >
                <ui.Icon name="Plus" /> Increment
              </ui.Button>
            </ui.CardContent>
          </ui.Card>
        </ui.PluginPage>
      );
    }

    return { renderers: { globalPages: { hello: Page } }, deactivate() {} };
  },
});

activate() runs once per browser document and returns renderer and handler maps keyed by the contribution ids in your manifest; unknown ids are refused. Render only through the ui catalog so the extension inherits theme, spacing, and accessibility. The host already frames a ui.page contribution as a centered max-w-6xl destination with responsive gutters — do not add a second outer margin.

6. Run it locally

Packaging is not required to iterate. Build-time fixtures load straight from public/:

  1. Add an InstalledPluginRecord for your package to src/plugins/registry/developmentFixtures.ts, importing the manifest from the served file and pointing assetBaseUrl at /plugin-fixtures/<id>/. Give it a fixed, distinct integrityHash stand-in — it keys your stored namespace, so a per-build value would discard the data on every reload.
  2. Set NEXT_PUBLIC_PLUGIN_FIXTURES=1 at both build and runtime.
  3. npm run dev, then open your contributed page, panel, or command.

Fixtures merge after real installations and never override a managed plugin with the same id.

The three shipped fixtures are the worked examples: flashcards-lite touches every contribution kind, task-board shows workspace-scope storage patterns under the quota, and infinite-canvas is the minimal native pointer-driven surface.

7. Pack the archive

# sandbox
npm run pack:plugin public/plugin-fixtures/hello-hub

# native — bundles main.tsx, rewrites the manifest, then packs
npm run build:native-plugin -- examples/native-plugins/hello-hub

Both write into dist/ unless you pass --out <dir>. The packer mirrors the limits the host enforces and fails at your terminal instead of at the upload dialog:

  • archive: 1 MiB maximum;
  • files: 64 maximum;
  • one file: 2 MiB maximum;
  • total uncompressed bytes: 4 MiB maximum.

Traversal, absolute paths, dot segments, case-insensitive collisions, unsupported extensions, compression bombs, malformed ZIP data, and a missing manifest are all rejected here and again by the host.

8. Install, review, publish

With PLUGIN_HUB_ENABLED=1, the owner uploads the .tuesh in Settings → Plugins (/settings/plugins). The host hashes every file, derives the package integrity hash, and pins the immutable revision identity:

<plugin-id>@<package-version>+<integrity-hash>

Then, in order:

  1. Permission review. The first upload, and every update that adds a capability, stays unpublished until the owner approves the delta. Dropped capabilities need no approval. Every native revision needs explicit native-runtime approval even when its capabilities are unchanged.
  2. Publish. Approval activates the exact verified revision; publishing adds it to the reader catalog. It does not run the extension for anyone.
  3. Enable. Every reader starts with the plugin off and chooses everywhere or selected courses, per browser. Absent or legacy inherit preferences resolve to off.

The owner can unpublish, roll back to a retained revision, uninstall, or explicitly delete the namespaced data. Uninstalling alone never deletes it.

9. Update an extension

Always bump version in manifest.json and re-pack. The package version keys the immutable revision identity; shipping changed bytes under an old version collides in client caches. Use semantic versioning — patch for fixes, minor for features and UI, major for breaking changes — then re-run npm run pack:plugin or npm run build:native-plugin.

Migrate stored data by reading storageSchemaVersion back off your own records rather than assuming the new shape.

10. Verify

npm run check
npm run build

Generated fixtures are reproduced by src/course-os/plugins/fixtures.generate.test.ts. Native packages have a targeted suite:

npm test -- src/plugins/native

Then exercise the real surfaces: each contributed page, panel, and block; the command palette entry and its shortcut; light and dark themes; mobile width; keyboard focus order; and a reload, to confirm storage survives it.

Definition of done

  • id is lowercase-dashed and final; storage and routes depend on it.
  • Every contribution has its required capability, and nothing extra is requested.
  • Optional capabilities are marked optional: true and the package loads without them.
  • The entry branches on studyHub.context.surface and renders nothing when it should.
  • All persistence goes through namespaced storage with expectedVersion handled.
  • version was bumped and the archive re-packed.
  • The package packs within all four archive limits.
  • npm run check and npm run build pass.
  • Surfaces verified in both themes, at mobile width, and after a reload.
  • No application code imports the package.

Agent handoff prompt

Read docs/authoring/making-a-plugin.md, docs/plugins/sdk.md, and docs/authoring/developing-a-tuesh-extension.md. Build the extension as a self-contained package reaching the host only through the documented SDK, request the minimum capabilities, bump the package version, pack it with the repository script, and run npm run check and npm run build. Do not import the package from application code and do not add host-side special cases for it.