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.readis refused on a workspace surface; a workspace package that points at course content stores the in-application href and opens it throughworkspace.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:
Manifest rules worth restating, because each one fails an upload:
idis lowercase alphanumeric with dashes.- Every
entrypath is package-relative, points at a file that exists in the archive, and contains no dot segments or absolute paths. At least one ofentry.uiandentry.workeris required; native packages may declare onlyentry.ui. contributesholds at most 50 entries across all kinds.- A source manifest spells the API major as the number
pluginApiMajor. The archive spells itpluginApiVersionas a string.npm run pack:plugintranslates while archiving, and each parser rejects the other spelling, so never write both by hand. storageSchemaVersionis 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/:
- Add an
InstalledPluginRecordfor your package tosrc/plugins/registry/developmentFixtures.ts, importing the manifest from the served file and pointingassetBaseUrlat/plugin-fixtures/<id>/. Give it a fixed, distinctintegrityHashstand-in — it keys your stored namespace, so a per-build value would discard the data on every reload. - Set
NEXT_PUBLIC_PLUGIN_FIXTURES=1at both build and runtime. 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:
Then, in order:
- 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.
- Publish. Approval activates the exact verified revision; publishing adds it to the reader catalog. It does not run the extension for anyone.
- Enable. Every reader starts with the plugin off and chooses everywhere or selected courses,
per browser. Absent or legacy
inheritpreferences 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¶
Generated fixtures are reproduced by src/course-os/plugins/fixtures.generate.test.ts. Native
packages have a targeted suite:
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¶
-
idis 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: trueand the package loads without them. - The entry branches on
studyHub.context.surfaceand renders nothing when it should. - All persistence goes through namespaced storage with
expectedVersionhandled. -
versionwas bumped and the archive re-packed. - The package packs within all four archive limits.
-
npm run checkandnpm run buildpass. - 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, anddocs/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 packageversion, pack it with the repository script, and runnpm run checkandnpm run build. Do not import the package from application code and do not add host-side special cases for it.