nodebpy — Blender node trees from Python
All node-tree work goes through nodebpy via the Blender MCP.
Runtime and repository scope
Never wire nodes with raw bpy links — nodebpy owns tree construction and linking.
Property edits on existing nodes with raw bpy is fine e.g.
- renaming a shader
Attribute node's attribute_name
- restyling a
ColorRamp's stops.
Node-group conventions
Prefer reusable node groups. When the logic can be reused or has more
than a trivial number of nodes, encapsulate it in a node group and expose
only the useful controls through group inputs.
Give exposed inputs reasonable defaults. Choose useful values, ranges,
and labels so the group works immediately after insertion. Keep defaults
close to the nodebpy interface declaration, for example:
scale = tree.inputs.float("Scale", 6.0, min_value=0.1, max_value=50.0)
color = tree.inputs.color("Color", (1.0, 0.0, 0.0, 1.0))
Hide node option buttons by default. After constructing a tree, hide the
option-button strip unless the user explicitly asks to show it:
for node in tree.tree.nodes:
node.show_options = False
This is a display property on the Blender nodes; using raw bpy for this
property edit is allowed. Apply it to the parent material/geometry tree as
well as to newly created group nodes when appropriate.
Set the node-group color tag to match the group’s purpose. Do not leave
it at NONE: use SHADER for shader groups, GEOMETRY for geometry groups,
TEXTURE for texture-oriented groups, VECTOR for vector utilities, and
the closest matching tag for other specialized groups. Set it on the real
node tree after construction, e.g. tree.tree.color_tag = "SHADER".
Keep node auto-arrange on. TreeBuilder/g.tree(...) default to
arrange="sugiyama", so a plain with g.tree("Name") as tree: auto-lays-out
the tree on exit — rely on that default and do not pass arrange=None.
arrange=None disables auto-layout and is only correct when you also supply an
explicit tree.node_positions = {...} map (as the nodes-to-code export does to
round-trip exact positions). For newly authored trees, never turn arranging off.
Workflow
Minimize Blender MCP latency
- use one call to read/export the active tree and one call to edit it.
- No verification of the edit result.
- Extra calls only after an error or when the initial read leaves the requested change ambiguous.
Do not add exploratory shell calls between MCP calls. Most observed latency
variance comes from MCP/session startup and Blender-side execution, not from
nodebpy tree construction.
Nodes to code first. Assume the node tree is already open and on screen for
the currently selected object. Export it to nodebpy code:
import bpy
from nodebpy.export import to_python
obj = bpy.context.active_object
tree = obj.modifiers.active.node_group # or the tree open in the editor
print(to_python(tree))
Read the generated code to understand the existing structure.
Edit as code. Modify the generated nodebpy code and re-run it to rebuild the
tree. Running the code creates a new node group — repoint the modifier to it
and remove the stale one (or delete the old group first to free the name).
Also check tree.animation_data before deciding to rebuild: keyframes on
node defaults (e.g. an animated Mix factor) live on the tree datablock and are
destroyed by a rebuild. If fcurves exist, don't rebuild — graft the change
into the existing tree instead.
New trees when needed. Add separate node groups with with g.tree("Name"):
when logic is reusable; they nest into other trees like any node.
Note:
with g.tree(...) as tree yields a TreeBuilder, not the underlying
bpy.types.NodeTree. Anything that needs a real ID datablock — assigning to a
modifier, bpy.data.node_groups lookups — needs the unwrapped tree:
modifier.node_group = tree raises TypeError: expected a NodeTree type, not TreeBuilder; use modifier.node_group = tree.tree instead.
g.tree() takes no fake_user kwarg. tree.fake_user = True on the builder
works. If you've already unwrapped via tree.tree, that's a plain bpy ID and
needs its real property name instead: tree.tree.use_fake_user = True
(.fake_user doesn't exist on it and raises AttributeError).
- Interface sockets are created once via
tree.inputs.* / tree.outputs.*; keep a
variable to link to them (tree.outputs is not subscriptable).
Further instructions
- If
import nodebpy fails in Blender, run:
import sys, subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "nodebpy"])
Rebuilding CustomGeometryGroup trees: existing nested groups with the same
_name are reused. Detach the modifier, delete the outer tree and only nested
groups whose _build_group changed, then rebuild, repoint, and re-export to
verify. Keep unchanged nested groups intact.
Grafting nodes into an existing tree: place the new node between the two
nodes it links to (midpoint of upstream and downstream partner). Compute this
in absolute coords: nodes inside a node frame store .location relative to that frame — and frames nest — so sum .location up the .parent chain first.
Setting a Geometry Nodes modifier's input values from Python (e.g. to drive
test values into a tree without rendering): the classic mod["Socket_0"] = value
raises TypeError: id properties not supported for this type on recent Blender
(5.x). Inputs live under mod.properties.inputs, and each socket is a wrapper —
read/write through .value:
inputs = mod.properties.inputs
inputs.Socket_0.value = (0.0, 0.0, 0.0) # vector socket
inputs.Socket_2.value = 0.05 # float socket
Get the Socket_N identifier for a given input name from
tree.interface.items_tree (match on .name, read .identifier) — don't assume
numbering matches declaration order.
render_viewport_to_path's output_path argument is not authoritative — Blender
may write the file to its own temp location and return the real path in the
result. Only relevant if a render is explicitly requested (see Workflow step 4);
read the returned filepath, not the one passed in.
Rebuilding a tree that contains a SimulationZone orphans the sim cache.
Rebuilding always creates a new tree datablock, even if it has the same name as
the old one — the simulation cache is tied to the old datablock, so playback
will re-simulate from the scene's start frame on the next frame change. This is
expected, not a bug; just don't be surprised the timeline "resets."
Modifier input values don't survive a rebuild unless you carry them over
manually. If the modifier has exposed inputs (mod.properties.inputs), read
them before detaching and reapply them after repointing to the new tree:
before = {k: v.value for k, v in mod.properties.inputs.items()}
mod.node_group = None
# ...rebuild...
mod.node_group = tree.tree
for k, v in before.items():
mod.properties.inputs[k].value = v
References
- references/writing-node-trees.md — core structure: tree contexts, adding/linking nodes, interface sockets, zones
- references/node-api.md — socket access (
i/o, slicing, .x/.y/.z), enum options, convenience class methods
- references/operators.md — Python operators (
+ * ** % // > & | ~ @ >>) and the nodes they create
- references/nodes-to-code.md —
to_python() export: options, round-tripping, zones, frames
- references/custom-node-groups.md — reusable
CustomGeometryGroup classes
- references/scene-recon.md — orienting in an unfamiliar scene: objects, modifiers, node groups, evaluated attributes
- references/attribute-driven-color.md — pattern for coloring instanced geometry (e.g. particles) by a stored attribute
1---2name: nodebpy3description: Build Blender node trees (geometry nodes, shader nodes, compositor) programmatically with the nodebpy Python library, executed via the Blender MCP. Use when the user wants to create or modify Blender node setups, geometry nodes, shaders, or compositor trees. Do not use for generic scene inspection or scene summaries; call the configured scene-summary MCP tool directly.4---56# nodebpy — Blender node trees from Python78All node-tree work goes through [nodebpy](https://bradyajohnston.github.io/nodebpy/) via the Blender MCP.910## Runtime and repository scope1112Never wire nodes with raw `bpy` links — `nodebpy` owns tree *construction and linking*.1314Property edits on existing nodes with raw `bpy` is fine e.g.15- renaming a shader `Attribute` node's `attribute_name`16- restyling a `ColorRamp`'s stops. 1718## Node-group conventions1920- **Prefer reusable node groups.** When the logic can be reused or has more21 than a trivial number of nodes, encapsulate it in a node group and expose22 only the useful controls through group inputs.23- **Give exposed inputs reasonable defaults.** Choose useful values, ranges,24 and labels so the group works immediately after insertion. Keep defaults25 close to the nodebpy interface declaration, for example:2627 ```python28 scale = tree.inputs.float("Scale", 6.0, min_value=0.1, max_value=50.0)29 color = tree.inputs.color("Color", (1.0, 0.0, 0.0, 1.0))30 ```3132- **Hide node option buttons by default.** After constructing a tree, hide the33 option-button strip unless the user explicitly asks to show it:3435 ```python36 for node in tree.tree.nodes:37 node.show_options = False38 ```3940 This is a display property on the Blender nodes; using raw `bpy` for this41 property edit is allowed. Apply it to the parent material/geometry tree as42 well as to newly created group nodes when appropriate.43- **Set the node-group color tag to match the group’s purpose.** Do not leave44 it at `NONE`: use `SHADER` for shader groups, `GEOMETRY` for geometry groups,45 `TEXTURE` for texture-oriented groups, `VECTOR` for vector utilities, and46 the closest matching tag for other specialized groups. Set it on the real47 node tree after construction, e.g. `tree.tree.color_tag = "SHADER"`.48- **Keep node auto-arrange on.** `TreeBuilder`/`g.tree(...)` default to49 `arrange="sugiyama"`, so a plain `with g.tree("Name") as tree:` auto-lays-out50 the tree on exit — rely on that default and do **not** pass `arrange=None`.51 `arrange=None` disables auto-layout and is only correct when you also supply an52 explicit `tree.node_positions = {...}` map (as the nodes-to-code export does to53 round-trip exact positions). For newly authored trees, never turn arranging off.5455## Workflow5657Minimize Blender MCP latency 58- use one call to read/export the active tree and one call to edit it.59- No verification of the edit result. 60- Extra calls only after an error or when the initial read leaves the requested change ambiguous.6162Do not add exploratory shell calls between MCP calls. Most observed latency63variance comes from MCP/session startup and Blender-side execution, not from64nodebpy tree construction.65661. **Nodes to code first.** Assume the node tree is already open and on screen for67 the currently selected object. Export it to nodebpy code:6869 ```python70 import bpy71 from nodebpy.export import to_python7273 obj = bpy.context.active_object74 tree = obj.modifiers.active.node_group # or the tree open in the editor75 print(to_python(tree))76 ```77 Read the generated code to understand the existing structure. 78792. **Edit as code.** Modify the generated nodebpy code and re-run it to rebuild the80 tree. Running the code creates a *new* node group — repoint the modifier to it81 and remove the stale one (or delete the old group first to free the name).8283 Also check `tree.animation_data` before deciding to rebuild: keyframes on84 node defaults (e.g. an animated Mix factor) live on the tree datablock and are85 destroyed by a rebuild. If fcurves exist, don't rebuild — graft the change86 into the existing tree instead.87883. **New trees when needed.** Add separate node groups with `with g.tree("Name"):`89 when logic is reusable; they nest into other trees like any node.909192Note:9394- `with g.tree(...) as tree` yields a `TreeBuilder`, not the underlying95 `bpy.types.NodeTree`. Anything that needs a real ID datablock — assigning to a96 modifier, `bpy.data.node_groups` lookups — needs the unwrapped tree:97 `modifier.node_group = tree` raises `TypeError: expected a NodeTree type, not98 TreeBuilder`; use `modifier.node_group = tree.tree` instead.99- `g.tree()` takes no `fake_user` kwarg. `tree.fake_user = True` on the *builder*100 works. If you've already unwrapped via `tree.tree`, that's a plain bpy ID and101 needs its real property name instead: `tree.tree.use_fake_user = True`102 (`.fake_user` doesn't exist on it and raises `AttributeError`).103- Interface sockets are created once via `tree.inputs.*` / `tree.outputs.*`; keep a104 variable to link to them (`tree.outputs` is not subscriptable).105106## Further instructions107108- If `import nodebpy` fails in Blender, run:109```python110import sys, subprocess111subprocess.check_call([sys.executable, "-m", "pip", "install", "nodebpy"])112```113114- **Rebuilding `CustomGeometryGroup` trees:** existing nested groups with the same115 `_name` are reused. Detach the modifier, delete the outer tree and only nested116 groups whose `_build_group` changed, then rebuild, repoint, and re-export to117 verify. Keep unchanged nested groups intact.118- **Grafting nodes into an existing tree:** place the new node between the two119 nodes it links to (midpoint of upstream and downstream partner). Compute this120 in absolute coords: nodes inside a node frame store `.location` relative to that frame — and frames nest — so sum `.location` up the `.parent` chain first.121122- **Setting a Geometry Nodes modifier's input values from Python** (e.g. to drive123 test values into a tree without rendering): the classic `mod["Socket_0"] = value`124 raises `TypeError: id properties not supported for this type` on recent Blender125 (5.x). Inputs live under `mod.properties.inputs`, and each socket is a wrapper —126 read/write through `.value`:127128 ```python129 inputs = mod.properties.inputs130 inputs.Socket_0.value = (0.0, 0.0, 0.0) # vector socket131 inputs.Socket_2.value = 0.05 # float socket132 ```133134 Get the `Socket_N` identifier for a given input name from135 `tree.interface.items_tree` (match on `.name`, read `.identifier`) — don't assume136 numbering matches declaration order.137138- `render_viewport_to_path`'s `output_path` argument is not authoritative — Blender139 may write the file to its own temp location and return the real path in the140 result. Only relevant if a render is explicitly requested (see Workflow step 4);141 read the returned `filepath`, not the one passed in.142- **Rebuilding a tree that contains a `SimulationZone` orphans the sim cache.**143 Rebuilding always creates a new tree datablock, even if it has the same name as144 the old one — the simulation cache is tied to the old datablock, so playback145 will re-simulate from the scene's start frame on the next frame change. This is146 expected, not a bug; just don't be surprised the timeline "resets."147- **Modifier input values don't survive a rebuild** unless you carry them over148 manually. If the modifier has exposed inputs (`mod.properties.inputs`), read149 them before detaching and reapply them after repointing to the new tree:150151 ```python152 before = {k: v.value for k, v in mod.properties.inputs.items()}153 mod.node_group = None154 # ...rebuild...155 mod.node_group = tree.tree156 for k, v in before.items():157 mod.properties.inputs[k].value = v158 ```159160## References161162- [references/writing-node-trees.md](references/writing-node-trees.md) — core structure: tree contexts, adding/linking nodes, interface sockets, zones163- [references/node-api.md](references/node-api.md) — socket access (`i`/`o`, slicing, `.x/.y/.z`), enum options, convenience class methods164- [references/operators.md](references/operators.md) — Python operators (`+ * ** % // > & | ~ @ >>`) and the nodes they create165- [references/nodes-to-code.md](references/nodes-to-code.md) — `to_python()` export: options, round-tripping, zones, frames166- [references/custom-node-groups.md](references/custom-node-groups.md) — reusable `CustomGeometryGroup` classes167- [references/scene-recon.md](references/scene-recon.md) — orienting in an unfamiliar scene: objects, modifiers, node groups, evaluated attributes168- [references/attribute-driven-color.md](references/attribute-driven-color.md) — pattern for coloring instanced geometry (e.g. particles) by a stored attribute