Workflows

Custom workflow actions

Build your own typed workflow steps in JavaScript or TypeScript: the manifest and ctx contract, the QuickJS WebAssembly sandbox and capability model, the starter kit, testing, packaging, signing, versions, API, MCP and CLI.

A custom workflow action is your own workflow step: a JavaScript or TypeScript module with a typed manifest.json. Upload it once in Extensibility → Workflow actions and it appears in the workflow designer's palette under Custom actions, with a function picker (an action can offer several operations), an input panel generated from the manifest and typed outputs in the data picker. It runs in a sandbox — QuickJS compiled to WebAssembly, a fresh instance for every run — and can reach the network, data tables, files or secrets only through capabilities it declares and an administrator grants.

Start from the kit: action-starter.zip has a two-operation Acme tax calculator in TypeScript and in plain JavaScript, a local test harness that runs in the real sandbox, and build and package scripts. The tutorial below walks through it.

Need maths, text, dates, data reshaping, validation or IDs? Install one of the six official workflow actions from the gallery instead of writing it.

How it works

  1. You upload a package: manifest.json plus one self-contained ES module. UserTasks validates the manifest, loads the module once in the sandbox to check every declared operation has a function, and stores an immutable version with its SHA-256 (and signature, if any).
  2. An administrator reviews the action's capabilities and grants what it needs: HTTP hosts, data tables, secret values.
  3. A designer drops the action on the canvas, picks a function, maps inputs (values or expressions such as {{form.amount}}) and uses the outputs downstream.
  4. At run time the engine reads the code from the workspace library (never from the workflow definition), coerces the inputs to their declared types, runs the operation in a fresh sandbox with the manifest's limits, checks the outputs against their declared types (mismatches are logged as warnings) and stores them as step variables.

Outputs are available as {{<prefix>_<output>}} (the node's output prefix, editable on the node) and as {{<output>}}; the data picker lists them with their types, including the fields of object outputs and of array items.

The contract

The source of truth is lib/custom-actions/contract.ts, also exported by the @usertasks/action package.

manifest.json

manifest.json (excerpt)
{
  "manifest_version": 1,
  "key": "acme_tax",
  "name": "Acme tax calculator",
  "version": "1.0.0",
  "icon": "calculator",
  "category": "Finance",
  "operations": [
    {
      "id": "calculate",
      "label": "Calculate tax",
      "inputs": [
        { "name": "amount", "type": "number", "required": true, "example": 100 },
        { "name": "rate_percent", "type": "number", "required": true, "example": 15 },
        { "name": "rounding", "type": "select", "default": "half_up",
          "options": [{ "value": "half_up" }, { "value": "half_even" }, { "value": "none" }] }
      ],
      "outputs": [
        { "name": "net", "type": "number" },
        { "name": "tax", "type": "number" },
        { "name": "gross", "type": "number" }
      ],
      "sample": { "amount": 100, "rate_percent": 15 }
    }
  ],
  "capabilities": {},
  "limits": { "timeout_ms": 5000, "memory_mb": 16 }
}
FieldRequiredMeaning
manifest_versionyes1
keyyesThe action's identity in a workspace: 2–63 lowercase letters, digits and underscores, starting with a letter (acme_tax). ut_ is reserved for official actions. Uploading the same key again adds a version.
nameyesPalette label.
versionyesSemver. Every upload is a new, immutable version.
operationsyesOne or more functions (up to 40): id (lowercase, underscores), label, description, inputs, outputs (at least one) and sample inputs (used by Test, the gallery and the starter kit's harness).
capabilitiesWhat the code may ask the host for: http, data, files, credentials (named secrets, below). Nothing by default.
limits{ timeout_ms, memory_mb } defaults for every run (clamped to the limits).
description, icon, color, categoryLibrary and palette: a Lucide icon name (calculator), #RRGGBB, a palette group.
entryThe module file in a zip. Default dist/action.js, else the only .js / .mjs file.
summary, purpose, recommended_for, setup_notesGuidance shown to designers and in the library.
author, license, homepage, tagsShown in the library.

Field types

Each input and output is { name, label, type, description, required, default, example, options, items, fields, placeholder }:

TypeDesigner inputThe code gets / returns
stringsingle-line text (with placeholder)string
textmulti-line textstring
number / integernumbernumber (integers are checked)
booleancheckboxtrue / false
date / datetimedate / date-timeISO 8601 string
selectdropdown of optionsthe option's value
objectexpression or JSON (with fields for the data picker)object
arrayexpression or JSON (items: { type, fields })array
jsonexpression or JSONany JSON value
filea file from an earlier step or forma file reference to read with ctx.files.read

Inputs are coerced before the code runs ("42" becomes 42 for a number); a required input that is missing, or a value that can't be coerced, fails the step with code invalid without running the code. An empty input gets its default.

Input and output names may not look like credentials (token, password, *_secret, api_key, *_token …): exports, packages and config as code blank values under such keys, so a workflow's input mappings would be lost. Use names like jwt or signature, and declare real secrets under capabilities.credentials.

The module

ONE self-contained ES module — bundle dependencies, no import statements or dynamic import() (the sandbox has no module loader). Its default export maps operation ids to functions:

src/action.ts
import { defineAction } from '@usertasks/action'

export default defineAction({
  operations: {
    calculate(inputs: { amount: number; rate_percent: number }, ctx) {
      if (inputs.rate_percent < 0 || inputs.rate_percent > 100) ctx.fail('rate_percent must be between 0 and 100')
      const tax = Math.round(inputs.amount * inputs.rate_percent) / 100
      ctx.log.info('Calculated tax', { rate: inputs.rate_percent })
      return { net: inputs.amount, tax, gross: inputs.amount + tax }
    },
  },
})

A plain object (export default { operations: { … } }) works the same; a default-exported function serves a single-operation action. Functions may be async. Return an object with the declared outputs; throw an Error (or call ctx.fail) to fail the step.

ctx

MemberCapabilityWhat it does
ctx.operation, ctx.run—The operation id; { id, workflow_id, step_id, test } (ids only, test is true for Save & Test and Test runs)
ctx.log.info / warn / error(message, metadata?)—Lines in the run log, prefixed with the step's name (console.log is mapped to info)
ctx.fail(message, { retryable })—Fails the step; retryable: true lets the node's retry policy try again
ctx.http.fetch(url, { method, headers, body, json, timeout_ms })http→ { status, ok, headers, text, json }. A header value can be secret://name or Bearer secret://name: the host substitutes the secret
ctx.data.query(table, { filters, search, sort, limit, offset }), ctx.data.get(table, rowId)data.readRows of a granted data table (by id or name) → { rows, total, truncated } / a row
ctx.data.insert(table, values), ctx.data.update(table, rowId, values)data.writeWrite rows of a table granted with write access
ctx.files.read(file, { as: 'text' | 'base64' })filesThe bytes of a file passed to the action as a file input → { name, mime_type, size, text | base64 }
ctx.secrets.get('secret://name')secretsA declared secret's value
ctx.crypto.randomUUID / randomBytes / randomInt / digest / hmac / timingSafeEqual / encode—Crypto primitives on the host. hmac takes a secret://name key so the key never enters the sandbox
ctx.intl.formatNumber / formatDate / zonedParts / zonedToUtc / timeZones / collate—Intl on the host (QuickJS has none): locale formatting and time zones

Security model

Action code is tenant-supplied code running on your servers, so the defaults assume it is untrusted.

Isolation: QuickJS in WebAssembly

Each run gets a fresh QuickJS interpreter compiled to WebAssembly (quickjs-emscripten), created for the run and thrown away after it:

  • A separate JavaScript engine. The guest shares no objects, prototypes or globals with Node: there is no process, require, Buffer, fetch, timers or file system to reach. The classic escape (0).constructor.constructor('return process')() reaches the guest's own Function, which only sees the guest's globals.
  • Bounds-checked memory. WebAssembly linear memory can't read or write host memory, and no state survives between runs, actions or workspaces.
  • Hard limits. The heap is capped per run, the stack is small (256 KB), CPU is bounded by an interrupt handler the interpreter polls (while (true) {} is stopped at the deadline), and a wall-clock limit covers host calls too.
  • One narrow door. The only way out is ctx: a few host functions that take and return JSON text. Every call is checked on the host against the manifest and the administrator's grants; the guest can't forge a grant.

Why not the alternatives:

OptionWhy not
node:vm / new FunctionNot a security boundary: the code shares the Node process and its prototypes; well-known escapes reach process. Node's own documentation says not to use vm for untrusted code.
isolated-vmA strong V8 isolate, but a native addon: it breaks on serverless and some container builds and must match the Node ABI.
A container or microVM per runStrong, but seconds of start-up and an orchestrator to run; overkill for steps that take milliseconds.
QuickJS in WebAssembly (chosen)A separate engine in bounds-checked memory, a single-file build that runs wherever Node 20 runs, ~3 ms to start, deterministic limits. Slower than V8 for heavy CPU work — keep actions to data shaping and API calls.

Capabilities and grants

An action can do nothing outside pure computation unless its manifest declares a capability and an administrator grants it on the installed action:

CapabilityManifestAdministrator grantsEnforced by
http{ "hosts": ["api.example.com", "*.example.org"] }Approved hosts: a subset of the declared hostsEvery request is checked against both lists, then goes through the egress guard (below).
data{ "read": true, "write": true }Data table grants: tables with read or write accessQueries and writes go through the data table service, limited to the granted tables.
filestrue—Only files passed as file inputs of the current step, read from the run's own workspace storage.
credentials[{ "name": "api_key", "label": "API key" }]Secret values for the declared namesOnly declared names, read on request; never logged or returned by the API.

Uploading a new version keeps the approvals that still apply: hosts the new version no longer declares are dropped, and write grants become read grants if the new version only declares read. Package imports never carry approvals or secrets — an administrator reviews them in the target workspace.

Secrets

Secret values are set on the action's Access tab (Manage in Extensibility → Workflow actions), with PUT /api/v1/workflow-activities/{id}/secrets ({ "values": { "api_key": "…" } }, null clears one), the MCP tool workflow_activities_secrets_set or usertasks activities secrets. They are encrypted by the app (AES-256-GCM, ENCRYPTION_KEY) into a table that only the server's service role can read — no client, API or export returns them; the API only says which are set. Prefer passing a secret by reference (Bearer secret://api_key in a header, secret://hmac_key to ctx.crypto.hmac) so the value never enters the sandbox.

The egress guard

ctx.http.fetch uses the same guard as integrations: http / https only, no private, loopback, link-local or cloud metadata addresses (checked after DNS resolution), every redirect re-checked against the approved hosts, a timeout (30 s by default, never beyond the run's remaining time) and a 5 MB response limit. On self-hosted instances ALLOW_PRIVATE_EGRESS=true permits private ranges for actions that call internal services; cloud metadata addresses stay blocked.

Limits

LimitValue
Run time (wall clock, host calls included)10 s default, 100 ms – 60 s via limits.timeout_ms or the node's Timeout; CUSTOM_ACTION_MAX_TIMEOUT_MS lowers the ceiling
Heap32 MB default, 8 – 128 MB via limits.memory_mb
Stack256 KB (about 1,300 frames of plain recursion)
Host calls (http, data, files, secrets, hmac)200 per run
Inputs, outputs5 MB of JSON each
HTTP response5 MB
File read10 MB
Data query500 rows
Logs200 entries per run, 2,000 characters each
Module1 MB
Manifest128 KB
Zip package5 MB
Operations, fields per operation40, 40

Kill switches

  • Per action: Disable on the action (or PATCH … { "enabled": false }). It disappears from the palette and every step that uses it fails fast with code disabled — in all workflows at once, because the code is always read from the library.
  • Per server: set CUSTOM_ACTIONS_DISABLED=true and no custom action or legacy script activity runs on that server (steps fail with disabled).

What the sandbox doesn't have

MissingUse instead
fetch, XMLHttpRequestctx.http.fetch (declare http)
setTimeout, setIntervalNothing to wait for: do the work, or let the workflow wait with a Delay step
Intl (NumberFormat, DateTimeFormat, time zones), toLocaleString with localesctx.intl
crypto, crypto.subtlectx.crypto
process.envDeclared credentials (ctx.secrets.get('secret://name'))
require, import at run time, Node built-insBundle pure-JavaScript dependencies into the module
Buffer, TextEncoderctx.crypto.encode for hex / base64

Tutorial: from starter kit to workflow

You need Node.js 20 or later.

  1. Download the kit. Get action-starter.zip, unzip it and open action-starter/typescript (or javascript).
  2. Install.
bash
cd action-starter/typescript
npm install
  1. Edit. Change key, name and the operations in manifest.json, and the functions in src/action.ts (one per operation id).
  2. Build. npm run build typechecks and bundles one ES module into dist/action.js with a banner naming the action and version. It fails on leftover imports, a missing default export, an operation the module never mentions, a module over 1 MB, or different versions in package.json and manifest.json.
  3. Test locally. npm test runs dist/action.js in the same QuickJS sandbox the app uses, with the manifest's limits: every operation's sample inputs (each declared output must come back with its type), then the cases in test/cases.json:
test/cases.json (excerpt)
{ "name": "tax-inclusive price", "operation": "calculate",
  "inputs": { "amount": 115, "rate_percent": 15, "prices_include_tax": true },
  "expect": { "net": 100, "tax": 15, "gross": 115 } }

expect_error takes an error code; mocks answers HTTP, data, files and secrets. npm test -- --verbose prints outputs and logs.

  1. Package. npm run package builds, tests and writes release/acme_tax-1.0.0.zip (manifest.json, dist/action.js, README.md). Add -- --sign key.pem to include signature.json (signing).
  2. Upload. Extensibility → Workflow actions → Upload, choose the zip. The summary shows the key, version, operations, capabilities, SHA-256 and signature.
  3. Approve. If the action declares capabilities, an administrator opens Manage → Access on the action to approve hosts, grant tables and set secret values. Until then, calls are refused with code capability.
  4. Test in the library. Manage → Test runs one operation with its sample inputs (or yours) and shows the outputs, logs, duration, host calls and memory.
  5. Use it in a workflow. In the designer, open the palette's Custom actions group and drop Acme tax calculator on the canvas. Pick the function, map the inputs ({{form.amount}}, a literal 15…), and use the outputs from the data picker downstream ({{tax_gross}}). The node also has Timeout, Retries (default 2, retryable failures only, with exponential backoff), an optional pinned version, and continue on error edges like other steps.
  6. Save & Test, then read the run. The run log has your ctx.log lines prefixed with the step name, any output type warnings, and a summary line: ran acme_tax@1.0.0 calculate in 3 ms (0 host calls) with the version's SHA-256, duration, host calls, memory and attempts as metadata. The run trace shows the step's inputs and outputs.

Testing with runAction

@usertasks/action/testing exports runAction, the runner behind npm test — use it from any test framework:

action.test.ts
import { readFileSync } from 'node:fs'
import { runAction } from '@usertasks/action/testing'

const module = readFileSync('dist/action.js', 'utf8')
const manifest = JSON.parse(readFileSync('manifest.json', 'utf8'))

const result = await runAction({
  module, manifest, operation: 'lookup', inputs: { customer_id: '42' },
  mocks: {
    http: async (url, init) => ({ status: 200, json: { name: 'Ada' } }),
    secrets: { api_key: 'test-key' },
    data: { customers: [{ name: 'Ada', tier: 'gold' }] },
    files: { 'invoice.txt': 'text content' },
  },
})
// { ok, outputs, logs, error?: { code, message, retryable }, stats: { duration_ms, host_calls, memory_bytes } }

Capabilities are enforced as in the app — a request to a host not in the manifest, an undeclared secret, or a write without data.write fails with capability — so the mocks only stand in for the administrator's grants and the outside world. runAction never throws; it needs the peer dependencies quickjs-emscripten-core and @jitl/quickjs-singlefile-cjs-release-sync (0.32.0). The package isn't on npm yet: the starter kit carries its sources in vendor/usertasks-action/.

Packaging, versions and signing

Upload either a zip — manifest.json at the root (or in one top-level folder) and the module at entry (default dist/action.js); README, LICENSE and sources are ignored — or the module text with its manifest JSON.

text
acme_tax-1.0.0.zip
├── manifest.json
├── dist/action.js
├── README.md
└── signature.json   (optional)
  • Immutable versions. Each upload (listed under Manage → Versions) of a key is a new version with its SHA-256, size, origin and uploader. Re-uploading the same version with the same bytes is a no-op; with different bytes it is refused — bump the version. Uploading an older version adds it to the history without changing the current one.
  • Upgrades. Workflow nodes use the current version unless they pin one (custom_activity_version). Bump the patch for fixes, the minor for new operations or optional inputs, the major when inputs or outputs change incompatibly.
  • Signatures. signature.json is { "alg": "ed25519", "public_key": "<base64 SPKI or PEM>", "signature": "<base64>" }, signing the module's SHA-256 (as hex text). The version records the key's fingerprint and Verified or Unverified; compare the fingerprint with the publisher's. Official gallery actions show Signed package with the gallery key.
  • Usages and deletion. Manage → Used by lists the workflows (drafts and published) that use the action; Delete is refused while any does.

API, MCP and CLI

TaskRESTMCPCLI
List actionsGET /api/v1/workflow-activitiesworkflow_activities_listusertasks activities list
Show one (manifest, versions, grants, which secrets are set)GET /api/v1/workflow-activities/{id}workflow_activities_getusertasks activities get <id>
Upload a packaged action or a new versionPOST /api/v1/workflow-activities/upload (package_base64, or module + manifest + optional signature; multipart file=@action.zip also works)workflow_activities_uploadusertasks activities upload -f body.json
Create an inline (script) activityPOST /api/v1/workflow-activitiesworkflow_activities_createusertasks activities create
Enable / disable, approve hosts, grant tablesPATCH /api/v1/workflow-activities/{id} (enabled, approved_hosts, data_grants)workflow_activities_updateusertasks activities update <id>
Set or clear secretsPUT /api/v1/workflow-activities/{id}/secretsworkflow_activities_secrets_setusertasks activities secrets <id>
Test an operationPOST /api/v1/workflow-activities/{id}/test (operation, inputs, version)workflow_activities_testusertasks activities test <id>
One version (manifest, checksum, signature)GET /api/v1/workflow-activities/{id}/versions/{version}workflow_activities_version_getusertasks activities version <id> <version>
Where it is usedGET /api/v1/workflow-activities/{id}/usagesworkflow_activities_usagesusertasks activities usages <id>
Delete (when unused)DELETE /api/v1/workflow-activities/{id}workflow_activities_deleteusertasks activities delete <id>
Export, importGET /api/v1/workflow-activities/{id}/export, POST /api/v1/workflow-activities/importworkflow_activities_export, workflow_activities_importusertasks activities export, import

The upload responds with { workflow_activity, version, outcome: "created" | "version" | "unchanged", warnings }. The API reference, MCP tools and CLI commands list the exact parameters.

Permissions: extensibility.view (library, versions, usages), extensibility.create (upload or import a new action), extensibility.edit (new versions, enable / disable, approvals, secrets, Test), extensibility.delete (delete unused actions).

Config as code and packages

Actions are the asset type workflow_activity in exports, packages and config as code (folder workflow-activities/). The export carries the manifest, the module and its checksum — no ids, grants or secret values. Exporting a workflow with its dependencies brings the actions it uses. On import, an action is matched by key: the same version with the same checksum is reused, a different checksum under the same version is refused. Approvals and secrets are set again by an administrator in the target workspace.

Migrating legacy activities

Activities created before manifest v1 — a script body with run(inputs, context, helpers) — keep working, but they now run in the same sandbox (they used to run in the app process behind ENABLE_CUSTOM_ACTIVITY_CODE, which is now ignored). Their helpers (resolveExpression, getVariable, setVariable, log, uuid, now) still work. What changes:

  • Code that used Node — require(...), fetch, process.env, Buffer, timers — now fails. Move it to a packaged action: declare http hosts for API calls and credentials for keys, and use ctx.http.fetch and secret:// references.
  • To convert one: create a manifest.json whose key is the legacy activity's key, with one operation whose inputs and outputs match the old ones, wrap the old function body in that operation (inputs is the same; return the outputs object), and upload it with a higher version. It becomes the next version of the same activity, so workflows that use it keep their reference; open each to re-map inputs if you renamed any.

Troubleshooting

A failed step's error ends with its code in brackets (Acme tax calculator: rate_percent must be between 0 and 100 [action]); Test and the API return it as error.code.

CodeMeaningWhat to do
timeoutThe run passed its time limit (CPU or wall clock, host calls included). Retryable.Look for unbounded loops; raise limits.timeout_ms or the node's Timeout (60 s at most); page large HTTP or data reads.
memoryThe heap or stack limit was hit (deep recursion counts).Process less at once, avoid building huge strings or arrays, raise limits.memory_mb (128 MB at most), make recursion iterative.
capabilityThe code asked for something not declared or not granted: an unlisted or unapproved host, an ungranted table, an undeclared secret, a file that wasn't an input, more than 200 host calls.Declare it in the manifest (new version) and ask an administrator to approve or grant it.
actionThe code threw or called ctx.fail (retryable only with { retryable: true }).Read the message and your ctx.log lines in the run log; reproduce with npm test.
invalidBad module (imports, no default export, no function for the operation), unknown operation, or inputs that are missing or can't be coerced.Rebuild with the kit (one module, no imports); check the node's function and input mappings.
hostA host service failed: an HTTP network error, a row not found, an unreadable file, a secret with no value. Network failures are retryable.Check the URL, the table and row, and that every declared secret is set.
disabledThe action is disabled, archived, or CUSTOM_ACTIONS_DISABLED=true on the server.Enable it in Extensibility → Workflow actions, or ask your operator.

Other symptoms:

SymptomLikely cause
The upload is refusedInvalid manifest (key format, non-semver version, an operation without outputs, an unknown capability), imports left in the module, an operation without a function, over 1 MB, or that version already exists with different contents.
The action isn't in the paletteIt is disabled or archived, or you lack extensibility.view.
An output is missing in the data pickerIt isn't declared in the operation's outputs (returned but undeclared values are still stored, but not listed).
Warnings such as output "tax" should be number but is string or returned undeclared output "total" (kept)An output doesn't match its declaration; the value is kept as returned. Fix the code or the manifest.
toLocaleString, Intl or time zones misbehaveQuickJS has no Intl: use ctx.intl.