Install
openclaw skills install @mcmuff86/rhinoclawControl Rhino 3D via the RhinoClaw plugin over TCP. Use whenever you need to create, modify, query, measure, or render 3D geometry, manage layers/materials, run Boolean and transform ops, drive Grasshopper definitions, work with VisualARQ BIM objects, or batch multiple steps as one atomic operation. Requires Rhino 7/8 running with tcpstart (the plugin's command line server) on the Rhino host.
openclaw skills install @mcmuff86/rhinoclawDirect TCP control of Rhino 3D from an OpenClaw agent. Plugin is running on a Windows or macOS Rhino instance; this skill talks to it from WSL / Linux / wherever you live.
Step 0 — ALWAYS run
preflightfirst. One call returns the complete connection/auth state and an exactnext_action. Only call other tools whendata.auth == "ready". Then read § "Decision Tree" to pick the cheapest tool.
Never trial-and-error the connection. Run preflight (MCP) — or, on the raw
TCP path, hello then a single ping — and act on the result. hello is
auth-free: it reports the server's state even with no/wrong token and even
while blocked, without tripping the brute-force counter.
preflight.data.auth / error_code | Meaning | Do exactly this |
|---|---|---|
ready | Connected, auth ok | Proceed. |
missing_client_token / AUTH_REQUIRED (no token) | Plugin wants a token, client sends none | Set RHINOCLAW_AUTH_TOKEN on the client (same value as the plugin). Do NOT retry blindly. Using the deployed skill? Re-sync it: scripts/sync-skill.sh. |
token_mismatch / AUTH_REQUIRED (wrong token) | Both sides have a token, they differ | Make RHINOCLAW_AUTH_TOKEN identical on both sides, then restart Rhino (the plugin reads the token only at start). |
blocked / AUTH_BLOCKED | Too many failed auth attempts | Wait until blocked_until. Do NOT retry — retries extend the cooldown. |
not connected / CONNECTION_REFUSED | Server not running / wrong host:port | Open Rhino, run tcpstart (remote) or mcpstart (local). On WSL the Windows host IP changes per session — check RHINOCLAW_HOST. |
TIMEOUT | Command reached Rhino but blocked the UI thread / too slow | Command-specific — pass a larger timeout. Not an auth problem; do not re-auth. |
Rules of thumb:
RHINOCLAW_AUTH_TOKEN only at start — changed the env after
Rhino was running? Restart Rhino (or the RhinoClaw server).~/projects/RhinoClaw. The deployed skill at
~/clawd/skills/rhinoclaw is generated by scripts/sync-skill.sh — if preflight
or hello look stale, re-sync. ┌──────────────────────────────────┐
│ What do you need to do? │
└──────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
single op many ops at once inspect / measure
│ │ │
▼ ▼ ▼
typed module batch_operations scene_summary
(geometry, with rollback find_nearby
transforms, get_relationships
booleans, get_object_info
layers,
materials,
visualarq…)
Preference order — always try in this order, drop one level only when the previous can't express what you need:
scripts/rhinoclaw_client/<module>.py
(geometry.create_box, booleans.union, transforms.copy, …).
Validated, undo-aware, never executes arbitrary code.batch_operations when you need ≥2 steps in one atomic shot
(rollback / abort / continue / best-effort policies).script_exec.run_python with import rhinoscriptsyntax as rs
(rs.AddSphere, rs.LayerNames, …). Use when no typed helper
exists. RhinoScript is well documented and agent-friendly.script_exec.run_python. Use when
rhinoscriptsyntax doesn't expose what you need. Cheatsheet at
docs/rhinocommon-cookbook.md in the dev repo.run_native_command — fires RhinoApp.RunScript("_Loft …").
Last-resort hatch for commands that have no RhinoCommon equivalent
(or where the native one is much simpler). Allowlisted by the
plugin.If you don't know what tools exist, call list_capabilities once at
session start. It returns a categorised inventory of typed tools,
helpers, native-command allowlist, and RhinoCommon cookbook topics.
mcpstart for local-only AI clients (Cursor / Claude Desktop).tcpstart for remote / WSL / raw-TCP clients.RHINOCLAW_AUTH_TOKEN
on both the Rhino host and the client side. See SECURITY.md and
scripts/setup-auth-token.ps1 in the repo.config.json next to the scripts:
{
"connection": {
"host": "172.31.96.1",
"port": 1999,
"timeout": 15.0
}
}
All four fields can be overridden by env vars: RHINOCLAW_HOST,
RHINOCLAW_PORT, RHINOCLAW_TIMEOUT, RHINOCLAW_AUTH_TOKEN. The
rhino_client.py module attaches the auth token to every TCP command
automatically — agent never has to manage it manually.
preflight firstcd ~/clawd/skills/rhinoclaw/scripts
python3 rhino_client.py preflight # one call → connection/auth state + next_action
# → {"connected": true, "auth": "ready", "plugin_version": "0.5.0.0", ...}
Act only when "auth": "ready". Otherwise follow next_action exactly
(see §0). python3 rhino_client.py hello shows the raw auth-free handshake;
python3 rhino_client.py ping is the authenticated liveness check.
The raw client reaches every plugin command via the generic
send_command(cmd_type, params) — the same capabilities as the MCP server:
from rhino_client import RhinoClient
with RhinoClient() as c:
c.preflight() # connection/auth verdict
c.send_command("create_object", {"type": "BOX", "params": {...}})
c.send_command("build_and_bake_gh", {...}) # author + solve + bake a .gh
c.build_and_bake_recipe("box", "C:/tmp/b.gh", # native parametric recipe
params={"x": 40, "y": 20, "z": 10}, layer="Boxes")
c.build_and_bake_recipe("list") # list recipes
Client-side high-level helpers mirror the MCP tools: preflight(), hello(),
build_and_bake_recipe(). Everything else is a direct send_command (the
plugin dispatches it). Recipes live in the plugin, so MCP and TCP behave
identically. Requires a plugin build with these commands (0.5.0+ after rebuild).
Five concrete recipes. Adapt freely; always prefer typed helpers over inline Python where possible.
from rhino_client import RhinoClient
client = RhinoClient(); client.connect()
result = client.send_command("batch_operations", {
"name": "Layered box",
"on_error": "rollback",
"steps": [
{"tool": "create_layer",
"args": {"name": "Walls", "color": [180, 180, 180]}},
{"tool": "create_material",
"args": {"name": "Concrete", "color": [200, 200, 200]}},
{"tool": "assign_material_to_layer",
"args": {"layer_name": "Walls", "material_id": 0}},
{"tool": "create_object",
"args": {"type": "BOX", "layer": "Walls",
"params": {"width": 5, "length": 3, "height": 2.5}}},
],
})
nearby = client.send_command("find_nearby", {
"point": [0, 0, 0], "radius": 100, "layer": "Walls",
})
for wall in nearby["results"]:
relations = client.send_command("get_relationships", {
"object_id": wall["id"],
"touch_tolerance": 0.05,
})
print(wall["name"], "touches", len(relations["touching"]), "objects")
# `abort` policy: stop on first failure but keep completed steps. Lets
# the user inspect/fix manually instead of an automatic rollback.
client.send_command("batch_operations", {
"on_error": "abort",
"steps": [
{"tool": "create_object", "args": {"type": "CYLINDER", "params": {...}}},
{"tool": "boolean_operation",
"args": {"operation": "difference",
"primary_id": "<wall>", "secondary_ids": ["<cylinder>"]}},
],
})
# Inspect first to discover what the GH file actually wants — never guess.
info = client.send_command("inspect_grasshopper_definition", {
"file_path": r"C:\path\to\Door.gh",
"only_player_inputs": True,
})
print("Inputs:", [i["name"] for i in info["inputs"]])
# Run with the defaults the GH file already exposed:
client.send_command("run_grasshopper_interactive", {
"file_path": r"C:\path\to\Door.gh",
"inputs": {i["name"]: i.get("default")
for i in info["inputs"] if i.get("default") is not None},
})
# rhinoscriptsyntax has no clean Sweep1 wrapper — use the underlying
# native Rhino command. Plugin allowlists known-safe commands.
client.send_command("run_native_command", {
"command": "_Sweep1",
"args": ["_Pause", "_Pause", "_Enter"],
})
When you need something no typed RhinoClaw tool covers, you have two
scripting paths via script_exec.run_python:
rhinoscriptsyntax (start here)Friendlier API, well documented, almost-Rhino-Python-Console-equivalent. Find functions:
get_rhinoscript_python_function_names(["surface", "curve"])
returns matching function names.get_rhinoscript_python_code_guide("AddLoftSrf") returns the parameter
signature + example.Example: get all object IDs on a layer
client.send_command("execute_rhinoscript_python_code", {
"code": "import rhinoscriptsyntax as rs; print(rs.ObjectsByLayer('Walls'))",
})
Mächtiger, stricter API. Use the cheatsheet at
docs/rhinocommon-cookbook.md in the dev repo for hot-path patterns
(Brep ops, Curve math, Mesh conversion, Transform composition,
Intersection events, …).
When you need a function not in the cookbook:
Rhino.Geometry, Rhino.DocObjects,
Rhino.Display, Rhino.Render.execute_python3_code BEFORE wiring into a long
pipeline.Example (mesh from Brep):
client.send_command("execute_python3_code", {
"code": """
import scriptcontext as sc
import System
from Rhino.Geometry import Mesh, MeshingParameters
brep = sc.doc.Objects.Find(System.Guid.Parse('<id>')).Geometry
meshes = Mesh.CreateFromBrep(brep, MeshingParameters.Default)
print('mesh count:', len(meshes))
""",
})
rs.command("_Move ...") — interactive command, blocks the server.
Use the typed move_object / transforms.move instead, or
run_native_command for non-interactive scripts.create_object calls when batch_operations works.
TCP latency dominates; batching is 10× faster on Tailscale.execute_rhinoscript_python_code for a single primitive — that's
what the typed tools are for. Save scripting for when typed tools
can't express the operation.inspect_grasshopper_definition(only_player_inputs=True) first.get_object_info or find_nearby first — the user might have
deleted, locked, or hidden things since your last call.| Script | Purpose |
|---|---|
rhino_client.py | Base TCP client; auto-attached auth token + retry |
geometry.py | Primitives (box, sphere, cylinder, point, line, curve, …) |
transforms.py | Move, rotate, scale, copy, mirror, arrays |
booleans.py | Union, difference, intersection |
solids.py | Solid-specific ops (extrude, fillet edges, shell) |
surfaces.py | Loft, sweep, revolve, patches |
curves.py | Offset, fillet, chamfer, join, explode |
selection.py | Select / list by layer, type, name, IDs |
analysis.py | Object info, properties, bounding box, volume, distance |
scene.py | Document-level info, scene summary, find_nearby |
layers.py | Create / set / list / delete / lock / hide layers |
materials.py | PBR materials, assignments |
viewport.py | Views, camera, screenshots |
render.py | Lights, render settings, render-to-file |
groups.py | Create / ungroup / list groups |
objects.py | Generic object ops (delete, rename, recolor, layer change) |
text.py | Text, text dots, leaders, annotations |
files.py | Open / save / export / import |
grasshopper.py | Run / inspect / build / bake GH definitions |
script_exec.py | Inline Python (rhinoscriptsyntax + RhinoCommon) |
visualarq.py | VisualARQ BIM objects (walls / doors / windows / IFC) |
presets.py | Reusable parameter sets |
utils.py | Common helpers (logging, validation) |
config.json — runtime defaults (host, port, timeout, …).config/presets.yaml — domain presets (door types, wall thicknesses).config/templates.yaml — full pipeline templates.references/commands.md — quick command lookup.| Symptom | First check |
|---|---|
AUTH_REQUIRED | echo $RHINOCLAW_AUTH_TOKEN matches the value on the Windows side? Both sides restarted after setting the env var? |
Connection refused | tcpstart already typed in Rhino's command line? Plugin loaded? Try mcpstatus. |
Connection timed out | RHINOCLAW_HOST correct? On WSL the Windows host IP changes per session. ip route show | awk '/default/ {print $3}' from WSL gives current value. |
AddressAlreadyInUse (port 1999) | Old Rhino zombie. Get-NetTCPConnection -LocalPort 1999 in PowerShell on the host. |
| Tool returns success but nothing visible | Likely on a hidden layer or off-screen. Run scene_summary + zoom_extents. |
| Long script "hangs" | cancel_rhino_command() is the last resort. Default timeout is 15s; pass timeout=60 for long ones. Server-side max is RHINOCLAW_MAX_TIMEOUT=120. |