Skip to content

Plugin authoring guide

Use this guide while building and maintaining an OWOX Data Marts plugin. It defines the production plugin contract, SDK usage, UI foundation, deployment, releases, publication, and updates.

If the repository and development tools are not ready yet, first follow Get Started.

Create a React and TypeScript application with Vite in the prepared repository:

Terminal window
npm create vite@latest . -- --template react-ts
npm install
npm install @owox/plugin-sdk lucide-react
npm run build

A simple plugin can use this project structure:

.
├── .github/
│ └── workflows/
│ └── deploy-pages.yml
├── src/
│ ├── components/
│ │ └── ui/
│ ├── App.tsx
│ ├── main.tsx
│ └── styles.css
├── .gitignore
├── AGENTS.md
├── index.html
├── package.json
├── plugin.json
├── tsconfig.json
└── vite.config.ts

Keep reusable controls in src/components/ui/. Vite’s default build output is dist/; GitHub Pages publishes that directory. Keep plugin.json at the repository root: OWOX Data Marts reads it from the GitHub Release commit rather than from the deployed page.

A plugin whose page and actions are served by one Worker has wrangler.toml and worker/ in place of .github/workflows/deploy-pages.yml, and its delivery.url is the Worker’s URL. See Deploy the page and the Worker together.

An OWOX Data Marts plugin is a small web application hosted outside OWOX Data Marts. OWOX opens the application inside a protected frame and connects it to the installing member through @owox/plugin-sdk.

These constraints follow from the plugin sandbox. No OWOX Data Marts setting relaxes them.

The page runs in an opaque origin. It has no cookies, localStorage, sessionStorage, IndexedDB, or service workers. Keep short-lived state in memory. For state that must persist, use host-managed plugin collections. For work that must not run in the browser, use backend actions.

Forms do not submit. The frame is sandboxed without allow-forms, so the browser blocks every form submission before a submit event fires. Handle a button’s click instead of a form’s submit.

Calls from the page to your own server arrive with Origin: null. Such a server must answer Access-Control-Allow-Origin: *, and it cannot authenticate requests with cookies. Backend actions avoid this: OWOX Data Marts calls your backend server-to-server and tells it who is calling.

Your assets are cross-origin to your page. An opaque origin matches nothing, including the host that served the page, so anything fetched in CORS mode needs Access-Control-Allow-Origin: * from its server. This includes <script type="module">, fonts, and every fetch request. GitHub Pages sends the required header; a plain static server may not. Without it, the page can load while the browser blocks its module script and the plugin runs no code. A page with an inline script and no assets does not encounter this issue.

The entry page must be embeddable. It must not send X-Frame-Options. If it sends a Content-Security-Policy with frame-ancestors, the directive must permit OWOX Data Marts by using *, https:, or the exact origin of the deployment. Publication checks this and refuses a page that could not be displayed.

The plugin never holds an OWOX credential. Calls through ctx.owox are brokered by the host, which attaches the member’s token. Requests have the authority of the member who installed the plugin—never more, and never on behalf of another member. Protected routes still apply their server-side authorization and reject calls that member may not make.

Never place GitHub tokens, OWOX API keys, or other credentials in plugin source files, AGENTS.md, coding-agent prompts, commits, or release notes. Plugin code must not read or store an OWOX credential. Use OWOX_API_KEY only for owox-ctl or an external script running outside the plugin.

  • Add .env and .env.* to .gitignore; keep only a secret-free .env.example when useful.
  • Let gh use its own authenticated credential store.
  • Put backend secrets in the backend host’s secret manager, never in Vite environment variables. Values exposed to browser code are public even when their names contain “secret.”

Import connect from the @owox/plugin-sdk, then use the context it returns:

import { connect } from '@owox/plugin-sdk';
const ctx = await connect();
const dataMarts = await ctx.owox.dataMarts.list();

connect() completes a handshake with the OWOX Data Marts host. It rejects outside an OWOX Data Marts frame or if no host answers within 10 seconds.

Context valueWhat it provides
ctx.owoxAuthenticated OWOX Data Marts API client, supplied by @owox/plugin-sdk. Use the API client documentation to discover available methods. Do not install @owox/api-client or provide an API key inside a plugin; the SDK owns its transport, which cannot be replaced or inspected.
ctx.credentialsProject-owned Credential handles declared by the current plugin version and selected for this installation. Raw secrets never enter the plugin frame.
ctx.collections(name)Provides a host-managed JSON collection declared by the current plugin version.
ctx.actionsRuns the actions your backend declares in plugin.json; see backend actions. undefined on a host without them.
ctx.ui.openExternal(url)Asks the host to open an external HTTPS address in a new tab.
ctx.ui.navigate(path)Asks the host to navigate to a page inside OWOX Data Marts—for example, /ui/${ctx.projectId}/data-marts/${id}—in place of the plugin frame. Resolutions off the app’s origin are refused.
ctx.routeThe plugin’s own route the member opened, e.g. /dashboards/42; undefined on a host without page links.
ctx.ui.setRoute(path)Tells the host which of your pages is showing, so the address in the browser is a link to it.
ctx.ui.copyLink(path?)Asks the host to copy a link to one of your pages (the current one by default) and confirm it.
ctx.signalAborts when the host tears the plugin down.
ctx.userId, ctx.projectId, ctx.themeProvides display context without exposing tokens. The member’s name and avatar are available through ctx.owox.auth when needed.

See the API client method reference and Support Matrix for currently supported calls. Requests time out after 30 seconds. Streamed reads do not, because data traversals can run for minutes. ctx.actions.invoke and ctx.actions.wait are not limited either: they poll for up to 20 seconds at a time until the action ends. At most 32 requests may be in flight at once.

Prefer the typed ctx.owox resources. For an endpoint that has no typed abstraction, the client also exposes getJson<T>(path, query?), postJson<T>(path, body, accept?), putJson<T>(path, body), patchJson<T>(path, body), deleteJson<T = void>(path), and getStream(path, query?). The generic does not validate the response at runtime, so validate returned data yourself. Paths must be root-relative /api/... and are limited to 2,048 characters; unsafe or redirecting paths are refused.

A failed ctx.owox, collection, or credential call rejects with a PluginTransportError. Its payload holds code, message, and, for API errors, status and details:

payload.codeMeaning
HTTP_ERRORThe API answered with an error. status is the HTTP status, message the API’s message, and details its error details.
SUSPENDEDAn administrator suspended the plugin while it was open.
FORBIDDENThe host refused the request before sending it, for example because of its path or method.
NETWORK_ERRORThe host could not complete the request.
TIMEOUTThe host did not answer within 30 seconds, or 120 seconds for an AI Credential call.
PROTOCOL_ERRORThe request could not be exchanged: the handshake was not complete, a response was malformed, or 32 requests were in flight.

For HTTP_ERROR, apiCode holds the API’s own error code when it sends one, for example PLUGIN_COLLECTION_NOT_ENTITY_BOUND, and details.errors holds field errors. Backend actions reject with a PluginActionError instead; an INVALID_INPUT from ctx.actions.start or ctx.actions.invoke reaches the page that way, with errors. See Handle action errors.

An exact Credential’s asFetch() rejects only when the host fails the request. A provider’s own error, such as 401, 403, 429, or 500, resolves with a Response whose ok is false: check response.ok and handle the provider’s status and body yourself.

Some typed methods wrap the failure in an OWOXApiError and keep the PluginTransportError as its cause. dataMarts.traverseData(), for example, rejects with “Failed to open OWOX Data Mart data stream” whatever the API answered. Neither class is exported to plugin code, so find the payload by error.name and walk cause:

import type { PluginErrorPayload } from '@owox/plugin-sdk';
function describeError(error: unknown): string {
for (let current = error; current instanceof Error; current = current.cause) {
if (current.name === 'PluginTransportError' && 'payload' in current) {
const { code, status, message } = current.payload as PluginErrorPayload;
return [code, status, message].filter(Boolean).join(' ');
}
}
return error instanceof Error ? error.message : String(error);
}

Show the member what failed instead of a blank or empty view.

A plugin’s pages are reachable at /ui/<project>/plugins/<pluginId>/open<route>, where <route> is your own route. Read ctx.route after connect() and show that page; call ctx.ui.setRoute(route) whenever the member moves to another page, and ctx.ui.copyLink() from your own Share button.

  • ctx.route is undefined on a host without page links — that is how you detect support; hide your Share button then. A host that supports page links always sends a route, at least /.
  • Treat ctx.route as untrusted, link-supplied input: use it only to choose what to show. Never start a write, a run, or a credential use from it without the member’s own click, and never put secrets or personal data in a route — it appears in the address bar, history, and analytics. It may still carry percent-encoded sequences (e.g. %2F); decode deliberately.
  • A route starts with /, may carry ?query and #hash. Its path must not contain . or .. segments, \, an empty segment, or a trailing space; control, bidi, and invisible characters are refused anywhere in the route — the host refuses such a route and opens your plugin at /. ctx.route is percent-encoded, as in the address, and never carries a utm_* parameter — one you report through setRoute or copyLink is dropped the same way. A query or hash may carry a backslash or a trailing space; percent-encode a bidi or zero-width character you put in a route yourself, or it is refused.
  • Use ctx.ui.setRoute for the plugin’s own pages; ctx.ui.navigate is for host pages.
  • Call ctx.ui.copyLink() from a click handler: the host copies only during the member’s own interaction, one link at a time.
  • The host replaces the address rather than adding history entries, so the browser’s Back button leaves the plugin instead of stepping through its pages.
  • A member without the plugin who opens such a link is offered the install: automatically if a publication lists the plugin for them, otherwise via a banner on the page.

Declare external access in plugin.json; do not ask a member to paste a provider secret into the plugin itself:

{
"credentials": [
"github",
{ "id": "ai", "models": ["fast", "reasoning"] },
{ "id": "openai", "optional": true }
]
}

github, openai, anthropic, gemini, openrouter, and google-service-account are built-in exact definitions. An object may set optional: true; otherwise installation requires an explicit Credential selection. The logical ai requirement may declare one or more unique capabilities from fast, reasoning, and embedding. It can be satisfied by any selected Credential whose definition implements the AI contract and provides the requested mappings; the logical requirement does not select a provider. The shorthand string "ai" means required fast AI.

An external exact requirement uses @owner/repository. Public definition repositories work without deployment credentials. Private definition repositories use the same configured GitHub access as private plugin repositories.

See Credential definitions for the external definition format, release rules, and compatibility contract.

An exact handle exposes a guarded fetch implementation. OWOX adds authentication and permits only HTTPS requests to origins declared by the selected Credential definition:

import { exactCredential } from '@owox/plugin-sdk';
const github = exactCredential(ctx.credentials, 'github');
if (!github) throw new Error('GitHub access is not configured');
const response = await github.asFetch()('https://api.github.com/user');
if (!response.ok) throw new Error(`GitHub answered ${response.status}`);

Logical AI handles implement the Vercel AI SDK provider v4 contract:

import { generateText } from 'ai';
const model = ctx.credentials.ai;
if (!model) throw new Error('Fast AI is not configured');
const result = await generateText({ model, prompt: 'Summarize this report' });

The project maintainer maps fast, reasoning, and embedding to provider models. A plugin sees the logical capability, not the provider secret. The common "ai" requirement declares fast, so ctx.credentials.ai is itself the fast model and ctx.credentials.ai.fast is an alias for it. Additional named models are available only when declared. Logical AI never exposes asFetch(); declare an exact provider if the plugin needs provider-specific APIs. Optional or unusable handles are absent from the context, so check a handle before using it.

A google-service-account requirement must declare the OAuth scopes the plugin needs:

{
"credentials": [
{
"id": "google-service-account",
"scopes": ["https://www.googleapis.com/auth/documents"]
}
]
}

OWOX mints an access token for exactly these scopes and adds it to requests to https://*.googleapis.com. A release that changes the scopes asks members to configure the Credential again. Use @googleapis/docs and @googleapis/sheets rather than the full googleapis package, and give the client the handle’s fetch with no auth:

import { docs_v1 } from '@googleapis/docs';
import { exactCredential } from '@owox/plugin-sdk/server';
const google = exactCredential(ctx.credentials, 'google-service-account');
if (!google) throw new Error('Google access is not configured');
const docs = new docs_v1.Docs({ fetchImplementation: google.asFetch() });
await docs.documents.batchUpdate({
documentId,
requestBody: { requests: [{ insertText: { location: { index: 1 }, text: 'Hello' } }] },
});

A request body is capped at 1 MiB and a response at 5 MiB, so read a long document in parts. The Docs API allows 60 write requests per minute per project for one service account.

When Google refuses the stored key, the request fails with CREDENTIAL_REJECTED (403) and the Credential is marked as rejected until a member replaces the key or checks it again; the Credentials page’s Validation filter lists such Credentials. When Google issues no access token for the declared scopes, the request fails with CREDENTIAL_SCOPE_INVALID (400); fix the scopes in plugin.json.

ctx.owox.dataMarts.traverseData() streams a Data Mart’s rows. Its columns, filters, sorting, aggregation, and date buckets are documented in the API client reference. Four things are not visible from the rows:

  • Schema. dataMarts.list() returns no schema. Read a Data Mart’s fields, with their types and reporting settings, from schema.fields of ctx.owox.getJson(`/api/data-marts/${id}`). For joined fields, read ctx.owox.getJson(`/api/data-marts/${id}/blendable-schema`): blendedFields[].name is the column name to request, and availableSources[].aliasPath identifies a joined source. A pre-join filter may reference only a joined source’s columns.
  • Unique count. The Unique Count metric that reports offer is not available here. For a single-column primary key, aggregate it with COUNT_DISTINCT; blendable-schema lists the key in mainUniqueCountKeyFields.
  • Grand totals. Rows do not carry them. OWOX computes totals for the read’s metrics in a separate query and records them on the run. Read every row first, then call ctx.owox.runs.forDataMart(id).get(data.runId); its totals is null when there was nothing to total or the totals query failed.
  • Errors. A failure while opening the stream arrives wrapped, as described in Handle request errors.

Plugins run in an iframe and do not inherit OWOX Data Marts styles. They must import and bundle their own CSS. @owox/ui is an internal package, not a public plugin dependency, so do not import it.

For now, keep a small UI foundation in the plugin repository. This makes the plugin independent and can later be replaced by an official starter or a versioned plugin UI package without changing the plugin contract.

Use semantic variables and the standard page structure in src/styles.css:

:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
--background: oklch(1 0 0);
--foreground: oklch(0.3346 0.0123 279.25);
--card: oklch(1 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.5148 0.0128 274.72);
--border: oklch(0.922 0 0);
--primary: oklch(0.6179 0.2295 250.87);
--primary-foreground: oklch(0.985 0 0);
}
:root.dark {
color-scheme: dark;
--background: oklch(0.205 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--border: oklch(0.269 0 0);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--background);
color: var(--foreground);
}
.dm-page {
min-height: 100vh;
}
.dm-page-header {
padding: 1.5rem 3rem;
}
.dm-page-header-title {
margin: 0;
font-size: 1.5rem;
font-weight: 500;
}
.dm-page-content {
padding: 0 3rem 3rem;
}
.dm-card {
padding: 1rem;
border: 1px solid var(--border);
border-radius: 0.625rem;
background: var(--card);
}
.dm-muted {
color: var(--muted-foreground);
}
@media (max-width: 640px) {
.dm-page-header {
padding: 1rem 1.25rem;
}
.dm-page-content {
padding: 0 1.25rem 1.25rem;
}
}

Use those classes for the application shell:

<div className="dm-page">
<header className="dm-page-header">
<h1 className="dm-page-header-title">Plugin name</h1>
</header>
<main className="dm-page-content">
<section className="dm-card">Plugin content</section>
</main>
</div>

After connect(), apply the host theme before rendering:

document.documentElement.classList.toggle('dark', ctx.theme === 'dark');

Keep reusable controls in src/components/ui/. Radix or shadcn-style primitives can be kept locally, and Lucide is a good default for icons. Use semantic variables instead of fixed colors, visible keyboard focus, accessible names, and responsive layouts. Every data-driven screen should include loading, empty, error, and success states.

Do not depend on undocumented host HTML or CSS: it cannot cross the iframe boundary and may change without notice.

Add plugin.json at the repository root:

{
"name": "Example Plugin",
"description": "What this plugin helps a member accomplish",
"delivery": {
"type": "remote",
"url": "https://OWNER.github.io/PLUGIN_NAME/"
},
"credentials": []
}

Replace OWNER and PLUGIN_NAME after the first successful deployment. The URL must use HTTPS, be publicly reachable without a sign-in prompt, and not resolve to a private or metadata network, including through redirects.

The manifest contains the plugin-authored fields above. OWOX Data Marts derives plugin identity from the GitHub repository and the current version from the eligible GitHub Release tag. Renaming or transferring a repository does not create another plugin; two repositories with identical contents are two different plugins.

There is no separate API-key or permissions list in plugin.json. The plugin can call only the SDK-supported APIs, acting with the permissions of the member who installed it. A backend block adds one line to the install dialog: the plugin runs code on the backend’s origin with the member’s access, including when the plugin is closed.

Persist plugin state as JSON with collections

Section titled “Persist plugin state as JSON with collections”

Use host-managed collections to persist plugin state that must survive reloads, browser changes, or device changes. Collections are asynchronous and server-backed, so the same member can retrieve their state in another browser or on another device.

Why collections instead of browser storage? The plugin sandbox makes browser persistence unavailable. See the security and trust model for the restrictions.

Add every collection to the collections array in plugin.json. For example, a plugin can declare private preferences with scope: "member":

{
"collections": [
{
"name": "preferences",
"scope": "member"
}
]
}

Use a stable document ID for a preference or draft that has one current value:

interface Preferences {
compactView: boolean;
selectedTab: string;
}
const preferences = ctx.collections<Preferences>('preferences');
await preferences.put('current', { compactView: true, selectedTab: 'overview' });
const saved = await preferences.get('current');
const prefs = saved?.document ?? { compactView: false, selectedTab: 'overview' };

Choose the scope based on who owns the state:

ScopeVisibility and namespaceTypical uses
memberPrivate to the current member for this plugin and project. It is not shared with other project members or with the same member in another project.Preferences, personal layouts, drafts, and last-used settings. This is the usual replacement for localStorage.
projectShared by eligible members of the project.Shared dashboards, configuration, and other collaborative state.

Collection declarations are immutable in structure within a compatibility line. A release may add collections and change action mappings, but cannot remove a collection or change its name, scope, or entity binding — such a release is rejected and the previous version stays current; the reason appears in the publisher diagnostics and on the plugin page. Opening a new line waives the check — it is how a breaking collection change ships deliberately. From 1.0.0 on, the line is the major version, so the declared break is a major bump. Below 1.0.0, where SemVer promises no stability, the line narrows to the minor version: a 0.x plugin ships a breaking change by bumping the minor, without leaving 0.x.

Bind collection documents to OWOX entities

Section titled “Bind collection documents to OWOX entities”

A member- or project-scoped collection can optionally bind each document to a data-mart, storage, destination, or report. Binding does not change its visibility: member-scoped documents remain private, while project-scoped documents remain shared with eligible project members. OWOX checks the mapped existing action on every request. For a bound collection, parentId is required on create and cannot change later.

A collection without entityBinding takes no parentId. OWOX reads entityBinding from the current version’s plugin.json, not from your working copy. A put that omits parentId on a bound collection, or sends it to an unbound one, fails with HTTP_ERROR, status 400, and a message naming the collection and the current version. The API’s codes are PLUGIN_COLLECTION_PARENT_ID_REQUIRED and PLUGIN_COLLECTION_NOT_ENTITY_BOUND.

Adding or removing entityBinding on an existing collection, or changing its type, is a breaking change that needs a new line; changing its actions is not. Documents saved before that change keep their old parentId, which the new declaration cannot authorize, so they become unreachable. To bind existing data, declare a new collection with entityBinding instead, which a release within the line may do, and copy the documents into it with their parentId.

Each operation mapping must use an action supported by the bound entity type:

Entity typeAllowed action identifiers
data-martSEE, USE, EDIT, DELETE, CONFIGURE_SHARING, MANAGE_OWNERS, MANAGE_TRIGGERS
storage, destinationSEE, USE, EDIT, DELETE, CONFIGURE_SHARING, MANAGE_OWNERS, COPY_CREDENTIALS
reportSEE, EDIT, DELETE, RUN

See Actions and access by entity for what each action means and which members receive it.

{
"collections": [
{
"name": "dashboards",
"scope": "project",
"entityBinding": {
"type": "data-mart",
"actions": {
"read": "SEE",
"create": "SEE",
"update": "SEE",
"delete": "SEE"
}
}
}
]
}
const dashboards = ctx.collections<Dashboard>('dashboards');
await dashboards.put('revenue', dashboard, { parentId: dataMartId });
const saved = await dashboards.get('revenue');
const page = await dashboards.list({ limit: 50 });
await dashboards.delete('revenue');

Design collection data for network persistence

Section titled “Design collection data for network persistence”

Collections store non-secret JSON only. Do not put credentials, access tokens, refresh tokens, or other secrets in a document. Keep rapidly changing state in memory, then debounce or batch writes instead of sending a request for every keystroke, drag event, or resize. Provide an in-memory default while the initial collection read is loading, and handle network errors explicitly.

Platform limits are 1 MiB per document, 10,000 documents and 100 MiB per namespace, 500 MiB per plugin and project, and 2 GiB across collections in a project. JSON may contain at most 100 nested containers. List pages contain at most 100 documents and 4 MiB of JSON. Entity-bound collections inspect at most 10 stored documents per request so authorization checks stay bounded. Such a page can contain fewer items than requested, or no items, while still returning a non-null nextCursor; continue until the cursor is null.

Collection data survives uninstall, suspension, and recoverable deletion. Documents bound to a recoverably deleted parent are inaccessible until the parent is restored. There is no document schema validation or automatic migration; the plugin owns compatibility of its JSON. Mutation and authorization-denial audit records never include document bodies. They use rolling 90-day retention and are additionally capped at 50,000 rows per plugin/project and 500,000 rows per project, with the oldest records removed first.

A plugin can declare actions that run on its own backend, for example a Cloudflare Worker or a Cloud Run service. The plugin page starts an action with ctx.actions. OWOX Data Marts checks the input against the action’s JSON Schema, calls the backend, and gives it an execution token. The token acts with the installing member’s access and works only while that action runs. It keeps the role the member had when the action started until the action ends, at most timeoutSeconds, even if the member leaves the project or gets another role meanwhile; the installation is checked on every request. Credential secrets stay in OWOX Data Marts; the backend uses Credentials through ctx.credentials, as the page does. AI Credentials need @owox/plugin-sdk 0.40.0 or later; a backend on an older SDK gets only exact ones. An AI Credential that is not usable is left out of ctx.credentials and does not stop the action.

The SDK README describes the same API in its “Backend actions” and “@owox/plugin-sdk/server” sections.

Use an action for work that should not run in the page: calls that need a Credential’s secret header, jobs longer than one request, and work that must finish after the member closes the plugin.

The backend calls back the deployment’s PUBLIC_ORIGIN. OWOX Data Marts Cloud is public; a self-managed deployment can use actions only when the backend can reach its PUBLIC_ORIGIN.

{
"name": "Acme Docs",
"description": "Updates Acme documents from Data Mart data",
"delivery": { "type": "remote", "url": "https://acme-docs.acme.workers.dev/" },
"backend": {
"url": "/owox",
"actions": {
"update-document": {
"timeoutSeconds": 60,
"input": {
"type": "object",
"required": ["documentId"],
"properties": { "documentId": { "type": "string" } }
}
}
}
}
}
  • backend.url is resolved against delivery.url the way a link is, so /owox above means https://acme-docs.acme.workers.dev/owox. A relative URL must stay on the delivery origin. An absolute https:// URL is also accepted, for example when the page is on GitHub Pages and the backend is a separate Worker or a Cloud Run service. The resolved URL must be public HTTPS.
  • Up to 50 actions. Names match ^[a-z][a-z0-9-]{0,63}$.
  • timeoutSeconds is 1 to 300, 30 by default.
  • input is an optional JSON Schema 2020-12 of at most 32 KiB. It must compile in Ajv strict mode: properties needs "type": "object", every name in required must be defined in properties, and unknown keywords such as x-ui are refused. Only a local, non-recursive $ref works, and it may not have # after its first character. pattern, patternProperties, format and uniqueItems are not allowed, because a regular expression or a pairwise comparison from a schema could stall the server that validates it. $async, $dynamicRef and $recursiveRef are not supported. A schema that expands to more than 2,000 nodes with every $ref inlined is refused; each enum entry and each name in dependentRequired or dependencies counts as a node. The release check rejects such a schema with ACTION_INPUT_UNSUPPORTED. It does the same when the input schemas of a release together take longer than 2 seconds, or more memory than allowed, to compile.
  • Within a compatibility line a release may add actions and change url on the same origin, timeoutSeconds and input, but not remove an action; that release is rejected with ACTIONS_INCOMPATIBLE. It cannot add a backend or move its origin either (BACKEND_ORIGIN_CHANGED). A new compatibility line can, and once its release is current, every installation runs it. Members see the origin on the plugin’s page.

Wait for the result:

const ctx = await connect();
const result = await ctx.actions?.invoke('update-document', { documentId });

invoke cancels the action when you abort its signal or when the member leaves the plugin page. If the member closes or reloads the tab instead, nothing is cancelled and the execution can run until timeoutSeconds. invoke gives the page no execution ID while the action runs. When the page needs it then, for example to read it again after the plugin is reopened, use start and then wait or get. To let an action finish after the member leaves, start it and read it later:

if (ctx.actions) {
const { id } = await ctx.actions.start('update-document', { documentId });
// later, even after a reload
const execution = await ctx.actions.get(id);
const result = await ctx.actions.wait(id);
await ctx.actions.cancel(id);
}

An execution from get also carries scheduleId: the schedule that started it, or null.

OWOX clears an execution’s input and result 24 hours after it finishes, so a later get returns result: null. It clears the errorMessage of a failure your backend reported with its own code at the same time.

Input nested deeper than 100 levels is refused with INVALID_INPUT. So is input whose validation exceeds its time and memory budget, with the message “Input validation exceeded its budget”.

At most 5 actions run at once per installation. An installation can also start at most 60 actions per minute and 5,000 per 24 hours, and OWOX refuses new starts once 100 MiB of inputs and results it has not cleared yet are stored. Every execution that starts counts, even a cancelled or failed one. So does a queued run that was cancelled, failed or timed out before it started; only a run still waiting does not. Each waiting invoke or wait holds one of the 32 request slots of the page while it polls.

A failed action rejects with a PluginActionError. Find it by error.name and read code and message. It also carries errors for INVALID_INPUT, as {instancePath, message}, and executionId when the action had started:

codeMeaning
ACTION_NOT_DECLAREDThe current version does not declare this action.
INPUT_TOO_LARGEThe input is over 256 KiB.
INVALID_INPUTThe input fails the schema, nests over 100 levels or exceeds its budget.
TOO_MANY_EXECUTIONSThe installation hit a run, start or storage limit, or too many validations.
TIMED_OUTThe action did not finish within timeoutSeconds, or a run waited 24 hours.
CANCELLEDThe action was cancelled.
BACKEND_UNREACHABLEOWOX Data Marts could not connect to the backend.
BACKEND_INVALID_RESPONSEThe answer was not a valid result or error, or the result cannot be stored.
ACTION_FAILEDThe handler threw an error with no valid code, or one with a reserved code.
INSTALLATION_INACTIVERare: the plugin was uninstalled while the action started or a run waited.
PLUGIN_SUSPENDEDA queued run was due to start while the plugin was suspended.
OWNER_NOT_MEMBERThe installing member left the project, or their account is locked, before a scheduled run started.
UNAUTHORIZEDThe backend did not accept the execution token or the request.
HOST_NOT_ALLOWEDThe calling host is not in hosts or, without hosts, not public HTTPS.
PLUGIN_MISMATCHThe execution belongs to a plugin other than the backend’s repositoryId.
ACTION_NOT_IMPLEMENTEDThe backend declares no handler for this action.
EXECUTION_CONTEXT_UNAVAILABLEThe backend could not read the execution from OWOX Data Marts.
any otherAn OWOX refusal of the request, or the code your backend returned.

A start from a plugin that is no longer installed is refused with AUTHORIZATION_ERROR. Refusals become a PluginActionError only for start, invoke and the ctx.schedules calls; a refusal from get, wait or cancel, such as EXECUTION_NOT_FOUND, stays a PluginTransportError that carries apiCode.

Transport failures still reject with PluginTransportError; for a suspended plugin its payload.code is SUSPENDED.

import { createBackend } from '@owox/plugin-sdk/server';
export default createBackend({
repositoryId: '123456789',
actions: {
'update-document': async (input, ctx) => {
const { documentId } = input as { documentId: string };
const dataMarts = await ctx.owox.dataMarts.list();
return { documentId, dataMarts: dataMarts.length };
},
},
});
  • repositoryId is the plugin’s GitHub repository id, which stays the same when the repository is renamed or transferred. Find it with gh api repos/OWNER/NAME --jq .id. An execution of another plugin is refused with PLUGIN_MISMATCH before a handler runs. OWOX names the plugin; a host you do not list in hosts can name any.
  • Without hosts, any OWOX Data Marts deployment on a public HTTPS origin may call the backend, Cloud or self-managed. A host that uses http:, is an IP address or a single-label name, or is localhost or a name under .localhost, .local, .internal or .home.arpa is refused with HOST_NOT_ALLOWED before any request. Everything the handler reaches through ctx goes back to the deployment that called.
  • hosts lists the exact origins allowed to call, such as hosts: ['https://app.owox.com']. Set it when a handler also uses the backend’s own secrets or storage, and to allow an internal or http: deployment. A request naming another host is refused before anything runs.
  • @owox/plugin-sdk/server without hosts runs only on Cloudflare Workers. Deployed Workers do not connect to private addresses, but wrangler dev does: list hosts while a Worker runs locally, especially behind a tunnel. The Worker needs the global navigator, which comes with a compatibility date of 2022-03-21 or later and no no_global_navigator flag. On Node, import createBackend from @owox/plugin-sdk/server/node. On other runtimes, such as Bun or Deno, list hosts.
  • Before a handler runs, createBackend asks the named host for the execution. A request whose token that host does not accept gets 401 and runs nothing.
  • The handler gets the validated input and ctx: ctx.owox, ctx.credentials, ctx.collections(name), ctx.execution (executionId, action, input, trigger, scheduleId, deadlineAt, pluginVersion, installationId, projectId, userId and plugin, with the plugin’s id and repositoryId), ctx.host, the OWOX origin to build links to OWOX pages with, and ctx.signal, which aborts at the deadline. When OWOX drops the call, for example on cancel or on its own timeout, Cloudflare Workers cancels the whole invocation: the handler stops, code after an await does not run, and request.signal never fires. Do not rely on cleanup after an await. On Node with @hono/node-server, the handler keeps running and ctx.signal aborts.
  • Return JSON of at most 1 MiB, nested at most 100 levels. A result outside these limits fails the execution with BACKEND_INVALID_RESPONSE. To fail with your own code, throw an error with a code such as DOCUMENT_LOCKED; codes match ^[A-Z][A-Z0-9_]{0,63}$. Any other error, such as a plain throw new Error(), ends the execution with ACTION_FAILED. Codes that start with BACKEND_, and the codes OWOX Data Marts sets itself, are reserved.
  • Never log the x-owox-execution-token header.

One Worker can serve the built page and the actions. With Vite base: '/':

wrangler.toml
name = "acme-docs"
main = "worker/index.ts"
compatibility_date = "2026-10-01"
[assets]
directory = "./dist"

Workers Static Assets sends no Access-Control-Allow-Origin, and the page runs in an opaque origin, so without it the browser blocks the module script. Add public/_headers, which Vite copies to dist:

/*
Access-Control-Allow-Origin: *
Terminal window
npm run build
npx wrangler deploy

Requests for files in dist get the files; every other request reaches the Worker, whatever its path. backend.url only tells OWOX where to POST, and a stray POST still fails token verification. Use the Worker’s URL as delivery.url, then create a release as usual.

On Node, import createBackend from @owox/plugin-sdk/server/node and run it behind @hono/node-server. Without hosts, it checks every address it connects to and refuses a host name that resolves to a loopback, private, link-local or other non-public address with HOST_NOT_ALLOWED. Keep the page on GitHub Pages and put the backend in its own directory, such as server/, with this package.json:

{
"type": "module",
"scripts": { "start": "node server.js" },
"engines": { "node": ">=22.22.0" }
}
server/server.js
import { serve } from '@hono/node-server';
import { createBackend } from '@owox/plugin-sdk/server/node';
const backend = createBackend({
repositoryId: '123456789',
actions: {
'update-document': async (input, ctx) => {
const dataMarts = await ctx.owox.dataMarts.list();
return { documentId: input.documentId, dataMarts: dataMarts.length };
},
},
});
serve({ fetch: backend.fetch, port: Number(process.env.PORT) || 8080 });
Terminal window
cd server
npm install @owox/plugin-sdk @hono/node-server
gcloud run deploy acme-docs-backend --source . --region europe-west1 --allow-unauthenticated
  • --allow-unauthenticated is required: OWOX Data Marts sends no Google identity token, and createBackend checks every call with OWOX Data Marts instead.
  • Set backend.url to the service URL the command prints, for example https://acme-docs-backend-123456789012.europe-west1.run.app/. A new deploy of the same service keeps that URL.
  • Cloud Run’s default request timeout, 300 seconds, covers the largest timeoutSeconds.

A plugin can create schedules for its own installation. OWOX Data Marts then starts the scheduled action on time with the installing member’s access, whether or not anyone has the plugin open. In OWOX Data Marts, the plugin’s page lists its schedules, and members pause, resume and delete them under Plugins → Schedules. That page also lists their runs; a queued or running run can be cancelled there. A project admin sees and manages every plugin schedule and run of the project on the same page.

import type { PluginContext } from '@owox/plugin-sdk';
/** One schedule per watch: every Monday at 09:00 in the member's own time zone. */
async function scheduleWeeklyCheck(ctx: PluginContext, watchId: string) {
if (!ctx.schedules) {
return; // This OWOX Data Marts deployment has no schedules.
}
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const existing = (await ctx.schedules.list()).find(
schedule => (schedule.parameters as { watchId?: string } | null)?.watchId === watchId
);
if (existing) {
return ctx.schedules.update(existing.id, { cron: '0 9 * * 1', timeZone });
}
return ctx.schedules.create({
name: 'Weekly check',
action: 'run-check',
parameters: { watchId },
cron: '0 9 * * 1',
timeZone,
});
}
  • cron is any expression of at most 255 characters that the cron package accepts, as in Data Mart schedules. It needs a run in the next eight years: 0 9 30 2 *, the 30th of February, is refused. There is no minimum interval, but OWOX checks schedules once a minute, so a schedule runs at most once a minute.
  • timeZone is required: an IANA time zone of 1 to 64 characters that Intl.DateTimeFormat accepts, such as Europe/Kyiv. Names such as local or system, which mean the server’s zone, are refused. The member’s zone is Intl.DateTimeFormat().resolvedOptions().timeZone.
  • parameters become the action’s input. They must match the action’s input schema and be at most 64 KiB. OWOX checks them on every create and update, and again before every run.
  • enabled: false creates a paused schedule. name is optional, at most 200 characters after OWOX trims it; an empty name is stored as no name.
  • An installation has at most 20 schedules. A self-managed deployment can change this limit and the 64 KiB one; see Environment variables.
  • list(), get(id), update(id, patch), pause(id), resume(id) and delete(id) manage them. update changes action, parameters, cron, timeZone and name; only pause and resume change whether a schedule runs. Each schedule carries nextRunAt, disabledReason and lastRun.
  • Schedule ids are UUIDs. A call with an id that is not a UUID rejects with a PluginTransportError (HTTP_ERROR, status 400), not SCHEDULE_NOT_FOUND. In the plugin page, an id that contains /, % or \, or is . or .., rejects with a FORBIDDEN PluginTransportError before any request is sent.
  • Only the plugin’s own frame can create or change schedules; in OWOX Data Marts, members and project admins only pause, resume and delete them. A backend reads them with ctx.schedules.list() and get(id).

OWOX disables a schedule at run time and records why in disabledReason:

disabledReasonCause
ACTION_NOT_DECLAREDThe current version no longer declares the action.
INVALID_PARAMETERSThe parameters no longer match the action’s input schema.
PAUSED_BY_ADMINA project admin paused the schedule in Plugins → Schedules.

A disabled schedule does not run until resume, which checks it again. A run that fails in the queue with ACTION_NOT_DECLARED or INVALID_INPUT disables its schedule the same way. A run that the action fails, or that times out, does not.

While the member who installed the plugin is not an active project member, or their account is locked, OWOX skips the schedule’s runs and leaves it enabled, so it runs again once they are back. A run already queued then fails with OWNER_NOT_MEMBER. Plugins → Schedules shows a project admin which schedules wait for their owner.

ctx.schedules.resume of a schedule with PAUSED_BY_ADMIN is refused with the code SCHEDULE_PAUSED_BY_ADMIN. Only a resume in Plugins → Schedules, by the owner or an admin, lifts it. The plugin can still pause, update and delete that schedule.

Warning: A release that drops an action or changes its input schema disables that action’s schedules in every installation within a minute, with ACTION_NOT_DECLARED or INVALID_PARAMETERS. A later release that restores the action does not enable them again; each schedule has to be resumed.

Scheduled runs count toward the installation’s action limits. When a limit is reached, the run waits as QUEUED and starts once the limit allows, oldest first; a run held by the 24-hour limit can wait up to a day. A run still queued after 24 hours ends TIMED_OUT with the message “The run waited 24 hours in the queue”. An update that changes a schedule’s action or parameters cancels its queued run. When OWOX cannot validate a scheduled run’s parameters within its time budget, it tries again a minute later by default, or at the next run if that comes first, and does not disable the schedule. While a run waits or runs, the schedule skips its next slots. The schedules of an uninstalled plugin stop and run again after a restore; those of a suspended plugin skip their runs.

In the backend, ctx.execution.trigger is schedule and ctx.execution.scheduleId names the schedule. Make scheduled actions safe to run twice: when a replica fails, OWOX can run a slot again. After downtime, OWOX makes one overdue run, not one per missed slot.

'run-check': async (input, ctx) => {
const { watchId } = input as { watchId: string };
// A scheduled run takes its date in the schedule's time zone, not the backend's.
const timeZone =
ctx.execution.trigger === 'schedule' && ctx.execution.scheduleId
? (await ctx.schedules.get(ctx.execution.scheduleId)).timeZone
: 'UTC';
const today = new Date().toLocaleDateString('en-CA', { timeZone }); // 2026-10-20
return { watchId, today };
},

A refusal with any OWOX error code rejects with a PluginActionError, not only the codes below. A failure without a code stays a PluginTransportError, and so does the refusal of a suspended plugin, with the code SUSPENDED. These codes are the usual ones:

codeMeaning
INVALID_SCHEDULEThe cron expression, the time zone or the name is invalid or too long.
ACTION_NOT_DECLAREDThe current version does not declare the action.
INVALID_PARAMETERSThe parameters fail the schema or nest over 100 levels; errors lists fields.
PARAMETERS_TOO_LARGEThe parameters are over the limit, 64 KiB by default.
TOO_MANY_SCHEDULESThe installation has as many schedules as allowed, 20 by default.
SCHEDULE_BUSYOWOX was starting this schedule’s run for over 5 seconds; try again.
SCHEDULE_NOT_FOUNDThis installation has no schedule with this id.
SCHEDULE_PAUSED_BY_ADMINresume of a schedule that a project admin paused.
TOO_MANY_EXECUTIONSToo many inputs are being validated at once; try again.

Show the member why a change failed, for example after scheduleWeeklyCheck above:

import type { PluginActionError } from '@owox/plugin-sdk';
function describeScheduleError(error: unknown): string {
if (error instanceof Error && error.name === 'PluginActionError') {
const { code, message } = error as PluginActionError;
return code === 'TOO_MANY_SCHEDULES' ? 'Delete a schedule to add another.' : message;
}
return error instanceof Error ? error.message : String(error);
}

GitHub Pages can host the plugin’s static HTML, CSS, and JavaScript. A repository named OWNER/PLUGIN_NAME normally receives this address:

https://OWNER.github.io/PLUGIN_NAME/

Most plugins use the project-site address above. GitHub Pages serves that site below its repository name, so update vite.config.ts to make scripts, styles, and images use /PLUGIN_NAME/. Vite’s default build output remains dist/.

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
base: '/PLUGIN_NAME/',
plugins: [react()],
});

Replace PLUGIN_NAME with the exact repository name, then verify that npm run build creates dist/index.html.

Use base: '/' instead when the plugin is served from the domain root: an OWNER.github.io user or organization site, or a custom domain deployed at its root. For example:

export default defineConfig({
base: '/',
plugins: [react()],
});

Create .github/workflows/deploy-pages.yml:

name: Deploy plugin to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint --if-present
- run: npm run typecheck --if-present
- run: npm test --if-present
- run: npm run build
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4

The GitHub CLI can ask GitHub to use the Actions workflow:

Terminal window
gh api --method POST repos/OWNER/PLUGIN_NAME/pages -f build_type=workflow

This command needs permission to administer Pages for the repository. If GitHub refuses it, open Settings → Pages in the repository and select GitHub Actions under Build and deployment → Source. GitHub documents both the Pages API and the web settings.

After pushing, find and watch the workflow:

Terminal window
gh run list --workflow deploy-pages.yml --limit 1
gh run watch RUN_ID --exit-status

Replace RUN_ID with the value shown by the first command. Open the deployed address and confirm that it loads over HTTPS without a sign-in prompt. Put that exact address in plugin.json, commit and push the manifest change, and wait for the deployment again before creating a release.

OWOX Data Marts reads versions from GitHub Releases. A release becomes eligible when it is:

  • published rather than saved as a draft;
  • not marked as a GitHub prerelease;
  • tagged exactly MAJOR.MINOR.PATCH, optionally with a leading v;
  • attached to a resolvable commit with a valid root plugin.json;
  • configured with a public, embeddable delivery page.

After the deployment succeeds, create the first production release:

Terminal window
gh release create v0.1.0 --target main --generate-notes

To use GitHub’s web interface instead, open Releases, select Draft a new release, create the tag v0.1.0 from main, describe the change, leave Set as a pre-release unchecked, and select Publish release. See Managing releases.

Prerelease identifiers such as v1.2.3-rc.1 and build metadata such as v1.2.3+build.7 are not eligible, even though both are valid SemVer. Use GitHub’s prerelease checkbox for test releases.

The highest eligible version becomes current for everyone using the plugin, and members cannot pin an older version.

Publishing makes a plugin findable. Publish & Install also offers to install it for you; every other member decides whether to install it, and unpublishing later does not uninstall existing installations.

  1. Open Plugins in OWOX Data Marts.
  2. Select Publish Plugin.
  3. Enter OWNER/PLUGIN_NAME in GitHub repository.
  4. Under Who can find it, choose Only me for testing. A Project Admin can choose Everyone in this project after the plugin is ready.
  5. Select Publish & Install and confirm installation. The plugin opens and appears in your menu. To publish without installing, select Publish only from the button’s menu.

Publishing a plugin you have already installed changes only who can find it. The confirmation says that you already have it installed, your installation stays as it is, and Publish & Install opens the plugin.

For an agent or script, use the already configured owox-ctl:

Terminal window
# Publish only for yourself while testing.
owox-ctl plugins publish OWNER/PLUGIN_NAME --scope member
# Publish for everyone in the project. Requires Project Admin access.
owox-ctl plugins publish OWNER/PLUGIN_NAME --scope project
# Deployment administrators can publish to selected projects.
owox-ctl plugins publish OWNER/PLUGIN_NAME --scope deployment --project-id PROJECT_ID
# Deployment administrators can publish to every current and future project.
owox-ctl plugins publish OWNER/PLUGIN_NAME --scope deployment --all-projects

Member publication is the recommended first step for authors. Deployment scope is restricted to deployment administrators using API keys named in OWOX_DEPLOYMENT_PLUGIN_PUBLISHER_API_KEY_IDS; it also requires either one or more --project-id choices or --all-projects. Use selected project IDs when possible: --all-projects makes the plugin findable in every current and future project, and projects cannot be excluded from that audience. The operator’s side of this — the key allowlist, rollout verification, withdrawal and suspension — is covered in Trusted plugins for the whole deployment.

A public repository needs no GitHub App setup. For a private repository, publish once and follow the installation link returned by OWOX Data Marts to grant the correct GitHub App access, then publish again. A self-managed deployment may use its own GitHub App, so use the link returned by that deployment.

Unpublishing removes a listing, not an installation. A plugin you installed stays under Plugins in the sidebar and on the Plugins page even after nothing lists it any more; its card is marked as not listed.

To remove it, open the … menu next to the plugin in the sidebar and select Uninstall. With a mouse, the menu appears when you point at the plugin; expand the sidebar first if it is collapsed. The same action is in the ⋮ menu of the plugin’s page, which Settings in that sidebar menu opens. Uninstalling stops the plugin only for you: who can find it does not change, other members keep their installations, and any Credential access you granted ends. Uninstalling also stops the plugin’s schedules, and restoring runs them again.

To bring an uninstalled plugin back, open Plugins, select ⋮ → Plugin history, and select Restore next to the plugin’s latest removal, or under Restore a removed plugin when that removal is not loaded yet. The Publications filter shows no Restore. The Plugins section stays in the sidebar while you have a plugin to restore, and restoring works even when nothing lists the plugin. If the plugin needs Credentials, restoring asks for that access again.

Plugins → ⋮ → Plugin history lists, newest first, what happened to the plugins you can reach in this project:

  • your own installs, removals, and restores, which other members do not see;
  • publications for this project, with the member who published or unpublished, and your own publications for yourself;
  • changes to a plugin’s Verified listing, version updates, and suspensions.

Installations and Publications narrow the list. A Verified listing change shows for every project the listing is set to reach or has ever named, so it does not always mean the plugin appeared in, or left, this project. Events within the same second may appear in either order.

Publish the first release to Only me, install it, and test it inside OWOX Data Marts. The frontend depends on the host iframe and the real @owox/plugin-sdk handshake, so a standalone local frontend is not a supported preview environment.

Exercise the key flows with the installing member’s real access. Confirm loading, empty, error, and success states; light and dark themes; narrow and wide layouts; navigation; and every ctx.owox call the plugin uses. Fix issues, deploy the corrected files, and create a higher eligible release before sharing the plugin more broadly.

For a normal update, change and test the plugin, deploy it, and create a higher production release such as v0.1.1. OWOX Data Marts checks relevant repositories daily. Ask for an immediate check when needed, with Check and Update next to the version on the plugin’s page or from the command line:

Terminal window
owox-ctl plugins update OWNER/PLUGIN_NAME

A valid higher release applies to every installation. There is no member-level version pinning. To roll back, restore the last working source, deploy it, and create a new higher patch release that explains the restoration; do not try to move or recreate an older tag.

Check a release before you create it:

Terminal window
owox-ctl plugins check OWNER/PLUGIN_NAME --ref main

The check runs the release rules against a branch, tag, or commit:

  • the manifest;
  • collection compatibility with the highest recorded version in the candidate’s compatibility line;
  • the delivery URL;
  • backend actions: action compatibility, the backend origin and the backend URL;
  • Credential definitions.

It lists every failed rule it can evaluate, with release sync’s rejection codes. It records nothing and does not delay Check and Update or the daily check. The command prints JSON to stdout and a summary to stderr. It exits with code 1 when it finds issues.

Without --version, the check takes the next version after the current one. Below 1.0.0 that is the next patch; from 1.0.0 it is the next minor. A plugin with no current version then gets no collection compatibility check. Pass --version MAJOR.MINOR.PATCH to check another version. The check reports VERSION_CONFLICT for a recorded version and for an unrecorded version below the current one.

Deployment publishers and members who manage a publication of the plugin can run the check. They see GitHub errors in full. When OWOX Data Marts cannot read the repository, the error includes the GitHub App installation URL. PLUGIN_HOST_SYNC_MIN_INTERVAL_SEC limits checks per plugin and caller: 30 seconds by default with GitHub App or token access, 300 seconds without.

OWOX Data Marts records the exact commit referenced by each eligible release, but the delivery URL does not pin the files served there. Once an eligible release version is recorded, moving, deleting, or recreating its tag cannot rewrite that recorded version; use a new higher eligible release to roll back. Deploying different files changes what installed members run even before another release is created. Treat every deployment to the production URL as a production change and keep its release metadata aligned.

If the plugin cannot be published or opens as a blank page, verify that:

  • npm run build succeeds and creates dist/index.html;
  • the GitHub Actions workflow uploads dist;
  • Vite’s base exactly matches the deployed path: /PLUGIN_NAME/ for a project site, or / for a user or organization site or custom-domain root deployment;
  • the Pages deployment succeeds and the HTTPS address opens without signing in;
  • plugin.json is at the repository root and contains that exact delivery address;
  • the GitHub Release is published, is not a prerelease, and has an eligible version tag;
  • scripts, fonts, and fetched assets are served with Access-Control-Allow-Origin: *;
  • the entry page is not blocked by X-Frame-Options or frame-ancestors;
  • an action’s input schema stays within the size limits, uses no pattern, patternProperties, format, uniqueItems, $async or non-local, recursive $ref, and compiles in Ajv strict mode as JSON Schema 2020-12 (ACTION_INPUT_UNSUPPORTED);
  • a release keeps every action of its compatibility line (ACTIONS_INCOMPATIBLE) and adds no backend and moves no backend origin within it (BACKEND_ORIGIN_CHANGED);
  • the OWOX Data Marts GitHub App can read the repository when it is private;
  • the plugin catches errors and shows a useful message instead of a blank screen.

An administrator can suspend a plugin across a deployment. A suspended plugin cannot be opened, installed, or restored, but existing installations are not removed. Resuming makes it available again on the current version.

Before sharing the plugin with a project, confirm that:

  • the repository contains AGENTS.md, source files, plugin.json, and the Pages workflow, or wrangler.toml and worker/ when one Worker serves the page and the actions;
  • build, lint, type checking, and tests pass;
  • the installed personal test in OWOX Data Marts covers narrow and wide layouts, light and dark themes, and understandable loading, empty, error, and success states;
  • no credential or .env file is committed;
  • the deployed page and all its assets load from a public HTTPS address;
  • the production release points to the intended commit and has an eligible version tag;
  • the plugin has been published to Only me, installed, and tested with the installing member’s real access, including every ctx.owox call it uses;
  • every backend action has been run from the installed plugin, including a failure, and the page shows its PluginActionError usefully;
  • the plugin description matches what the plugin actually does;
  • project-wide publication happens only after the personal installation passes.