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 (BaseNode, HuggingFacePipelineNode, ProcessingContext) live in the separate nodetool-core and nodetool-huggingface repositories, which this repository does not contain. 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 Python API shown below is not checked against source here.
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) tells the UI what this node outputs
async def process(self, context: ProcessingContext) -> str:
return self.a + self.b
Key Takeaways:
- Use
Field(default="")to define inputs. - The
processmethod must beasync. - The return type annotation is crucial for the UI to validate connections.
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. - Set the return annotation of
processtoOutputType. - 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 self.iter_files(self.folder):
image = await context.image_from_bytes(...)
yield {"image": image, "path": path}
Key Takeaways:
- Use
gen_processinstead ofprocess. - Return type is
AsyncGenerator[OutputType, None]. 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.
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. Access the dynamic inputs provided by the user
# These are inputs that were added to the node at runtime
dynamic_inputs = self.get_dynamic_properties()
# Render the template using these inputs
return self.render_template(self.template, **dynamic_inputs)
Key Takeaways:
- Set
_is_dynamic = True. - The UI will allow users to add arbitrary inputs to this node.
- Access these inputs via
self.get_dynamic_properties()orself._dynamic_properties.
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
asset = await context.create_asset(
filename=self.filename,
mime_type="text/plain",
data=self.text.encode("utf-8")
)
# 2. Create a reference to return
result = TextRef(uri=asset.uri, asset_id=asset.id)
# 3. Notify the UI that a file was saved (optional but recommended)
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. Use
context.create_asset. - Return a
Refobject (likeTextRef,ImageRef) so other nodes can use the asset. - Use
SaveUpdateto show the saved file in the UI’s “Outputs” tab.
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: Any) -> 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 TypeScript,nullis a value and does send a message. Check how the Python worker treatsNonebefore relying on it to stop a branch.
7. Model Nodes (HuggingFace / model-backed)
Nodes that load a machine-learning model (HuggingFace, diffusers, …) extend
HuggingFacePipelineNode — a BaseNode subclass with extra hooks for loading,
device placement, and surfacing downloadable checkpoints. This is the pattern the
nodetool-huggingface package uses for every node.
Example: A text-to-audio model node
class MyAudioGen(HuggingFacePipelineNode):
"""
One-line description shown in the UI.
audio, generation, text-to-audio
"""
model: HFTextToAudio = Field(
default=HFTextToAudio(repo_id="org/my-model"),
description="The model to use.",
)
prompt: str = Field(default="", description="Text prompt.")
seed: int = Field(default=-1, ge=-1)
_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"],
),
]
def get_model_id(self) -> str:
return self.model.repo_id
async def preload_model(self, context: ProcessingContext):
# Called once before process(); load the model here.
from diffusers import MyPipeline
self._pipeline = await self.load_model(
context=context,
model_class=MyPipeline,
model_id=self.get_model_id(),
torch_dtype=available_torch_dtype(),
device="cpu", # the runner moves it to the GPU/MPS via move_to_device
)
async def move_to_device(self, device: str):
if self._pipeline is not None:
self._pipeline.to(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(prompt=self.prompt)
return await context.audio_from_numpy(output.audios[0], 24000)
Key Takeaways:
- Extend
HuggingFacePipelineNodeand declare amodel:field of the matching HF type (HFTextToImage,HFTextToVideo,HFTextToAudio,HFTextGeneration, …). get_recommended_models()lists checkpoints the Model Manager can download;get_basic_fields()selects which fields show on the node body;get_title()sets the display name.preload_model()loads the model,move_to_device()places it on the accelerator, andprocess()(orgen_process()) runs inference. Userun_pipeline_in_thread()so long-running inference doesn’t block the event loop.- 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.