Skip to content

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:

  1. 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.cookie and localStorage do not return empty values inside a guest — they throw.
  2. 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 stringparsePluginPackageManifest 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 pluginApiMajor disables that extension only.

Versioning and updates

  • Always bump the package version in manifest.json when modifying an extension. Any change to an extension's UI, scripts, styles, capabilities, or storage requires incrementing its semver version string (patch for fixes, minor for features/UI updates, major for breaking storage/API changes).
  • Re-pack the .tuesh bundle with npm 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

{ "id": "network.fetch", "version": 1, "origins": ["https://api.example.com"] }

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 — a plugin field 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, and core.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:

body { background: var(--background, transparent); color: var(--foreground, inherit); }

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.

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.