Call Excalidraw's imperative API from Python through a JSON-safe command prop dispatched once per id.

Command dispatch

Call Excalidraw's imperative API from Python through a JSON-safe command prop dispatched once per id.


Overview

Every imperative API the upstream Excalidraw exposes is callable from Python as a command prop: a dict with id, type, and payload. The component dispatches once per unique id, then clears the prop so React re-renders don't re-fire.

The twelve command types

typePayloadNotes
updateScene{elements?, appState?, collaborators?, captureUpdate?}See history, below
addFilesA list of {id, mimeType, dataURL, created}Raw passthrough
replaceFilesA map {fileId: {dataURL, mimeType?}}Stores the new bytes under a fresh id and repoints the elements
resetScene{}
scrollToContent{target?, opts?}
setActiveTool{type: "selection"}, {type: "rectangle"}, …Defaults to selection
setToast{message, duration?}, or None to clear
toggleSidebar{name, tab?, force?}name is the sidebar — Excalidraw's built-in one is "default"; "library" and "search" are tabs inside it. Naming a tab as the sidebar does nothing at all.
updateLibrary{libraryItems, merge?}
exportToSvgExport optionsReplies on lastExport
exportToBlobExport options, {mimeType?}Replies on lastExport
exportToCanvasExport options, {mimeType?}Replies on lastExport

addFiles and replaceFiles both reach Excalidraw's addFiles, but they are not interchangeable. Use replaceFiles to swap an existing file's bytes — it takes the id-keyed map, fills created and mimeType for you, and skips malformed entries; that is the shape File uploads uses to trade base64 for URLs.

It does not overwrite, because Excalidraw cannot. api.addFiles with an id the store already holds is a silent no-op — measured in a browser: add x inline, add x again with a URL, read getFiles(), and it is still the inline one. There is no removeFiles, and updateScene({files}) is ignored. So replaceFiles stores the new bytes under a new id and repoints every element that referenced the old one. The orphaned entry cannot be deleted, but nothing references it and externalizedSerializedData strips it anyway.

Until 2026-09-12 this command claimed the overwrite and silently did nothing, which is why /file-uploads' GIF auto-embed never fired: that page waits for a file whose dataURL has become a URL, and there never was one. Use addFiles only when you need the raw Excalidraw BinaryFileData list, including fields replaceFiles would drop. It is a straight passthrough, so the list you send is the list Excalidraw receives: you supply created and mimeType yourself, and an entry missing either is not an error — Excalidraw simply ignores it, so a malformed addFiles payload is a silent no-op rather than a visible failure.

# replaceFiles — id-keyed map; created/mimeType filled for you
{"id": cmd_id, "type": "replaceFiles",
 "payload": {file_id: {"dataURL": "https://cdn.example.com/a.png"}}}

# addFiles — raw BinaryFileData list; every field is yours to supply
{"id": cmd_id, "type": "addFiles",
 "payload": [{"id": file_id, "mimeType": "image/png",
              "dataURL": "data:image/png;base64,…",
              "created": 1757376000000}]}

Scene pushes and undo history

Excalidraw 0.18 replaced commitToHistory with captureUpdate, and changed what the default means. 0.17 left undo history untouched when no flag was given; 0.18 defaults to folding a programmatic push into the next captured action, so a user's first Ctrl+Z after your updateScene would also roll back their own previous edit. Nothing errors and nothing warns.

This wrapper therefore defaults captureUpdate to IMMEDIATELY: a dispatched scene push is a deliberate edit and undoes as one discrete step. Override it per command:

{
    "id": str(uuid.uuid4()),
    "type": "updateScene",
    "payload": {
        "elements": elements,
        "captureUpdate": "NEVER",   # or "IMMEDIATELY" (default), "EVENTUALLY"
    },
}
captureUpdateEffect on undo history
IMMEDIATELY(default) The push is its own undo step
NEVERThe push is not recorded; Ctrl+Z skips past it
EVENTUALLYFolded into the next captured action — Excalidraw's own 0.18 default

Two guardrails worth knowing. Passing the removed commitToHistory still works: it is translated (True → IMMEDIATELY, False → NEVER) and logs a one-time console warning telling you to switch. And an unrecognised captureUpdate value is not accepted silently — it warns and falls back to IMMEDIATELY, because a typo that looked like it took effect is the worse failure.

Live demo

Source

# File: docs/commands/commands.py

"""Command dispatch: drive Excalidraw imperatively from Python."""

from __future__ import annotations

import time
import uuid

import dash
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, no_update

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, code_block, sync_canvas_theme

sync_canvas_theme("commands-canvas")

CODE = """
@callback(
    Output('canvas', 'command'),
    Input('btn-rect', 'n_clicks'),
    prevent_initial_call=True,
)
def pick_rectangle(_):
    return {
        'id': str(uuid.uuid4()),
        'type': 'setActiveTool',
        'payload': {'type': 'rectangle'},
    }
"""


def _btn(btn_id: str, label: str, color: str = "indigo") -> dmc.Button:
    return dmc.Button(label, id=btn_id, color=color, variant="light", size="sm")


component = dmc.Stack(
    gap="md",
    children=[
        code_block(CODE),
        dmc.Paper(
            withBorder=True,
            p="md",
            radius="md",
            children=dmc.Stack(
                gap="sm",
                children=[
                    dmc.Text("Scene", size="sm", fw=600, c="dimmed"),
                    dmc.Group(
                        [
                            _btn("cmd-update", "updateScene (seed)"),
                            _btn("cmd-reset", "resetScene", color="red"),
                            _btn("cmd-scrollto", "scrollToContent"),
                        ]
                    ),
                    dmc.Text("Tools", size="sm", fw=600, c="dimmed"),
                    dmc.Group(
                        [
                            _btn("cmd-tool-rect", "setActiveTool: rectangle"),
                            _btn("cmd-tool-arrow", "setActiveTool: arrow"),
                            _btn("cmd-tool-select", "setActiveTool: selection"),
                        ]
                    ),
                    dmc.Text("UI", size="sm", fw=600, c="dimmed"),
                    dmc.Group(
                        [
                            _btn("cmd-toast", "setToast"),
                            _btn("cmd-sidebar", "toggleSidebar"),
                        ]
                    ),
                ],
            ),
        ),
        canvas_frame(
            DashExcalidraw(
                id="commands-canvas",
                height="600px",
            )
        ),
    ],
)


def _cmd(type_: str, payload: dict | None = None) -> dict:
    return {"id": f"{type_}-{uuid.uuid4()}", "type": type_, "payload": payload or {}}


@callback(
    Output("commands-canvas", "command"),
    Input("cmd-update", "n_clicks"),
    Input("cmd-reset", "n_clicks"),
    Input("cmd-scrollto", "n_clicks"),
    Input("cmd-tool-rect", "n_clicks"),
    Input("cmd-tool-arrow", "n_clicks"),
    Input("cmd-tool-select", "n_clicks"),
    Input("cmd-toast", "n_clicks"),
    Input("cmd-sidebar", "n_clicks"),
    prevent_initial_call=True,
)
def _dispatch(update_, reset_, scrollto_, rect_, arrow_, sel_, toast_, sidebar_):
    trigger = dash.ctx.triggered_id
    if trigger == "cmd-update":
        return _cmd(
            "updateScene",
            {
                "elements": [
                    {
                        "id": f"seeded-{int(time.time())}",
                        "type": "ellipse",
                        "x": 200,
                        "y": 200,
                        "width": 300,
                        "height": 180,
                        "angle": 0,
                        "strokeColor": "#e67700",
                        "backgroundColor": "#fff4e6",
                        "fillStyle": "solid",
                        "strokeWidth": 2,
                        "roughness": 1,
                        "opacity": 100,
                        "seed": 42,
                        "version": 1,
                        "versionNonce": 42,
                        "isDeleted": False,
                        "groupIds": [],
                        "frameId": None,
                        "boundElements": [],
                        "updated": 1,
                        "link": None,
                        "locked": False,
                    }
                ]
            },
        )
    if trigger == "cmd-reset":
        return _cmd("resetScene")
    if trigger == "cmd-scrollto":
        return _cmd("scrollToContent", {"opts": {"fitToViewport": True}})
    if trigger == "cmd-tool-rect":
        return _cmd("setActiveTool", {"type": "rectangle"})
    if trigger == "cmd-tool-arrow":
        return _cmd("setActiveTool", {"type": "arrow"})
    if trigger == "cmd-tool-select":
        return _cmd("setActiveTool", {"type": "selection"})
    if trigger == "cmd-toast":
        return _cmd(
            "setToast",
            {"message": "Toast dispatched from Python!", "duration": 2500},
        )
    if trigger == "cmd-sidebar":
        # "default" is the sidebar; "library" is a tab within it. Naming
        # the tab as the sidebar is a silent no-op — see docs/library.
        return _cmd("toggleSidebar", {"name": "default", "tab": "library"})
    return no_update

:defaultExpanded: false :withExpandedButton: true


Source: /commands

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: