Polyglot tasks
Keep the script, gain the runtime. A working Python, .NET, or shell step becomes a
queued, retried, traced unit of work by declaring it with defineTask — the script itself
does not change, and nothing about your platform has to stop being TypeScript for one step
that is not.
A polyglot task runs non-TypeScript work — a Python script, a .NET program, a
shell or PowerShell script, or any executable — as a managed subprocess spawned by the
worker runtime. NetScript hands the task its input as command-line arguments and
environment variables or a bounded stdin payload, captures every line of stdout/stderr, parses a final JSON line
into a structured result, and normalizes the exit code into a TaskResult. It is the
escape hatch for the moments your platform is otherwise all-TypeScript: an ML model in
Python, a legacy .NET DLL, a system pwsh script.
alpha
The typical story is an import pipeline with one step that already exists in another language: a legacy-data transform owned by a Python script, say, sitting in the middle of an otherwise-TypeScript flow. Rewriting it buys nothing; running it ad hoc loses the queue, retry, and trace behavior every other step has. Declaring it as a task resolves that tension — the ERP Sync tutorial walks exactly this step, and trace context is injected into the subprocess environment so the Python span stitches into the same trace as the TypeScript steps around it.
What it is
A job runs in-process TypeScript on a worker; a task runs a subprocess in
another runtime. Tasks share the worker plugin's queue, retry, and telemetry machinery
with background jobs — the difference is purely the
execution surface. The MultiRuntimeTaskExecutor keeps a map of runtime adapters
(one per TaskType) and dispatches a TaskDefinition to the adapter that supports its
type. Each adapter builds an argv for its runtime (e.g. python3 -u script.py …,
pwsh -File script.ps1 …) and runs it through a process runner backed by Deno.Command that streams
output and times the process out. This page covers that subprocess seam; for in-process
TS handlers, runtime modes, and the queue lifecycle, start at
background jobs.
Learn → / Do →
Minimal example
A task is authored with the defineTask typestate builder, then run through the default
multi-runtime executor. The builder's default runtime is 'deno'; call .runtime(type)
to target another language. Input reaches the script as argv (.args(...)) and
environment variables (.env({...})); the script returns a result by writing a single
JSON object as the last line of stdout.
// workers/tasks/score-batch.ts
import { defineTask } from '@netscript/plugin-workers-core/builders';
import { createDefaultTaskExecutor } from '@netscript/plugin-workers-core/executor';
// Build a task definition: python runtime, script entrypoint, explicit inputs.
const scoreBatch = defineTask('score-batch')
.runtime('python')
.entrypoint('./scripts/score.py')
.env({ MODEL_PATH: './models/scorer.pkl' })
.args('--threshold', '0.8')
.timeout(120_000) // ms; defaults to 300_000
.build();
// The executor resolves the python adapter and spawns: python3 -u ./scripts/score.py --threshold 0.8
const executor = createDefaultTaskExecutor();
const result = await executor.execute(scoreBatch, {
onStdout: (line) => console.log('[score]', line),
});
if (result.success) {
// result.result is the parsed JSON object from the LAST stdout line, or null.
console.log('scored', result.result);
} else {
console.error('task failed', result.exitCode, result.error);
}
# scripts/score.py
import json, os, sys
# Input arrives as argv + env in this example.
threshold = float(sys.argv[sys.argv.index('--threshold') + 1])
model_path = os.environ['MODEL_PATH']
# ... do the work ...
scored = {'kept': 42, 'dropped': 3, 'threshold': threshold}
# Any prior prints become captured logs. The result is the LAST line of stdout,
# and must be a single JSON object (not an array) to populate result.result.
print('scoring complete', file=sys.stderr)
print(json.dumps(scored))
Key types first — TaskResult
Every task — whatever its runtime — resolves to one TaskResult. This is the shape your
calling code reads; check it before writing per-runtime branches.
| Name | Type | Description |
|---|---|---|
taskId |
string |
The TaskDefinition.id this result belongs to. |
status |
string |
Lifecycle status: 'completed' | 'failed' | 'timeout' | 'cancelled' (running/pending used in-flight). |
success |
boolean |
True only when status is 'completed' (exit code 0). Branch on this. |
exitCode |
number |
Subprocess exit code; -1 when the process never started (spawn error, cancelled, timeout). |
stdout |
string |
All captured stdout lines, joined by newlines. |
stderr |
string |
All captured stderr lines (and the error message on a spawn failure). |
result |
Record |
The JSON object parsed from the LAST line of stdout, or null if that line is not a JSON object. |
error |
string | null |
Human-readable failure message (includes exit code and first stderr line); null on success. |
duration |
number |
Wall-clock execution time in milliseconds. |
startedAt / completedAt |
string |
ISO-8601 timestamps bracketing the run. |
attempt |
number |
Attempt index for this execution (0 from the executor itself). |
TaskDefinition & runtimes
defineTask(id) returns a typestate builder; .build() is only callable once an
.entrypoint(path) (subprocess) or .handler(fn) (in-process) is set. The resulting
TaskDefinition carries the runtime type, entrypoint, args, env, timeout, and
permissions. The seven supported runtimes (TaskType) and how each is launched:
| Name | Type | Description |
|---|---|---|
deno |
default |
Spawns deno run with permission flags built from .permissions(...). The only runtime that is sandboxed by per-task Deno permissions. |
python |
subprocess |
Spawns python3 -u (or py on Windows); honors metadata.pythonConfig.venvPath / pythonPath and NETSCRIPT_PYTHON_PATH. |
dotnet |
subprocess |
Runs a .cs via dotnet run, a project via dotnet run --project, or a built executable directly; metadata.dotnetConfig.runtimeArgs/useDotnetRun. |
shell |
subprocess |
Runs the entrypoint under bash (metadata.shellConfig.shell / loginShell); resolves Git Bash util paths on Windows. |
powershell |
subprocess |
Spawns powershell (Windows) or pwsh with -NoProfile -NonInteractive -ExecutionPolicy Bypass -File. |
cmd |
subprocess |
Spawns cmd.exe /c (Windows). |
executable |
subprocess |
Runs the entrypoint directly as a prebuilt binary with the task's args. |
The per-task permission set passed to .permissions(...) mirrors the Deno permission
model. It is enforced only for the deno runtime — those keys are translated into
--allow-* flags on the deno run command line. For python, shell, and the other
external runtimes there is no Deno sandbox; the subprocess inherits the OS-level access of
the worker process.
| Name | Type | Description |
|---|---|---|
net |
boolean | string[] |
--allow-net (true) or --allow-net=HOSTS (array). Network access. |
read |
boolean | string[] |
--allow-read or --allow-read=PATHS. Filesystem read. |
write |
boolean | string[] |
--allow-write or --allow-write=PATHS. Filesystem write. |
env |
boolean | string[] |
--allow-env or --allow-env=VARS. Environment-variable access. |
run |
boolean | string[] |
--allow-run or --allow-run=CMDS. Subprocess spawn. |
ffi |
boolean |
--allow-ffi. FFI access to native libraries. |
import |
string[] |
--allow-import=SPECIFIERS. Allowed dynamic-import sources. |
Send private input on stdin
Use .stdin(bytesOrJson) for a fixed task payload or options.stdin for input supplied at
execution time. The execution option replaces the builder payload. JSON is encoded as UTF-8;
Uint8Array values are sent unchanged. The runner writes the payload with backpressure and closes
stdin, so the script can read to EOF. Absent input uses null stdin and never inherits input from the
worker host. An explicitly supplied undefined payload fails with MissingStdinPayload.
import { defineTask } from '@netscript/plugin-workers-core/builders';
import { createDefaultTaskExecutor } from '@netscript/plugin-workers-core/executor';
const task = defineTask('private-input')
.runtime('python')
.entrypoint('./scripts/private-input.py')
.stdin({ uid: 'service-operator', roots: ['workspace-root'] })
.build();
const executor = createDefaultTaskExecutor();
const result = await executor.execute(task, {
stdin: { uid: 'another-operator', roots: ['another-root'] },
stdoutLimitBytes: 65_536,
stderrLimitBytes: 65_536,
});
console.log(result.success);
# scripts/private-input.py
import json, sys
payload = json.load(sys.stdin)
# Use private input without putting it in argv, env, or diagnostics.
print(json.dumps({'accepted': len(payload['roots'])}))
Stdin is capped at 1 MiB of encoded bytes, including JSON punctuation and escaping. Builder
input is validated and snapshotted when .stdin() is called; each .build() receives its own
byte buffer, which the adapter passes directly to the runner. Execution input is validated before
spawning. Oversized input produces StdinPayloadTooLarge. JSON accepts finite numbers, strings,
booleans, null, arrays, and plain objects with at most 64 levels of nesting; cycles, accessors, and
unsupported values produce InvalidStdinPayload. A subprocess that closes stdin before accepting
the payload produces StdinWriteFailed.
By default, stdout and stderr each retain only their last 1 MiB while continuing to read and
stream all output. Healthy tasks can print more than 1 MiB without failing; a final stdout JSON
object that fits in the retained tail remains available. Lines longer than 1 MiB of characters are delivered
to log callbacks in bounded fragments. Set positive integer stdoutLimitBytes / stderrLimitBytes
execution options, or configure them on
new ExecutableRuntimeAdapter({ stdoutLimitBytes, stderrLimitBytes }), to opt into a total output
cap. Exceeding an explicit cap terminates the process tree and returns status: 'failed',
success: false, and StdoutLimitExceeded or StderrLimitExceeded; explicit caps also apply with
streamLogs: false. Runtime validation and write errors return failed task results; builder
validation throws before building the task. Running aborts return cancelled, and timeouts return
timeout, both with exit code -1. Subprocesses inherit the worker's process group so terminal and supervisor
signals also reach active tasks.
Builder .stdin() applies only to direct executor calls. KV task registration validates through a
schema that strips this runtime field, and queue dispatch does not carry it. Supply private
per-execution input in options.stdin when calling the executor in a worker. Stdin is not a CLI
stdin flag. Scripts control their own stdout/stderr: do not print private payloads.
Production notes
Reference →
The full generated API — defineTask, MultiRuntimeTaskExecutor,
createDefaultTaskExecutor, every runtime adapter, and the executor option types — lives
in the workers reference.