Authoring a node plugin
Build a node in Python, a shader or compiled code, and have it appear in the palette next to the built-ins.
A node plugin is a folder in ~/.dna/plugins/ holding a small manifest and one source file. Once it loads, the node appears in node search, wires up like any other node, and saves inside a .dna project. This page covers the kind of node, the entry point, the manifest, the capabilities you ask for, and how to bundle it for other people.
Choosing a kind of node
| Kind | You write | Best for | Live reload |
|---|---|---|---|
| Python | a .py file | prototyping, data crunching, glue logic | yes, on save |
| Shader | a .wgsl file | GPU image effects, looks, filters | yes, on save |
| Native | compiled code (Rust or C) | number-crunching that has to be fast | no, restart to swap |
Start with Python. It is the fastest to iterate on and reloads the moment you save. Move a node to Native only once you have measured that you need the speed.
The simplest Python node
A Python node is a small class with an execute method, declaring its id, its display name and where it sits in the palette.
import nodetool
@nodetool.node(id="my_plugin.invert", display_name="Invert Values", category="Math")
class InvertNode:
inputs = [{"name": "value", "type": "number", "default": 0.0, "param": True}]
outputs = [{"name": "output", "type": "number"}]
def execute(self, inputs, cx):
return {"output": -inputs.get("value", 0.0)}
inputs and outputs are class attributes holding lists of dictionaries, not annotated fields. An input takes name and type, and optionally default, param, min, max, step, widget and widget_config. execute takes the input dictionary and a context dictionary carrying frame, time, timeline_frame, canvas_width, canvas_height and is_playing, and returns a dictionary keyed by output name. The class is constructed fresh for each evaluation, so __init__ must take no arguments.
Numbers, booleans, strings, two- and three-component vectors and colours cross the boundary in both directions. Nothing else does: images, fields, signals and arrays arrive in Python as None, and a returned list that is not two, three or four floats comes back as text.
A plugin can also declare a panel, a control surface of its own: a separate class decorated with @nodetool.panel(...) whose layout() returns a widget tree of labels, buttons, sliders, toggles, dropdowns and text inputs. Panels are loaded and counted in the Plugin Manager, and on macOS each one is added to View > Panels under a separator once the plugins finish loading. Choosing it does nothing: the menu item carries a plugin panel id, and the code that opens a panel from that menu recognises only the built-in ones, so a plugin panel still has no way to open.
A shader node
A shader node is pure GPU, with no per-frame code on your side. Write a fragment or compute shader and declare its controls in @param comments, which become real parameter knobs on the node. The format is the type, then the name, then the range and default:
// @param float intensity 0.0 2.0 1.0 "Intensity"
fn compute_pixel(coord: vec2<i32>) -> vec4<f32> {
let color = load_content(coord);
return vec4<f32>(color.rgb * get_intensity(), color.a);
}
That is a compute shader, where the bindings and the entry point are generated around your body. A fragment shader is a complete WGSL module instead: you declare the bindings, the vertex stage and fs_main yourself. You get up to four named image inputs either way, and uniforms.resolution, uniforms.time (seconds) and uniforms.frame are always available. Authoring a shader plugin covers both, with a worked fragment shader.
The manifest
Every plugin folder needs a plugin.toml describing what it is. Declare exactly one kind of node in it.
[plugin]
name = "My Plugin"
version = "1.0.0"
api_version = "2.0.0"
# Exactly ONE of these sections:
[plugin.python]
entry = "main.py"
# [plugin.native]
# library = "libfast_noise" # the file extension is added for you
# [plugin.shader]
# [[shader]]
# id = "custom.chromatic_aberration"
# file = "chromatic.wgsl"
# language = "WGSL"
# shader_type = "Fragment"
# inputs = ["content"] # up to 4 image inputs
api_version is the plugin API the plugin was built against. The host is at 2.0.0 and matches by major version, so a plugin declaring 2.0.0 loads into any 2.x host. Anything else is refused on load rather than run, so a mismatch fails loudly instead of misbehaving.
The Rust authoring kit handles the node's identity and execution. Inputs, outputs and parameters are still declared by hand; a friendlier way to declare them is planned.
Capabilities and trust
By default a plugin can compute and nothing else. Reaching outside the sandbox is requested in the manifest:
[capabilities]
gpu = false # use the GPU directly
state = false # remember values between cooks
filesystem = false # read or write files
network = false # talk over the network
plugin_host = false # load other plugins
Request only what you use. The declaration is checked when the plugin loads: in a restricted project, a native or Rust plugin asking for any capability is refused entirely rather than loaded with that one door shut. In a trusted project the flags do not yet mediate anything at runtime, so treat them as a disclosure to whoever installs the plugin rather than a fence around your own code.
A native plugin node that panics is caught. That node instance is marked and refuses to run again, while the rest of the graph and the other plugins carry on; reloading or restarting clears it. A Python error surfaces as a node error the same way. A Rust plugin's cook is not yet wrapped, so a panic there is not contained. Watch the console while you develop.
Capabilities & trust covers how grants work, and Trust & Permissions the project-wide trust levels.
Where plugins live, and how reload works
Each plugin is its own folder under ~/.dna/plugins/:
~/.dna/plugins/
├── my-python-plugin/ plugin.toml + main.py
├── my-shader-plugin/ plugin.toml + chromatic.wgsl
└── my-native-plugin/ plugin.toml + libfast_noise.dylib
Put a folder in and press Refresh in the Plugin Manager window, which then lists it with its version, its kind and its node and panel counts, alongside Reload, Remove and Open Folder. Nothing watches the folder, so after editing a Python or shader file press Reload. Native and Rust plugins load once and refuse to reload, so swapping one means restarting DNA.
Handing a plugin to someone else
Copy the folder. There is no install flow at the other end: they unpack it into their own plugins folder and press Refresh.
A .ntplugin bundle format exists, with an Ed25519 signature and a publisher allowlist, and xtask package-plugin builds one for a single target triple. Nothing in the app opens it, so a bundle is not a way to ship today. Compiled plugins are per-platform either way, so a native or Rust plugin needs a separate build for macOS, Windows and Linux; Python and shader plugins are portable as they stand.
Installing plugins covers what gets scanned and plugin.toml reference every manifest field.