@colixsystems/action-sdk
v0.9.0
Published
Authoring contract for AppStudio Actions — marketplace deliverables that ship server-side action code. Implements ActionManifest, the action contract, and propertySchema validation.
Downloads
1,334
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",
},
],
};my-action/
├── manifest.js
├── package.json
└── scripts/
└── push-invoices.js ← ordinary JavaScript, linted by your editorWhat'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 (8 tables, 24 columns each). |
| translations | no | Strings merged into the tenant dictionary under action.<id>.<key>. |
Property types
string, number, boolean, secret, select, multiselect, tableRef, columnRef, 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.
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.
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, console, record, request, tenantId, triggerType, triggerTableId
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.
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.
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. |
| 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. |
| 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 and property-schema.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
