dsh-nonead-universal-robots
English · 简体中文
A DeepSeek Harness (DSH) plugin that lets you control Universal Robots (UR) collaborative arms directly from natural language, developed by Suzhou Nonead Robot Technology Co., Ltd. based on the same logic as the company's own nUR MCP Server. (Suzhou Nonead Robot Technology Co., Ltd.)
The plugin starts a persistent Python worker (which internally reuses the vendored URBasic library) and exposes robot control as native DSH tools: the tools talk to the robot through that worker, so after a single connect you can keep issuing commands to the same IP. Repository: https://github.com/NoneadChina/dsh-nonead-universal-robots
⚠️ Safety notice: this plugin drives a real robotic arm directly. Always keep the robot in sight, keep the emergency stop within reach, and keep the workspace clear of people and obstacles. Treat it with the same care as granting a model the bash tool. You (or the model) bear full responsibility for any motion command.
🛡️ Motion approval gate: the 53 commands that physically move the arm, run a program, take the arm out of program control, remove its rigidity, overwrite/delete a program stored on the robot, drive a fieldbus device over Modbus, change the TCP / tool electrical configuration or switch digital/analog outputs, or push against a workpiece under force control pause and wait for human confirmation before being sent to the robot:
| Group | Tools |
|---|
| Motion | ur_movej ur_movel ur_movep ur_movec ur_servoj ur_move_optimized ur_move_x ur_move_y ur_move_z ur_move_tool_x ur_move_tool_y ur_move_tool_z ur_move_force |
| Drawing | ur_draw_circle ur_draw_square ur_draw_rectangle ur_draw_star |
| Programs & scripts | ur_load_program ur_run_program ur_send_script ur_reset_error |
| Control mode / power / safety | ur_set_freedrive ur_set_teach_mode ur_power_on ur_power_off ur_brake_release ur_unlock_protective_stop ur_shutdown |
| Force & velocity | ur_force_mode ur_force_mode_settings ur_speedj ur_speedl |
| Tool & I/O configuration | ur_set_tcp ur_set_tool_voltage ur_set_tool_output_mode ur_set_tool_communication ur_set_digital_out ur_set_analog_out ur_set_conveyor_tick |
| Program storage | ur_upload_program ur_delete_program |
| Physical state changes | ur_set_payload ur_set_payload_inertia ur_set_gravity ur_zero_ftsensor ur_motion_version ur_set_speed_scaling ur_set_conveyor_tracking |
| Modbus writes | ur_modbus_add_signal ur_modbus_delete_signal ur_modbus_send_custom_command ur_modbus_set_output_register ur_modbus_set_output_signal |
Decelerations and exits (ur_stopj, ur_stopl, ur_end_force_mode) are deliberately not gated: they are how an operator (or the model, after a cancellation) makes the arm stop, and a confirmation prompt would only slow that down. Gating rules live in one place (APPROVAL_OPS in lib/index.js) and are asserted by scripts/check-approval-gate.py + test/approval-gate.test.mjs.
In an interactive deployment (approval policy ask) a confirmation dialog appears in the UI. A call made without an approval service, without an agent, or answered with anything other than allowed-once fails closed — the command is rejected and never reaches the robot. The default lives in code (config.requireApprovalForMotion ?? true), so it cannot be turned off by a caller that skips schema parsing; set requireApprovalForMotion: false to disable the gate deliberately. test/approval-gate.test.mjs drives the real tool registry and proves all of the above, including that an extra op argument cannot re-route a call.
🧭 Pre-flight safety gate: a second layer that caps what a single command may ask for, independent of the approval gate. Limits: safetyMaxStepM (0.5 m — axis moves and drawing dimensions), safetyMaxLinearSpeed (0.5 m/s), safetyMaxJointSpeed (1.05 rad/s), safetyMaxAccel (2.0 m/s²), safetyMaxDurationS (60 s), safetyMaxForceN / safetyMaxTorqueNm (40 N / 10 N·m, force mode), plus an optional safetyWorkspace box ("x:-1,1 y:-1,1 z:0,1.2") checked against explicit Cartesian targets — including the center/origin of the four ur_draw_* tools (and their r / border / width / height / side are capped by safetyMaxStepM). It runs before the approval prompt — an impossible request should not bother a human — and is decoupled from it: requireApprovalForMotion: false turns off the human confirmation only, never these physical caps; that requires safetyGate: false. A malformed safetyWorkspace fails closed (every motion op is refused) instead of being silently ignored. It is a guard against typos and unit mistakes, not a safety boundary (the controller's own safety configuration still is), and it deliberately does not cover ur_send_script, which is arbitrary URScript this gate does not parse.
📝 Motion audit ledger: every gated command appends one NDJSON line (<DSH_HOME>/nonead-ur/audit.ndjson by default; auditLogPath to move it, auditLog: false to disable, auditLogAll: true to also record unchecked reads) as {ts, ip, op, tool_name, args, decision, reason} with decision ∈ {approved, denied, blocked, unchecked} — so "what did the model actually do to the robot" is answerable after the fact. Writing to it never affects the robot path: an audit failure is reported, never thrown.
Feature overview
104 tools (ur_*) in total.
| Category | Tool(s) (ur_*) | Description |
|---|
| Connection | ur_connect / ur_disconnect | Connect / disconnect a UR robot by IP |
| Status | ur_get_status | One-shot read of TCP, joints, model, serial, version, safety mode, run/program state, voltage, current, temperatures, uptime, joint currents/voltages/speeds, TCP speed and wrench, speed scaling, plus rtde_resync_bytes (how many bytes the RTDE receiver had to drop to re-synchronise; 0 means a clean stream) |
| Pose | ur_get_tcp_pose / ur_get_joint_pose | Read current TCP pose / joint angles |
| Targets | ur_get_target_values / ur_check_pose | Read where the controller is taking the arm: target joint positions/velocities/accelerations and target TCP pose/speed, plus the matching actual values for comparison. Comes from the RTDE target_* fields, so it is free — and it is the only direct evidence for "command sent but not executed yet / being blended / held back by the safety limit". ur_check_pose answers the question worth asking before moving: is this TCP pose inside the controller's safety limits, and does inverse kinematics solve (is it reachable)? A read-only pre-check: the controller runs is_within_safety_limits + get_inverse_kin and the answers come back through output registers behind start/finish sentinels, so "the controller never ran it" is reported rather than guessed |
| Device info | ur_get_robot_model / ur_get_serial_number / ur_get_uptime / ur_get_software_version / ur_get_safety_mode / ur_get_safety_status / ur_get_robot_mode | Model (with a remote_control field) / serial / uptime / software version / safety mode / safety and robot status bits (which safety function fired, incl. violation / fault; the numeric limits themselves are not exposed by RTDE or the dashboard and must be read from PolyScope's Safety page) / run state |
| Programs | ur_get_program_state / ur_load_program / ur_run_program / ur_stop_program / ur_pause_program / / / / |
Every tool except connect takes an ip argument and requires that IP to be connected first.
Read-only 3D digital twin
The plugin also ships a read-only 3D digital twin of the robot, rendered with three.js: the entry is a card in the right sidebar's Start panel, directly below the "Workspace files / New terminal / Browser" cards, and selecting it fills the right sidebar's content area with the live 3D view. The view subscribes to the host's SSE stream (/twin/stream, one frame every 100 ms) and stays in sync with the live robot's joint poses, tool (TCP) coordinate frame and recent motion trajectory. The twin is strictly read-only — it never sends a command to the robot: it only subscribes to the host's read-only routes and renders what it receives. A toolbar offers a reset-view control plus isometric / front / side / top presets, and the camera and base grid are framed from the model's bounding box, so a UR3 and a UR20 are both framed correctly. The numeric panel also shows the dashboard-side state (safety mode, robot mode, program state, speed scaling, joint temperatures, bus voltage/current), taken from the same route on a slower cadence.
The feed does not stall while the robot moves: telemetry is pushed by the worker at 20 Hz, and every twin frame reads that push cache first — without touching the single-threaded command queue, which is what a running motion confirmation loop occupies. On the wire a push is simply a JSON line without id, so an older host treats it as an unmatched response and nothing breaks. Only when the cache is stale (>300 ms) or missing does the twin fall back to an explicit read, and twin reads still use a dedicated 2.5 s budget with killOnTimeout:false — a read-only visualisation read must never destroy the robot session (RTDE/Dashboard) just because it was busy. If a stall does happen, the stream emits stale events and the panel says "data stopped updating Ns · retrying" rather than leaving a silently frozen arm on screen. After a disconnect the stream reconnects indefinitely (exponential backoff capped at 30 s) and retries immediately when the window becomes visible again or the feed has been stalled too long. Set DSH_UR_TWIN_PUSH=0 to turn the push channel off for comparison.
It follows the desktop app's colour theme: the panel's DOM half is coloured with the host's design tokens (--dsw-alias-*), so the toolbar, HUD and status line flip automatically with whatever theme is selected under Settings → Appearance (and with third-party themes) — no JS needed. The base grid lines and the fallback arm's material colour inside the WebGL canvas are repainted on a theme change by src/client/theme.js, which watches body[data-ds-dark-theme] (CSS cannot reach values inside WebGL). Theme detection only trusts that attribute and never falls back to the system preference.
Host-side routes (all fenced to loopback callers):
| Route | Description |
|---|
GET /dsh-nonead-ur/twin/stream[?ip=<ip>] | SSE stream (what the client uses by default): one full state frame every 100 ms, plus stale (this frame's read has not returned yet) and frame-error (this frame failed) events |
GET /dsh-nonead-ur/twin/state[?ip=<ip>][&detail=1] | Live pose (joint angles / TCP / model). detail=1 adds the dashboard-side state (safety mode, run state, speed scaling, joint temperatures/currents); the pose channel keeps polling independently and a failing detail query never takes it down |
GET /dsh-nonead-ur/twin/asset?model=<urXX> | GLB mesh, with a content-hash ETag + immutable and If-None-Match → 304 support |
GET /dsh-nonead-ur/twin/models | The model list actually present locally |
Failure responses carry a machine-readable code (no_robot / robot_not_connected / ambiguous_robot / worker_unavailable / robot_error) and echo the resolved ip, so the UI can say which of four very different failures happened. A read that is only temporarily unavailable (most often because the worker is busy executing a motion command) is not reported as robot_error: the host replays the last known reading with stale:true (for up to 30 s), so the twin keeps its picture and says the data is stale. A replayed frame never carries approval state — pending_motion is re-read live, so a finished approval cannot keep being drawn as a ghost target.
Languages (UI copy)
Since 0.8.0 the twin panel and the right-dock entry are localised into eight languages:
简体中文 · English · 日本語 · Español · Français · Deutsch · Русский · Português (Brasil)
The language follows the DSH Desktop app language: the eight dictionaries are registered with the
host's @deepseek-ai/dsh-client-locale (dsh.client.inject + ctx.locale.register('ur-twin', …)),
and the host's locale.preference decides which one is used. Switching the language in
Settings → 界面语言 / Language updates the panel without a reload. Without a host locale
service the plugin falls back to navigator.languages → navigator.language → Simplified Chinese.
Every user-visible string lives in one table (src/client/strings.js), so
adding a language means adding one dictionary — the key set is enforced by tests.
Install into a DSH profile
📦 Published on npm as dsh-nonead-universal-robots
— current release 0.8.6. The npm route (Option 0) is the simplest: no GitHub tarball download
and no local checkout.
Option 0 — from npm (recommended)
# DSH Desktop ships a CLI wrapper per profile; it adds --profile itself:
dsh.cmd plugin add dsh-nonead-universal-robots
# pin an exact version if you prefer:
dsh.cmd plugin add dsh-nonead-universal-robots@0.8.6
# on other setups: dsh plugin --profile <profile> add dsh-nonead-universal-robots
Or write the range into the profile's package.json yourself — the registry is the default source:
"dependencies": { "dsh-nonead-universal-robots": "^0.8.6" }
npm install dsh-nonead-universal-robots also works if you want to vendor the tarball into another
package. The installed copy contains lib/, python/ (worker + vendored URBasic), assets/,
cordis.patch.yml, the changelogs and THIRD_PARTY_NOTICES.md.
Option 1 — plugin market / plugin page (GUI)
The plugin is listed in the DSH plugin market as dsh-nonead-universal-robots. Open Settings → Plugin market (or the sidebar Plugins page), find it and install.
Two things to know:
- The market refuses to install while an agent is running ("installing changes plugin files, so a working agent can fail mid-turn"). Finish or cancel the running turn first.
- The market installs from the entry's GitHub URL, and pnpm fetches
github: sources as a tarball from codeload.github.com. On a slow or filtered network that download can time out; the CLI route below avoids it entirely.
Option 2 — CLI, from a local checkout (recommended for development)
# DSH Desktop ships a CLI wrapper per profile:
# %APPDATA%\<app>\host-commands\<profile>\generations\<hash>\bin\dsh.cmd
dsh.cmd plugin add "file:D:/path/to/dsh-nonead-universal-robots"
# (the wrapper adds --profile <profile> itself; on other setups use `dsh plugin --profile <profile> add <spec>`)
This is the same path the plugin page uses: it writes the dependency and the dsh.profile.bundles entry, and the plugin is hot-loaded into running sessions (no restart needed — the ur_* tools appear immediately).
⚠️ A file: install is a copy, not a symlink: editing the checkout does not affect the installed plugin — re-run the command after changing the source.
Option 3 — as a bundle, by hand
-
Add the plugin to your target profile's package.json:
// <DSH_HOME>/profiles/<profile>/package.json
{
"dependencies": {
"dsh-nonead-universal-robots": "^0.8.6"
// offline / air-gapped fallbacks: "file:D:/path/to/dsh-nonead-universal-robots"
// or, once GitHub is reachable: "github:NoneadChina/dsh-nonead-universal-robots"
},
"dsh": {
"profile": {
"bundles": [ // add the plugin here (later order is fine)
"...",
"dsh-nonead-universal-robots"
]
}
}
}
-
Install dependencies via dsh plugin (equivalent to running pnpm inside the profile directory):
dsh plugin --profile <profile> install
# or: cd "$DSH_HOME/profiles/<profile>" && pnpm install
-
Restart DSH. The plugin's cordis.patch.yml inserts the plugin line into the config tree, and the tools appear in the model's tool list as ur_*.
Python runtime
The plugin needs a usable Python with numpy and paramiko to start the worker:
pip install -r requirements.txt
⚠️ Install those dependencies for the interpreter the worker actually uses. lib/worker.js
points PYTHONPATH at the plugin's python/ directory, and CPython imports sitecustomize.py
from there at startup if it exists — so a machine-local sitecustomize.py that pokes a user-level
site-packages into sys.path "works" on one machine and fails everywhere else. Point pythonBin
at an interpreter (or a venv) that has the dependencies.
pythonBin (default python), commandTimeoutMs (motion/script timeout, default 60000) and connectTimeoutMs (first-connect timeout, default 30000) are configurable in cordis.patch.yml. If python is not on PATH, use an absolute path, e.g. "C:\\Python312\\python.exe" or "/usr/bin/python3". ur_ping / npm run test:python is the self-check for this chain.
Health check / testing
Verify the plugin and Python runtime without touching a real robot:
npm run test:python # python ur_worker.py --selfcheck: verify Python/numpy/paramiko/URBasic/RTDE config
npm test # 56 items: all 42 test files + 9 gates + 5 offline probes, then a summary
npm run test:node # Node side only: skip the test files *and* the gates that need Python
npm run check # all static + cross-language gates without running the test files
npm run verify:host # validate every tool schema through the host's real value-schema DSL, plus peer ranges
npm run verify:models # validate the structural contract of the 14 GLBs
npm test runs two kinds of thing, and both must pass:
- 42 test files under
test/ (end-to-end protocol self-check, approval gate, twin routes, client state machine, FK, vendored-library regressions) — enumerated from test/test-manifest.json, which check:manifest keeps honest so a Python-using file can never be silently unlisted.
- 9 check gates plus 5 offline probes:
check-test-manifest / check-package-metadata / check-client-bundle / check-doc-tools (Node) and check-worker-ops / check-tool-params / check-rtde-recipe / check-approval-gate / check-new-ops (Python), then the scripts/probe-*.mjs twin/SSE probes.
- The host compatibility check is also attempted (
check-host-compat --allow-missing-host). It is an environment check, so it is reported on its own summary line and excluded from the 56-item count: a machine without DSH Desktop gets a loud SKIP with the reason, never a silent pass; a machine (or CI) that provides a host turns it into PASS — 宿主真实 value-schema DSL 校验通过.
Skipped is not green. A run with any skipped item exits non-zero — the only way to accept skips is to ask for them explicitly (--allow-skip, or --skip-python for the Node-only run), and a run in which everything was skipped always fails. Two probes need a global EventSource and run with --experimental-eventsource (Node ≥22.3); between that and module.registerHooks (Node ≥22.15) the real development floor is recorded in devEngines.
npm test prints a per-item summary. If python is not on PATH, set it via the environment:
UR_PYTHON=C:\\Python312\\python.exe npm test
Run the host compatibility check before a release and whenever the DSH runtime is upgraded — it validates the plugin against an installed host instead of a copy of its keyword list:
npm run verify:host # strict: exits 2 when no installed host is found
npm run check:host # same check, but a missing host is a loud SKIP
node scripts/check-host-compat.mjs <node_modules> # or an explicit host
node scripts/check-host-compat.mjs --host-asar "<...>/resources/app.asar" # DSH Desktop installs
npm run check:host:strict # what prepublishOnly runs: no host => exit 2
It checks the peer ranges against the installed versions, compiles every tool parameter schema with the host's real value-schema DSL (a rejected schema silently drops that tool), drives the host route registration, and validates the dsh.client declaration plus the client bundle's __ModuleLoader__ id. Exit code 0 means this plugin works with that host, and it reports how many tools registered out of how many were declared (currently 104/104).
--host-asar reads the Electron app.asar directly (DSH Desktop's normal install shape, where node_modules is packed inside the archive): the needed packages are extracted lazily to a temp directory and the real host DSL still runs. prepublishOnly is npm run check && npm run check:host:strict && npm test, so a release can no longer be made without a real host check or without the test suite.
When --host-asar is omitted, the script auto-discovers a DSH Desktop install (Program Files, %LOCALAPPDATA%\Programs, and <drive>:\Software\<vendor> are scanned; candidates whose directory name contains dsh win, so an unrelated Electron app that happens to bundle @deepseek-ai/dsh-tools is not picked first). Set DSH_HOST_ASAR=<...>/resources/app.asar to pin an archive explicitly.
CI (.github/workflows/ci.yml) runs the same two commands on Node 22.15/24 × Python 3.9/3.12 with a full checkout (the 14 GLB assets are regular git objects and are really read by the model tests).
The first ur_connect to a robot has an ~20 s RTDE readiness wait; on timeout it returns a clear error rather than hanging.
Host compatibility
The compatibility boundary lives in package.json → peerDependencies, and npm run check:host:strict validates it against the host's real schema DSL — not just version numbers: it actually registers all 104 tools into the host, drives the twin routes and checks the dsh.client declaration.
- Stable releases:
>=0.1.2-rc.1 <0.3.0 (@deepseek-ai/dsh / dsh-tools), >=4.0.2 <5.0.0 (cordis), >=3.18.2 <4.0.0 (schemastery) — any stable release in those ranges is covered without touching the declaration.
- Pre-releases: semver only matches a pre-release when the range explicitly names that same
major.minor.patch. Each pre-release triple therefore needs its own clause (e.g. >=0.2.2-alpha.2 <0.2.3). When the host publishes a pre-release with a new patch number (say 0.2.3-alpha.1), check:host:strict fails and prints the exact clause to append.
- Historical matrix:
scripts/check-host-compat.mjs pins a set of historical/current host versions and re-checks on every run that the declaration still covers them — narrowing the range turns this red immediately, so adopting a new host can't silently drop an old one.
- Old config and old data: every cordis config field has a default, so a profile left with an older config (even just
pythonBin) still parses and takes the defaults; pose files carry their own version, the audit ledger is an append-only NDJSON, and program files are plain .urp — none of them need a migration.
How it works
DSH model @deepseek-ai/dsh-tools python/ur_worker.py UR robot
│ ur_movej(...) │ │ │
├────────────────► ctx.tools.register(defineTool) │
│ │ lib/worker.js │ │
│ ├── spawn(ur_worker.py) ──────►│ import URBasic │
│ │ {id,op,params} ───────────►│ RTDE+Dashboard+RTC │
│ │ ◄── {message,data} ─────────┤ run each op │
│ ◄── text ───────┤ │ │
python/ur_worker.py — a persistent stdio JSON worker. It keeps ROBOTS / ROBOT_MODELS dicts, connects by IP, and runs one command per call. Motion commands use (bounded) "arrival confirmation" polling and return a structured error on failure.
lib/worker.js — lazily starts the worker once and keeps it alive, matching line-delimited JSON requests/responses, with timeout and cancel (forwards exec.signal).
lib/index.js — the Cordis plugin that imports defineTool to register the above as tools; inject: ['tools'].
Tool naming & examples
Tool names are stable as ur_*. Example prompts:
Connect to 192.168.1.199 and read the current TCP pose.
ur_connect(ip="192.168.1.199")
ur_get_tcp_pose(ip="192.168.1.199")
Lower the TCP of 192.168.1.199 by 20 mm along the Z axis.
ur_get_tcp_pose(ip="192.168.1.199")
ur_move_z(ip="192.168.1.199", distance=-0.02)
Draw a circle of radius 50 mm (vertical plane).
ur_draw_circle(ip="192.168.1.199", center=[0.3,-0.2,0.4,0,3.14,0], r=0.05)
Notes / limitations
- Connection persistence — the worker process lives for the plugin's lifetime and keeps robot connections by IP; after a worker crash or a DSH restart you must
connect again.
- Remote control mode — some UR robots must be in "remote control" before they execute motion/program commands, and
ur_connect reports that state. CB3 robots running URSoftware 3.1 through 3.20 already allow remote control by default; a remote_control: false reading still means the controller is currently not in remote mode — in local/teach-pendant mode URScript and motion commands are silently discarded, so switch the pendant to remote control. Other firmware (e-Series, or CB3 outside that range) needs Remote Control enabled in PolyScope first. An unreadable state is reported as unknown rather than pretended to be false.
- Reads you cannot trust are reported as such — a state-setting command that has no read-back channel answers
verified: false; a controller that refuses the RTDE session is reported as not running instead of "connected"; a twin frame that could not be refreshed is flagged stale.
- Local file access —
ur_upload_program reads a local file and ur_download_program writes one, so both are fenced to programDir (<DSH_HOME>/nonead-ur/programs by default; change it with the programDir config). The host resolves the path after realpath (so a junction/symlink cannot smuggle it out), checks .urp + maxProgramBytes (1 MiB), and only then injects the absolute _local_path / _remote_path into the worker payload; the model cannot set those underscore-prefixed keys itself (sanitizeArgs drops anything not declared by the schema) and the worker refuses a _local_path that is not absolute. An empty programDir falls back to the default directory; a missing or unusable directory fails closed — nothing local is read or written. This is the same class of exposure as handing the model a shell, which is exactly why it is fenced rather than trusted.
- SSH credentials are redacted in approval and audit —
password / passphrase / token / secret / known_hosts values are replaced with *** in the approval prompt and in the NDJSON audit ledger, so an operator confirming an upload/download does not leak the password into the UI or . The worker still receives the real values it needs for SFTP.
Reference implementation
This plugin shares the same ancestry as Nonead's Nonead-Universal-Robots-MCP (GitHub: https://github.com/nonead/Nonead-Universal-Robots-MCP), reusing its URBasic library and robot-control logic, re-encapsulated by the same team for DeepSeek Harness as this plugin's ur_* tools and stdio worker protocol. URBasic is MIT (© Tony Ke & Anthony Zhuang / Universal Robots, 2009-2025).
Third-party assets & licensing
Beyond its own code, this package distributes 14 robot meshes under assets/models/*.glb (≈21 MB) plus assets/kinematics.json, all derived from Universal Robots' Universal_Robots_ROS2_Description (branch humble, fetched 2026-09-21).
Two licence regimes apply, split by model, and they are never mixed within a model:
- BSD-3-Clause (9 models):
ur3, ur5, ur10, ur3e, ur5e, ur7e, ur10e, ur12e, ur16e — this covers the meshes and the kinematic / joint-limit configuration that assets/kinematics.json is derived from.
- UR "Graphical Documentation" terms (5 models):
ur8long, ur15, ur18, ur20, ur30. These meshes are not BSD-3-Clause; they are UR "Graphical Documentation" and their use is governed by UR's Terms and Conditions for use of Graphical Documentation, which is not an OSI open-source licence but does allow use, modification and sharing under certain restrictions. Questions: legal@universal-robots.com.
The client-side 3D renderer builds on three.js (MIT); esbuild (MIT) is used only at build time. The published bundle lib/client.js embeds three.js code (esbuild bundles it into the client half), so the three.js MIT notice must travel with the bundle.
See THIRD_PARTY_NOTICES.md for the per-model derivation list, the conversion parameters and the licence texts. That file is now included in the npm package (files allow-list in package.json), and it carries the full MIT text for the vendored URBasic and for the three.js code embedded in lib/client.js.
License
This project adopts a User-Segmented Dual Licensing model:
- AGPLv3 for individual users and organizations with ≤3 people (open source; see https://www.gnu.org/licenses/agpl-3.0.html).
- Commercial license (required) for organizations with >3 people, or for anyone who needs to avoid the AGPLv3 source-disclosure obligation (e.g. SaaS / closed distribution).
See LICENSE for the full agreement. For a commercial license, contact service@nonead.com.
python/URBasic (vendored) remains under its own MIT License (© Anthony Zhuang / Universal Robots, 2009-2025).