Need scene/object data?
blender_get_scene_info, blender_get_object_info, blender_browse_data, or the blender://scene/* resources.
Two payloads can land on a Blender addon: command_dispatch (a named handler with typed kwargs) and job_dispatch (an arbitrary Python script). Both round-trip through the same bus, both resolve through the same reply machinery. They differ in what the LLM sends and what runs on the addon side.
For almost every LLM call, use a dispatch tool directly:
blender_get_scene_info()blender_get_object_info(name="Cube")blender_download_polyhaven_asset(asset_id="kloofendal_43d_clear_puresky", asset_type="hdris")blender_execute_code(code="import bpy; print(len(bpy.data.objects))")Each is a flat MCP tool. The LLM calls it like any other tool, gets a JSON result back, moves on. No job_id to invent, no _message_bus subscription to maintain, no notification filter to write.
The bus is still there — the server runs the round-trip under the hood — but the LLM doesn’t see it.
job_dispatchblender_send_message with a job_dispatch payload is the escape hatch. Use it when:
bmesh and other low-level APIs that aren’t wrapped as @command handlers. The dispatch surface covers what the addon’s registry exposes; everything else needs execute_code or job_dispatch.execute_code calls each get a fresh exec_globals.@command handler, rebuild the addon, and reinstall it.blender_execute_code is the middle ground — it’s a dispatch tool, so it gets the synchronous-feel, but the body is a free-form Python string. Reach for raw job_dispatch only when you want to bypass the per-tool timeout cap or layer additional metadata on the payload.
Need scene/object data?
blender_get_scene_info, blender_get_object_info, blender_browse_data, or the blender://scene/* resources.
Need to run Python?
blender_execute_code(code=...). Returns stdout. 60s default timeout.
Need a screenshot?
blender_get_viewport_screenshot(filepath=...). Returns {filepath, width, height}.
Need PolyHaven/Sketchfab/Rodin?
Status probe first (blender_get_polyhaven_status), then the matching _search / _download tool.
Need orchestration across steps?
One blender_execute_code with the whole flow, or blender_send_message + job_dispatch for full control.
Need to observe other dispatches?
Subscribe to _message_bus notifications directly. See Write your own LLM client.
| Axis | command_dispatch (via tool) | job_dispatch (script) |
|---|---|---|
| Latency | One bus round-trip per tool call | One round-trip for N statements |
| Safety | Typed kwargs, no exec of LLM-generated strings | exec(compile(script, ...)) — full surface |
| Payload size | Small (kwargs dict) | Whole script string |
| Debuggability | job_id in reply; addon handler name in tracebacks | <job_id> shows as the source filename in tracebacks |
| Expressiveness | Whatever the handler exposes | Anything bpy/bmesh/mathutils can do |
| Versioning | Tool surface stable across addon updates | Script may break if bpy API shifts |
| MCP discoverability | Appears in list_tools, schema introspection works | Single tool (blender_send_message) with opaque payload |
The dispatch tools were built so the default LLM interaction doesn’t need to know about the bus. Script-dispatch stays available because no fixed command surface covers everything Blender can do.
Both paths resolve through the same (user_id, job_id) correlation. A dispatch tool’s body:
no_client / ambiguous_target).asyncio.Future via the server’s JobWaiter keyed on (user_id, job_id).command_dispatch payload through the user’s bus to the chosen client.await asyncio.wait_for(future, timeout=<per-tool default>).blender_job_update(job_id, status, result, error).job_update tool routes the reply on the bus and calls job_waiter.deliver(user_id, job_id, ...).A script-dispatch from a custom LLM client does the same thing, except step 2 is implemented client-side (the LLM holds its own JobWaiter and subscribes to _message_bus). The reply at step 5 is identical — blender_job_update doesn’t know or care which path the dispatch came from.
What a dispatch tool sends:
{ "message_type": "command_dispatch", "job_id": "j-7a3f2c1e9b04", "command": "get_object_info", "params": {"name": "Cube"}}What the addon does:
# addon/client/drainer.py — execute_commandresult = client.executor.execute_command( {"type": command, "params": params})What comes back:
{ "status": "completed", "command": "get_object_info", "target_uuid": "blender-abc", "job_id": "j-7a3f2c1e9b04", "result": "<addon handler's return value, JSON-encoded>", "error": ""}What blender_send_message carries with a job_dispatch payload:
{ "message_type": "job_dispatch", "job_id": "job-42", "script": "import bpy\nfor o in bpy.data.objects:\n print(o.name)"}What the addon does:
# addon/client/drainer.py — execute_scriptwith redirect_stdout(output): exec(compile(script, f"<job_{job_id}>", "exec"), exec_globals)What comes back (via blender_job_update):
{ "kind": "job_update", "job_id": "job-42", "status": "completed", "result": "<captured stdout>", "error": ""}command_dispatch adds structured failure statuses the script path doesn’t have:
no_client — no Blender peer registered on the user’s bus.ambiguous_target — multiple Blender peers; pass target_uuid to disambiguate.unknown_target — target_uuid doesn’t match any registered client.timeout — addon didn’t reply within the per-tool deadline; the Future is cancelled.failed — handler raised, or the command is gated off in addon preferences.completed — handler returned; result is the JSON-encoded return value.job_dispatch doesn’t have a server-side disambiguation step because blender_send_message takes target_uuid directly. The same completed/failed outcome applies, surfaced via the kind: "job_update" payload on the bus.