Export Houdini nodes as Markdown for LLM
When debugging a complex network with an LLM, copying VEX snippets one by one is slow. This shelf tool selects any nodes and exports everything — code, connections, hierarchy — as a single Markdown block you can paste straight into Claude or ChatGPT.
What it exports
For each selected node:
- Node name, type, full path
- Wrangle mode (Points / Detail / Primitive / Vertices)
- Input and output connections
- Key non-default parameters
- VEX and Python code
- Connection chain per network
Selecting a subnet, solver, or HDA pulls in all children recursively. One click on your top-level HDA dumps the entire internal network.
Two output modes
| Button | What it does |
|---|---|
| Save as .md | Opens file browser, defaults to your $HIP folder |
| Copy to Clipboard | Copies full Markdown — paste straight into Claude / ChatGPT |
Example output
## Network: `/obj/geo1/Rolling1/solver/d/s`
**Connections:**
- `Prev_Frame` → `PieceStats` → `switch1` → `Rolling` → `output0`
### `reproject_Y`
- **Type:** `attribwrangle`
- **Mode:** Points
- **Inputs:** `attribpromote4`
` ` `vex
f@project_up = -getbbox_min(0)[1];
@P.y += f@project_up;
` ` `
Then ask: “debug this” or “explain what this network does”.
Setup
1. Right-click any shelf → New Tool
2. Options tab:
- Label:
Node to MD - Icon:
MISC_python
3. Script tab — paste the script below
4. Click Accept — done.
The script
import hou
import datetime
# ---------------------------------------------------------------------------
# Parameters considered "key" to always export (beyond VEX/Python)
# ---------------------------------------------------------------------------
KEY_PARAMS = {
"attribwrangle": ["class", "grouptype", "group"],
"iswrangle": ["class", "grouptype"],
"attribvop": ["class", "grouptype"],
"object_merge": ["objpath1", "xformtype", "objpath2", "objpath3"],
"switch": ["input"],
"null": [],
"output": [],
}
VEX_PARM_NAMES = ["snippet", "vex_code", "vexcode", "code"]
PY_PARM_NAMES = ["python", "pyscript", "script"]
WRANGLE_CLASS = {0: "Detail", 1: "Primitive", 2: "Points", 3: "Vertices"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parm_val(node, name):
p = node.parm(name)
return p.eval() if p else None
def _get_code(node, parm_names):
for n in parm_names:
v = _parm_val(node, n)
if v and str(v).strip():
return str(v)
return None
def _wrangle_class(node):
v = _parm_val(node, "class")
if v is not None:
return WRANGLE_CLASS.get(int(v), str(v))
return None
def _key_params(node):
result = {}
type_name = node.type().name()
names = KEY_PARAMS.get(type_name, [])
for n in names:
p = node.parm(n)
if p and not p.isAtDefault():
try:
result[n] = p.eval()
except Exception:
result[n] = p.unexpandedString()
return result
def _input_names(node):
names = []
for i, inp in enumerate(node.inputs()):
names.append(inp.name() if inp else f"[{i}] unconnected")
return names
def _output_names(node):
return [o.name() for o in node.outputs()]
# ---------------------------------------------------------------------------
# Node collection (recursive into children)
# ---------------------------------------------------------------------------
def collect_with_children(nodes):
visited = set()
ordered = []
def _walk(n):
if n.path() in visited:
return
visited.add(n.path())
ordered.append(n)
try:
for child in n.children():
_walk(child)
except Exception:
pass
for n in nodes:
_walk(n)
return ordered
# ---------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------
def _format_node(node, header_level=3):
h = "#" * header_level
lines = []
lines.append(f"{h} `{node.name()}`")
lines.append(f"- **Type:** `{node.type().name()}`")
lines.append(f"- **Path:** `{node.path()}`")
cls = _wrangle_class(node)
if cls:
lines.append(f"- **Mode:** {cls}")
ins = _input_names(node)
if ins:
lines.append(f"- **Inputs:** {', '.join(f'`{i}`' for i in ins)}")
outs = _output_names(node)
if outs:
lines.append(f"- **Outputs:** {', '.join(f'`{o}`' for o in outs)}")
kp = _key_params(node)
if kp:
for k, v in kp.items():
lines.append(f"- **{k}:** `{v}`")
lines.append("")
vex = _get_code(node, VEX_PARM_NAMES)
if vex:
lines.append("```vex")
lines.append(vex.rstrip())
lines.append("```")
lines.append("")
if not vex:
py = _get_code(node, PY_PARM_NAMES)
if py:
lines.append("```python")
lines.append(py.rstrip())
lines.append("```")
lines.append("")
return "\n".join(lines)
def _connection_map(nodes):
node_set = {n.path() for n in nodes}
chains = []
for node in nodes:
ins = [i for i in node.inputs() if i and i.path() in node_set]
if not ins:
chain = [node.name()]
cur = node
while True:
nexts = [o for o in cur.outputs() if o.path() in node_set]
if len(nexts) == 1:
chain.append(nexts[0].name())
cur = nexts[0]
else:
break
if len(chain) > 1:
chains.append(" ".join(f"`{n}`" for n in chain))
return chains
def build_markdown(selected_nodes):
all_nodes = collect_with_children(selected_nodes)
groups = {}
for node in all_nodes:
parent = node.parent().path() if node.parent() else "/"
groups.setdefault(parent, []).append(node)
lines = []
lines.append("# Houdini Node Export")
lines.append(f"**Date:** {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}")
lines.append(f"**Total nodes:** {len(all_nodes)}")
lines.append("")
lines.append("---")
lines.append("")
for parent_path, group in groups.items():
lines.append(f"## Network: `{parent_path}`")
lines.append("")
chains = _connection_map(group)
if chains:
lines.append("**Connections:**")
for c in chains:
lines.append(f"- {c}")
lines.append("")
for node in group:
lines.append(_format_node(node, header_level=3))
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------
def _save_to_file(text):
try:
default = hou.expandString("$HIP/node_export.md")
except Exception:
default = "node_export.md"
path = hou.ui.selectFile(
title="Save Node Export as Markdown",
collapse_sequences=False,
file_type=hou.fileType.Any,
pattern="*.md",
default_value=default,
chooser_mode=hou.fileChooserMode.Write,
)
if not path:
return False
path = hou.expandString(path)
if not path.lower().endswith(".md"):
path += ".md"
with open(path, "w", encoding="utf-8") as f:
f.write(text)
return path
def _copy_to_clipboard(text):
try:
hou.ui.copyTextToClipboard(text)
return True
except AttributeError:
pass
import subprocess
proc = subprocess.Popen(["clip"], stdin=subprocess.PIPE)
proc.communicate(input=text.encode("utf-8"))
return proc.returncode == 0
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
selected = hou.selectedNodes()
if not selected:
hou.ui.displayMessage(
"No nodes selected.\nSelect one or more nodes (subnets and solvers "
"will include all children automatically).",
title="Node Exporter",
severity=hou.severityType.Warning,
)
return
md = build_markdown(selected)
node_count = len(collect_with_children(selected))
choice = hou.ui.displayMessage(
f"Ready to export {node_count} node(s) as Markdown.\n\n"
"Subnets and solvers include all children.\n"
"VEX snippets and Python code are included.",
title="Houdini Node Exporter — LLM",
buttons=("Save as .md file", "Copy to Clipboard", "Cancel"),
)
if choice == 0:
result = _save_to_file(md)
if result:
hou.ui.displayMessage(f"Saved to:\n{result}", title="Exported")
elif choice == 1:
ok = _copy_to_clipboard(md)
if ok:
hou.ui.displayMessage(
f"Copied {len(md):,} characters to clipboard.",
title="Exported",
)
else:
hou.ui.displayMessage("Clipboard copy failed.", title="Error",
severity=hou.severityType.Error)
main()