Export Houdini nodes as Markdown for LLM

• 5 min read

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

ButtonWhat it does
Save as .mdOpens file browser, defaults to your $HIP folder
Copy to ClipboardCopies 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()