hermes-agent/skills/creative/touchdesigner/references/pitfalls.md
kshitijk4poor 7a5371b20d feat: add TouchDesigner integration skill
New skill: creative/touchdesigner — control a running TouchDesigner
instance via REST API. Build real-time visual networks programmatically.

Architecture:
  Hermes Agent -> HTTP REST (curl) -> TD WebServer DAT -> TD Python env

Key features:
- Custom API handler (scripts/custom_api_handler.py) that creates a
  self-contained WebServer DAT + callback in TD. More reliable than the
  official mcp_webserver_base.tox which frequently fails module imports.
- Discovery-first workflow: never hardcode TD parameter names. Always
  probe the running instance first since names change across versions.
- Persistent setup: save the TD project once with the API handler baked
  in. TD auto-opens the last project on launch, so port 9981 is live
  with zero manual steps after first-time setup.
- Works via curl in execute_code (no MCP dependency required).
- Optional MCP server config for touchdesigner-mcp-server npm package.

Skill structure (2823 lines total):
  SKILL.md (209 lines) — setup, workflow, key rules, operator reference
  references/pitfalls.md (276 lines) — 24 hard-won lessons
  references/operators.md (239 lines) — all 6 operator families
  references/network-patterns.md (589 lines) — audio-reactive, generative,
    video processing, GLSL, instancing, live performance recipes
  references/mcp-tools.md (501 lines) — 13 MCP tool schemas
  references/python-api.md (443 lines) — TD Python scripting patterns
  references/troubleshooting.md (274 lines) — connection diagnostics
  scripts/custom_api_handler.py (140 lines) — REST API handler for TD
  scripts/setup.sh (152 lines) — prerequisite checker

Tested on TouchDesigner 099 Non-Commercial (macOS/darwin).
2026-04-18 17:43:42 -07:00

15 KiB
Raw Blame History

TouchDesigner MCP — Pitfalls & Lessons Learned

Hard-won knowledge from real TD sessions. Read this before building anything.

Setup & Connection

1. The .tox from the git repo is BROKEN

The td/mcp_webserver_base.tox in the 8beeeaaat/touchdesigner-mcp git clone is incomplete. It's missing the td_server Python module (generated by npm run gen:webserver which requires Docker). Port 9981 opens, but every route returns 404.

Always download the release zip:

curl -L -o td.zip \
  "https://github.com/8beeeaaat/touchdesigner-mcp/releases/latest/download/touchdesigner-mcp-td.zip"
unzip -o td.zip -d touchdesigner-mcp-td

2. The release .tox also breaks (frequently)

Even the correct release .tox fails after drag-and-drop import because import_modules.py resolves modules/ via parent().par.externaltox.eval() — a relative path that often goes wrong. Symptoms: port 9981 listens, all routes 404, TD Textport shows [ERROR] Failed to setup modules.

The custom API handler (scripts/custom_api_handler.py) is more reliable. It has zero external module dependencies — just a WebServer DAT + Text DAT callback. The skill's setup workflow should try the .tox first, test with curl, and auto-deploy the handler if 404.

3. You CANNOT automate the .tox import from outside TD

TD has no CLI flag to import a .tox. macOS blocks keystroke injection via System Events for security. The only way to get code into TD from outside is:

  • Have a WebServer DAT already running (chicken-and-egg)
  • AppleScript to open Textport + clipboard paste (fragile, not always reliable)
  • User manually drags the .tox or pastes a script

Plan for one manual step from the user (either .tox drag-drop or Textport paste). Make it as frictionless as possible: open -R /path/to/file to reveal in Finder.

4. The npm package name is touchdesigner-mcp-server (not @anthropic/...)

The Hermes config should use:

command: npx
args: ["-y", "touchdesigner-mcp-server@latest"]

5. MCP tools may register but not be callable

Hermes may report "17 MCP tool(s) now available" but the tools aren't exposed as function calls. Use the REST API directly via curl in execute_code as a reliable fallback:

def td_exec(script):
    escaped = json.dumps({"script": script})
    cmd = f"curl -s -X POST -H 'Content-Type: application/json' -d {shlex.quote(escaped)} 'http://127.0.0.1:9981/api/td/server/exec'"
    return json.loads(terminal(cmd)['output'])

TD WebServer DAT Quirks

6. Response body goes in response['data'], NOT response['body']

When writing custom WebServer DAT handlers, the response payload must be set on the data key:

response['data'] = json.dumps({"result": 42})  # ✓ works
response['body'] = json.dumps({"result": 42})   # ✗ ignored

7. Request POST body comes as BYTES in request['data']

Not request['body'], and it's bytes not str:

raw = request.get('data', b'')
if isinstance(raw, bytes):
    raw = raw.decode('utf-8')
body = json.loads(raw) if raw else {}

8. Non-Commercial license caps resolution at 1280×1280

Setting resolutionw=1920 silently clamps to 1280. Always check effective resolution after creation:

n.cook(force=True)
actual = str(n.width) + 'x' + str(n.height)

Parameter Names

9. NEVER hardcode parameter names — always discover

Parameter names change between TD versions. What works in 099 may not work in 098 or 2023.x. Always run discovery first:

n = root.create(glslTOP, '_test')
pars = [(p.name, type(p.val).__name__) for p in n.pars()]
n.destroy()

Known differences from docs/online references:

What docs say TD 099 actual Notes
dat pixeldat GLSL TOP pixel shader DAT
colora alpha Constant TOP alpha
sizex / sizey size Blur TOP (single value)
fontr/g/b/a fontcolorr/g/b/a Text TOP font color (r/g/b)
fontcolora fontalpha Text TOP font alpha (NOT fontcolora)
bgcolora bgalpha Text TOP bg alpha
value1name vec0name GLSL TOP uniform name

10. Use safe_par() pattern for cross-version compatibility

def safe_par(node, name, value):
    p = getattr(node.par, name, None)
    if p is not None:
        p.val = value
        return True
    return False

11. td.tdAttributeError crashes the whole script

If you do node.par.nonexistent = value, TD raises tdAttributeError and stops the entire script. There's no way to catch it with try/except in some TD versions. Always check with getattr first or use safe_par().

GLSL Shaders

12. uTDCurrentTime does NOT exist in TD 099

The GLSL builtin for time was removed or never existed in some builds. Feed time via a 1×1 Constant TOP input. CRITICAL: set format to rgba32float — the default 8-bit format clamps values to 0-1, so absTime.seconds % 1000.0 gets clamped and the GLSL shader sees a frozen time value of 1.0:

t = root.create(constantTOP, 'time_driver')
t.par.format = 'rgba32float'  # ← REQUIRED! Without this, time is stuck at 1.0
t.par.outputresolution = 'custom'
t.par.resolutionw = 1
t.par.resolutionh = 1
t.par.colorr.expr = "absTime.seconds % 1000.0"
t.par.colorg.expr = "int(absTime.seconds / 1000.0)"
t.outputConnectors[0].connect(glsl.inputConnectors[0])

In GLSL:

vec4 td = texture(sTD2DInputs[0], vec2(0.5));
float t = td.r + td.g * 1000.0;

13. GLSL compile errors are silent in the API

The GLSL TOP shows a yellow warning triangle in the UI but node.errors() may return empty string. Check node.warnings() too, and create an Info DAT pointed at the GLSL TOP to read the actual compiler output.

14. TD GLSL uses vUV.st not gl_FragCoord

Standard GLSL patterns don't work. TD provides:

  • vUV.st — UV coordinates (0-1)
  • uTDOutputInfo.res.zw — resolution
  • sTD2DInputs[0] — input textures
  • layout(location = 0) out vec4 fragColor — output

Node Management

15. Destroying nodes while iterating root.children causes tdError

The iterator is invalidated when a child is destroyed. Always snapshot first:

kids = list(root.children)  # snapshot
for child in kids:
    if child.valid:  # check — earlier destroys may cascade
        child.destroy()

16. Feedback TOP: use top parameter, NOT direct input wire

In TD 099, the feedbackTOP's top parameter references which TOP to delay. Do not also wire that TOP directly into the feedback's input — this creates a real cook dependency loop (warning flood, potential crash). The "Not enough sources" error on feedbackTOP is benign and resolves after a few frames of playback.

Correct setup:

fb = root.create(feedbackTOP, 'fb_delay')
fb.par.top = comp.path          # reference only — no wire to fb input
fb.outputConnectors[0].connect(xf)  # fb output -> transform -> fade -> comp

The resulting "Cook dependency loop detected" warning on the transform/fade chain is expected and correct — that's what feedback loops do. It's informational, not an error.

16. GLSL TOP auto-creates companion nodes

Creating a glslTOP also creates name_pixel (Text DAT), name_info (Info DAT), and name_compute (Text DAT). These are visible in the network and count toward node totals. Don't be alarmed by "extra" nodes.

17. The default project root is /project1

New TD files start with /project1 as the main container. System nodes live at /, /ui, /sys, /local, /perform. Don't create user nodes outside /project1.

18. open -R reveals the file but can't automate the drag

Use open -R /path/to/file.tox to open Finder highlighting the file. The user must then drag it into TD manually. No AppleScript workaround exists on modern macOS due to accessibility restrictions.

Workflow

19. Always verify after building — errors are silent

Node errors and broken connections produce no output. Always check:

for c in list(root.children):
    e = c.errors()
    w = c.warnings()
    if e: print(c.name, 'ERR:', e)
    if w: print(c.name, 'WARN:', w)

20. Build in one big execute_python_script call, not many small ones

Each API round-trip adds latency. Bundle node creation + parameter setting + wiring into a single script that creates everything at once, then verify in one final call.

21. Window COMP param for display target is winop (not top or window)

To display output in a separate window:

win = root.create(windowCOMP, 'display')
win.par.winop = '/project1/logo_out'  # ← this is the correct param
win.par.winw = 1280; win.par.winh = 720
win.par.winopen.pulse()  # open the window

22. Save the project to make API persistent across TD restarts

After deploying the custom API handler, save the project:

td_exec("project.save(os.path.expanduser('~/Documents/HermesAgent.toe'))")

TD auto-opens the last saved project on launch. The API handler is now baked into the .toe file — next time TD opens, port 9981 is live with zero manual steps. To explicitly launch with this project: open /Applications/TouchDesigner.app ~/Documents/HermesAgent.toe

23. sample() returns frozen pixels when called from WebServer DAT callback

out.sample(x, y) called from inside the API handler's exec() returns pixels from a single cook snapshot. It does NOT update between multiple API calls in quick succession. To verify animation is working, either:

  • Compare samples with a 2+ second delay between separate td_exec() calls
  • Use screencapture on the display window
  • Check absTime.seconds is advancing and shader uses time correctly

22. outputresolution is a string menu, not an integer

25. MovieFileOut TOP: H.264/H.265 requires Commercial license

In Non-Commercial TD 099, encoding with H.264 or H.265 produces an error: "GPU Accelerated H.264/H.265 Encoding requires a Commercial license". Use Motion JPEG instead:

rec = root.create(moviefileoutTOP, 'recorder')
rec.par.type = 'movie'
rec.par.file = '/tmp/output.mov'
rec.par.videocodec = 'mjpa'  # Motion JPEG — works on Non-Commercial

For image sequences, use type = 'imagesequence' and the file param must use me.fileSuffix:

rec.par.type = 'imagesequence'
rec.par.imagefiletype = 'png'
rec.par.file.expr = "'/tmp/frames/out' + me.fileSuffix"

26. MovieFileOut .record() method may not exist

In TD 099, there is no .record() method on moviefileoutTOP. Use the toggle parameter instead:

rec.par.record = True   # start recording
rec.par.record = False  # stop recording

When setting the file path and starting recording in the same script, use run() with delayFrames to avoid a race condition where the old filename is used:

rec.par.file = '/tmp/new_output.mov'
run("op('/project1/recorder').par.record = True", delayFrames=2)

27. TOP.save() captures same frame when called rapidly

op('null1').save(path) captures the current GPU texture at call time. When called multiple times in a single script (or rapid API calls), TD doesn't cook new frames between saves — all exported PNGs will be identical. To get unique frames, use the MovieFileOut TOP which records in real-time from TD's cook cycle.

28. AudioFileIn CHOP: cue before recording for sync

When recording audio-reactive visuals, always cue the audio to the start before beginning the recording. Otherwise the visuals are synced to wherever the audio happens to be in its playback:

op('/project1/audio_in').par.cue.pulse()  # reset to start
run("op('/project1/recorder').par.record = True", delayFrames=3)

The audio plays via playmode=0 (Locked to Timeline), so it stays in sync with TD's frame clock. Use audiodeviceoutCHOP to hear the audio during recording.

29. Audio Spectrum CHOP output is weak — boost with Math CHOP

The raw AudioSpectrum CHOP output has very small values (often 0.001-0.05 range). When fed directly to CHOP To TOP → GLSL, the shader barely reacts. Always insert a Math CHOP with gain=5 (or higher) between the spectrum and the CHOP To TOP to get usable 0-1 range values in the shader.

30. CHOP To TOP texture size — Resample to 256 first

choptoTOP creates a texture where width = number of samples. An AudioSpectrum CHOP at 44100Hz has ~24000 samples — creating a 24000×1 texture is wasteful. Use a Resample CHOP set to 256 or 512 samples before the CHOP To TOP for an efficient spectrum texture.

31. CHOP To TOP has NO input connectors — use par.chop reference

choptoTOP does NOT have input connectors. resample.outputConnectors[0].connect(chop_to_top.inputConnectors[0]) silently does nothing. Use the chop parameter instead:

spec_tex = root.create(choptoTOP, 'spectrum_tex')
spec_tex.par.chop = resample  # ← correct: parameter reference
# NOT: resample.outputConnectors[0].connect(spec_tex.inputConnectors[0])  # ← WRONG: no connectors

22. outputresolution is a string menu, not an integer

The outputresolution param is a menu with string values:

menuNames: ['useinput','eighth','quarter','half','2x','4x','8x','fit','limit','custom','parpanel']

Always use the string form. Setting outputresolution = 9 may silently fail.

node.par.outputresolution = 'custom'  # ✓ correct
node.par.resolutionw = 1280; node.par.resolutionh = 720

Discover valid values: list(node.par.outputresolution.menuNames)

23. Large GLSL shaders break curl JSON escaping

GLSL code full of single/double quotes, backslashes, and special chars will corrupt the JSON payload when sent via curl -d. Write the shader to a temp file and load it in TD:

# Agent side: write shader to /tmp/shader.glsl via write_file
# TD side (via td_exec):
sd = root.create(textDAT, 'shader_code')
with open('/tmp/shader.glsl', 'r') as f:
    sd.text = f.read()

This avoids all escaping issues. The TD Python environment has full filesystem access.

24. TD crashes lose everything — the WebServer DAT must be re-deployed

If TD crashes (common with heavy GLSL or rapid-fire API calls), all nodes including the WebServer DAT are lost. On relaunch, port 9981 is dead. Recovery:

  1. Detect: curl returns exit code 7 (connection refused) or lsof -i :9981 shows nothing
  2. Check: pgrep TouchDesigner to confirm TD is running
  3. Re-deploy: user must paste exec(open('...custom_api_handler.py').read()) into Textport again
  4. Verify: poll port 9981 until API responds

The td_exec() helper should handle this gracefully:

def td_exec(script):
    escaped = json.dumps({"script": script})
    cmd = f"curl -s --max-time 15 -X POST -H 'Content-Type: application/json' -d {shlex.quote(escaped)} 'http://127.0.0.1:9981/api/td/server/exec'"
    r = terminal(cmd, timeout=20)
    if r.get('exit_code') == 7:
        return {'error': 'TD not responding — WebServer DAT may need re-deploy'}
    try:
        return json.loads(r['output'])
    except:
        return {'error': 'Bad response', 'raw': r['output'][:200]}