Skip to content

Extensions

TouchDesigner's extension system attaches Python classes to COMPs, providing organized, reusable behavior.

Basics

An extension is a Python class defined in a text DAT, attached to a COMP via the Extension parameter. Methods can be "promoted" to be callable directly on the COMP.

class MyExtension:
    def __init__(self, ownerComp):
        self.ownerComp = ownerComp

    def DoSomething(self):
        """Promoted method (uppercase) — callable as op.myComp.DoSomething()"""
        pass

    def helperMethod(self):
        """Non-promoted method — callable as op.myComp.ext.MyExtension.helperMethod()"""
        pass

Lifecycle Methods

onDestroyTD(self)

Called on the old extension instance before TD reinitializes with a new one. Essential for clean teardown.

def onDestroyTD(self):
    """Clean up before reinitialization."""
    # Cancel timers, close connections, remove callbacks
    pass

Warning

Without onDestroyTD, old extension instances linger in memory due to Python garbage collection issues (circular references, cached callbacks). Always implement it.

onInitTD(self)

Called at the end of the frame after the extension initialized. Use for post-init setup that needs a fully-cooked network.

def onInitTD(self):
    """Called after the frame the extension was created."""
    # Safe to access other extensions and cooked operators here
    pass

Initialization and TDN Import Timing

Critical: onInitTD runs BEFORE TDN import

If your extension lives inside a TDN-strategy COMP (or the extension's ownerComp is one), onInitTD fires before TDN reconstruction completes. Any state your extension sets up — created operators, parameter values, stored data, internal network structure — is overwritten when the TDN import runs.

Why this happens

Embody uses TDN (TouchDesigner Network) files to externalize COMP contents as diffable YAML. When Embody reconstructs a TDN COMP it calls ImportNetwork with clear_first=True — this deletes all children inside the COMP and recreates them from the .tdn file.

When reconstruction runs depends on Tdnmode. In the experimental Roundtrip mode, every TDN COMP is reconstructed on project open (and stripped/restored on every save), so the timing below applies in full. In the default Export-on-Save mode the .toe stays authoritative on open — existing COMPs are not rebuilt, only a COMP absent from the .toe is reconstructed from its .tdn, and save does not strip. Even so, treat the deferral pattern below as the safe default: it also covers manual import_network reloads, which use clear_first=True in any mode.

The timing sequence on project open (Roundtrip mode, or an absent-COMP recovery):

  1. COMP shell is created — the COMP exists but its children haven't been imported yet
  2. Extension initializes__init__ runs, then onInitTD fires at end of frame
  3. TDN import runs (frame 60) — deletes all children, recreates network from .tdn file
  4. Extension state is lost — anything onInitTD set up inside the COMP is gone

In Roundtrip mode a similar sequence occurs on every Ctrl+S due to the strip/restore cycle: children are stripped before save, then re-imported afterward. Extensions may reinitialize during this process. (Export-on-Save does not strip, so this save-time cycle does not apply there.)

The fix: defer initialization

Use run() with delayFrames to push your setup code past the TDN import:

class MyFeatureExt:
    def __init__(self, ownerComp):
        self.ownerComp = ownerComp

    def onInitTD(self):
        # DON'T set up state here — it will be overwritten by TDN import.
        # Instead, defer to after the import completes:
        run('args[0].postInit()', self, delayFrames=5)

    def postInit(self):
        """Runs after TDN import is complete. Safe to set up state here."""
        # Create operators, set parameters, build internal state
        child = self.ownerComp.op('my_child')
        if child:
            child.par.value0 = self.computeInitialValue()

Guidelines

Rule Reason
Always defer initialization inside TDN COMPs onInitTD fires before import — any setup is overwritten
Make deferred init idempotent It may run multiple times: project open, every save, manual reimport
Null-check operators in deferred init During strip phase, children are temporarily gone
Use store() on the COMP for persistent state Storage on the COMP itself survives TDN import (it's preserved in phase 6a)
Use a delay of at least 5 frames The import runs across multiple phases; 5 frames provides sufficient margin

How to tell if you're inside a TDN COMP

Check whether your COMP (or an ancestor) has a TDN entry in the externalizations table. In Claude Code, call get_externalizations and look for a tdn strategy on the COMP path. If your extension is a child of a TDN-strategy COMP, this timing issue applies to you.

Extensions outside TDN COMPs are unaffected

If your extension's ownerComp is not managed by TDN (e.g., it's a TOX-strategy COMP or not externalized at all), onInitTD behaves normally — no deferral needed.

Extension Referencing

# Promoted methods (uppercase) — called directly on the component:
op.Embody.Update()
op.Embody.Save('/path/to/comp')   # Save() requires a COMP path argument

# Non-promoted methods (lowercase) — through ext:
op.Embody.ext.Embody.getExternalizedOps(COMP)

# Check if extension exists:
if hasattr(op.myComp.ext, 'MyExtension'):
    op.myComp.ext.MyExtension.doSomething()

Never cache extension references

Extension instances become stale when TD reinitializes them (e.g., when source code changes on disk). Always call inline:

# CORRECT — always call inline:
self.ownerComp.ext.Embody.SomeMethod()

# WRONG — cached reference goes stale:
ext = self.ownerComp.ext.Embody
ext.SomeMethod()  # May call the dead old instance

extensionsReady Guard

Parameter expressions that reference extension-promoted attributes must guard against initialization timing:

# In a parameter expression:
parent().MyExtensionProperty if parent().extensionsReady else 0

Without this, TD raises "Cannot use an extension during its initialization."

Creating Extensions via MCP

Use the create_extension Envoy tool to create a fully wired extension:

create_extension(
    parent_path="/project1",
    class_name="MyExtension",
    code="class MyExtension:\n    def __init__(self, ownerComp):\n        self.ownerComp = ownerComp"
)

This creates a baseCOMP with a text DAT containing the extension class, properly wired up and initialized.

Naming Convention

Extension classes and their source DATs must follow the NameExt convention:

  • EmbodyExt — class name EmbodyExt, DAT name EmbodyExt
  • EnvoyExt — class name EnvoyExt, DAT name EnvoyExt
  • TestRunnerExt — class name TestRunnerExt, DAT name TestRunnerExt