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.
Scaffold the plugin
Section titled “Scaffold the plugin”Create a React and TypeScript application with Vite in the prepared repository:
npm create vite@latest . -- --template react-tsnpm installnpm install @owox/plugin-sdk lucide-reactnpm run buildA 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.tsKeep 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.
Understand the plugin runtime
Section titled “Understand the plugin runtime”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.
Security and trust model
Section titled “Security and trust model”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
.envand.env.*to.gitignore; keep only a secret-free.env.examplewhen useful. - Let
ghuse 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.”
Use the plugin SDK
Section titled “Use the plugin SDK”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 value | What it provides |
|---|---|
ctx.owox | Authenticated 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.credentials | Project-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.actions | Runs 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.route | The 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.signal | Aborts when the host tears the plugin down. |
ctx.userId, ctx.projectId, ctx.theme | Provides 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.
Low-level API escape hatch
Section titled “Low-level API escape hatch”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.
Handle request errors
Section titled “Handle request errors”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.code | Meaning |
|---|---|
HTTP_ERROR | The API answered with an error. status is the HTTP status, message the API’s message, and details its error details. |
SUSPENDED | An administrator suspended the plugin while it was open. |
FORBIDDEN | The host refused the request before sending it, for example because of its path or method. |
NETWORK_ERROR | The host could not complete the request. |
TIMEOUT | The host did not answer within 30 seconds, or 120 seconds for an AI Credential call. |
PROTOCOL_ERROR | The 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.
Make pages shareable
Section titled “Make pages shareable”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.routeisundefinedon 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.routeas 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?queryand#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.routeis percent-encoded, as in the address, and never carries autm_*parameter — one you report throughsetRouteorcopyLinkis 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.setRoutefor the plugin’s own pages;ctx.ui.navigateis 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.
Use project Credentials
Section titled “Use project Credentials”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.
Call Google APIs with a service account
Section titled “Call Google APIs with a service account”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.
Read Data Mart data
Section titled “Read Data Mart data”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, fromschema.fieldsofctx.owox.getJson(`/api/data-marts/${id}`). For joined fields, readctx.owox.getJson(`/api/data-marts/${id}/blendable-schema`):blendedFields[].nameis the column name to request, andavailableSources[].aliasPathidentifies a joined source. Apre-joinfilter 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-schemalists the key inmainUniqueCountKeyFields. - 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); itstotalsisnullwhen 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.
Make the plugin feel native
Section titled “Make the plugin feel native”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.
Define the plugin manifest
Section titled “Define the plugin manifest”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:
| Scope | Visibility and namespace | Typical uses |
|---|---|---|
member | Private 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. |
project | Shared 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 type | Allowed action identifiers |
|---|---|
data-mart | SEE, USE, EDIT, DELETE, CONFIGURE_SHARING, MANAGE_OWNERS, MANAGE_TRIGGERS |
storage, destination | SEE, USE, EDIT, DELETE, CONFIGURE_SHARING, MANAGE_OWNERS, COPY_CREDENTIALS |
report | SEE, 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.
Run code on your backend with actions
Section titled “Run code on your backend with actions”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.
Declare actions
Section titled “Declare actions”{ "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.urlis resolved againstdelivery.urlthe way a link is, so/owoxabove meanshttps://acme-docs.acme.workers.dev/owox. A relative URL must stay on the delivery origin. An absolutehttps://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}$. timeoutSecondsis 1 to 300, 30 by default.inputis an optional JSON Schema 2020-12 of at most 32 KiB. It must compile in Ajv strict mode:propertiesneeds"type": "object", every name inrequiredmust be defined inproperties, and unknown keywords such asx-uiare refused. Only a local, non-recursive$refworks, and it may not have#after its first character.pattern,patternProperties,formatanduniqueItemsare not allowed, because a regular expression or a pairwise comparison from a schema could stall the server that validates it.$async,$dynamicRefand$recursiveRefare not supported. A schema that expands to more than 2,000 nodes with every$refinlined is refused; eachenumentry and each name independentRequiredordependenciescounts as a node. The release check rejects such a schema withACTION_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
urlon the same origin,timeoutSecondsandinput, but not remove an action; that release is rejected withACTIONS_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.
Call an action from the page
Section titled “Call an action from the 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.
Handle action errors
Section titled “Handle action errors”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:
code | Meaning |
|---|---|
ACTION_NOT_DECLARED | The current version does not declare this action. |
INPUT_TOO_LARGE | The input is over 256 KiB. |
INVALID_INPUT | The input fails the schema, nests over 100 levels or exceeds its budget. |
TOO_MANY_EXECUTIONS | The installation hit a run, start or storage limit, or too many validations. |
TIMED_OUT | The action did not finish within timeoutSeconds, or a run waited 24 hours. |
CANCELLED | The action was cancelled. |
BACKEND_UNREACHABLE | OWOX Data Marts could not connect to the backend. |
BACKEND_INVALID_RESPONSE | The answer was not a valid result or error, or the result cannot be stored. |
ACTION_FAILED | The handler threw an error with no valid code, or one with a reserved code. |
INSTALLATION_INACTIVE | Rare: the plugin was uninstalled while the action started or a run waited. |
PLUGIN_SUSPENDED | A queued run was due to start while the plugin was suspended. |
OWNER_NOT_MEMBER | The installing member left the project, or their account is locked, before a scheduled run started. |
UNAUTHORIZED | The backend did not accept the execution token or the request. |
HOST_NOT_ALLOWED | The calling host is not in hosts or, without hosts, not public HTTPS. |
PLUGIN_MISMATCH | The execution belongs to a plugin other than the backend’s repositoryId. |
ACTION_NOT_IMPLEMENTED | The backend declares no handler for this action. |
EXECUTION_CONTEXT_UNAVAILABLE | The backend could not read the execution from OWOX Data Marts. |
| any other | An 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.
Write the backend
Section titled “Write the backend”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 }; }, },});repositoryIdis the plugin’s GitHub repository id, which stays the same when the repository is renamed or transferred. Find it withgh api repos/OWNER/NAME --jq .id. An execution of another plugin is refused withPLUGIN_MISMATCHbefore a handler runs. OWOX names the plugin; a host you do not list inhostscan 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 useshttp:, is an IP address or a single-label name, or islocalhostor a name under.localhost,.local,.internalor.home.arpais refused withHOST_NOT_ALLOWEDbefore any request. Everything the handler reaches throughctxgoes back to the deployment that called. hostslists the exact origins allowed to call, such ashosts: ['https://app.owox.com']. Set it when a handler also uses the backend’s own secrets or storage, and to allow an internal orhttp:deployment. A request naming another host is refused before anything runs.@owox/plugin-sdk/serverwithouthostsruns only on Cloudflare Workers. Deployed Workers do not connect to private addresses, butwrangler devdoes: listhostswhile a Worker runs locally, especially behind a tunnel. The Worker needs the globalnavigator, which comes with a compatibility date of 2022-03-21 or later and nono_global_navigatorflag. On Node, importcreateBackendfrom@owox/plugin-sdk/server/node. On other runtimes, such as Bun or Deno, listhosts.- Before a handler runs,
createBackendasks 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
inputandctx:ctx.owox,ctx.credentials,ctx.collections(name),ctx.execution(executionId,action,input,trigger,scheduleId,deadlineAt,pluginVersion,installationId,projectId,userIdandplugin, with the plugin’sidandrepositoryId),ctx.host, the OWOX origin to build links to OWOX pages with, andctx.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 anawaitdoes not run, andrequest.signalnever fires. Do not rely on cleanup after anawait. On Node with@hono/node-server, the handler keeps running andctx.signalaborts. - 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 acodesuch asDOCUMENT_LOCKED; codes match^[A-Z][A-Z0-9_]{0,63}$. Any other error, such as a plainthrow new Error(), ends the execution withACTION_FAILED. Codes that start withBACKEND_, and the codes OWOX Data Marts sets itself, are reserved. - Never log the
x-owox-execution-tokenheader.
Deploy the page and the Worker together
Section titled “Deploy the page and the Worker together”One Worker can serve the built page and the actions. With Vite base: '/':
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: *npm run buildnpx wrangler deployRequests 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.
Deploy the backend to Cloud Run
Section titled “Deploy the backend to Cloud Run”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" }}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 });cd servernpm install @owox/plugin-sdk @hono/node-servergcloud run deploy acme-docs-backend --source . --region europe-west1 --allow-unauthenticated--allow-unauthenticatedis required: OWOX Data Marts sends no Google identity token, andcreateBackendchecks every call with OWOX Data Marts instead.- Set
backend.urlto the service URL the command prints, for examplehttps://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.
Run actions on a schedule
Section titled “Run actions on a schedule”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, });}cronis any expression of at most 255 characters that thecronpackage 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.timeZoneis required: an IANA time zone of 1 to 64 characters thatIntl.DateTimeFormataccepts, such asEurope/Kyiv. Names such aslocalorsystem, which mean the server’s zone, are refused. The member’s zone isIntl.DateTimeFormat().resolvedOptions().timeZone.parametersbecome the action’sinput. They must match the action’sinputschema and be at most 64 KiB. OWOX checks them on every create and update, and again before every run.enabled: falsecreates a paused schedule.nameis 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)anddelete(id)manage them.updatechangesaction,parameters,cron,timeZoneandname; onlypauseandresumechange whether a schedule runs. Each schedule carriesnextRunAt,disabledReasonandlastRun.- Schedule ids are UUIDs. A call with an id that is not a UUID rejects with a
PluginTransportError(HTTP_ERROR, status 400), notSCHEDULE_NOT_FOUND. In the plugin page, an id that contains/,%or\, or is.or.., rejects with aFORBIDDENPluginTransportErrorbefore 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()andget(id).
OWOX disables a schedule at run time and records why in disabledReason:
disabledReason | Cause |
|---|---|
ACTION_NOT_DECLARED | The current version no longer declares the action. |
INVALID_PARAMETERS | The parameters no longer match the action’s input schema. |
PAUSED_BY_ADMIN | A 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
inputschema disables that action’s schedules in every installation within a minute, withACTION_NOT_DECLAREDorINVALID_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:
code | Meaning |
|---|---|
INVALID_SCHEDULE | The cron expression, the time zone or the name is invalid or too long. |
ACTION_NOT_DECLARED | The current version does not declare the action. |
INVALID_PARAMETERS | The parameters fail the schema or nest over 100 levels; errors lists fields. |
PARAMETERS_TOO_LARGE | The parameters are over the limit, 64 KiB by default. |
TOO_MANY_SCHEDULES | The installation has as many schedules as allowed, 20 by default. |
SCHEDULE_BUSY | OWOX was starting this schedule’s run for over 5 seconds; try again. |
SCHEDULE_NOT_FOUND | This installation has no schedule with this id. |
SCHEDULE_PAUSED_BY_ADMIN | resume of a schedule that a project admin paused. |
TOO_MANY_EXECUTIONS | Too 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);}Deploy with GitHub Pages
Section titled “Deploy with GitHub Pages”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/Configure Vite for the deployment path
Section titled “Configure Vite for the deployment path”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()],});Add the GitHub Actions workflow
Section titled “Add the GitHub Actions workflow”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@v4Enable and verify GitHub Pages
Section titled “Enable and verify GitHub Pages”The GitHub CLI can ask GitHub to use the Actions workflow:
gh api --method POST repos/OWNER/PLUGIN_NAME/pages -f build_type=workflowThis 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:
gh run list --workflow deploy-pages.yml --limit 1gh run watch RUN_ID --exit-statusReplace 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.
Create a release
Section titled “Create 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 leadingv; - 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:
gh release create v0.1.0 --target main --generate-notesTo 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.
Publish and install
Section titled “Publish and install”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.
Publish in OWOX Data Marts
Section titled “Publish in OWOX Data Marts”- Open Plugins in OWOX Data Marts.
- Select Publish Plugin.
- Enter
OWNER/PLUGIN_NAMEin GitHub repository. - Under Who can find it, choose Only me for testing. A Project Admin can choose Everyone in this project after the plugin is ready.
- 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:
# 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-projectsMember 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.
Uninstall or restore
Section titled “Uninstall or restore”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.
Plugin history
Section titled “Plugin history”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.
Test the installed plugin
Section titled “Test the installed plugin”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.
Update or roll back
Section titled “Update or roll back”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:
owox-ctl plugins update OWNER/PLUGIN_NAMEA 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:
owox-ctl plugins check OWNER/PLUGIN_NAME --ref mainThe 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.
Troubleshoot
Section titled “Troubleshoot”If the plugin cannot be published or opens as a blank page, verify that:
npm run buildsucceeds and createsdist/index.html;- the GitHub Actions workflow uploads
dist; - Vite’s
baseexactly 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.jsonis 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-Optionsorframe-ancestors; - an action’s
inputschema stays within the size limits, uses nopattern,patternProperties,format,uniqueItems,$asyncor 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.
Definition of done
Section titled “Definition of done”Before sharing the plugin with a project, confirm that:
- the repository contains
AGENTS.md, source files,plugin.json, and the Pages workflow, orwrangler.tomlandworker/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
.envfile 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.owoxcall it uses; - every backend action has been run from the installed plugin, including a failure, and the
page shows its
PluginActionErrorusefully; - the plugin description matches what the plugin actually does;
- project-wide publication happens only after the personal installation passes.