Install
Inside DeepSeek Harness, with dsh-market
dsh plugin --profile web add dshmarket
Or from the command line
dsh plugin --profile web add github:NoneadChina/dsh-nonead-universal-robots
Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.
README
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.)
This plugin shares the same ancestry as the company's Nonead-Universal-Robots-MCP (see "Reference implementation" below). It exposes the "control a UR robot with AI" capability as native DSH tools: the plugin starts a persistent Python worker (which internally reuses the vendored URBasic library), and tools talk to the robot through that worker. After a single connect, you can keep issuing commands to the same IP.
⚠️ 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/obstacles. Treat it with the same care as granting the
bashtool. You (or the model) bear full responsibility for any motion command.
🛡️ Motion approval gate: commands that physically move the arm, run a program, take the arm out of program control, or remove its rigidity —
ur_movej/ur_movel/ur_movep/ur_movec/ur_servoj/ur_move_optimized/ur_move_x|y|z/ur_move_tool_x|y|z/ur_draw_*/ur_load_program/ur_run_program/ur_send_script/ur_reset_error; (0.5.0)ur_set_freedrive/ur_set_teach_mode/ur_power_on/ur_power_off/ur_brake_release/ur_unlock_protective_stop/ur_shutdown/ur_zero_ftsensor/ur_set_conveyor_tracking; (0.6.0)ur_force_mode/ur_end_force_mode/ur_force_mode_settings/ur_speedj/ur_speedl/ur_stopj/ur_stopl/ur_set_payload_inertia, plusur_motion_version,ur_set_payloadandur_set_gravity— pause and wait for human confirmation before being sent to the robot. In an interactive deployment (approval policyask) a confirmation dialog is shown in the UI; a call made without an approval service, without an agent, or answered with anything other thanallowed-oncefails closed (the command is rejected) and never moves without approval. The default lives in code (config.requireApprovalForMotion ?? true), so it cannot be turned off by a caller that skips schema parsing. SetrequireApprovalForMotion: falseto disable this gate deliberately.test/approval-gate.test.mjsdrives the real tool registry and proves all of the above, including that an extraopargument cannot re-route a call.
Feature overview
83 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 |
| Pose | ur_get_tcp_pose / ur_get_joint_pose |
Read current TCP pose / joint angles |
| Targets | ur_get_target_values |
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 (added to the receive recipe in 0.6.0), 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" |
| 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 remote_control field) / serial / uptime / software version / safety mode / safety and robot status bits (which safety function fired, incl. violation / fault; numeric limits 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 / ur_list_programs |
Program state, load, run, stop, pause, SSH listing (list_programs works with the real robot's /programs and URSim ~/URSim_Linux-*/programs.*; load/run accept full/URSim paths and programs_dir). Run/stop/pause now check the controller's reply and report the run state, instead of treating "could not understand" as success |
| Registers | ur_get_int_register / ur_get_double_register / ur_get_bit_register |
Read Int / Double / Bool registers |
| Health | ur_ping |
Check worker/Python/URBasic readiness without a robot |
| I/O | ur_get_digital_in / ur_set_digital_out / ur_get_digital_in_bits / ur_get_digital_out_bits / ur_get_analog_in / ur_set_analog_out / ur_get_tool_analog_in |
Digital in/out (incl. bit-mask reads, plus which="tool" for the tool-flange digital I/O), standard analog in/out (in engineering units: URScript's set_analog_out takes a relative level [0,1], and the old code sent 5 as full scale; full_scale may be 20 for a current-domain port), tool analog in (function name confirmed against the bundled official manuals; argument semantics not verified on hardware) |
| Tool config | ur_set_tool_voltage / ur_set_tcp / ur_set_payload / ur_set_payload_inertia / ur_set_gravity / ur_zero_ftsensor / ur_set_tool_output_mode / ur_set_tool_communication |
Tool voltage (0/12/24 — the old implementation called a NotImplementedError stub and had never once succeeded), TCP, payload mass/centre of gravity, mass + CoG + inertia matrix in one call (set_target_payload, 5.10+; avoids the set_payload behaviour that resets the inertia matrix and leaves the three parameters inconsistent), gravity direction (for non-horizontal mounting), force/torque sensor zeroing, tool output mode (normal / power dual-pin), tool serial (TCI/RS-485 — ⚠️ enabling it disables the tool analog inputs) |
| 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 |
Freedrive / teach mode (move the arm by hand), power on / off / brake release (⚠️ the arm may fall under gravity), protective-stop unlock only (no power-on, no brake release — that is what distinguishes it from ur_reset_error), controller shutdown |
| Force control | ur_force_mode / ur_end_force_mode / ur_force_mode_settings |
Force Mode: the arm becomes compliant along/about the selected axes and keeps applying the requested force/torque. Arguments match the manual one by one — task_frame / selection_vector (1 = compliant) / wrench / type (1-3) / limits (compliant axes = max TCP speed, stiff axes = max deviation) / damping / gain_scaling. The script inserts the manual's recommended sleep(0.02) before entering force mode; exit with ur_end_force_mode. ⚠️ damping/gain_scaling cannot be read back from the controller, so the tool reports what it set rather than pretending to read the current value |
| Velocity control | ur_speedj / ur_speedl / ur_stopj / ur_stopl / ur_wait_steady |
Joint/TCP velocity commands (speedj/speedl) and the matching decelerations (stopj/stopl). ⚠️ Velocity commands are open-ended: with t=0 (the default) the function returns once the target speed is reached while the arm keeps moving — always finish with a stop* or ur_wait_steady. ur_wait_steady polls the RTDE speed fields instead of using URScript's is_steady() (which is documented to return false in force/teach mode) |
| Live telemetry | ur_get_runtime_telemetry / ur_get_robot_voltage / ur_get_robot_current / ur_get_joint_temperatures / ur_get_speed_scaling / ur_get_tcp_force / ur_get_tool_telemetry |
Joint currents/voltages/speeds, TCP speed and wrench, tool accelerometer, speed scaling, robot voltage/current, joint temperatures, tool current/voltage, I/O current. These fields are already in the 500 Hz RTDE stream, so reading them is free and needs no reconfiguration. Single-value reads and the one-shot ur_get_runtime_telemetry summary coexist: poll a single value when that is all you need, use the summary for diagnosis |
| Conveyor | ur_get_conveyor / ur_set_conveyor_tick / ur_set_conveyor_tracking |
Conveyor tick read / set, and starting/stopping linear or circular tracking |
| 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 |
Joint-space / linear / path / genuine circular motion (the old implementation hardcoded movetype='p' internally, so it actually sent movep and discarded the via point; 0.6.0 adds the manual's mode argument: 0 = interpolate orientation, 1 = fixed orientation) / continuous servo / OptiMove (optimovej/optimovel: jerk-limited, smoother, less vibration — ⚠️ its a/v are fractions of capability in (0,1], not rad/s or m/s) / axis-aligned motion — the _move_* tools move along base axes, the _move_tool_* ones along the current tool axes (converted in the worker with a rotation matrix, so it does not depend on URScript pose_trans). The default a/v are the manual's again (movej 1.4 / 1.05, movel 1.2 / 0.25; the old code defaulted movel to v=1 m/s, four times the manual value) |
| Motion planning | ur_motion_version / ur_get_freedrive_status |
Set the Motion Version (manual chapter 14) and the jerk gain (0.01-1.0, which only affects jerk-limited profiles: version-2 movej/movel and optimovej/optimovel). Version 2 clamps velocities/accelerations to the hardware limits while planning and shrinks blend radii dynamically instead of skipping the whole move with an "Overlapping Blends" warning as version 1 does. ⚠️ Newer robot models and PolyScope X support only version 2; CB3 has no such setting. ⚠️ Neither setting has a read-back channel, so the tool reports what it set. ur_get_freedrive_status returns how close the current pose is to a singularity during freedrive (0 normal / 1 near / 2 too close — not an on/off flag), which is what tells an operator to pick another path |
| Drawing | ur_draw_circle / ur_draw_square / ur_draw_rectangle / ur_draw_star |
Draw a circle / square / rectangle / pentagram. "Completed" is only reported after the script was actually observed running — the old code probed immediately after sending, inevitably saw "not running", and reported success whether or not the script ever executed |
| Script / emergency | ur_send_script / ur_reset_error |
Send URScript (execution-verified: sentinels are injected inside the function body / between top-level statements and read back, so "sent" never masquerades as "ran"; with several functions and no visible call site it refuses to verify and returns verified: null) / reset errors |
Every tool except connect takes an ip argument and requires that IP to be connected first.
📚 0.6.0 manual alignment: this release checked signatures, defaults, ranges and deprecated functions against the three official manuals bundled in
ScriptManual/(URSoftware 3.15.4 / PolyScope 5 / PolyScope X); the analysis is indocs/urscript-manual-analysis.md. Three findings changed behaviour: ①movel/movejdefaults are the manual's again; ②ur_force_modestates plainly thatdamping/gain_scalingcannot be read back; ③set_target_payloadis only used when an inertia matrix is supplied (older firmware falls back toset_payload_mass+set_payload_cog).
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 polls a single state source, so it 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 polls 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.
Host-side routes (all fenced to loopback callers):
| Route | Description |
|---|---|
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 (it used to render one "not connected" line for all of them).
Install into a DSH profile
Option 1 — install as a bundle (manual)
Add the plugin as a dependency in your target profile's
package.json:// C:\Users\<you>\.dsh\profiles\web\package.json { "dependencies": { "dsh-nonead-universal-robots": "^0.6.5" }, "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 web install # or cd "$DSH_HOME/profiles/web" && pnpm installRestart DSH. The plugin's
cordis.patch.ymlautomatically inserts the plugin line into the config tree, and the tools appear in the model's tool list asur_*.
Option 2 — local path (development)
Add the repository path to the profile dependency (pnpm supports file:) to iterate in this repository:
"dependencies": {
"dsh-nonead-universal-robots": "file:D:/MyProgram/GitLab/dsh-Nonead-Universal-Robots/dsh-nonead-universal-robots"
}
Python runtime
The plugin needs a usable Python and a few dependencies (numpy, paramiko) to start the worker. Point it at an interpreter with the pythonBin config:
pip install -r requirements.txt
pythonBin (default python), commandTimeoutMs (motion/script timeout, default 60000), and connectTimeoutMs (first-connect timeout, default 30000) in cordis.patch.yml can all be overridden per deployment. If python is not on PATH, use an absolute path, e.g. "C:\\Python312\\python.exe" or "/usr/bin/python3".
⚠️ The dependencies must be installed for the interpreter the worker actually uses (a real trap)
lib/worker.jspointsPYTHONPATHat the plugin'spython/directory, and CPython importssitecustomize.pyfrom there at startup if it exists. This repository once carried a machine-localpython/sitecustomize.pythat hardcoded a user-levelsite-packagespath intosys.path— so it worked on that one machine, while on any other machine, or from a published install (the file is not in thefileslist),import numpyfails outright and every tool call dies. The correct setup is to install the dependencies for the interpreterpythonBinpoints at (or point it at a venv).ur_ping/npm run test:pythonis the self-check for that chain, andnpm run verify:hostvalidates tool registration through the host's real schema DSL (it should report 83/83).
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 # run all 22 test files AND every check gate (see below), then summarise
npm run test:node # Node-side only: skips 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:
- 22 test files under
test/(e2e protocol self-check, approval gate, twin routes, client state machine, FK, vendored-library regressions) — enumerated fromtest/test-manifest.json, whichcheck:manifestkeeps honest so a Python-using file can never be silently unlisted. - 10 check gates:
check-test-manifest/check-package-metadata/check-client-bundle/check-doc-tools(Node) andcheck-worker-ops/check-tool-params/check-rtde-recipe/check-approval-gate/check-new-ops(Python). When the interpreter has nonumpy, the Python-dependent items are reported as SKIP with the reason, never as a pass.
New in 0.6.0:
scripts/pdf-extract2.pyextracts text fromScriptManual/*.pdfintoScriptManual/txt/*.txtusing only the standard library (the PolyScope manuals encode glyph ids, so the text only comes out through each font'sToUnicodeCMap). It is the reproducible source behind every signature, default and range quoted in this release — when in doubt, re-extract and read the manual.
check-client-bundle.mjsrebuildssrc/client/**into a scratch file and compares the result with the committedlib/client.jsbyte for byte, so a stale bundle (source changed UI, shipped artifact did not) fails the build instead of silently shipping. It never overwrites the committed artifact;scripts/build-client.mjshonoursBUILD_CLIENT_OUTfor that reason.
npm test prints a per-file summary and exits non-zero if anything failed. If python is not on PATH, set it via the environment:
UR_PYTHON=C:\\Python312\\python.exe npm test
Before 0.5.0
npm testran one worker ping and none of the other 20-plus*.test.mjsfiles — so a greennpm testsaid nothing about the plugin.scripts/run-tests.mjsnow enumerates and runs them all, plus every check gate, and summarises the result.
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 # auto-discovers a real DSH installation
node scripts/check-host-compat.mjs <node_modules> # or an explicit host
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. Against DSH 0.1.7-rc.2 it reports 83/83 tools registered.
The first
ur_connectto a robot has an ~20s RTDE readiness wait; on timeout it returns a clear error rather than hanging.
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 keepsROBOTS/ROBOT_MODELSdicts, 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 (forwardsexec.signal).lib/index.js— the Cordis plugin that importsdefineToolto 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 20mm 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 50mm (vertical plane).
ur_draw_circle(ip="192.168.1.199", center=[0.3,-0.2,0.4,0,3.14,0], r=0.05)
Reference implementation
- Plugin repository: https://github.com/NoneadChina/dsh-nonead-universal-robots
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).
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
connectagain. - Remote control mode — some UR robots must be in "remote control" before they execute motion/program commands, and
ur_connectreports that state. CB3 robots running URSoftware 3.1 through 3.20 already allow remote control by default (nothing to enable at the settings level); aremote_control: falsereading 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.ur_statusalso reportsremote_control_raw, and an unreadable state is reported as unknown rather than pretended to befalse. - Tool digital I/O —
ur_get_digital_in/ur_set_digital_outwithwhich="tool"control the tool-flange digital I/O. Tool digital signals are not carried by RTDE, so reading a tool input runs a short URScript program (viaSendProgram) that may interrupt a running program; writing a tool output sends the URScriptwrite_tool_digital_outcommand. Tool digital output read-back is not supported (no reliableread_tool_digital_outexpression). - Joint current — the current RTDE recipe does not expose per-joint current directly, so
ur_get_joint_currentis not provided (the reference implementation's version of this tool has a value bug; this implementation does not carry it over). - Threading / cancel — motion commands poll until arrival within
commandTimeoutMs; for long trajectories, remind the model in the prompt to set a reasonable timeout or split the motion into steps. - Not a safety boundary — this plugin is on par with the
bashtool and can drive physical equipment; test thoroughly on a real robot before production use. - Package size — the published package is about 35 MB, almost entirely the 14
assets/models/*.glbmeshes; the plugin's own code adds only a few hundred KB. If size matters for your deployment, regenerate a subset withpython scripts/convert-meshes.py --only <model…>. - Unknown models fall back — a robot whose model has no bundled mesh (unknown or customized model string) is rendered with approximate geometry instead of failing; the twin never errors out on an unknown model.
- Asset pipeline — the meshes are generated by
python scripts/convert-meshes.pyand their structure contract (7 named nodes per GLB, embedded textures) is validated bypython scripts/verify-models.py(also exposed asnpm run verify:models).
Third-party assets & licensing
Beyond its own code, this package distributes 14 robot meshes under assets/models/*.glb (≈35 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 thatassets/kinematics.jsonis 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.
See THIRD_PARTY_NOTICES.md for the per-model derivation list, the conversion parameters and the licence texts.
License
This project adopts a User-Segmented Dual Licensing model:
- AGPLv3 for individual users and organizations with ≤10 people (open source; see https://www.gnu.org/licenses/agpl-3.0.html).
- Commercial license (required) for organizations with >10 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).
Comments
Comments live in GitHub Discussions. Sign in with GitHub to post or react.