The Stack I'd Need for Claude to Direct a Whole YouTube Video in Blender
Introduction
I've been experimenting with LLM agents that control Blender. The goal: have Claude generate a complete YouTube video—script, 3D scenes, animations, camera moves, and render—without human intervention. The result is a stack of Python components that bridge natural language to Blender's Python API. This post outlines the architecture, the code, and the hard limits I hit.
Problem
Blender is a GUI application with a massive Python API (bpy). An LLM can emit Python code, but directly executing it is dangerous and brittle. You need:
- Safety: Arbitrary code execution can delete files or crash the system.
- State management: Blender scenes are complex; the agent must track objects, materials, and timelines.
- Iterative refinement: A single prompt won't produce a polished video. The agent must plan, execute, observe, and fix.
- Rendering: Final output requires invoking Blender's render engine, which can take hours.
Naively asking Claude to "write a Blender script" yields code that often fails due to API changes or missing context.
Solution
I built a tool-using agent loop (inspired by ReAct-style prompting) where Claude calls a set of Python functions that manipulate Blender via bpy. The agent runs inside Blender's Python environment (or a subprocess with blender --background). The loop terminates when the agent calls a finish tool or exceeds a step limit.
Key components:
- Blender Bridge: A JSON-RPC server running inside Blender that executes whitelisted operations.
- Tool Definitions: Python functions decorated with metadata for Claude's tool-use API.
- Agent Loop: Sends conversation + tool results to Claude, parses tool calls, executes them, and appends results.
- Renderer: A final tool that triggers
bpy.ops.render.render(animation=True).
This avoids eval of arbitrary code. Each tool is a specific, auditable function.
Implementation
1. Blender Bridge (inside Blender)
Run this script in Blender's scripting tab or via blender --background --python bridge.py. It listens on a local socket for JSON commands.
# bridge.py import bpy import json import socket import threading HOST = '127.0.0.1' PORT = 65432 # Whitelisted operations def op_create_cube(name, location): bpy.ops.mesh.primitive_cube_add(location=location) obj = bpy.context.active_object obj.name = name return {'name': obj.name, 'location': list(obj.location)} def op_set_camera(location, rotation): cam = bpy.context.scene.camera if not cam: bpy.ops.object.camera_add() cam = bpy.context.active_object bpy.context.scene.camera = cam cam.location = location cam.rotation_euler = rotation return {'camera': cam.name} def op_render(output_path, frame_start=1, frame_end=1): bpy.context.scene.frame_start = frame_start bpy.context.scene.frame_end = frame_end bpy.context.scene.render.filepath = output_path bpy.ops.render.render(animation=True) return {'rendered': output_path} OPERATIONS = { 'create_cube': op_create_cube, 'set_camera': op_set_camera, 'render': op_render, } def handle_client(conn): with conn: data = conn.recv(4096).decode('utf-8') if not data: return try: msg = json.loads(data) op = msg.get('operation') args = msg.get('args', {}) if op not in OPERATIONS: response = {'error': f'Unknown operation: {op}'} else: result = OPERATIONS[op](**args) response = {'result': result} except Exception as e: response = {'error': str(e)} conn.sendall(json.dumps(response).encode('utf-8')) def start_server(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind((HOST, PORT)) s.listen() print(f'Bridge listening on {HOST}:{PORT}') while True: conn, _ = s.accept() threading.Thread(target=handle_client, args=(conn,)).start() # Start server in a background thread so Blender's UI remains responsive threading.Thread(target=start_server, daemon=True).start() Security note: This bridge executes only whitelisted functions. Never expose it to untrusted networks. Bind to 127.0.0.1 and use authentication if needed.
2. Tool Definitions for Claude
Define tools in a format compatible with Anthropic's tool-use API.
# tools.py tools = [ { "name": "create_cube", "description": "Create a cube mesh at a given location", "input_schema": { "type": "object", "properties": { "name": {"type": "string"}, "location": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3} }, "required": ["name", "location"] } }, { "name": "set_camera", "description": "Set the active camera's location and rotation (Euler angles in radians)", "input_schema": { "type": "object", "properties": { "location": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, "rotation": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3} }, "required": ["location", "rotation"] } }, { "name": "render", "description": "Render an animation to the given output path", "input_schema": { "type": "object", "properties": { "output_path": {"type": "string"}, "frame_start": {"type": "integer"}, "frame_end": {"type": "integer"} }, "required": ["output_path"] } } ] 3. Agent Loop
This loop sends messages to Claude, executes tool calls via the bridge, and appends results. It terminates when Claude calls a finish tool (not shown) or after max_steps.
# agent.py import json import socket import anthropic client = anthropic.Anthropic(api_key="YOUR_API_KEY") def call_bridge(operation, args): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.connect(('127.0.0.1', 65432)) s.sendall(json.dumps({'operation': operation, 'args': args}).encode('utf-8')) response = s.recv(4096).decode('utf-8') return json.loads(response) def run_agent(prompt, max_steps=20): messages = [{"role": "user", "content": prompt}] for step in range(max_steps): response = client.messages.create( model="claude-3-5-sonnet-20241022", max_tokens=1024, tools=tools, messages=messages ) # Append assistant's response messages.append({"role": "assistant", "content": response.content}) tool_calls = [block for block in response.content if block.type == "tool_use"] if not tool_calls: print("No tool calls, ending.") break for tool_call in tool_calls: if tool_call.name == "finish": print("Agent finished.") return result = call_bridge(tool_call.name, tool_call.input) messages.append({ "role": "user", "content": [{ "type": "tool_result", "tool_use_id": tool_call.id, "content": json.dumps(result) }] }) else: print(f"Reached max steps ({max_steps}).") 4. Orchestration
Start Blender with the bridge, then run the agent from a separate process.
blender --background --python bridge.py & python agent.py Key Takeaways
- Tool whitelisting beats code generation: Instead of letting Claude write arbitrary
bpycode, expose a small set of safe operations. This prevents catastrophic mistakes and makes debugging easier. - Explicit termination: The agent loop stops on a
finishtool call or a step limit. Without this, it could run indefinitely. - Security first: Never
evalLLM output. My bridge uses a whitelist and runs locally. If you must execute generated code, sandbox it (e.g.,RestrictedPythonor a container). - Iterative refinement is key: The agent can observe results (e.g., object locations) and adjust. For a full video, you'd add tools for keyframing, text, and compositing.
- Rendering is the bottleneck: Blender renders can take hours. The agent should plan the entire scene before triggering a render.
This stack is a starting point. With more tools (e.g., add_keyframe, set_material), Claude can direct increasingly complex animations. The code is available on GitHub—link in comments.