Skip to content

Command dispatch vs script dispatch

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.

blender_send_message with a job_dispatch payload is the escape hatch. Use it when:

  • Multi-step Python in one round-trip. A script that allocates a mesh, runs bmesh operations, materializes the object, links it to the collection, and reports back is one bus hop. The equivalent as separate command calls is N hops with N timeouts.
  • 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.
  • Stateful patterns — script-local variables that survive across statements in one execution context. Multiple execute_code calls each get a fresh exec_globals.
  • Tools you’re prototyping. Faster to send a script than to add a @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.

Axiscommand_dispatch (via tool)job_dispatch (script)
LatencyOne bus round-trip per tool callOne round-trip for N statements
SafetyTyped kwargs, no exec of LLM-generated stringsexec(compile(script, ...)) — full surface
Payload sizeSmall (kwargs dict)Whole script string
Debuggabilityjob_id in reply; addon handler name in tracebacks<job_id> shows as the source filename in tracebacks
ExpressivenessWhatever the handler exposesAnything bpy/bmesh/mathutils can do
VersioningTool surface stable across addon updatesScript may break if bpy API shifts
MCP discoverabilityAppears in list_tools, schema introspection worksSingle 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:

  1. Picks a target Blender client (or returns no_client / ambiguous_target).
  2. Registers an asyncio.Future via the server’s JobWaiter keyed on (user_id, job_id).
  3. Routes a command_dispatch payload through the user’s bus to the chosen client.
  4. await asyncio.wait_for(future, timeout=<per-tool default>).
  5. The addon’s drainer executes the handler, calls blender_job_update(job_id, status, result, error).
  6. The server’s job_update tool routes the reply on the bus and calls job_waiter.deliver(user_id, job_id, ...).
  7. The Future resolves. The dispatch tool returns its JSON wire body.

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_command
result = 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": ""
}

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_targettarget_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.