Real Examples from Codebase
This document provides annotated examples of Python nodes. Each example highlights specific patterns and implementation details to help you build your own nodes.
To write a TypeScript node, use the Custom Nodes Guide, Node Patterns, and Node Reference instead.
The Python base classes live in the separate nodetool-core repository (src/nodetool/), and HuggingFacePipelineNode lives in nodetool-huggingface. This repository contains neither. This repository runs those nodes through the Python bridge: it reads the metadata the worker reports (is_dynamic, is_streaming_output, is_streaming_input, required_settings, properties, outputs) and sends execute and execute.stream calls. The samples on this page are checked against both repositories.
Imports used by the samples:
from collections.abc import AsyncGenerator
from typing import Any, ClassVar, TypedDict
from pydantic import Field
from nodetool.metadata.types import AudioRef, ImageRef, TextRef
from nodetool.workflows.base_node import BaseNode
from nodetool.workflows.processing_context import ProcessingContext
from nodetool.workflows.types import SaveUpdate
How the worker finds a node pack
A pack is a Python package under the nodetool.nodes namespace, for example nodetool.nodes.huggingface. The worker (nodetool-core/src/nodetool/worker/node_loader.py) resolves which namespaces to load in this order: the --namespaces argument, the NODETOOL_WORKER_NAMESPACES environment variable (comma-separated), then auto-discovery (resolve_namespaces, lines 283-299). Auto-discovery is the union of the nodetool.namespaces entry-point group (each value is a comma-separated list of namespace names) and every subdirectory of the nodetool.nodes namespace path (_discover_namespaces, lines 12-56). The worker then imports each nodetool.nodes.<ns> module and every submodule below it so the BaseNode subclasses register. A node’s type is its module path without the nodetool.nodes. prefix, plus the class name with a trailing Node removed (get_namespace and get_node_type, workflows/base_node.py:923-946).
nodetool-pkg scan (package_tools/__main__.py) builds a package’s metadata JSON. --write saves it to <package-dir>/src/nodetool/package_metadata/<name>.json.
1. Simple Processing Nodes
These nodes take one or more inputs, perform a calculation, and return a single result. They are the building blocks of most workflows.
Example: Text Concatenation
Pattern: BaseNode with simple inputs and a single return value.
class Concat(BaseNode):
"""
Concatenates two text inputs into a single output.
text, concatenation, combine, +
"""
# Define inputs using Pydantic fields
a: str = Field(default="")
b: str = Field(default="")
@classmethod
def get_title(cls):
return "Concatenate Text"
# The return type annotation (str) gives the node its single "output" slot
async def process(self, context: ProcessingContext) -> str:
return self.a + self.b
Key Takeaways:
- Use
Field(default="")to define inputs. - The
processmethod must beasync. BaseNode.return_type()reads the annotation to build the output slots (workflows/base_node.py:1819). A plain type gives one slot namedoutput.get_title()defaults to the class name without a trailingNode, split on camel case (workflows/base_node.py:949). The bridgediscoverpayload reads atitleclass attribute instead and falls back to the last part of the node type (worker/node_loader.py:64).
2. Structured Output Nodes
Sometimes a node needs to return multiple values (e.g., an audio transcription that returns both the text and the detected language).
Example: Automatic Speech Recognition
Pattern: Using TypedDict to define multiple named outputs.
class AutomaticSpeechRecognition(BaseNode):
"""
Automatic speech recognition node.
audio, speech, recognition
"""
# Define the output structure
class OutputType(TypedDict):
text: str
language: str
model: ASRModel = Field(...)
audio: AudioRef = Field(...)
async def process(self, context: ProcessingContext) -> OutputType:
# ... logic to transcribe audio ...
return {
"text": "Hello world",
"language": "en"
}
Key Takeaways:
- Define a
TypedDictnamedOutputTypeinside your class.return_type()checks for this attribute first, then the annotation ofgen_process, then the annotation ofprocess(workflows/base_node.py:1827-1844). Each key becomes an output slot. - Annotate
processwithOutputTypeby convention. - Return a dictionary matching the structure.
3. Streaming Nodes
For operations that take a long time or produce results incrementally (like reading a large folder of images), use a generator.
Example: Load Image Folder
Pattern: gen_process with AsyncGenerator.
class LoadImageFolder(BaseNode):
"""
Load all images from a folder.
image, load, folder
"""
folder: str = Field(default="")
class OutputType(TypedDict):
image: ImageRef
path: str
# Use gen_process instead of process
async def gen_process(
self, context: ProcessingContext
) -> AsyncGenerator[OutputType, None]:
# Iterate over files and yield results one by one
for path in list_image_paths(self.folder): # your own helper
image = await context.image_from_bytes(read_bytes(path))
yield {"image": image, "path": path}
Key Takeaways:
- Use
gen_processinstead ofprocess. A node is streaming when it overridesgen_process(is_streaming_output,workflows/base_node.py:1806-1812), and such a node is not cacheable (is_cacheable, line 1703). - Return type is
AsyncGenerator[OutputType, None]. - Yield a dictionary that maps output names to values.
BaseNode.run()and the worker raiseTypeErrorfor any other item, even though thegen_processdocstring mentions yielding a bare value (workflows/base_node.py:2137,2183,worker/executor.py:591,615). yieldresults as they become available. This allows downstream nodes to start processing immediately.
4. Dynamic Nodes
Some nodes need to adapt their inputs based on user configuration. For example, a template node might need different inputs depending on the variables in the template string.
Example: Format Text (Jinja2)
Pattern: _is_dynamic flag and _dynamic_properties.
import jinja2
class FormatText(BaseNode):
"""
Replaces placeholders in a string with dynamic inputs.
"""
# 1. Flag this node as dynamic
_is_dynamic: ClassVar[bool] = True
template: str = Field(default="Hello {{ name }}!")
async def process(self, context: ProcessingContext) -> str:
# 2. Read the dynamic inputs provided by the user
# These are inputs that were added to the node at runtime
dynamic_inputs = self._dynamic_properties
# Render the template using these inputs
return jinja2.Template(self.template).render(**dynamic_inputs)
Key Takeaways:
- Set
_is_dynamic = True. - The UI will allow users to add arbitrary inputs to this node.
- Read the values from
self._dynamic_properties(adict[str, Any]) or thedynamic_propertiesproperty.get_dynamic_properties()returns a dict ofPropertytype descriptions, not the values (workflows/base_node.py:1711-1726). BaseNode.is_dynamic()reads_is_dynamic, and the worker reports it indiscover(workflows/base_node.py:665,worker/node_loader.py:101). Assigning an undeclared property on a dynamic node stores it in_dynamic_properties(workflows/base_node.py:1292).
5. Working with Assets
Nodes often need to load or save heavy assets like images or audio.
Example: Save Text to File
Pattern: Using ProcessingContext to create assets.
class SaveText(BaseNode):
"""
Saves input text to a file.
"""
text: str = Field(default="")
filename: str = Field(default="output.txt")
async def process(self, context: ProcessingContext) -> TextRef:
# 1. Create the asset using the context. Passing a name persists it.
result = await context.text_from_str(self.text, name=self.filename)
# 2. Notify the UI that a file was saved (optional)
context.post_message(SaveUpdate(
node_id=self.id,
name=self.filename,
value=result,
output_type="text"
))
return result
Key Takeaways:
- Never write directly to disk if you can avoid it.
text_from_str(s, name=...)stores an asset and returns aTextRefwithasset_idanduriset. Withoutnameit returns amemory://reference and persists nothing (workflows/processing_context.py:1880-1899). - For raw bytes,
context.create_asset(name, content_type, content)takes a file-likecontent(for exampleBytesIO) and returns aSimpleNamespacewithid,name,content_type,file_name,size,user_id,workflow_idandnode_id. It has nouri(workflows/processing_context.py:665-709). - Return a
Refobject (likeTextRef,ImageRef) so other nodes can use the asset. Media helpers includeimage_from_bytes,image_from_pil,image_from_numpy,audio_from_numpyandaudio_from_bytes. SaveUpdatehas the fieldsnode_id,name,value,output_typeandmetadata(workflows/types.py:199-208). The bridge worker forwards onlyNodeProgress,PreviewUpdate,LogUpdateandBinaryUpdatemessages and drops the rest, so aSaveUpdateposted by a bridge node does not reach the UI (worker/context_stub.py:260-285).- In the bridge,
WorkerContextoverrides the image and audio helpers to returnblob://references backed by bytes sent over the bridge (worker/context_stub.py:55-235).
6. Control Flow
Nodes can control the execution flow of the graph.
Example: If Node
Pattern: Conditional logic in gen_process.
class If(BaseNode):
"""
Conditionally executes branches.
"""
condition: bool = Field(default=False)
value: Any = Field(default=None)
class OutputType(TypedDict):
if_true: Any
if_false: Any
async def gen_process(self, context: ProcessingContext) -> AsyncGenerator[OutputType, None]:
if self.condition:
# Only emit to the 'if_true' output
yield {"if_true": self.value, "if_false": None}
else:
# Only emit to the 'if_false' output
yield {"if_true": None, "if_false": self.value}
Key Takeaways:
- Control flow nodes often use
gen_processto conditionally yield results. - The TypeScript
nodetool.control.Ifreturns only the taken branch’s key. A key that is absent sends no message, so nodes wired only to the untaken branch do not run. - In Python,
BaseNode.run()skips any yielded value that isNone, so the untaken branch sends nothing (workflows/base_node.py:2188). The bridge worker aggregates the final result the same way, but each streamed chunk carries the raw mapping includingNoneplaceholders (worker/executor.py:605-625). What the TypeScript host does with anullchunk value is not checked here.
7. Model Nodes (HuggingFace / model-backed)
Nodes that load a machine-learning model (HuggingFace, diffusers, …) extend
HuggingFacePipelineNode, a BaseNode subclass in the nodetool-huggingface
package (src/nodetool/nodes/huggingface/huggingface_pipeline.py:94). It adds
model loading, device placement, a thread-pool inference helper, and the model
cache key hooks. This is the pattern that package uses for its nodes. The sample
below follows MusicLDM (src/nodetool/nodes/huggingface/text_to_audio.py:232).
Example: A text-to-audio model node
from typing import Any
from pydantic import Field
from nodetool.huggingface.memory_utils import move_pipeline_to_device
from nodetool.metadata.types import AudioRef, HFTextToAudio, HuggingFaceModel
from nodetool.nodes.huggingface.huggingface_pipeline import HuggingFacePipelineNode
from nodetool.workflows.processing_context import ProcessingContext
class MyAudioGen(HuggingFacePipelineNode):
"""
One-line description shown in the UI.
audio, generation, text-to-audio
"""
model: HFTextToAudio = Field(
default=HFTextToAudio(),
title="Model",
description="The model to use.",
)
prompt: str = Field(default="", description="Text prompt.")
_pipeline: Any = None
@classmethod
def get_title(cls) -> str:
return "My Audio Gen"
@classmethod
def get_basic_fields(cls) -> list[str]:
# Fields rendered on the node body; the rest live in the expanded view.
return ["model", "prompt"]
@classmethod
def get_recommended_models(cls) -> list[HuggingFaceModel]:
# Checkpoints offered for one-click download in the Model Manager.
return [
HFTextToAudio(
repo_id="org/my-model",
allow_patterns=["*.safetensors", "*.json", "*.txt"],
),
]
async def preload_model(self, context: ProcessingContext):
# Called once before process(); load the model here.
from diffusers import MusicLDMPipeline # use your model's pipeline class
self._pipeline = await self.load_model(
context, MusicLDMPipeline, self.model.repo_id
)
async def move_to_device(self, device: str):
# HuggingFacePipelineNode already implements this for self._pipeline.
# Override only to change how the move happens.
if self._pipeline:
move_pipeline_to_device(self._pipeline, device)
async def process(self, context: ProcessingContext) -> AudioRef:
# run_pipeline_in_thread keeps the asyncio event loop responsive.
output = await self.run_pipeline_in_thread(self.prompt)
return await context.audio_from_numpy(output.audios[0], 16_000)
Key Takeaways:
- Extend
HuggingFacePipelineNodeand declare amodel:field of the matching HF type (HFTextToImage,HFTextToVideo,HFTextToAudio,HFTextGeneration, …, all extendingHuggingFaceModelinnodetool-coremetadata/types.py:933). get_recommended_models()lists checkpoints the Model Manager can download (BaseNode,workflows/base_node.py:967).get_basic_fields()selects which fields show on the node body (base_node.py:1086).get_title()sets the display name. TheHuggingFacePipelineNodedefault prefixes the class name withHF(huggingface_pipeline.py:205-208), so override it for a plain name.- The bridge runs
pre_process,preload_model,move_to_device, thenprocessorgen_process(nodetool-coreworker/executor.py:281-284).HuggingFacePipelineNode.pre_processsets up HF logging, so callsuper().pre_process(context)if you override it. load_model(context, model_class, model_id, ...)is an instance method with optionalskip_cache,cache_key, and keyword arguments such astorch_dtype,variantanddevice, which pass through to the module function innodetool/huggingface/local_provider_utils.py:551(huggingface_pipeline.py:275-298).load_pipeline(context, pipeline_task, model_id, ...)is the transformers pipeline counterpart.run_pipeline_in_thread(*args, **kwargs)callsself._pipelinein a shared thread pool undertorch.inference_mode(), and raisesValueErrorwhen_pipelineisNone(huggingface_pipeline.py:300-333).get_model_id()is not part of the base class. Many nodes define it themselves asreturn self.model.repo_id(for exampletext_to_audio.py:109).available_torch_dtype(device=None)is a helper innodes/huggingface/stable_diffusion_base.py:590, not a node method. Pass its result astorch_dtype=toload_modelwhen a pipeline needs a dtype.- The base
process()raisesNotImplementedError, andrequires_gpu()returnsTrue. - To make a model usable outside the node graph (e.g. via an agent or the
generation API), implement the matching method on the package’s
local provider — e.g.
text_to_audio()for theTEXT_TO_AUDIOcapability.