@colixsystems/action-sdk
v0.24.0
Published
Authoring contract for AppStudio Actions — marketplace deliverables that ship server-side action code. Implements ActionManifest, the action contract, and propertySchema validation.
Readme
@colixsystems/action-sdk
The authoring contract for an AppStudio Action.
An Action is a marketplace deliverable that ships server-side action code only. It has no React component and renders nothing, so it carries none of the widget concerns — no supportedPlatforms, no events, no inputs, no styleSchema — and it has no web↔native parity surface: an action runs in the backend's isolated-vm runner regardless of how the app is rendered.
A widget renders; an action automates. If you are building a component the app displays, you want
@colixsystems/widget-sdkinstead. The two are separate deliverables with separate developer guides, separate submit flows and separate review — but they live in the same marketplace and can be bundled together.
Install
npm install @colixsystems/action-sdkThe manifest
export default {
id: "com.acme.fortnox.sync",
name: "Fortnox Sync",
version: "1.0.0",
category: "ACCOUNTING",
icon: "lucide:plug",
description: "Pushes new invoices to Fortnox every 15 minutes.",
author: { name: "Acme AB", url: "https://acme.example" },
minAppStudioVersion: ">=0.1.0",
requestedScopes: ["datastore:read", "datastore:write"],
// Filled in by the INSTALLING OPERATOR on the Studio's Integrations page —
// not by a page author in the builder. The resolved values are delivered to
// every action below.
propertySchema: {
baseUrl: { type: "string", label: "API base URL", required: true },
apiToken: { type: "secret", label: "API token", required: true },
batchSize: { type: "number", label: "Batch size", default: 50 },
},
actions: [
{
key: "push-invoices",
name: "Push new invoices",
description: "Sends invoices created since the last run.",
triggerTypes: ["schedule"],
scheduleCron: "*/15 * * * *",
timeoutMs: 30000,
// The code lives in its own file; `pack` inlines it.
script: "scripts/push-invoices.js",
},
{
key: "notify-on-invoice",
name: "Notify on each new invoice",
triggerTypes: ["record_created"],
// Which table to fire off, named through the widget that seeds it.
// Install resolves this and binds the table for the operator.
triggerTable: { widget: "com.acme.invoices", suffix: "Invoices" },
script: "scripts/notify-on-invoice.js",
},
],
};my-action/
├── manifest.js
├── package.json
└── scripts/
└── push-invoices.js ← ordinary JavaScript, linted by your editorWhat's new in 0.24.0 (contract 0.13.0)
A datastoreTemplate can describe a whole system. It may now declare up to CONTRACT.datastoreTemplateMaxTables (100) tables of CONTRACT.datastoreTemplateMaxColumns (200) columns, CONTRACT.datastoreTemplateMaxRows (100) sample rows per table and CONTRACT.datastoreTemplateMaxRoles (32) roles. Sample rows have one more bound: CONTRACT.datastoreTemplateMaxSampleCells (20000) is the total number of cells the template seeds, where each row counts the keys it sets and at least one, summed over every row of every table. A template past any limit is refused at publish with a message naming the limit and the count. Every template valid under 0.23.0 is still valid.
What's new in 0.23.0 (contract 0.12.0)
A script calls a shipped connector by its template key. connectors.call("fortnox", { method, path }) reaches the connector your connectorTemplates entry fortnox created in the installing workspace, even when its slug became fortnox-2 because the workspace already had a fortnox, and even after the operator renames it. A key you declare reaches only the connector your install created: never another Action's template with the same key, and never a workspace connector whose slug happens to equal it, so a connectorRef pick with that slug is reached by its key's connector instead. When that connector is missing (not installed yet, or deleted by the operator) the call throws CONNECTOR_NOT_FOUND. Any other name is still a workspace connector's slug.
token_extra_params may not name a parameter the token request sets itself. CONTRACT.connectorReservedTokenParams lists them (grant_type, client_id, scope, …); a manifest that names one is refused at publish instead of installing an Action whose connector never appears.
The connectorTemplates format is contract data. CONTRACT.connectorTemplateFields describes each field of an entry, CONTRACT.connectorAuthConfigKeys lists the authConfig keys each authType accepts and CONTRACT.connectorAuthConfigKeyMeanings says what each one holds. CONTRACT.connectorTemplateKeyPattern, CONTRACT.connectorTemplateNameMaxLength, CONTRACT.connectorTemplateDescriptionMaxLength, CONTRACT.connectorTemplateMaxHeaders and CONTRACT.connectorTemplateExample complete it. validateConnectorTemplates reads the same data, so what it accepts and what is documented cannot drift.
What's new in 0.22.0 (contract 0.11.0)
The datastoreTemplate format is contract data. CONTRACT.datastoreTemplateDataTypes lists the column types a template accepts (STRING, TEXT, NUMBER, FLOAT, BOOL, DATE, DATETIME, FILE, STRING_ARRAY, INT_ARRAY, RELATION, USER, USER_GROUP). CONTRACT.datastoreTemplateDataTypeMeanings says what each holds (NUMBER is a whole number, FLOAT a decimal). CONTRACT.datastoreTemplateTableFields, CONTRACT.datastoreTemplateColumnFields and CONTRACT.datastoreTemplateExample describe a table, a column and a worked example, and CONTRACT.datastoreTemplateMaxRows / CONTRACT.datastoreTemplateSeedableDataTypes bound a table's sample rows. The publish check reads the same data, so what it accepts and what is documented cannot drift. Every column needs a dataType.
datastoreTemplate: {
tables: [{
suffix: "Stores",
columns: [
{ name: "Name", dataType: "STRING", required: true },
{ name: "Rating", dataType: "FLOAT" },
],
}],
},A script reaches a template table by its installed name, <install prefix>_<suffix>. The prefix comes from the Action's name, so "Kristinehamn Supermarkets" reaches Stores as datastore.records("Kristinehamn_Supermarkets_Stores"). When a workspace already has a table of that name, its install takes the next free prefix (Kristinehamn_Supermarkets_2), so the name belongs to the workspace the Action is installed in.
What's new in 0.21.0 (contract 0.10.0)
Send-later invitations are withdrawn. 0.20.0 let directory.users.invite() take send_invitation: false and added directory.invites.resend(inviteId). Both are removed: directory.users.invite({ name, email, group_ids }) emails the invitation at once, as before 0.20.0, and directory.invites.list() no longer accepts not_sent.
What's new in 0.19.0 (contract 0.10.0)
manifest.description is optional. An Action publishes with a name and no description; when present, the description must still be a string.
What's new in 0.18.0 (contract 0.9.0)
input joins the script globals (sc-9031). When a published app's button or widget event is bound to Run action, the author's params and the widget's event data reach your script as input — a deep-frozen object, null on every other trigger. Validate it before use: it comes from the app. The app trigger stays operator-granted, so a manifest still cannot declare it.
What's new in 0.17.0 (contract 0.8.0)
directory gains the two methods an onboarding automation was missing. No new scope — both sit under the grants you already declare.
invites.list(query)(directory:read) — a pending invite is not a member row, so a script that deduplicated againstusers.listalone re-invited everyone invited since, burning a write slot per attempt on a rejection it could not see coming. Query it with{ status, limit, offset }, wherestatusispending(the default) /accepted/revoked/expired/all; anything else is refused rather than quietly widened. The default is deliberately notall— the full log holds addresses of people who declined or never joined. Same{ data, meta }envelope as the other lists.users.update(id, { is_active })(directory:write) — an automation that could add people and never suspend them owned only half of an access-control lifecycle.is_activeis the only field, and a body that sets nothing is refused rather than reported as a successful update. It routes through the same deactivate/reactivate path the Studio's own admin does, so the account-status mail and the subscription handling still happen. It returns only{ id, is_active }—directory:writedoes not buy you a profile read — refuses anINTEGRATIONtarget (that is the identity Actions themselves run as; suspending one would stop every automation in the workspace), counts against the 50-write budget, is refused on anapp-triggered run, and is logged like every other write. Setting a flag that already has that value is a no-op: no mail, no billing effect, no slot.- Page to the end of a list before you trust it. 200 is the largest page either method will return, so in a workspace with more members than that, a single call is a subset — dedupe against it and you re-invite everyone on page 2.
meta.totalis there to tell you when to stop.
async function everyPage(fetchPage) {
const out = [];
let offset = 0;
for (;;) {
const page = await fetchPage(offset);
out.push(...page.data);
offset += page.data.length;
if (!page.data.length || offset >= page.meta.total) return out;
}
}
const members = await everyPage((offset) => directory.users.list({ limit: 200, offset }));
const pending = await everyPage((offset) => directory.invites.list({ limit: 200, offset }));
const known = new Set(members.map((u) => u.email).concat(pending.map((i) => i.email)));
if (!known.has(email)) {
await directory.users.invite({ name, email, group_ids: [group.id] });
}What's new in 0.16.0 (contract 0.8.0)
Your Action can manage the workspace's people — with the operator's consent.
- Why. A script could read the identity it runs as (
datastore.users.me()) and notify someone whose id it already held, but it could not list members, invite one, or touch group membership. So an automation that onboards or off-boards people had no path at all, and the obvious workaround is closed:fetchagainst the platform's own user-admin endpoints is refused, because an integration key is not a Studio principal and cannot mint the scope header those routes require. directoryis the new global. Reads:users.list(query),users.get(id),groups.list(query). Writes:users.invite({ name, email, group_ids }),groups.create({ name }),groups.addMember(groupId, userId),groups.removeMember(groupId, userId). A list resolves to{ data, meta }— the same envelope the widget SDK's directory client returns — someta.totaltells you whether more members remain.- Declare the scope, or the global is not there.
directory:readanddirectory:writeinrequestedScopes. They do not imply one another: an Action that only provisions cannot also enumerate your workspace. The scope is re-checked on every call, and a call you were not granted throwsDIRECTORY_SCOPE_REQUIREDnaming what is missing — it never partially applies. directory:is now a CLOSED namespace. Anything else under it —directory:admin, a typo — is a publish error rather than a scope that installs, is consented to, and then grants nothing. Scopes outside that namespace stay free-form, so every already-published Action validates unchanged.- There is no
users.create. A script that set a password would have to know one. Provisioning goes through an invite, exactly as the widget SDK's directory client does, so the person who joins chooses their own credential. - Writes are bounded and attributable. At most 50 directory writes per run, and each one is written to that run's log — unattended provisioning that nobody can reconstruct afterwards is not something a workspace should have to accept. Reads are not logged.
What's new in 0.13.0 (contract 0.7.0)
Say which table your record trigger fires off, and install binds it for you.
- Why. A
record_*action installs with no table bound, because only the installing workspace has tables. So it materialises disabled and stays inert until the operator finds it on the Actions page and picks the right table — and until they do, nothing tells them anything is wrong. An Action shipped in a bundle beside the widget that seeds that very table had the platform holding both halves of the answer and joining neither. - Declare
triggerTableon the action:{ widget: "<manifest id>", suffix: "<datastoreTemplate suffix>" }. It names the widget whosedatastoreTemplateseeds the table plus that table's suffix — never a table id, since ids are tenant-local and the table does not exist until that widget is installed. - Install resolves and binds it, against the prefix that widget's install actually seeded under, and enables the action once it is fully wired (a bound table and an automation identity to run as). The installer has already consented to your manifest's scopes, so a fully wired action is one the workspace asked for.
- Unresolved is not an error. The widget is not installed, seeded nothing, or the table was renamed — the action materialises unbound and disabled exactly as before, and the operator binds it by hand. An install never fails because a convenience could not be applied.
- The operator stays in charge. They can rebind or disable from the Actions page, and a re-install or version upgrade never overwrites what they chose.
- Only for record triggers. Declaring
triggerTableon aschedule- ormanual-only action is a publish error: nothing would ever bind it, so your intent would be silently lost. - It must be a widget you ship with — one of your own, or a co-member of a bundle holding both. Naming a stranger's widget resolves to nothing: binding to someone else's table is a grant the installing operator never made.
- It names a WIDGET's table, not your own. The prefix comes from a widget installation, so an Action cannot point
triggerTableat tables its owndatastoreTemplateseeded — bind those on the Actions page.
What's new in 0.12.0 (contract 0.6.0)
Ship the REST connector your Action needs, prefilled — install creates it.
- Why.
connectorReflet you say which connector you need, but only one the operator had already hand-built. So installing your Action still meant reading your README and retyping a base URL, an authorize URL, a token URL, a scope string and a header map that you already know — and getting any of them wrong yields an Action that installs green and fails on the first run. - Declare
connectorTemplatesand each entry becomes a real connector in the installing workspace, carrying everything you prefilled. At most 4, and they persist across uninstalls (the operator's credential is in them). - Prefill everything except the credential. A
token,password, apiKeyvalueorclient_secretin a manifest is a publish error, not a silently-dropped field: the manifest is reviewed once and then shipped to every workspace, so an author-supplied credential would be a shared one. The same goes for aclient_id— that is the workspace's own OAuth app. - Until the operator supplies it, calls are refused with
CONNECTOR_SETUP_INCOMPLETErather than sent unauthenticated. Your script sees a thrownErrorit can catch; the operator sees exactly which field is missing. AconnectorRefpointing at such a connector also keeps the installis_configured: false, and so does every connector you ship: until each one is set up, the install's actions stay disabled. Ship only connectors your Action needs. - A re-pin reconciles additively. Correct a base URL or add a scope in a new version and it reaches every workspace; the operator's credential, their renamed connector and their slug are untouched. A connector they deleted stays deleted.
- The installing operator is told. The Installed Actions card names each connector your Action relies on and what it still needs, so nobody has to read the README to discover that your Action talks to an external API.
What's new in 0.11.0
Ship a default for every optional property — it now reaches your script even on an install nobody has configured.
- Why. A declared
defaultused to be applied only when the operator SAVED the properties form. Until then your script received nothing for that property, so the advice was to code the fallback yourself (const { batchSize = 50 } = properties) and the declaration was decoration. An Action that shipped working configuration ran with none of it. - One binding time. The save path no longer stores a default for a blank field; it defers to the resolver. So "blank means whatever the Action ships" holds for the life of the install rather than until the operator's first save — and if you later publish a better default, every workspace that never overrode it picks it up on the next version pin.
- A default is delivered coerced to its declared type, and validated against that type and its
enumat publish. Previously{ type: "number", default: "50" }passed and handed your script the string"50", while the same property typed by an operator stored50. - A tenant-local reference may not declare one.
connectorRefalready refused a default;tableRefandcolumnRefnow refuse one too, for the same reason — an id names a row in ONE workspace, so it would dangle everywhere but yours. An illegal default satisfies norequiredcheck either. - A default is PUBLIC. The operator sees it in the form and it is never redacted from run logs. Anything of yours that must stay yours belongs in
hiddenProperties; asecretstill cannot declare a default at all. - All of your defaults together are bounded by the same byte budget as
hiddenProperties, since they are copied into the isolate on every run.
What's new in 0.10.0 (contract 0.5.0)
An action can declare which REST connector it needs. Add a connectorRef property to propertySchema and the installing operator picks one of their workspace's connectors; your script is handed its slug, ready for connectors(slug).
- Why. An action's script could always call
connectors(...), but the manifest could not say which connection it needed — so an operator installed the Action, filled in the properties, enabled it, and the first run failed on a connector that did not exist or was never authorized. Nothing told them a connector was the missing piece. - A required
connectorRefis only satisfied when the connector is usable — present, not deleted, and (for an OAuth connector) holding a live grant. Until then the install reportsis_configured: falseand its actions stay disabled, which is the same rule a missingsecretalready followed. - No new declaration mechanism. It is a
propertySchematype, so it inherits validation, coercion, the operator form, required-ness and the configured check that were already there.
What's new in 0.8.0 (contract 0.3.0)
An action's code can live in its own .js file. Declare script: "scripts/<name>.js" instead of an inline scriptSource string, and appstudio-action lint / pack read the file and inline it for you.
- Why. A script embedded in the manifest as a template literal loses every tool that reads
.js: syntax highlighting, your editor's linter, jump-to-definition, a readable diff. A manifest carrying several actions became mostly code with the metadata buried in it. - The path is relative to the manifest and may not escape the package — the tarball is what gets published, so a traversing reference would either miss or pull in a file you never meant to ship.
- It is an authoring convenience, resolved before anything leaves your machine.
packwritesappstudio.action.jsonwithscriptSourceinlined verbatim, so the artefact the marketplace stores, reviews, pins amanifest_sha256over and runs is unchanged. Nothing about publishing, review or the runner is different. - Declare one or the other, never both — that would leave real doubt about which code ships, so it is refused with the action's key in the message. Inline
scriptSourceremains fully supported for a one-liner, and every already-published Action is unaffected. - A
scriptthat reaches the wire is refused (manifest.actions[].script is a source reference, not publishable). That means the artefact was hand-built orpackwas skipped, so the code never made it — better a clear rejection than an Action that installs and no-ops.
Fields
| Field | Required | Notes |
| ----- | -------- | ----- |
| id | yes | Reverse-DNS, e.g. com.acme.fortnox.sync. |
| name | yes | Human-readable. |
| version | yes | Semver. |
| category | yes | One of the storefront categories (see CONTRACT.manifestCategories). |
| icon | yes | Icon identifier, e.g. lucide:plug. |
| description | yes | 1–2 sentence storefront description. |
| author | yes | { name, url?, email? }. |
| minAppStudioVersion | yes | Semver range. |
| requestedScopes | yes | [] unless the actions read/write data. |
| propertySchema | yes | Operator-supplied configuration. {} if none. |
| actions | yes | At least one, at most 16. The action's whole payload. |
| datastoreTemplate | no | Tables seeded at install (100 tables, 200 columns each, 100 sample rows per table, 20000 sample cells in all, 32 roles; see CONTRACT.datastoreTemplateMax*). Each table is { suffix, columns, rows? } and each column { name, dataType, required? }; the types are CONTRACT.datastoreTemplateDataTypes. |
| connectorTemplates | no | REST connectors created at install, prefilled by you (at most 4). Never carry a credential. |
| translations | no | Strings merged into the tenant dictionary under action.<id>.<key>. |
Property types
string, number, boolean, secret, select, multiselect, tableRef, columnRef, connectorRef, object, array.
A secret property is stored encrypted at rest, is never returned by any read path, and is redacted out of captured action output. It may not declare a default — a credential does not belong in a manifest.
A tableRef or columnRef property may not declare a default either: an id names a row in one workspace, so it would dangle everywhere but your own.
A connectorRef property asks the operator to pick one of their workspace's REST connectors, so an Action that calls an external API can say WHICH connection it needs instead of the operator discovering the requirement on the first failed run. Three things follow from that:
- Your script receives the connector's
slug, not the stored id. Pass it straight toconnectors.call(slug, { method, path }). The host resolves it, because a script has no way to look a connector up. - A required
connectorRefkeeps the install unconfigured until the connector is actually usable — picked AND, for an OAuth connector, authorized. Sois_configuredanswers "can this run", not merely "is a value present", and a scheduled Action will not start against a connection nobody has approved. - It may not declare a
default. A connector id names a row in one workspace, so a default would dangle everywhere else.
The page-authoring types a widget has (pageRef, eventBinding, expression, recordBinding, valueRef, asset, icon, image, richText, …) address a page or a rendered component, so they are not available here.
Connector templates
A connectorTemplates entry creates a REST connector in the installing workspace at install time, prefilled from what you already know about the upstream API. connectorRef says which connector an Action needs; this ships one, so the operator's whole job is pasting their credential.
connectorTemplates: [
{
key: "fortnox", // stable; the idempotency key on re-install
name: "Fortnox", // the operator may rename it
baseUrl: "https://api.fortnox.se/3",
authType: "oauth2_authorization_code",
authConfig: { // NON-SECRET prefill only
authorize_url: "https://apps.fortnox.se/oauth-v1/auth",
token_url: "https://apps.fortnox.se/oauth-v1/token",
scope: "invoice",
pkce: "S256",
},
defaultHeaders: { Accept: "application/json" },
description: "Reads invoices nightly.", // shown to the operator
},
]- A manifest may never carry credential material.
token,password, apiKeyvalueandclient_secretare refused at publish, because the manifest ships to every workspace.client_idis refused as prefill for the same reason in practice — each workspace registers its own OAuth app. - Prefill the rest. Base URL, auth type, default headers, OAuth endpoints, scope,
pkce,token_auth_method, the non-secretassertion_audience/assertion_kid, a client-credentialsgrant_type,token_extra_headers/token_extra_params, an apiKeyheader_name, abasicusername. An unknownauthConfigkey is refused rather than passed through, so a typo is a publish error instead of a silently ignored field. - Every declared URL must be absolute
http(s)and publicly routable — checked at publish, so a private or loopback host is your problem rather than every installing workspace's. - Call it by its
key.connectors.call("fortnox", { method, path })reaches the connector this entry created, whatever slug or name it has in the workspace. - Until the credential arrives, the connector cannot call.
connectors.callthrowsCONNECTOR_SETUP_INCOMPLETEinstead of sending an unauthenticated request that returns a confusing upstream 401. - Idempotent for the life of the install. A reinstall or a version pin reuses the connector that already holds the operator's credential; a new version's corrected base URL or added scope is merged in, and their rename and slug survive.
Action scripts
An action script is not a React component. It runs against the runner globals and has no SDK imports, no hooks, no React and no DOM:
datastore, secrets, properties, fetch, connectors, notifications, directory, console, record, request, tenantId, triggerType, triggerTableId, triggeredBy, input
directory is the exception: it is defined ONLY when your manifest requested a directory: scope and the installing workspace consented to it. Every other global is always there.
triggerTypes may hold any of schedule, record_created, record_updated, record_deleted, manual. It may not hold app or http_post (sc-5366): those expose the script to a published app's buttons or to a public webhook, so the installing workspace grants them in its Actions admin page — a manifest that declares one is rejected. request is { body } — the JSON an inbound http_post caller sent — and null on every other trigger; request headers are never passed through.
fetch(url, { method, headers, body }) is outbound HTTP. The headers object you pass is sent unchanged. Without your own User-Agent the request carries a generic default, which many public APIs refuse (a 403, 406 or 429). When you call one, send a descriptive User-Agent (your Action's name plus a contact, e.g. "Harbour Finder/1.0 ([email protected])") and the Content-Type the API expects. Bodies are strings or JSON only, with no streaming and no FormData. An object body is JSON-encoded, but no Content-Type is added for you. A form-encoded body is a string built with encodeURIComponent, because the runner has no URLSearchParams, FormData or Headers. The response has ok, status, headers.get(name) (any case), text() and json(). Prefer connectors when the workspace has the API configured as one.
On a non-2xx, log what the operator needs and fail the run. A thrown error marks the run as an error in the Runs panel, while a bare return reports success and hides the outage. Never retry a 429 in a loop: there are no timers to back off with, and the next scheduled run is the retry.
const res = await fetch("https://overpass-api.de/api/interpreter", {
method: "POST",
headers: {
"User-Agent": "Harbour Finder/1.0 ([email protected])",
"Content-Type": "application/x-www-form-urlencoded",
},
body: "data=" + encodeURIComponent(query),
});
if (!res.ok) {
const excerpt = (await res.text()).slice(0, 500);
console.error("Overpass " + res.status + ", Retry-After: " +
res.headers.get("Retry-After") + ", body: " + excerpt);
throw new Error("Overpass request failed with " + res.status);
}properties is this install's configuration — the values the operator filled in for your propertySchema plus the hiddenProperties you shipped, merged into one frozen object. Read properties.baseUrl, not a hard-coded literal.
triggeredBy is who started the run — { type, id, name, email, role }, or null when nothing did (a schedule or record_* run), so guard before reading it. type is "app_user" on an app button press or an http_post webhook and "studio_user" on an operator's Run now; one id, two directories, so check type before addressing the id to notifications.notifyUser or a USER column. role carries the app-user vocabulary (USER / INTEGRATION) and is null for a studio user, as is email — an operator's address has no consumer in a script (notifyUser addresses app users), so it is deliberately withheld. It describes the caller only — your script still runs as the workspace's bound integration identity, so this grants it nothing extra.
input is the parameters a Run action event sent — the author's binding params merged over the data the emitting widget passed, the params winning on a clash — as a deep-frozen plain object, or null on every trigger but app (and on an app run that sent none). The server accepts only a plain JSON object of at most 16 KiB and 8 nested objects or arrays, with no __proto__ / constructor / prototype keys. It comes from the app, so treat it as untrusted: validate every field before you use it, and never let it choose which record or connector the script is allowed to reach.
Limits: scriptSource ≤ 200 KiB, timeoutMs between 100 and 300000.
Never declare triggerTableId or apiKeyId in the manifest — those are tenant-local and the operator binds them after install.
The CLI
appstudio-action lint <dir|manifest> # validate the manifest + every action script
appstudio-action pack <dir> # emit appstudio.action.json, then npm packlint runs two passes: the manifest's structure (the same validateManifest the publish endpoint runs, so the two can never disagree) and each action's scriptSource against the runner surface. It reports every finding and exits non-zero only on an error — a warning is printed but does not block.
| Rule | Severity | Why |
| ---- | -------- | --- |
| manifest | error | A structural problem in the manifest. |
| action-script-module | error | The runner has no module system — inline what you need. |
| action-script-react | error | React and SDK hooks are the widget surface. |
| action-script-jsx | error | JSX renders nothing here. |
| action-script-dom | error | There is no DOM in the runner. |
| action-script-host | error | No Node or host-escape globals (process, eval, …). |
| action-script-no-globals | warning | The script touches no runner global — probably a stub. |
| action-script-missing-await | warning | An async API called with no await/.then — the run finishes before the work does, and reports success. |
Banned words inside comments and string literals are not flagged, so a script that logs "closing the window" lints clean.
pack lints first and refuses to write anything if there are errors, then writes appstudio.action.json (the contract the marketplace reads) and runs npm pack to produce the uploadable .tgz.
manifest.version is the Action's only version. Because npm names the tarball <name>-<version>.tgz from package.json, pack points package.json's version at the manifest's before packing (and says so) — bump the manifest and the .tgz you upload is named for the release it carries. package.json must exist in the packed directory; npm resolves one upward, so packing without it would tarball an ancestor package.
There is deliberately no dev command. The live-reload loop is a first-party internal surface; the external loop is lint → pack → publish to npm or upload the .tgz in the Developer dashboard → install in the workspace.
API
import { CONTRACT, validateManifest, validatePropertySchema } from "@colixsystems/action-sdk";
const result = validateManifest(manifest);
if (!result.ok) console.error(result.errors);| Export | Purpose |
| ------ | ------- |
| CONTRACT | The frozen vocabulary — categories, property types, action triggers, globals, caps. |
| validateManifest(manifest) | { ok: true } or { ok: false, errors: string[] }. |
| canonicalCategory(category) | Uppercase category, or null. |
| validatePropertySchema(schema, label?) | Validates a propertySchema on its own. |
| validateHiddenProperties(hidden, opts?) | Validates the values YOU ship that the workspace never sees. A name also in propertySchema is refused. |
| hiddenPropertyNames(hidden) | The hidden names a manifest ships. |
| validatePropertyValues(schema, values, opts?) | Coerces what the OPERATOR typed. { ok, value, secrets } — secrets come back separately because they are stored encrypted; an undeclared key is rejected. |
| requiredPropertyNames(schema) | The names that must hold a value before the Action can run. |
| defaultPropertyValues(schema) | The values your schema supplies itself — every property declaring a default. The host layers these under an install's stored values, so your Action runs on them before the operator has opened the form. A credential or a tenant-local reference (secret, connectorRef, tableRef, columnRef) can never appear, and a value is delivered coerced to its declared type. |
| SECRET_PLACEHOLDER | What a stored secret reads back as; sending it means "keep it". |
| secretPropertyNames(schema) | The property names holding credentials. |
| actionTranslationKey(id, key) | The namespaced dictionary key. |
| isSecretPropertyType(type) | Whether a property type holds a credential. |
| validateConnectorTemplates(templates) | Validates a manifest's connectorTemplates, including the refusal of credential material. |
| connectorTemplatesOf(manifest) | The well-formed templates a manifest declares; a malformed entry is dropped rather than half-provisioned. |
| missingCredentialFields(authType, authConfig) | What the installing workspace still owes before a connector can call anything. The one definition of "incomplete" — derived, never stored. |
| connectorSecretFields(authType) | The authConfig keys a manifest may never carry. |
| connectorOperatorFields(authType) | The authConfig keys the workspace supplies itself. |
| lintManifest(manifest) | { ok, findings } — structure + every action script. |
| lintActionScript(script, key) | Findings for one script on its own. |
| totalScriptBytes(manifest) | UTF-8 size of every declared script. |
contract.cjs, manifest.cjs, property-schema.cjs and connector-template.cjs hold the single implementation of each concern; the matching .js files are thin ESM re-exports, so the two module systems can never disagree.
License
MIT
