Plugin SDK reference¶
Study Hub has two extension runtimes. Packages without runtime use the default
sandbox runtime and execute in the existing opaque-origin iframe. Packages with
runtime: "native" and nativeApiMajor: 1 execute as trusted client code in the
hydrated Study Hub page. Native code can access everything available to the signed-in
browser session; capability grants negotiate contributions but are not a security
boundary for native code.
Native packages declare entry.ui and cannot declare entry.worker. Their IIFE is
loaded once per immutable revision and registers renderers and handlers through the
versioned host global. Multiple panes reuse that activation while each mount receives
its own surface context. Native renderers should use only the exported ui catalog,
which contains the actual host React components and semantic layout primitives.
The host frames native ui.page contributions as centered wide destinations with
responsive 16/24/32px gutters and a max-w-6xl content boundary. Page renderers should
fill that frame and keep any intentional horizontal scrolling inside it; they should not
add a second outer page margin. Panel and block contributions are not page-framed and
must size themselves for their embedding surface.
Everything an extension can do, and everything the host will refuse. Written for someone building a package against Plugin API major 1.
Two facts shape the default sandbox SDK, and every sandbox rule below follows from them:
- An extension runs in a sandboxed iframe at an opaque origin. It has no
access to the host's DOM, cookies, storage, or network.
document.cookieandlocalStoragedo not return empty values inside a guest — they throw. - Every sandbox limit is enforced by the host. The SDK does not ask an extension to respect a budget it could also choose to exhaust. What follows is a description of the environment, not a contract you uphold.
Installation, in this release¶
Owner-uploaded .tuesh packages enter through the Plugin Hub when
PLUGIN_HUB_ENABLED=1. Upload, permission approval, revision selection, and
publishing are owner controls. Publishing adds the verified revision to the
reader catalog; it does not execute the plugin for every reader. Each browser
starts with the plugin off and stores its own enablement choice in Study Hub
preferences. The server reads the mutable Blob registry, selects each published
active immutable installation record, revalidates it against this SDK, narrows
its capabilities to the registry-approved grant, and passes only that result to
the runtime dispatcher. An invalid, unreadable, incompatible, unpublished, or
quarantined installation contributes no optional executable surface; sibling
plugins and the core application continue to load.
Build-time development fixtures remain available from
src/plugins/registry/developmentFixtures.ts. Set
NEXT_PUBLIC_PLUGIN_FIXTURES=1 at build and runtime to enable them; they are
merged after Blob installations and never override a managed plugin with the
same ID.
Courses resolve an exact retained revision from requiredPlugins; optional and
workspace surfaces use the registry's active revision only after the reader has
enabled that plugin. A required course revision still runs inside its declaring
course unless a safety control stops it. Package files stay on
their immutable public Blob URLs and are fetched only when an enabled surface is
actually rendered.
The reference extension, flashcards-lite, exercises every contribution kind in
one package and is the best worked example to read.
A second fixture, task-board, is the worked example for a workspace-scope
package: a personal kanban on its own global page, with a settings page, two
sidebar panels, shortcuts, and JSON transfers. It requests no course capability at
all, because course.read is refused on a workspace surface; a task that points at
course content stores the in-application href and opens it through
workspace.layout. Read it for the storage patterns the limits force — one
debounced write of one key per drag rather than a call per pointer move, and a
per-task merge with tombstones when expectedVersion reports a conflict between
two open panes.
A third fixture, infinite-canvas, is the minimal workspace-scope shape: one
global page, one command, and namespaced storage — nothing else. It is the
worked example for pointer-driven surfaces (pan/zoom, freehand strokes, sticky
notes) that must persist through one debounced write per gesture and keep a
compact scene graph inside the storage quota.
Package layout¶
public/plugin-fixtures/flashcards-lite/
manifest.json # the vocabulary below
main.js # entry.ui — runs in the guest document
worker.js # entry.worker — runs in the background runtime
styles.css # entry.styles[] — styles the guest only
Manifest¶
{
"id": "flashcards-lite",
"name": "Flashcards Lite",
"version": "1.0.0",
"pluginApiMajor": 1,
"courseOsCompatibility": ">=0.1.0",
"entry": { "ui": "main.js", "worker": "worker.js", "styles": ["styles.css"] },
"requestedCapabilities": [{ "id": "ui.page", "version": 1 }],
"contributes": { "globalPages": [{ "id": "decks", "segment": "decks", "title": "Decks" }] },
"storageSchemaVersion": 1,
"provenance": { "author": "…", "license": "MIT", "sourceUrl": "https://…" }
}
| Field | Rule |
|---|---|
id |
Lowercase alphanumeric with dashes. Becomes a route segment and a storage namespace. |
version |
Starts with a digit or letter; digits, letters, dots, dashes. Always increment version when updating an extension. |
pluginApiMajor |
A positive integer. A major this host does not implement is refused with unsupported_major, and only that extension is disabled. An uploaded .tuesh spells this field pluginApiVersion and takes the major as a string — parsePluginPackageManifest reads that name, parsePluginManifest reads this one, and each rejects the other's spelling, so a package cannot carry both. npm run pack:plugin translates while archiving. |
entry.ui / entry.worker |
Package-relative .js paths. No traversal, no absolute paths. At least one is required. |
entry.styles |
Package-relative .css paths. |
requestedCapabilities |
See the catalog below. optional: true means "grant if available, load anyway if not". |
contributes |
At most 50 entries across all kinds. |
storageSchemaVersion |
Your own schema number, returned to you on every read. |
Contribution kinds¶
Each kind requires the capability beside it; contributing without requesting the capability is rejected at parse time.
| Kind | Capability | Where it appears |
|---|---|---|
globalPages |
ui.page |
/tools/<pluginId>/<segment> |
coursePages |
ui.page |
/<courseId>/tools/<pluginId>/<segment> |
courseTabs |
ui.page |
Course navigation |
panels |
ui.panel |
course-sidebar, course-inspector, workspace-sidebar |
blocks |
ui.block |
Inside course content, resolved through the pinned plugin id |
editorActions |
editor.actions |
The owner's block action row, in edit mode |
commands |
commands.register |
Command palette, with an optional shortcut |
transfers |
transfer.provide |
Import/export menu. extension includes its dot: ".json" |
searchProviders |
search.provide |
A labelled group after core search results |
reviewProviders |
review.provide |
After the scheduled review queue |
Capability catalog¶
| Capability | v | Status | Needs a course | What the owner is told |
|---|---|---|---|---|
ui.page |
1 | implemented | no | Show its own full pages |
ui.panel |
1 | implemented | no | Show panels beside your content |
ui.block |
1 | implemented | yes | Render its own block types inside courses |
commands.register |
1 | implemented | no | Add commands and keyboard shortcuts |
course.read |
1 | implemented | yes | Read the open course's content |
course.write |
1 | implemented | yes | Propose edits (you confirm each one) |
storage.namespaced |
1 | implemented | no | Store its own data on this device |
events.subscribe |
1 | implemented | no | React to course and revision events |
search.provide |
1 | implemented | no | Add its own results to search |
review.provide |
1 | implemented | yes | Add its own items to review sessions |
transfer.provide |
1 | implemented | no | Offer imports and exports |
editor.actions |
1 | implemented | yes | Add actions to the course editor |
worker.background |
1 | implemented | no | Run background computation in your browser |
network.fetch |
1 | implemented | no | Contact the exact web addresses it lists |
workspace.layout |
1 | implemented | no | Open, move, resize, focus, split, and close workspace panes |
ai.complete |
1 | reserved | no | Host-mediated AI (not available in this release) |
Reserved means the vocabulary admits it but this release implements no
provider. Requesting it as required makes the package incompatible
(reserved_required_capability); requesting it as optional is fine and it is
simply not granted.
A capability marked needs a course is refused on a workspace-scope surface, even when granted — the surface has no course to act on.
Compatibility rules¶
- Negotiation grants exactly what the manifest requested — never more. A host release that adds capabilities or raises a version changes no existing grant.
- An unsupported required capability fails the whole package
(
unsupported_required_capability); an unsupported optional one is reported and omitted. - An unsupported
pluginApiMajordisables that extension only.
Versioning and updates¶
- Always bump the package
versioninmanifest.jsonwhen modifying an extension. Any change to an extension's UI, scripts, styles, capabilities, or storage requires incrementing its semverversionstring (patch for fixes, minor for features/UI updates, major for breaking storage/API changes). - Re-pack the
.tueshbundle withnpm run pack:plugin <fixture-dir>whenever updating so the generated archive, versioned revision ID (<id>@<version>+<hash>), and manifest stay strictly in sync.
Declaring network origins¶
Exact origins only: https, no path, no wildcard, no credentials, no IP literal,
and no private-network name (.local, .internal, .home, .lan). A request
is contacted from the reader's browser, so a private address would aim your
extension at their own network. An empty or absent list can reach nothing.
The studyHub global¶
studyHub.protocolMajor // 1
studyHub.context // { surface, surfaceId, courseId?, paneId?, pluginId, revisionId,
// entryUrl, styleUrls, workerUrl, label, block?,
// grantedCapabilities, nonce }
studyHub.onTheme((payload) => {}) // { theme, tokens } — CSS custom property values
studyHub.onLifecycle(({ phase }) => {}) // "mounted" | "hidden" | "unmounting"
studyHub.onCommand((payload) => {}) // see "Host commands" below
studyHub.requestFocus()
studyHub.course.getCurrent()
studyHub.course.getBlock(blockId)
studyHub.course.updateBlock({ blockId, props }) // owner confirms before anything is written
studyHub.storage.get(key)
studyHub.storage.set(key, value, expectedVersion) // omit expectedVersion to overwrite
studyHub.storage.remove(key, expectedVersion)
studyHub.storage.usage() // { keys, usedBytes, quotaBytes, schemaVersion }
studyHub.commands.register({ id, title, shortcut })
studyHub.events.on(name, handler)
studyHub.search.provide(results) // answers an open search.query
studyHub.review.provide(items) // answers an open review.query
studyHub.transfer.emit({ requestId, filename, mimeType, content })
studyHub.network.fetch({ url, method, headers, body })
studyHub.ai.complete({ prompt })
studyHub.workspace.openView({ contributionId }) // one of your own views
studyHub.workspace.openView({ href }) // an allowlisted core view
studyHub.workspace.split({ paneId, edge, contributionId | href })
studyHub.workspace.movePane({ paneId, stackId, index })
studyHub.workspace.resizeSplit({ splitId, sizes })
studyHub.workspace.focusPane({ paneId })
studyHub.workspace.closePane({ paneId })
studyHub.worker.post(message)
studyHub.worker.onMessage((message) => {})
studyHub.worker.mode // "worker" | "inline" | "unavailable"
surface is one of page, panel, block, background. Branch on it: the same
main.js is loaded for every surface your package contributes.
Rendering nothing¶
A surface that paints nothing gets no chrome. The SDK measures your document and tells the host when every element is zero-height; the host then drops the panel title and the card border it would otherwise draw around your frame, and the column collapses if every panel in it is empty. Clear your root — the frame stays alive and the panel returns the moment you render into it again, with no reload. So a panel a user has switched off in your settings should render nothing at all, not an empty-state line.
Host commands¶
onCommand receives host-initiated work. The commandId says which:
commandId |
Payload | Answer with |
|---|---|---|
| your command id | — | anything |
editor.action |
{ actionId, blockId, containerId, containerKind } |
usually course.read then course.write |
search.query |
{ requestId, query } |
studyHub.search.provide(results) |
review.query |
{ requestId, courseId } |
studyHub.review.provide(items) |
transfer.export |
{ requestId, contributionId } |
studyHub.transfer.emit({ requestId, … }) |
transfer.import |
{ contributionId, filename, content } |
store it however you like |
An editor action carries only where it was invoked. To read the block, ask
through course.read; to change it, propose through course.write, which the
owner confirms.
Background runtime¶
worker.js implements the same contract in both modes:
self.studyHubWorker = {
handle(message) {
if (message?.type !== "shuffle") return;
self.studyHubPost({ type: "shuffled", cards: shuffle(message.cards) });
},
};
The guest constructs a blob: Worker inside its own opaque origin, so background
code never runs at the host's origin. Where that fails — construction refused, or
the entry cannot be imported — the same entry runs inline in the guest document
on an idle-callback loop, and messages already posted are replayed. Read
studyHub.worker.mode if you care which you got; the API does not change.
Error codes¶
Every rejection carries error.code:
| Code | Meaning for your extension |
|---|---|
capability_not_granted |
Not in your grant, or needs a course this surface does not have. Degrade; do not retry. |
capability_unavailable |
Reserved or not configured in this release (e.g. ai.complete). |
unknown_method |
The capability exists but that method does not. A bug on one side or the other. |
invalid_message |
Your payload failed validation. |
message_too_large |
Over maxMessageBytes. |
rate_limited |
You exceeded the call window; the surface is stopped, not throttled. |
quota_exceeded |
Storage or response-size ceiling. |
version_conflict |
expectedVersion did not match; re-read and retry. |
confirmation_declined |
The owner said no. Not an error to report loudly. |
timeout |
The host's handler did not settle in time. |
unknown_session |
The surface was quarantined or remounted. A new session is the only recovery. |
workspace_refused |
A workspace.layout call was refused. error.detail.reason says why (see below). |
Controlling the workspace¶
workspace.layout is never inferred from ui.page or ui.panel; the owner
approves it as its own line, and an installation that never requested it cannot
call these methods at all.
context.paneId names the pane your surface is rendered in, so you can act on
your own pane without guessing. It is absent outside a hydrated workspace.
What you may name:
- Your own contributed views, by
contributionId. The host builds the pane descriptor from your authenticated session — apluginfield in your payload is ignored, so you cannot open a pane attributed to somebody else. - These core views only, by
href:core.home, the course overview, topic, resources, progress, review, and exam views,core.search,core.review, andcore.study-map.
Everything else is refused, including Settings, plugin management, the editor, downloads, another extension's page, and any core view added in a later release.
You may move, resize, focus, split, and close core panes and your own panes. A pane attributed to another extension is never yours to touch.
workspace_refused reasons, in error.detail.reason:
| Reason | Meaning |
|---|---|
view_not_allowed |
Not one of your views, and not on the core allowlist. |
pane_not_owned |
That pane belongs to another extension. |
invalid_payload / invalid_sizes |
The payload failed strict validation. |
dirty_view |
The pane has unsaved changes and refused to close or move. |
final_pane |
The last workspace pane must stay open. |
visible_pane_limit / split_depth_limit / minimum_size |
A layout limit was reached. |
unknown_pane / unknown_stack |
The target is no longer open. |
A refused mutation leaves the layout exactly as it was.
Limits, and who enforces them¶
All host-enforced. From src/plugins/runtime/limits.ts:
| Limit | Value | What happens |
|---|---|---|
maxMessageBytes |
64 KiB | Refused before parsing |
maxCallsPerWindow / callWindowMs |
60 per 10 s | Quarantine, not throttling |
handlerTimeoutMs |
5 s | Call aborted with timeout |
handshakeTimeoutMs |
10 s | Surface fails closed |
storageQuotaBytes |
2 MiB per plugin | quota_exceeded |
maxSurfacesPerPlugin / maxSurfacesPerWorkspace |
4 / 8 | Counted across every visible workspace pane; extra surfaces render a placeholder |
providerTimeoutMs |
1.5 s | Your results are omitted; you are not quarantined |
maxNetworkResponseBytes |
512 KiB | quota_exceeded |
networkTimeoutMs |
8 s | Request aborted |
Search and review results are additionally validated before rendering: ids match
^[a-z0-9][a-z0-9._-]{0,63}$, text is truncated to 200 characters, href must
be an in-application path, and at most 20 results / 50 items per plugin survive.
Styling¶
The host sends theme token values, never a stylesheet URL. The SDK client
applies them to the guest root for you, so a package with no JavaScript at all
still paints in the host's theme; onTheme remains available when you need the
values themselves. Style only your own document:
Tokens sent: --background, --foreground, --card, --card-foreground,
--popover, --popover-foreground, --border, --input, --ring,
--primary, --primary-foreground, --secondary, --secondary-foreground,
--muted, --muted-foreground, --accent, --accent-foreground,
--destructive, --destructive-foreground, --success, --warning,
--radius, --font-sans. The guest root also carries data-theme="light" |
"dark"; use it for color-scheme, which decides what a browser paints inside a
date or number input.
Write fallbacks that do not assume a light host. A literal #e5e7eb border is a
light-theme value hardcoded into a package that will also be framed by a dark
one; color-mix(in oklab, currentColor 18%, transparent) is correct in both.
onTheme and onLifecycle are state, not streams: the host delivers the
first of each while your entry is still being fetched, so the last value is
replayed to a handler that subscribes late. Subscribing at top level is safe.
Hosting constraint: assets must be cookie-free¶
Plugin assets must be reachable without cookies. The guest is an opaque
origin and is sent none, so any cookie-based gate in front of your assets — Vercel
deployment protection, an SSO proxy — redirects client.js to a login page, and
the guest CSP then correctly refuses to execute it. The symptom is an extension
that appears never to boot.
Where the application itself must stay protected, serve packages from a separate
public origin and list it in NEXT_PUBLIC_PLUGIN_ASSET_ORIGINS; the guest CSP
admits exact origins there for precisely this reason.
Native lifecycle and limitations¶
definePlugin() registers one definition for the immutable revision currently
being evaluated. Its activate() function runs once per browser document and
returns declared renderer and handler maps plus an optional deactivate().
Only contribution ids present in the installed manifest are accepted. Disabling,
replacing, quarantining, or removing the revision drops the registration, styles,
tracked SDK cleanups, and then runs deactivate().
Native code shares Study Hub's JavaScript realm. It can create arbitrary DOM, storage, network, and event side effects outside the SDK; capability grants do not contain those actions, and the host cannot reliably clean them up. Install native extensions only when the source and publisher are trusted. The sandbox runtime remains the correct default for untrusted code.