ExtendModule SDK
Write a Tangle device module in JavaScript, TypeScript or Python: manifest, settings, controls, protocol, SDK APIs, testing and installing.
A device module teaches Tangle to control one kind of device. It is a folder with a manifest (tangle-module.json) and a program that tangle-core starts as a child process and talks to over stdin and stdout. This page shows how to write one with the JavaScript/TypeScript SDK (@tangle/module-sdk) or the Python SDK (tangle-module-sdk), test it, and install it.
#What a module is
- One folder per module, named after the module id.
tangle-module.json: the manifest. It says how to start the module, which capabilities it uses, which settings a device needs (configSchema), and which ready-made dashboard controls it offers.
- An entry file:
index.js, main.py or a native executable.
- tangle-core starts one process per module and sends it every device that uses it. The module owns the connection to each device (TCP, UDP, HTTP, WebSocket...). The SDK handles the protocol, reconnects, polling, timeouts and status reporting, so you write only the device logic.
Start from one of the reference modules in the Tangle source tree: tangle-contract/examples/virtual-light-js, virtual-light-ts and virtual-light-py (the same simulated light in three languages), or tangle-connect/modules/misc.pjlink.projector, a complete networked module with password, polling and discovery.
#Naming and folder
Module ids follow <manufacturer or misc>.<product or protocol>.<category>[.<version>]:
- manufacturer: lowercase brand without spaces (
barco, qsc), or misc for open protocols and generic devices.
- product or protocol: the product line (
watchout, pulse) or the protocol (pjlink, artnet).
- category: one of
timecode, projector, display, mediaserver, engine, player, computer, io, dsp, lighting, console, switcher, control.
- version (optional): a protocol or product generation when they are not compatible (
v6, v7, ma2). It is not the module's own version.
Use only a-z, 0-9 and - inside a part. When the manifest declares a category, the id's third part must equal it and the folder must be named exactly like the id: tangle module add refuses a folder my-projector holding barco.pulse.projector. A manifest without a category installs with a warning.
Python package names cannot contain dots, so put Python code in a normally named package inside the folder (misc.ssh.computer/ssh_computer/). Only the folder name matters.
#The manifest
tangle-module.json is validated against ModuleManifest in tangle-contract/schema/module.schema.json.
| Field | Required | Meaning |
|---|
id | yes | Module id, as above |
name | yes | Display name |
version | yes | Module version, 1.2.3 (optional -suffix) |
publisher | yes | { "name": "...", "url": "https://..." } |
channel | yes | official or community |
contract | yes | Semver range of the Tangle contract supported, for example "^0.2.0" |
runtime | yes | { "kind": "node", "minVersion": "20.0.0" }, { "kind": "python", "minVersion": "3.11" } or { "kind": "native" } |
entry | yes | Entry file, relative to the folder |
description | | One or two sentences |
category | | Device category (see above) |
devices | | Supported models, [{ "manufacturer": "Epson", "model": "EB-L1100U" }]. Discovery uses this list to suggest the module |
capabilities | | Standard capabilities used, for example ["tangle.power", "tangle.input"] |
customCapabilities | | Capabilities this module defines (see below) |
configSchema | | JSON Schema for one device's settings |
permissions | | Any of network, serial, usb, midi, filesystem. Shown to the operator for consent at install |
discovery | | true if the module implements discover |
controls, controlLayouts | | Ready-made dashboard controls and arrangements |
configurators | | Device-side setup steps |
process | | Native modules only: modules with the same process name share one process |
While the contract is 0.x, "^0.2.0" does not match 0.3.0. Tangle refuses to install a module whose contract range does not include its own contract version.
#Capabilities
A capability is a set of state keys and actions that lets dashboards, scenes and timelines control any device the same way. Standard capabilities:
| Capability | State | Actions |
|---|
tangle.power | on | on, off, toggle |
tangle.input | input | select (input) |
tangle.level | level (0..1) | set (level, fadeMs) |
tangle.mute | muted | mute, unmute, toggle |
tangle.preset | current | recall (preset) |
tangle.transport | status, positionMs, clip | play, pause, stop, seek (positionMs), load (clip) |
tangle.timecode | timecode, fps, running, signal | start, stop, locate (timecode) |
Define your own in customCapabilities, with an id <module prefix>.<name>:
{
"id": "pjlink.lamp",
"version": 1,
"description": "Lamp use (first lamp).",
"state": { "hours": { "type": "number" }, "on": { "type": "boolean" } },
"actions": {}
}
State values and action parameters are typed: boolean, number (min, max, step, unit), string, enum (values) or json.
#Settings: configSchema
configSchema is a JSON Schema for one device's config. Tangle validates device settings against it, and the designer builds the device's settings form from it: strings, numbers, integers, booleans and enums become fields, default becomes a placeholder, required fields are marked, and anything else is edited as JSON.
Mark passwords, PINs, tokens and keys with "x-secret": true. Tangle stores them in the OS credential store, never in the project file, and fills them in before sending the device to your module.
"configSchema": {
"type": "object",
"properties": {
"host": { "type": "string", "minLength": 1, "description": "IP address or host name of the projector." },
"port": { "type": "integer", "minimum": 1, "maximum": 65535, "default": 4352 },
"password": { "type": "string", "x-secret": true, "description": "PJLink password, if the projector has one." }
},
"required": ["host"],
"additionalProperties": false
}
DMX modules put the universe a device uses in an integer universe setting. Licensing counts distinct universe values to apply the edition's universe limit.
#Controls and layouts
controls are ready-made widgets for this module's devices, offered by the dashboard builder. Bindings and actions apply to whichever device the control is used with.
| Field | Meaning |
|---|
id, label, description | Identity and text |
widget | button, toggle, slider, meter, value, source, scenes, status, transport, cue, clock, label, image or group |
props | Widget properties, each bound to a constant { "kind": "const", "value": ... }, a state value { "kind": "state", "capability": "...", "key": "..." } or the device status { "kind": "status" } |
action | Intent sent when used: { "kind": "intent", "name": "level.set", "params": { "level": "{{value}}" } }. {{value}} (or the older $value) is replaced by the widget's value |
offAction | Intent for the off position of a toggle |
confirm | none, confirm or hold |
size | Default size in grid cells, { "w": 3, "h": 2 } |
controlLayouts arrange controls into named sets (compact, full...) that the builder offers when a device is dragged onto a dashboard. Each placement gives control, x, y, w, h in grid cells. See Dashboards.
"controls": [
{
"id": "power", "label": "Power", "widget": "toggle",
"props": { "value": { "kind": "state", "capability": "tangle.power", "key": "on" } },
"action": { "kind": "intent", "name": "power.on" },
"offAction": { "kind": "intent", "name": "power.off" },
"size": { "w": 3, "h": 2 }
},
{ "id": "status", "label": "Status", "widget": "status", "props": { "value": { "kind": "status" } }, "size": { "w": 3, "h": 1 } }
],
"controlLayouts": [
{ "id": "compact", "label": "Compact", "controls": [
{ "control": "power", "x": 0, "y": 0, "w": 3, "h": 2 },
{ "control": "status", "x": 0, "y": 2, "w": 3, "h": 1 }
] }
]
#Configurators
configurators describe setup needed on the device side, such as a script to copy to a player or remote access to enable on a PC. Each has an id, a name, optional platforms (macos, windows, linux, device) and ordered steps of kind:
info: text only (Markdown in text);
download: files your module generates, to copy to the device;
script: a script your module generates, for the operator to run on the target;
check: runs an action (capability, action) to confirm the setup worked.
Your module builds the files and script from a device's configuration when the host calls configurator.run (see the SDK APIs below).
Note: The designer wizard that shows configurator steps is not available yet.
#A minimal module in JavaScript
A simulated dimmable light, as in tangle-contract/examples/virtual-light-js. Folder misc.virtual-light.lighting/:
tangle-module.json:
{
"id": "misc.virtual-light.lighting",
"name": "Virtual light",
"version": "0.1.0",
"description": "Simulated dimmable light.",
"category": "lighting",
"publisher": { "name": "Your name" },
"channel": "community",
"contract": "^0.2.0",
"runtime": { "kind": "node", "minVersion": "20.0.0" },
"entry": "index.js",
"devices": [{ "manufacturer": "Tangle", "model": "Virtual light" }],
"capabilities": ["tangle.power", "tangle.level"],
"configSchema": {
"type": "object",
"properties": { "startOn": { "type": "boolean", "default": false } },
"additionalProperties": false
},
"permissions": []
}
package.json:
{
"name": "misc.virtual-light.lighting",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": { "@tangle/module-sdk": "workspace:*" }
}
index.js:
import { runModule } from "@tangle/module-sdk";
runModule({
version: "0.1.0",
createDriver(ctx) {
let on = ctx.config.startOn === true;
let level = 1;
return {
async connect() {
// A real module opens its connection here.
ctx.setState("tangle.power", { on });
ctx.setState("tangle.level", { level });
},
async execute(cmd) {
const key = `${cmd.capability}.${cmd.action}`;
switch (key) {
case "tangle.power.on":
case "tangle.power.off":
case "tangle.power.toggle":
on = cmd.action === "toggle" ? !on : cmd.action === "on";
ctx.setState("tangle.power", { on });
return;
case "tangle.level.set":
level = Math.min(1, Math.max(0, Number(cmd.params?.level)));
ctx.setState("tangle.level", { level });
return;
default:
throw new Error(`unsupported action: ${key}`);
}
},
};
},
});
The SDK lives in the Tangle source tree at tangle-contract/sdk/js. The workspace:* dependency works when the module folder is inside the Tangle pnpm workspace, for example in tangle-connect/modules/. Build the SDK once:
pnpm install
pnpm --filter @tangle/module-sdk build
In TypeScript, import the types too: import { runModule, type ActionCommand, type DeviceContext, type DeviceDriver } from "@tangle/module-sdk"; and compile to the entry file named in the manifest (see examples/virtual-light-ts).
#A minimal module in Python
The same light in Python, as in tangle-contract/examples/virtual-light-py. Folder misc.virtual-light-py.lighting/, with the same manifest except "id": "misc.virtual-light-py.lighting", "runtime": { "kind": "python", "minVersion": "3.11" } and "entry": "main.py".
main.py:
"""A simulated dimmable light, written in Python."""
from tangle_module_sdk import DeviceDriver, run_module
class VirtualLight(DeviceDriver):
async def connect(self):
# A real module opens its connection here.
self.on = self.ctx.config.get("startOn", False)
self.level = 1.0
self.ctx.set_state("tangle.power", {"on": self.on})
self.ctx.set_state("tangle.level", {"level": self.level})
async def execute(self, cmd):
key = f"{cmd['capability']}.{cmd['action']}"
if key in ("tangle.power.on", "tangle.power.off", "tangle.power.toggle"):
self.on = (not self.on) if cmd["action"] == "toggle" else cmd["action"] == "on"
self.ctx.set_state("tangle.power", {"on": self.on})
elif key == "tangle.level.set":
self.level = min(1.0, max(0.0, float(cmd.get("params", {}).get("level", 0))))
self.ctx.set_state("tangle.level", {"level": self.level})
else:
raise ValueError(f"unsupported action: {key}")
if __name__ == "__main__":
run_module(VirtualLight, version="0.1.0")
The Python SDK uses the standard library only and needs Python 3.11 or later. For development, install it from the source tree:
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e tangle-contract/sdk/python
For an installed module, the SDK must be importable when Tangle runs main.py: copy tangle-contract/sdk/python/tangle_module_sdk next to the entry file, which is how the official Python modules are packaged.
#Talking to a real device
Open the connection in connect() with a transport from the device context. The SDK watches it: if it closes, the device goes offline and connect() runs again with backoff. Use poll() to read state regularly.
This module drives output 1 of a 4x2 video matrix that answers 3*1! with Out1 In3 All and 1! with the current input. The simulator's example config has such a matrix on TCP port 5023, so you can try it without hardware.
import { runModule } from "@tangle/module-sdk";
runModule({
version: "0.1.0",
createDriver(ctx) {
const output = Number(ctx.config.output ?? 1);
let conn;
const readInput = async () => {
const answer = await conn.request(`${output}!\r\n`, { match: (f) => /^\d+$/.test(f) });
ctx.setState("tangle.input", { input: answer });
};
return {
async connect() {
conn = await ctx.tcp({ host: ctx.config.host, port: Number(ctx.config.port ?? 5023), delimiter: "\r\n" });
await readInput();
},
poll: readInput,
async execute(cmd) {
if (cmd.capability !== "tangle.input" || cmd.action !== "select") {
throw new Error(`unsupported action: ${cmd.capability}.${cmd.action}`);
}
const input = String(cmd.params?.input ?? "");
if (!/^[1-4]$/.test(input)) throw new Error("input must be 1 to 4");
await conn.request(`${input}*${output}!\r\n`, { match: (f) => f.startsWith(`Out${output} `) });
ctx.setState("tangle.input", { input });
},
};
},
});
The same in Python:
import re
from tangle_module_sdk import DeviceDriver, run_module
class Matrix(DeviceDriver):
async def connect(self):
cfg = self.ctx.config
self.output = int(cfg.get("output", 1))
self.conn = await self.ctx.tcp(cfg["host"], int(cfg.get("port", 5023)), delimiter="\r\n")
await self.poll()
async def poll(self):
answer = await self.conn.request(f"{self.output}!\r\n", match=lambda f: f.isdigit())
self.ctx.set_state("tangle.input", {"input": answer})
async def execute(self, cmd):
if (cmd["capability"], cmd["action"]) != ("tangle.input", "select"):
raise ValueError(f"unsupported action: {cmd['capability']}.{cmd['action']}")
value = str(cmd.get("params", {}).get("input", ""))
if not re.fullmatch(r"[1-4]", value):
raise ValueError("input must be 1 to 4")
await self.conn.request(f"{value}*{self.output}!\r\n", match=lambda f: f.startswith(f"Out{self.output} "))
self.ctx.set_state("tangle.input", {"input": value})
if __name__ == "__main__":
run_module(Matrix, version="0.1.0")
Its manifest lists "capabilities": ["tangle.input"], "category": "switcher", "permissions": ["network"], and a configSchema with host (required), port and output.
#SDK APIs
#Module definition
JavaScript (runModule({...})) | Python (run_module(Driver, version, ...)) | Purpose |
|---|
version | version | Module version reported to the host; use the manifest's |
createDriver(ctx) | the DeviceDriver subclass | One driver instance per device |
discover({ timeoutMs, log }) | discover=async fn(timeout_seconds) | Return devices found on the network |
configurators: { id: fn } | configurators={"id": async fn} | Build configurator files and scripts |
timing: { ... } | timing=Timing(...) | Change timeouts and delays |
#Driver methods
| Method | When it runs |
|---|
connect() | When the device is added, and again after a lost connection. Open the connection and send the initial state. Throwing marks the device offline and retries with backoff |
execute(cmd) | For each action. cmd has deviceId, capability, action, optional params and at (epoch ms, for timeline pre-roll; the SDK waits until then). Return nothing for success, or throw |
poll() | Every 10 s while online, never at the same time as an action. Read the device and set what changed |
disconnect() | When the device is removed or reconnected |
pollIntervalMs (JS), poll_interval (Python, seconds) | Per-device poll interval |
Errors to throw:
RetryableError: the action may work later (device busy, warming up). Core may retry it.
ConfigError: from connect(), when retrying cannot help (password rejected, setting missing). The device shows error and is not retried.
TimeoutError (JS: exported by the SDK; Python: the built-in one): from connect() or poll() when the device did not answer. The device shows timeout instead of offline.
- Anything else from
execute() fails the action, not retryable.
#Device context
JavaScript (ctx) | Python (self.ctx) | Purpose |
|---|
ctx.device, ctx.config | ctx.device, ctx.config, ctx.device_id | The device and its settings, secrets filled in |
ctx.dataDir | ctx.data_dir | Folder the module may write to |
ctx.setState(capability, values) | ctx.set_state(capability, values) | Report changed state. Send the initial state after connecting, then every change, including changes made outside Tangle |
ctx.setStatus(status, detail) | ctx.set_status(status, detail) | Report a status yourself, for example degraded with an alarm text |
ctx.status | ctx.status | Current status |
ctx.emit(name, data) | ctx.emit(name, data) | Device event (a button press, an alarm) for automations |
ctx.log.info(msg, data) (also debug, warn, error) | ctx.log("info", msg, **data) | Logs, written to stderr |
ctx.connectionLost(reason, "offline" or "timeout") | ctx.connection_lost(reason, status) | For transports you manage yourself: reconnect with backoff |
ctx.signal | ctx.stopped | Aborted (JS) or set (Python) when the device is removed |
#Transports
| JavaScript | Python | What it is |
|---|
ctx.tcp({ host, port, delimiter }) | ctx.tcp(host, port, delimiter=...) | TCP with framing (default delimiter \r), one request at a time with a timeout (default 3 s), answer matching with match, unsolicited frames to onFrame / on_frame. A closed connection reconnects the device |
udpSearch({ port, message, timeoutMs }), udpSend(...) | udp_search(port, message, timeout), udp_send(...) | Broadcast a search and collect answers (for discover), or send one datagram |
ctx.udp({ host, port }) | | UDP socket for one device, with request and onMessage |
ctx.http({ baseUrl }) | | REST client: get, post, put, patch, delete. Timeouts become TimeoutError, 503 and 429 are retryable |
ctx.websocket({ url }) | | WebSocket (Node.js 22+). A closed socket reconnects the device |
ctx.osc({ host, port }) | | OSC over UDP, with encodeOsc / decodeOsc |
JsonRpcClient | | JSON-RPC over any of the above |
ctx.watch(transport) | | Watch a transport you opened yourself |
TCP options also include frameLength (custom framing for binary or length-prefixed protocols such as Modbus TCP), jsonFrameLength (JSON objects sent back to back), encoding ("binary" for Buffers), requestTimeoutMs and sendIntervalMs (minimum time between writes). UDP cannot see a device go away: implement poll() so a silent device becomes timeout. @tangle/module-sdk/testing has a small WebSocket server for writing simulators.
Note: A serial (RS-232) transport is not available yet in the SDKs. Use a serial-to-IP adapter with the TCP helper.
#Timing
| JavaScript (ms) | Python (Timing, seconds) | Default |
|---|
actionTimeoutMs | action_timeout | 10 s |
connectTimeoutMs | connect_timeout | 10 s |
reconnectMinMs | reconnect_min | 1 s, doubling up to the maximum |
reconnectMaxMs | reconnect_max | 30 s |
pollIntervalMs | poll_interval | 10 s |
#Discovery
Set "discovery": true in the manifest and implement discover. The host calls it during network scans, in a separate short-lived process, never the project's running one. Search for at most the given time, then return a list of { address, name, manufacturer, model, config } (only address is required). config pre-fills the device settings when the operator adds it.
import { runModule, udpSearch } from "@tangle/module-sdk";
runModule({
version: "0.1.0",
createDriver: (ctx) => ({ /* ... */ }),
async discover({ timeoutMs }) {
const answers = await udpSearch({ port: 4352, message: "%2SRCH\r", timeoutMs: timeoutMs - 500 });
return answers.map((a) => ({ address: a.address, config: { host: a.address } }));
},
});
#Configurators
runModule({
version: "0.1.0",
createDriver: (ctx) => ({ /* ... */ }),
configurators: {
"enable-remoting": ({ config }) => ({
files: [],
script: { language: "powershell", content: "Enable-PSRemoting -Force" },
}),
},
});
In Python: run_module(Driver, version="0.1.0", configurators={"enable-remoting": build}) where build is async def build(config, device_id) returning a dict with files (each name, content as text or bytes, optional executable), script (language, content) and instructions. Script languages are powershell, bash, shell, brightscript and text.
#Statuses
The SDK reports device status for you:
| Situation | Status |
|---|
| First connection in progress | connecting |
connect() succeeded | online |
| One failed poll | degraded |
connect() failed, or the connection closed | offline, then retry after 1 s, 2 s, 4 s... up to 30 s |
TimeoutError, or two failed polls without an answer | timeout, then retry |
ConfigError | error, no retry |
Every module must report at least online, offline and timeout. Actions for an offline or timeout device fail at once with retryable: true. Actions sent while the first connection is being made wait for it, up to the connect timeout. Report degraded yourself with setStatus when the device answers but has a problem (a lamp warning, an alarm).
#The module protocol
The SDKs implement the protocol, so you only need this to debug or to write a module in another language. The full reference is tangle-contract/docs/module-protocol.md; message shapes are in tangle-contract/schema/module-protocol.schema.json.
- The host starts the module with the runtime from the manifest (
node <entry>, python <entry>, or the native executable), with the module folder as working directory.
- stdin carries host-to-module messages, stdout module-to-host messages: JSON-RPC 2.0, one JSON object per line, UTF-8. stderr is for logs. Never print logs to stdout. The SDKs redirect
console.log to stderr.
- When stdin closes, the module must clean up and exit.
Methods (host to module):
| Method | Purpose |
|---|
initialize | First call, with contractVersion, host, moduleId, dataDir. Answer within 5 s with moduleId, version, sdk |
device.add | A device to drive, secrets filled in. Answer as soon as it is registered; report progress with device.status |
device.remove | Disconnect and forget a device |
device.execute | Run an action. The result is { "ok": true } or { "ok": false, "error": "...", "retryable": true }; failures are results, not JSON-RPC errors. Actions for one device run one at a time, in order |
discover | Search the network for timeoutMs (only with "discovery": true) |
configurator.run | Build a configurator's files and script for one device configuration |
ping | Answer { "now": <epoch ms> }. 3 missed pings and the host restarts the module |
shutdown | Disconnect every device, answer, exit with code 0 |
Notifications (module to host): device.status, device.state (changed values for one capability) and device.event.
{"jsonrpc":"2.0","id":4,"method":"device.execute","params":{"deviceId":"proj-1","capability":"tangle.power","action":"on"}}
{"jsonrpc":"2.0","id":4,"result":{"ok":false,"error":"lamp warming up","retryable":true}}
{"jsonrpc":"2.0","method":"device.state","params":{"deviceId":"proj-1","capability":"tangle.power","values":{"on":true}}}
Error codes: -32700 invalid JSON, -32601 unknown method (also discover in a module without it), -32602 bad params (unknown configurator), -32603 internal error. The host fails an action not answered within 15 s, without restarting the module.
#Test your module
The conformance kit starts your module the way Tangle does and checks it: answers initialize within 5 s, answers ping, rejects unknown methods and invalid JSON, handles actions for unknown devices, answers discover in time (when declared), runs a device scenario, validates every message against the contract, keeps stdout clean, shuts down within 5 s and exits when stdin closes.
pnpm exec tangle-conformance path/to/module # inside the Tangle workspace
node tangle-contract/conformance/run.mjs path/to/module
For Python modules, set PYTHON to choose the interpreter. Add a conformance.json to the module folder to run a device scenario:
{
"device": { "id": "light-1", "name": "Test light", "moduleId": "misc.virtual-light.lighting", "config": {} },
"steps": [
{
"execute": { "capability": "tangle.power", "action": "on" },
"expect": { "ok": true, "state": { "capability": "tangle.power", "values": { "on": true } } }
},
{ "execute": { "capability": "tangle.power", "action": "explode" }, "expect": { "ok": false } }
]
}
The device must come online within 5 s, so point it at something that answers (a simulator).
#Against a simulated device
Test against a virtual device before you have hardware. The simulator runs PJLink projectors, any line-based TCP or UDP device described by rules (ascii type), and Art-Net/sACN receivers, with faults to switch on: offline, silent, latencyMs, fail, rejectAuth, drop and reboot. Check that your module reports offline for an offline device, timeout for a silent one, and online again when the fault is cleared. Most official modules also ship their own sim.js or sim.py that you can use as a model.
#In a running project
Run tangle-core with an extra modules folder (one sub-folder per module), without installing:
tangle-core run --project show.yaml -m ./my-modules
tangle-core modules -m ./my-modules # list the modules found
Then test a device with tangle device test or Test connection in the designer.
#Package and install
To install a module on a Tangle machine:
tangle module add ./misc.virtual-light.lighting
The folder is copied into the Tangle modules folder (tangle module list shows it as local and unsigned) and becomes the active version. Before installing, make sure:
- JavaScript: the folder runs on its own. Either keep
node_modules with the runtime dependencies (symlinks are followed when copying; development dependencies are skipped), or bundle the entry into one file. The official packages bundle with esbuild.
- TypeScript: compile first;
entry points to the compiled JavaScript.
- Python: list third-party dependencies in
requirements.txt. At install, Tangle creates a virtual environment for the module (.venv) and installs them, from a wheels/ folder when present (so it works offline). Ship the tangle_module_sdk package next to the entry file.
- Permissions: declare what the module needs. The operator confirms them at install and again when an update adds one.
See Devices for updates, rollback and per-project version locks.
Note: Publishing modules to a marketplace, signed packages and managed Node.js/Python runtimes are not available yet.
#Native modules
Timing-critical modules (timecode, MIDI, DMX) are native Rust programs built with the Rust SDK (tangle-native-sdk, in tangle-connect/crates/native-sdk), which behaves like the JS and Python SDKs. Their manifest uses "runtime": { "kind": "native" }, and entry names the executable, looked up in the module folder (with .exe on Windows), then in TANGLE_NATIVE_DIR, then next to the host executable. Modules with the same process name share one process, which receives the devices of all of them; each device.add carries the device's own moduleId.