Get started

Plugin SDK

The reference for building third-party plugins for QuoxCORE. Four packages on npm, Apache 2.0 licensed, source on GitHub.

Overview

The Quox Plugin SDK scaffolds, builds, validates and signs plugins for QuoxCORE. A community plugin is a React bundle that runs in an isolated sandbox and talks to Quox through one object, window.quox. It can add sidebar views, store data, call its own backend (on instances that run plugin backends, see below), declare an agent and show notifications.

The packages are on npm under these exact names (under the @quoxai scope, version 0.4.1). The source is at github.com/quoxai/quox-plugin-sdk under the Apache 2.0 licence.

PackagePurpose
@quoxai/create-quox-pluginScaffolder: npx @quoxai/create-quox-plugin my-plugin
@quoxai/quox-plugin-sdkRuntime helpers, TypeScript types, and a mock window.quox for tests
@quoxai/quox-plugin-viteVite plugin and the quox-plugin CLI (validate, keygen, sign)
@quoxai/eslint-pluginLint rules that flag code the sandbox will not allow
What this does not cover yet. There is no marketplace submission or listing flow today: the catalogue is not open, so you install your plugin on your own instance. Self-hosted installs from the public release do not run plugin backends yet. Plugins that Quox itself ships are a separate path with a wider surface and are not covered by this SDK.

Quick Start

From zero to an installed, signed plugin. You need Node.js 20 or later and a QuoxCORE instance you can install plugins into.

1. Scaffold

Terminal
npx @quoxai/create-quox-plugin my-plugin
# or pick a template: npx @quoxai/create-quox-plugin my-plugin --template basic
cd my-plugin
npm install

There are 8 templates. basic is the right place to start. All of them target the community sandbox:

basic
Hello-world sidebar plugin
monitoring-dashboard
Status table; shows sample data and a backend unavailable state
fleet-management
Host table and confirm modal; sample data, backend unavailable state
service-bridge
Items and metrics view plus a notes panel in plugin storage
ui-feature
Client-side tool saved in plugin storage
enterprise-extension
Usage meters, upgrade gates and a backend skeleton
specialist-agent
Agent status, tool runner and ask-the-agent panel (sample data)
agentic-team
Members, policies, task queue and chat with the lead (sample data)

Templates that would need a third-party service, a host or a model call your plugin's own backend, because the sandbox cannot reach them directly. Until your backend answers (and on installs that do not run plugin backends), those templates show their own sample data and an explicit backend unavailable state.

2. Develop

Edit src/views/MainView.jsx. The entry point at src/index.jsx registers your routes and sidebar entries. Run npm run dev for a local dev preview that simulates the sandbox.

src/index.jsx
import { registerPlugin } from '@quoxai/quox-plugin-sdk';
import MainView from './views/MainView';

registerPlugin('my-plugin', {
  routes: { '/plugins/my-plugin': MainView },
  sidebar: [
    { path: '/plugins/my-plugin', label: 'My Plugin', icon: 'puzzle' },
  ],
});

3. Build and validate

Terminal
npm run build      # writes dist/plugin.esm.js and dist/plugin.css (manifest.json stays at the project root)
npm run validate   # checks the manifest and bundle, exits 1 on any problem

4. Sign

Terminal
npx quox-plugin keygen --out .keys   # once; .keys/ is gitignored, keep the private key private
npm run sign                          # writes dist/signature.json

5. Install

Install through the dashboard plugins page, or from the CLI as an org admin:

Terminal
quox plugin install \
  --manifest manifest.json \
  --bundle dist/plugin.esm.js \
  --css dist/plugin.css \
  --signature dist/signature.json

--signature is required when the org has trusted keys configured. Community plugins are labelled unverified unless the org trusts the signing key. See Signing.

Plugin Architecture

A community plugin is a React bundle that runs inside an iframe with sandbox="allow-scripts", an opaque origin and a Content Security Policy of connect-src 'none'. It cannot make network requests, read the host page or touch other plugins. Everything it needs from Quox goes through window.quox.

What you ship

Install inputs
manifest.json         # at the project root: id, name, version, permissions, sidebar, backend
dist/plugin.esm.js    # the bundle
dist/plugin.css       # your styles, including the bundled qx design-system CSS
dist/signature.json   # Ed25519 signature over the manifest and both files, from npm run sign

Shared Dependencies

Quox gives the frame React, ReactDOM, React Router and the JSX runtime through window.__quoxSharedDeps. Your plugin does not bundle them. The scaffold marks them as externals for you.

Accessing shared deps
import { getSharedDeps } from '@quoxai/quox-plugin-sdk';
const { React, ReactDOM, ReactRouterDOM } = getSharedDeps();

Route Namespace

Plugin routes live under /plugins/{pluginId}/. The host sends route events with path strings, and the shim mounts the matching component into #plugin-root inside a memory router. Components are mounted with no props.

Plugin Registration

Call registerPlugin(pluginId, { routes, sidebar }) once at the bottom of your entry file (see the example in the Quick Start).

Styling

Only your own plugin.css is loaded in the frame. Host stylesheets are not present and there are no host components: no GlassPanel, no host contexts. The build bundles the qx-* design-system classes (qx-card, qx-btn, qx-pill, qx-table and others) into plugin.css, so use those or your own CSS.

Dev preview

npm run dev runs your plugin in the same kind of sandbox locally: an allow-scripts iframe with no network, an in-memory mock of window.quox, and throwing stubs for host-only globals. It is an approximation, not a QuoxCORE instance. Confirm storage and notifications by installing the built plugin. There is no hot module replacement: the frame is re-created on each rebuild.

Manifest Reference

Every plugin has a manifest.json at the project root. npm run validate checks it. The fields the host reads are below. Other fields (for example team and marketing fields) are accepted for official plugins and ignored by the community sandbox. agentDefinition is read for community plugins too, see Agent and Team Plugins.

FieldTypeRequiredDescription
idstringRequiredUnique plugin identifier. Must match ^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$ and must not start with a reserved prefix (core-, system-, quox-, admin-).
namestringRequiredDisplay name shown in the sidebar and plugin manager.
versionstringRequiredVersion string (for example "1.2.0").
authorstringOptionalAuthor name or organisation.
permissionsstring[]OptionalPermissions the plugin needs. See Permissions.
sidebarSidebarItem[]OptionalSidebar entries. Each has path, label, icon, and optionally section.
routeGuardsobjectOptionalRoute access guards.
integrityobjectOptionalIntegrity metadata for the bundle.
backendobjectOptionalYour plugin's own backend service. See Backend Plugins.
manifest.json (minimal)
{
  "id": "my-plugin",
  "name": "My Plugin",
  "version": "0.1.0",
  "author": "Your Name",
  "permissions": ["sidebar", "storage", "notifications"],
  "sidebar": [
    { "path": "/plugins/my-plugin", "label": "My Plugin", "icon": "puzzle" }
  ]
}

Plugin API: window.quox

window.quox is a promise-based API bridged to the host over postMessage. Get it with getQuox() from @quoxai/quox-plugin-sdk. It throws a clear error outside a Quox host. Every method returns a Promise and can fail, so render the error. If an API is not listed here, it does not exist for a community plugin.

Getting the API
import { getQuox } from '@quoxai/quox-plugin-sdk';
const quox = getQuox();   // same object as window.quox

quox.storage

Server-backed key/value store scoped to your plugin. It survives browser clears and syncs across devices. Values must be structured-cloneable. Do not use localStorage as your store: inside the frame it is not reliable.

Storage API
await quox.storage.set('prefs', { theme: 'dark' });
const prefs = await quox.storage.get('prefs');   // null when missing
await quox.storage.remove('prefs');
const keys = await quox.storage.keys();
const all = await quox.storage.getAll();         // { key: value, ... }

quox.fetch

Calls your plugin's own backend namespace. There is something to reach only when your plugin ships a backend and the instance runs plugin backends (see Backend Plugins). The namespace is /collector/api/v1/plugins/{id}/.... The path is relative to that namespace. Absolute URLs and .. traversal are refused, and HTTP methods are allowlisted. The global fetch, XMLHttpRequest, WebSocket and EventSource are blocked by the CSP.

Fetch API
const res = await quox.fetch('items', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ name: 'x' }),
});
if (res.ok) {
  const data = JSON.parse(res.body);   // body is the raw response text
}

It resolves with { status, ok, body } and does not throw on a non-2xx status. Check ok before parsing body.

quox.memory

Present in the API but refused with not_available for community plugins, even when declared, because there is no plugin memory route yet.

Memory API
await quox.memory.save({ type: 'observation', content: 'Customer prefers email', tags: ['crm'] });
const hits = await quox.memory.search('customer preferences', 10);

quox.aee

Present in the API but refused with not_available for community plugins, even when declared, because there is no plugin evidence route yet. It is meant for the Agent Envelope Exchange.

AEE API
await quox.aee.emit('my-plugin.report.generated', { reportId: 'r1' });

quox.notify and quox.navigate

Notify and navigate
await quox.notify('Saved', 'success');   // 'info' | 'success' | 'warning' | 'error'
await quox.navigate('/plugins/my-plugin/settings');

quox.getUser and quox.onEvent

User and events
const { userId, orgId, role, tier } = await quox.getUser();

quox.onEvent((method, args) => {
  if (method === 'route') console.log('navigated to', args[0]);
});   // synchronous; one handler at a time

What does not exist

You may expectStatus in the community sandbox
api.durable, api.cache, api.secrets, api.config, api.chatDo not exist. Use quox.storage.
window.__quoxGlass, GlassPanel and other host componentsNot available. Style with the bundled qx CSS.
CapabilityPanels, WorkspaceContext, AuthContext, LicenseValidator, ServerSyncQueue, OrgScopedStorageThrowing stubs.
Direct fetch, XMLHttpRequest, WebSocket, EventSourceBlocked by the CSP. Use quox.fetch.
Registering agent tools, or a team, from a manifestNot available. A declared agent has no tools.

@quoxai/eslint-plugin flags these (no-host-only-globals, no-cache-as-primary-storage).

Backend Plugins

Where backends run. Managed Quox instances run plugin backend containers. Self-hosted installs from the public quoxcore release do not include that component yet, so they run no plugin backend, official or community; the plugin's UI still installs and runs.

A community backend starts only when all of these hold, checked each time it starts and at boot: the package carries a signature v2 (SDK 0.3.0 or later, which binds the manifest and so the backend image); the org trusts the signing key; neither the key nor the package is revoked; the image is pinned by digest (image@sha256:<digest>, tags are refused); and every org on the instance that installed that plugin id passes the same checks, because one container serves them all. Revoking the key or the package stops a running backend the next time it is checked.

When a plugin needs server-side logic, such as a third-party API with credentials or long-running work, it ships a backend service that runs as a separate container alongside QuoxCORE. The frame reaches it only through quox.fetch. The enterprise-extension template includes an Express skeleton and a Dockerfile as a starting point.

Manifest fieldTypeDescription
imagestringContainer image, pinned by digest for a community plugin: registry/name@sha256:<digest>.
healthPathstringHTTP path for health checks (default /health).
portnumberPort the backend listens on (default 8080).

Request flow

The collector proxies requests to your container and adds the authenticated user, org and plugin identity as headers (X-User-Id, X-User-Email, X-Org-Id, X-Plugin-Id) plus X-Quox-Org-Ticket, a short-lived signed ticket for that org. The default rate limit is 600 requests per minute per user per plugin, and proxied requests time out after 30 seconds. The collector checks backend health every 60 seconds.

Request flow
Frame → quox.fetch('channels')
      → Collector: /collector/api/v1/plugins/my-plugin/channels
      → Your container: GET /channels (with user and plugin headers)

You do not register anything: the host starts the container when the plugin is opened. It runs as a non-root user with no capabilities, on its own network that reaches the collector and the public internet but not other Quox services, other plugins, the host or the local network, and it is capped at 1 GiB of memory and 2 CPUs by default. It receives a per-plugin credential that works only on the host chat and evidence routes, and only for an org when it sends back that request's ticket. Agent tools stay official-only. Details are in the backend plugins guide.

Agent and Team Plugins

A plugin can declare an agent in manifest.json with agentDefinition (name, role, color, systemPrompt, suggestions). Quox renders the agent sidebar next to the plugin, using the same component Quox's own agents (for example NotesQ) use. A community plugin's agent has no tools: its persona is treated as untrusted text and cannot grant tools, data access or delegation.

The specialist-agent and agentic-team templates scaffold the plugin's own view (status cards, tool runner, members, policies, task queue) with sample data, since they would need a backend that community plugins do not have yet. The plugin frame cannot run an agent, call a model or embed the host chat. Registering tools or a team from a community manifest is not available.

Signing

An org admin decides which authors are trusted by registering their Ed25519 public key. Once an org has at least one trusted key, QuoxCORE accepts only bundles carrying a valid signature from one of them. An org with no trusted keys accepts unsigned installs.

1. Generate a key pair (once)

Terminal
npx quox-plugin keygen --out .keys

This writes .keys/plugin-signing.pem (the private key, mode 0600; never commit it) and .keys/plugin-signing.pub (the public key as 64 hex characters). It refuses to overwrite an existing key unless you pass --force.

2. Build, validate, sign

Terminal
npm run build
npm run validate
npm run sign

sign writes dist/signature.json. Re-sign after every build and after any manifest change. Since 0.3.0 the signature binds the plugin id and version, manifest.json, dist/plugin.esm.js and dist/plugin.css; the installing instance recomputes them and refuses any change. A package signed with 0.2.0 binds the bundle only and installs only if it asks for nothing beyond sidebar, storage and notifications, with no backend.

3. The org admin trusts your public key

Terminal (org admin)
quox plugin trusted-keys add --label "Acme plugin signing" --public-key <64-hex-public-key>
quox plugin trusted-keys list

Revoke with quox plugin trusted-keys remove <keyId>. Removing the last trusted key reopens the org to unsigned bundles, so the CLI requires --confirm-unsigned for that case.

Permissions

Plugins declare the permissions they need in the manifest. The user reviews them at install time. A call the plugin has no permission for is refused by the host, so handle the error.

PermissionGrants access toAPI
sidebarAdd entries to the QuoxCORE sidebarregisterPlugin({ sidebar })
storagePlugin-scoped key-value storagequox.storage.*
networkCalls to your plugin backend, where the instance runs itquox.fetch()
memoryDeclarable, but refused as not_available for community pluginsquox.memory.save / search
notificationsToast notificationsquox.notify()
aeeDeclarable, but refused as not_available for community pluginsquox.aee.emit()
toolsOfficial plugins only; community plugins cannot register toolsBackend tool handlers

Security Model

  • Isolation. The plugin runs in an opaque-origin iframe (sandbox="allow-scripts") with connect-src 'none'. No direct network, no host DOM, no cookies, no other plugins.
  • One bridge. The only way out is window.quox over postMessage. Backend calls are confined to the plugin's own namespace, with absolute URLs and traversal refused.
  • Permissions. Declared in the manifest, reviewed at install, enforced by the host.
  • Signatures. When the org has trusted keys, only packages signed by a trusted key install. A changed manifest, bundle or stylesheet fails with manifest_mismatch or files_mismatch.
  • Revocation. An org admin can revoke a signing key or one package with quox plugin revocations. Revoked packages stop installing and loading, and their backend is stopped.
  • Audit. quox.aee is refused with not_available for community plugins today, so a community plugin emits no evidence of its own yet.
  • Unverified by default. Community plugins are labelled unverified unless the org trusts the signing key.

Limits to know: there is no Quox-run review or marketplace listing for community plugins yet, revocation is per organisation with no shared list, and self-hosted installs from the public release do not run plugin backends yet.

Testing

@quoxai/quox-plugin-sdk/testing provides an in-memory mock of window.quox.

Using the mock in tests
import { installMockQuox, uninstallMockQuox } from '@quoxai/quox-plugin-sdk/testing';

afterEach(() => uninstallMockQuox());

it('saves a preference', async () => {
  const quox = installMockQuox({
    pluginId: 'my-plugin',
    fetch: (path) => (path === 'items' ? { status: 200, body: [{ id: 1 }] } : { status: 404 }),
  });

  await quox.storage.set('theme', 'dark');
  expect(quox.storageMap.get('theme')).toBe('dark');
});

createMockQuox(opts) builds the mock without installing it. By default getUser() returns an admin test user and fetch returns 404 unless you pass a handler.

Validation

npm run validate runs quox-plugin validate. It checks the manifest and the built bundle and exits 1 on any problem. Run it before every sign.