back
loading skill details...
Control 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.
---
name: rhinoclaw
version: 0.7.5
description: Control 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.
---
# RhinoClaw Skill
Direct 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 `preflight` first.** One call returns the complete
> connection/auth state and an exact `next_action`. Only call other tools when
> `data.auth == "ready"`. Then read § "Decision Tree" to pick the cheapest tool.
---
## 0. Preflight & error-code playbook (read before touching auth)
**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:
- **Auth errors are configuration errors, not transient** — never loop-retry them.
- The plugin reads `RHINOCLAW_AUTH_TOKEN` **only at start** — changed the env after
Rhino was running? Restart Rhino (or the RhinoClaw server).
- One source of truth: `~/projects/RhinoClaw`. The deployed skill at
`~/clawd/skills/rhinoclaw` is generated by `scripts/sync-skill.sh` — if `preflight`
or `hello` look stale, re-sync.
---
## Decision Tree — pick the cheapest tool that works
```
┌──────────────────────────────────┐
│ 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:**
1. **Typed Python helper** in `scripts/rhinoclaw_client/<module>.py`
(`geometry.create_box`, `booleans.union`, `transforms.copy`, …).
Validated, undo-aware, never executes arbitrary code.
2. **`batch_operations`** when you need ≥2 steps in one atomic shot
(rollback / abort / continue / best-effort policies).
3. **`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.
4. **RhinoCommon directly** via `script_exec.run_python`. Use when
`rhinoscriptsyntax` doesn't expose what you need. Cheatsheet at
`docs/rhinocommon-cookbook.md` in the dev repo.
5. **`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.
---
## Prerequisites
1. **Rhino 7/8** running on Windows (or macOS).
2. **RhinoClaw plugin** ≥ 0.2.8 installed.
3. **Plugin server started**:
- `mcpstart` for local-only AI clients (Cursor / Claude Desktop).
- `tcpstart` for remote / WSL / raw-TCP clients.
4. **Auth token** (recommended for tcpstart): set `RHINOCLAW_AUTH_TOKEN`
on **both** the Rhino host and the client side. See SECURITY.md and
`scripts/setup-auth-token.ps1` in the repo.
---
## Configuration
`config.json` next to the scripts:
```json
{
"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.
---
## Quick Test — run `preflight` first
```bash
cd ~/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.
### TCP = MCP parity — what raw TCP can do
The raw client reaches **every plugin command** via the generic
`send_command(cmd_type, params)` — the same capabilities as the MCP server:
```python
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).
---
## Cookbook — common workflows
Five concrete recipes. Adapt freely; always prefer typed helpers over
inline Python where possible.
### Recipe 1 — "Create a layered box with a material" (atomic)
```python
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}}},
],
})
```
### Recipe 2 — "Find every object near origin and inspect relationships"
```python
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")
```
### Recipe 3 — "Boolean a hole through a wall, leave geometry on failure"
```python
# `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>"]}},
],
})
```
### Recipe 4 — "Run a Grasshopper definition with the file's own defaults"
```python
# 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},
})
```
### Recipe 5 — "Use a native Rhino command when nothing else fits"
```python
# 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"],
})
```
---
## RhinoCommon and rhinoscriptsyntax — when typed tools don't reach
When you need something no typed RhinoClaw tool covers, you have two
scripting paths via `script_exec.run_python`:
### Option A — `rhinoscriptsyntax` (start here)
Friendlier API, well documented, almost-Rhino-Python-Console-equivalent.
Find functions:
- Online: https://developer.rhino3d.com/guides/rhinopython/
- Indexed locally: `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
```python
client.send_command("execute_rhinoscript_python_code", {
"code": "import rhinoscriptsyntax as rs; print(rs.ObjectsByLayer('Walls'))",
})
```
### Option B — RhinoCommon directly
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:
1. Search https://developer.rhino3d.com/api/rhinocommon/?searchtext=<keyword>
2. Note the namespace — most common: `Rhino.Geometry`, `Rhino.DocObjects`,
`Rhino.Display`, `Rhino.Render`.
3. Write a tiny test via `execute_python3_code` BEFORE wiring into a long
pipeline.
Example (mesh from Brep):
```python
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))
""",
})
```
---
## Negative examples — what NOT to do
- ❌ `rs.command("_Move ...")` — interactive command, blocks the server.
Use the typed `move_object` / `transforms.move` instead, or
`run_native_command` for non-interactive scripts.
- ❌ Many separate `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.
- ❌ Guessing at GH parameter names. Always
`inspect_grasshopper_definition(only_player_inputs=True)` first.
- ❌ Acting on object IDs you didn't just receive. If unsure, call
`get_object_info` or `find_nearby` first — the user might have
deleted, locked, or hidden things since your last call.
---
## Scripts Reference
| 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) |
| `wsl_bridge.py` | WSL transport of last resort: one RhinoClaw call via a static `.ps1` over `\\wsl.localhost` (see below) |
---
## WSL transport bridge (`wsl_bridge.py`)
Field finding: from WSL, PowerShell **one-liners** passed as `-Command`
arguments get killed by a content-based process-start blockade (Defender/ASR;
command lines containing `TcpClient` + `GetStream` + `.Read` fail with
`execve EINVAL`). The path that works: a **static `.ps1` executed via
`-File` from a `\\wsl.localhost` UNC path, payload as a file**. The bridge
packages exactly that — one call, one command:
```bash
python3 wsl_bridge.py '{"type": "ping", "params": {}}'
python3 wsl_bridge.py get_document_info
python3 wsl_bridge.py list_block_definitions '{"name_filter": "glutz"}'
```
How it works: per call it writes a temp dir under
`~/.cache/rhinoclaw/wsl_bridge/` containing a **byte-identical** `bridge.ps1`
(static content → the blockade's verdict is cacheable), `config.json`
(host/port/timeout) and `request.json` (raw command JSON), then runs
`powershell.exe -NoProfile -ExecutionPolicy Bypass -File <UNC>`. The script
runs on the Windows side — where `127.0.0.1:1999` is local, so the
per-session WSL host-IP dance does not apply — and writes `response.json`,
which the bridge prints.
Auth: the token is read **only** from `$env:RHINOCLAW_AUTH_TOKEN` (Windows
env; a WSL-side value is forwarded via `WSLENV`). It is never written to any
file or output. Flags: `--host` (as seen FROM Windows — not the WSL-side
`RHINOCLAW_HOST` value), `--port`, `--timeout`, `--keep` (keep the temp dir
for debugging). Importable too: `from wsl_bridge import call_rhinoclaw`.
Prefer the direct TCP path (`rhino_client.py`) when it works; use the bridge
when direct TCP from WSL is unavailable or the one-liner blockade strikes.
---
## Configuration files
- `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.
---
## Troubleshooting
| 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`. |
---
## Reference
- **Plugin source / dev repo:** https://github.com/McMuff86/rhinoclaw
- **End-user release repo:** https://github.com/McMuff86/rhinoclaw-release
- **Issues:** https://github.com/McMuff86/rhinoclaw/issues
- **RhinoCommon API:** https://developer.rhino3d.com/api/rhinocommon/
- **Rhino.Python guide:** https://developer.rhino3d.com/guides/rhinopython/
- **rhinoscriptsyntax index:** https://developer.rhino3d.com/api/RhinoScriptSyntax/
don't have the plugin yet? install it then click "run inline in claude" again.