Skip to main content
0.0.x

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 worker runtime resolves a TaskDefinition to a runtime adapter, which builds an argv and spawns a python/node/dotnet subprocess; input flows in as args, env, or bounded stdin, the subprocess streams stdout/stderr back, and the last JSON line of stdout becomes the structured result returned to the queue and database.
A task is dispatched to a runtime adapter that spawns a subprocess. Input arrives as argv, env, or bounded stdin; the last JSON line of stdout is parsed into the result; the exit code, captured logs, and duration become a TaskResult.

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.

TaskResult — returned by executor.execute()
NameTypeDescription
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 | null 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:

TaskType — built-in runtime adapters
NameTypeDescription
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.

permissions() options — applied to the deno runtime
NameTypeDescription
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.

workers