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.
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
npx @quoxai/create-quox-plugin my-plugin
# or pick a template: npx @quoxai/create-quox-plugin my-plugin --template basic
cd my-plugin
npm installThere are 8 templates. basic is the right place to start. All of them target the community sandbox:
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.
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
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 problem4. Sign
npx quox-plugin keygen --out .keys # once; .keys/ is gitignored, keep the private key private
npm run sign # writes dist/signature.json5. Install
Install through the dashboard plugins page, or from the CLI as an org admin:
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
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 signShared 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.
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.
{
"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.
import { getQuox } from '@quoxai/quox-plugin-sdk';
const quox = getQuox(); // same object as window.quoxquox.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.
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.
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.
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.
await quox.aee.emit('my-plugin.report.generated', { reportId: 'r1' });quox.notify and quox.navigate
await quox.notify('Saved', 'success'); // 'info' | 'success' | 'warning' | 'error'
await quox.navigate('/plugins/my-plugin/settings');quox.getUser and quox.onEvent
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 timeWhat does not exist
@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.
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.
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)
npx quox-plugin keygen --out .keysThis 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
npm run build
npm run validate
npm run signsign 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
quox plugin trusted-keys add --label "Acme plugin signing" --public-key <64-hex-public-key>
quox plugin trusted-keys listRevoke 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.
Security Model
- Isolation. The plugin runs in an opaque-origin iframe (
sandbox="allow-scripts") withconnect-src 'none'. No direct network, no host DOM, no cookies, no other plugins. - One bridge. The only way out is
window.quoxoverpostMessage. 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_mismatchorfiles_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.aeeis refused withnot_availablefor 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.
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.