Skip to content

Script tasks

A script task runs a small piece of code as a step in your process. TinyConductor supports two kinds:

  • Inline FEEL. The engine evaluates a FEEL expression itself.
  • Job-worker script. A separate program, bpm-script-worker, runs JavaScript in a sandbox. The engine hands the script to it as a job.

A bpmn:scriptTask has two alternative implementations, written as extension elements in the tc: namespace:

FormBPMNWho runs it
Inline FEEL<tc:script expression="=..." resultVariable="..."/>The engine evaluates it during activation.
Job worker<tc:taskDefinition type="..." retries="..."/> plus optional <tc:taskHeaders>A job worker. In this form a script task behaves exactly like a service task.

A script task must declare exactly one of the two. If it declares both or neither, deployment is rejected with rule script-task-implementation:

Script task must declare exactly one of the script or taskDefinition extensions:
both are declared; keep the inline FEEL script extension or the job-worker taskDefinition extension, not both

In the job-worker form the engine uses the service-task path unchanged:

  • the type and retries expressions;
  • task headers, passed exactly as written (a leading = is not evaluated);
  • input mappings before the job, and output mappings after completion;
  • incidents when retries run out;
  • boundary events, including BPMN errors thrown by the worker;
  • start/end execution listeners.

bpm-script-worker is a separate process that keeps no state. It talks to the engine only over the public job API. You deploy, scale and secure it like any other connector runtime. For each job it does three things:

  1. It long-polls POST /v2/jobs/activation. If the engine answers that route with 404, 405 or 501, it falls back to POST /v1/jobs/activation. Every deployment mode serves both routes, so the fallback matters only for an endpoint that exposes /v1 alone.
  2. It runs each job’s script in a fresh JavaScript isolate, within the sandbox limits.
  3. It sends one of three commands: completion with the result variables, failure with retries and a backoff, or error for a BPMN error. It tries /v2 first for jobs activated over v2, and uses /v1 otherwise. Each command carries an Idempotency-Key, and transient transport errors and 5xx responses are retried up to three times.

This uses the community script-worker convention:

<bpmn:scriptTask id="price" name="Price the quote">
<bpmn:extensionElements>
<tc:taskDefinition type="script" retries="3"/>
<tc:taskHeaders>
<tc:header key="language" value="javascript"/>
<tc:header key="script" value="netPrice * (1 + vatRate)"/>
<tc:header key="resultVariable" value="grossPrice"/>
</tc:taskHeaders>
</bpmn:extensionElements>
</bpmn:scriptTask>

In the console’s Modeler, select the task and apply the Script (JavaScript) template (Apply a template… over the canvas, or Templates…), just as you would pick a connector. The template writes job type tc:script:1, the language and script headers, the result and error expressions, the retries and retry backoff, and optional limits for the task.

  • Variables. Every variable visible at the task can be used in two ways:
    • As a top-level binding (a + b). This is a mutable deep copy. A variable whose name is not a plain identifier, or would shadow a global such as JSON, is not bound this way.
    • Through variables, a deep-frozen object that holds every variable (variables['order-id']).
  • Result. The result is the value of the script’s last expression, as in the community worker. A script may also use top-level return, and await: it then runs as the body of a (possibly async) function. The worker picks the form once, when it prepares the script, without running it.
  • Promises must settle synchronously. There are no timers or I/O, so a script that waits on something that never resolves fails with “the script’s promise never settled”.
  • BPMN errors. Call throw new BpmnError(code, message?, variables?). The job gets a BPMN error, and a matching error boundary or event sub-process catches it.
  • scriptMeta holds { numberMode, convertedNumbers } (see Value mapping).
languageBehavior
javascript (also js, ecmascript)Runs on the configured backend (QuickJS by default).
feelEvaluated by the worker with the engine’s own FEEL implementation.
groovy, kotlin, mustache, anything elseRejected. The job fails with 0 retries and a message that names the supported languages. The community worker’s JVM engines don’t exist in this Rust worker.
  • There is no filesystem, network, process, timer (setTimeout/setInterval), console, require, or module loader. A static import does not parse, and a dynamic import() rejects.
  • Each job runs in a fresh isolate, so nothing one job’s script does survives into the next. This matters because a shared worker runs scripts from several tenants.
  • Preparation parses the script and never runs it. Even a source crafted to escape a wrapper (} while (true) {} {) does not run during preparation.

Worker-wide defaults come from environment variables. A task header can tighten a limit for one task, but never loosen it: the effective value is min(worker, header), so a model cannot widen the sandbox that an operator set for a shared worker.

LimitDefaultWorker envTask headerQuickJS (default)Boa
Wall-clock timeout5 sSCRIPT_TIMEOUT_MSscriptTimeoutMsEnforced by the interrupt handler, which is uncatchable.Not enforceable in-engine. A late result is discarded as a timeout, and the worker fails the job at the deadline, but the thread runs on until a loop or recursion limit stops it. The concurrency slot stays held until then.
CPU / instruction budget100,000,000 opsSCRIPT_INSTRUCTION_BUDGETscriptInstructionBudgetEnforced across the whole execution. Granularity is one interrupt poll per ~10,000 branch/call events, and it is uncatchable.Partial: it is a loop-iteration limit per call frame, so a loop split across function calls escapes it.
Memory cap64 MiBSCRIPT_MEMORY_LIMIT_BYTESscriptMemoryLimitBytesEnforced (JS_SetMemoryLimit).Not enforceable. Boa has no allocation limit.
Stack512 KiBSCRIPT_MAX_STACK_BYTESscriptMaxStackBytesEnforced. This byte cap also bounds recursion.The VM value-stack limit applies instead.
Recursion depth400 framesSCRIPT_RECURSION_LIMITscriptRecursionLimitNot enforced as a frame count. The stack byte cap bounds recursion.Enforced, as an exact frame count.

The instruction budget is the limit to rely on: it gives the same result on every run, so tests use it. The wall-clock timeout is only an outer safety net for untrusted code. As a last guard, the worker fails a job as timeout when its script has not returned 2 s after the timeout.

Class (metric label)CauseRetries sent
syntaxThe script does not parse.0: the failure is deterministic, so the incident is raised at once.
configurationUnknown language, missing script, or a malformed header or expression.0
valueA result that isn’t JSON (NaN, Infinity, a cycle), or an unsafe integer under the reject policy.0
runtimeThe script threw, or a promise rejected.job.retries - 1
timeout, cpu_budget, memory, stackA sandbox limit was hit.job.retries - 1
job_errorerrorExpression returned jobError(...).As the expression says.

The backoff comes from the retryBackoff header (ISO-8601, for example PT10S, or milliseconds). If that is absent, it is CONDUCTOR_SCRIPT_WORKER_RETRY_BACKOFF (default 5 s). When retries reach 0, the engine raises an incident. Resolving the incident retries the job with the retries the operator sets.

DirectionValueMapping
InJSON object/array/string/boolean/nullThe same JavaScript values.
InInteger with magnitude ≤ 2^53−1JavaScript number, exact.
InInteger beyond ±(2^53−1)Per NUMBER_POLICY: string (default) passes its exact digits as a string; bigint passes a BigInt; reject fails the job (value); lossy passes the nearest number. Converted locations are listed in scriptMeta.convertedNumbers as key paths such as [["order","id"]].
InFractionJavaScript number. The engine’s job API already carries fractions as IEEE-754 binary64 (its variable documents are JSON values), so the worker loses no further precision. FEEL decimals that are not binary64 values are rounded by the engine when it writes them to JSON, before the worker sees them.
InDate, time or durationThese are ISO-8601 strings on the job API. Parse them with new Date(...).
OutnumberA JSON number. NaN and ±Infinity fail the job (value). JSON would silently turn them into null.
OutBigIntA JSON integer when it fits i64/u64; otherwise its exact decimal string.
OutDateAn ISO-8601 UTC string (toJSON), for example 2024-02-29T12:30:00.000Z.
Outundefinednull as the whole result. Inside objects the key is dropped, and inside arrays it becomes null, exactly as JSON.stringify does.
OutMap, Set, functions, symbolsSerialized as JSON.stringify does: {} or dropped. Convert them explicitly.

resultExpression and errorExpression read the result through FEEL. JSON numbers become FEEL decimals there, and FEEL results go back to JSON numbers.

Every setting is an environment variable. There is no config file.

VariableDefaultMeaning
CONDUCTOR_SCRIPT_WORKER_ENGINE_URL(required)Engine base URL, for example http://tinyconductor:8080.
CONDUCTOR_SCRIPT_WORKER_API_VERSIONautoauto (v2, falling back to v1), v2 or v1.
CONDUCTOR_SCRIPT_WORKER_TOKEN or CONDUCTOR_SCRIPT_WORKER_TOKEN_FILEunsetStatic bearer token, or a file that holds one.
CONDUCTOR_SCRIPT_WORKER_OAUTH_TOKEN_URLunsetEnables the OAuth client-credentials grant — against the engine’s own <engine>/oauth/token or your identity provider. This wins over CONDUCTOR_SCRIPT_WORKER_TOKEN.
CONDUCTOR_SCRIPT_WORKER_OAUTH_CLIENT_IDunsetClient id. Required with CONDUCTOR_SCRIPT_WORKER_OAUTH_TOKEN_URL.
CONDUCTOR_SCRIPT_WORKER_OAUTH_CLIENT_SECRET or CONDUCTOR_SCRIPT_WORKER_OAUTH_CLIENT_SECRET_FILEunsetClient secret. Required with CONDUCTOR_SCRIPT_WORKER_OAUTH_TOKEN_URL.
CONDUCTOR_SCRIPT_WORKER_OAUTH_AUDIENCE, CONDUCTOR_SCRIPT_WORKER_OAUTH_SCOPEunsetOptional token-request parameters.
CONDUCTOR_SCRIPT_WORKER_TENANT_IDunsetSent as X-Tenant-Id, and on v2 as tenantIds. It must be a tenant the credential may act in; a credential of several tenants (Cluster mode) needs it.
CONDUCTOR_SCRIPT_WORKER_JOB_TYPEStc:script:1,scriptComma-separated job types, polled round-robin.
CONDUCTOR_SCRIPT_WORKER_WORKER_NAMEbpm-script-worker-$HOSTNAMEWorker name on activation.
CONDUCTOR_SCRIPT_WORKER_CONCURRENCYCPU countMaximum jobs in flight. Each job uses one blocking thread.
CONDUCTOR_SCRIPT_WORKER_JOB_TIMEOUT_MS60000Job lease requested on activation.
CONDUCTOR_SCRIPT_WORKER_REQUEST_TIMEOUT_MS20000Long-poll duration requested on activation.
CONDUCTOR_SCRIPT_WORKER_POLL_INTERVAL_MS250Pause after an empty or failed poll.
CONDUCTOR_SCRIPT_WORKER_BACKENDquickjsquickjs or boa. The backend must be compiled in.
CONDUCTOR_SCRIPT_WORKER_CACHE_SIZE256Prepared-script handles cached, by SHA-256 of the source.
CONDUCTOR_SCRIPT_WORKER_SCRIPT_TIMEOUT_MS5000Wall-clock limit.
CONDUCTOR_SCRIPT_WORKER_SCRIPT_INSTRUCTION_BUDGET100000000CPU budget in backend operations.
CONDUCTOR_SCRIPT_WORKER_SCRIPT_MEMORY_LIMIT_BYTES67108864Heap cap.
CONDUCTOR_SCRIPT_WORKER_SCRIPT_MAX_STACK_BYTES524288Stack cap (QuickJS).
CONDUCTOR_SCRIPT_WORKER_SCRIPT_RECURSION_LIMIT400Call-depth cap (Boa).
CONDUCTOR_SCRIPT_WORKER_NUMBER_POLICYstringHow unsafe integers are passed: string, bigint, reject or lossy.
CONDUCTOR_SCRIPT_WORKER_RETRY_BACKOFFPT5SDefault failure backoff (ISO-8601 or milliseconds).
CONDUCTOR_SCRIPT_WORKER_METRICS_BIND127.0.0.1:9464Listener for /metrics, /healthz and /readyz. A listener other hosts can reach needs METRICS_TOKEN.
CONDUCTOR_SCRIPT_WORKER_METRICS_TOKEN (or CONDUCTOR_SCRIPT_WORKER_METRICS_TOKEN_FILE)unsetBearer token /metrics requires. /healthz and /readyz stay open.
CONDUCTOR_SCRIPT_WORKER_SHUTDOWN_GRACE_MS30000How long a SIGTERM waits for in-flight jobs.
RUST_LOGinfoLog filter. Logs are JSON lines on stdout, with trace_id and span_id while a job runs.
CONDUCTOR_LOGGING_FORMATjsonjson or text.
OTEL_*unsetOpenTelemetry export of traces, metrics and logs, exactly as for the engine. See Observability.

An invalid value names its variable, and the process exits with code 2.

  • Metrics (GET /metrics, Prometheus text):
    • bpm_script_worker_executions_total{job_type,backend,outcome};
    • bpm_script_worker_failures_total{job_type,backend,class};
    • bpm_script_worker_execution_seconds{job_type,backend} (histogram);
    • bpm_script_worker_api_errors_total{operation};
    • bpm_script_worker_jobs_in_flight;
    • bpm_script_worker_script_cache_{hits,misses}_total and _entries. The listener is loopback-only by default. To scrape it from another host, bind 0.0.0.0:9464 and set CONDUCTOR_SCRIPT_WORKER_METRICS_TOKEN; Prometheus then sends Authorization: Bearer <token>. Job types appear as labels and can name business functions, so the worker refuses to start with a reachable listener and no token.
  • Tracing. Each job runs in a script.execute span that continues the trace in the job’s traceparent header, so the worker’s span sits between the engine’s job activation and the completion request in one trace. The completion, failure or error request carries the span’s traceparent and the job’s bpm.correlationId as bpm-correlation-id. Configure export with the OTEL_* variables (see Observability).
  • Health. GET /healthz returns 200 while the process runs. GET /readyz returns 200 after the first successful activation call. The image’s HEALTHCHECK runs bpm-script-worker healthcheck, because the runtime image has no shell. bpm-script-worker --version (or -V) prints the version and the source commit and exits without reading any settings.
  • Graceful shutdown. On SIGTERM or Ctrl-C the worker stops activating and waits up to SHUTDOWN_GRACE_MS for in-flight jobs. A job still running after that keeps its lease, times out on the engine, and another replica picks it up.
  • Scaling. Replicas share nothing: no state, no leader and no local storage. Run as many as the job load needs. The engine hands each job to one worker lease at a time.

The worker is published as the container image registry.tinyfactory.ai/tinyblox/tinyconductor-script-worker, for linux/amd64 and linux/arm64, with the same version tags as the engine images:

Terminal window
docker run -d --name script-worker \
-e CONDUCTOR_SCRIPT_WORKER_ENGINE_URL=http://tinyconductor:8080 \
-e CONDUCTOR_SCRIPT_WORKER_TOKEN=<engine token> \
registry.tinyfactory.ai/tinyblox/tinyconductor-script-worker:0.1.0

The image is distroless cc-debian12:nonroot (uid 65532), with no shell or package manager. It runs scripts with QuickJS; the published image does not include the Boa backend.

services:
tinyconductor:
image: registry.tinyfactory.ai/tinyblox/tinyconductor-bundled:0.1.0
ports: ["127.0.0.1:8080:8080"]
volumes: ["tinyconductor-data:/var/lib/postgresql/data"]
script-worker:
image: registry.tinyfactory.ai/tinyblox/tinyconductor-script-worker:0.1.0
depends_on: [tinyconductor]
environment:
CONDUCTOR_SCRIPT_WORKER_ENGINE_URL: http://tinyconductor:8080
CONDUCTOR_SCRIPT_WORKER_TOKEN_FILE: /run/secrets/engine-token
CONDUCTOR_SCRIPT_WORKER_CONCURRENCY: "8"
CONDUCTOR_SCRIPT_WORKER_SCRIPT_TIMEOUT_MS: "2000"
secrets: [engine-token]
deploy:
replicas: 2
secrets:
engine-token:
file: ./engine-token # an API client's token (or, to try it, the first-boot token)
volumes:
tinyconductor-data: {}

The tinyconductor-script-worker Helm chart runs the worker this way, with the token or client secret mounted from a Secret (see Kubernetes). The plain manifest below shows the same settings:

apiVersion: apps/v1
kind: Deployment
metadata:
name: bpm-script-worker
spec:
replicas: 3
selector:
matchLabels: { app: bpm-script-worker }
template:
metadata:
labels: { app: bpm-script-worker }
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9464"
spec:
terminationGracePeriodSeconds: 45 # > CONDUCTOR_SCRIPT_WORKER_SHUTDOWN_GRACE_MS
securityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile: { type: RuntimeDefault }
containers:
- name: worker
image: registry.tinyfactory.ai/tinyblox/tinyconductor-script-worker:0.1.0
env:
- { name: CONDUCTOR_SCRIPT_WORKER_ENGINE_URL, value: "http://tinyconductor:8080" }
- { name: CONDUCTOR_SCRIPT_WORKER_OAUTH_TOKEN_URL, value: "https://idp.example/oauth/token" }
- { name: CONDUCTOR_SCRIPT_WORKER_OAUTH_CLIENT_ID, value: "script-worker" }
- name: CONDUCTOR_SCRIPT_WORKER_OAUTH_CLIENT_SECRET
valueFrom: { secretKeyRef: { name: script-worker, key: client-secret } }
- { name: CONDUCTOR_SCRIPT_WORKER_CONCURRENCY, value: "4" }
- { name: CONDUCTOR_SCRIPT_WORKER_SHUTDOWN_GRACE_MS, value: "30000" }
# Reachable by the kubelet probes and Prometheus, so a token guards /metrics.
- { name: CONDUCTOR_SCRIPT_WORKER_METRICS_BIND, value: "0.0.0.0:9464" }
- name: CONDUCTOR_SCRIPT_WORKER_METRICS_TOKEN
valueFrom: { secretKeyRef: { name: script-worker, key: metrics-token } }
- { name: OTEL_EXPORTER_OTLP_ENDPOINT, value: "http://otel-collector.observability:4318" }
- { name: OTEL_EXPORTER_OTLP_INSECURE, value: "true" }
ports: [{ name: metrics, containerPort: 9464 }]
livenessProbe: { httpGet: { path: /healthz, port: metrics } }
readinessProbe: { httpGet: { path: /readyz, port: metrics } }
resources:
# QuickJS caps each script at SCRIPT_MEMORY_LIMIT_BYTES; size the
# limit as CONCURRENCY x that cap plus ~64 MiB for the process.
requests: { cpu: "500m", memory: "128Mi" }
limits: { cpu: "4", memory: "384Mi" }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }

Compatibility with existing script workers and connectors

Section titled “Compatibility with existing script workers and connectors”

These conventions come from the public README of the widely used community script worker, including the section on its older header conventions. No source code was consulted.

ConventionStatus here
Default job type script✅ Subscribed by default, next to tc:script:1.
language task header✅ javascript, and also feel.
script task header✅
resultVariable task header✅ Optional here. With no result header, the job completes without variables.
Variables available to the script✅ As top-level bindings and as the frozen variables object.
Script result = last expression value (a + b)✅ Also supports return and synchronous await.
JavaScript engine GraalVM JS≠ QuickJS by default, or Boa. ES2023-level syntax: see Backends.
groovy, kotlin, mustache❌ Rejected with 0 retries and a clear message. These are JVM engines.
job / client objects in the context❌ The community worker also dropped these (“no longer provides access”).
Script location/resource header❌ The README documents none. Scripts are inline.
Custom engines through ScriptEngineFactory / SPI❌ Not applicable: add a Rust ScriptBackend instead.
Throwing a BPMN error from a script➕ The README documents no convention. This worker adds throw new BpmnError(code, message, variables), plus errorExpression.

These are the published conventions of compatible connectors (see Compatibility).

ConventionStatus here
Connector-style job type (io.camunda:http-json:1 style)✅ tc:script:1, configurable.
Element template that generates the task definition, inputs and output/error handling✅ The Script (JavaScript) template the engine ships (GET /api/v1/console/modeling/element-templates), applied in the console’s Modeler. It follows the standard element-template JSON shape.
resultVariable header✅
resultExpression header, FEEL over the response✅ Evaluated in the worker with bpm-feel, over result (alias response) and the job’s variables. It must return a context.
errorExpression header with bpmnError(code, message[, variables]) / jobError(message[, variables[, retries[, retryBackoff]]])✅ Evaluated in the worker, over result/response after success, or over error = {code, type, message} after a failure. null or {} means “no error”.
retryBackoff header (ISO-8601)✅
Task-definition retries✅ The engine enforces it, as for service tasks.
Inputs as tc:ioMapping input variables (script, language)🟡 Accepted by the worker, but the template uses task headers for them. This engine compiles every input-mapping source as FEEL, so raw JavaScript in an input mapping is rejected at deployment. A FEEL string literal (="a + b") works. Headers are always delivered exactly as written.
Secrets ({{secrets.X}})❌ Not supported: this worker has no secret store. Pass values as process variables or headers.
Outbound connector SDK (Java OutboundConnectorFunction), connector runtime discovery, inbound connectors❌ Not applicable. This is a Rust job worker. It is “a connector” in the sense of the job-worker protocol, the template and the header conventions, not the Java SDK.
Stock connector runtime running this template❌ The template’s job type has no Java implementation. Conversely, stock connectors run against TinyConductor’s /v2 API as described in Connectors.

QuickJS (js-quickjs) is the default feature and the default backend. Boa (js-boa) is an optional feature. The choice rests on a benchmark of both (Apple M5 Max, release build, fresh isolate per execution):

ScenarioQuickJS warm p50 / p99 (ms)Boa warm p50 / p99 (ms)
Arithmetic + JSON transform0.25 / 0.410.40 / 0.98
String templating (50 lines)0.31 / 0.470.88 / 2.03
Array map/filter over 10k items26.3 / 29.5161.0 / 203.6
Date calculation0.21 / 0.280.38 / 2.44
Regex over 1k strings1.26 / 1.374.66 / 7.78

QuickJS enforces every limit: CPU budget, wall-clock timeout, memory cap and stack. Boa enforces only the per-frame loop limit and recursion depth. Because scripts from several tenants share a worker, limit enforcement decided the default.

The script worker image carries these third-party libraries beyond those of the engine, with their licences. The image’s third-party notices hold the full texts.

CratesLicenceFeature
rquickjs, rquickjs-core, rquickjs-sys 0.10 (bundles QuickJS-ng C sources, MIT)MITjs-quickjs (default)
foldhash 0.2 (via hashbrown 0.16 in rquickjs-core)Zlib (permissive)js-quickjs
hashbrown 0.16, rustc-hash 2, portable-atomic, proc-macro-crate, toml_edit/toml_parser/toml_datetime, rustversion, num-integerMIT OR Apache-2.0js-quickjs
tap, winnow 1MITjs-quickjs
boa_engine and boa_* 0.20MIT OR Unlicensejs-boa
icu_collections, icu_locid, icu_normalizer, icu_properties, icu_provider (+ data/macros) 1.5, litemap, tinystr, writeable, yoke, zerovecUnicode-3.0 (permissive)js-boa only
ryu-jsApache-2.0 OR BSL-1.0 (take Apache-2.0)js-boa
bytemuckZlib OR Apache-2.0 OR MIT (take MIT)js-boa
num_enumBSD-3-Clause OR MIT OR Apache-2.0 (take MIT)js-boa
regress, time, num-bigint, itertools, dashmap, phf, thin-vec, intrusive-collections, pollster, paste, static_assertions, and the restMIT and/or Apache-2.0js-boa

The worker also uses libraries it shares with the engine: reqwest (rustls), axum, tokio, serde_json, sha2, uuid, rust_decimal and chrono.