← All Modules

assay.excalidash

ExcaliDash client for a self-hosted dashboard: create and edit Excalidraw drawings, organise them into collections, walk their version history, and share them with users or by link.

The API is plain REST, but the credential decides how much of it you can reach — that is the one thing worth understanding before writing a script against it.

Two credentials, two reaches

ExcaliDash authenticates a script one of two ways, and they are not interchangeable.

API key   Authorization: Bearer exd_...     no CSRF, four routes only
session   Authorization: Bearer <jwt>       every route, CSRF handshake on writes

An API key (exd_…, made in the dashboard under Settings) is the credential built for automation. It carries scopes — drawings:read, drawings:write, collections:read, collections:write — and it is exempt from CSRF, but only on requests that carry no Origin and no Referer header. That exemption is how the server tells a script from a browser.

The catch is that the server's scope gate recognises only four route shapes:

RouteAPI keySession
GET/POST /drawingsyesyes
GET/PUT/DELETE /drawings/:idyesyes
GET/POST /collectionsyesyes
PUT/DELETE /collections/:idyesyes
/drawings/:id/history/*noyes
/drawings/:id/sharing, /permissions, /link-sharesnoyes
/drawings/:id/duplicatenoyes
/drawings/sharednoyes
/collections/:id/shares/*noyes

Anything deeper is refused before the handler runs — 403 on the requireAuth routes and a bare 401 on the optionalAuth ones, neither of which says why. The module refuses those calls itself, naming the credential you are missing:

excalidash: version history is not reachable with an API key; pass token=
(a session access token) or set EXCALIDASH_TOKEN

A session token is the JWT the browser holds after login. It reaches everything, but unsafe methods then need CSRF: the module fetches /csrf-token once on first write, keeps the token and the excalidash-csrf-client cookie it is bound to, and sends both on every write after that. Reads never pay for the handshake.

Hold both and the module picks per route — the API key wherever it works, the session for the rest.

Client

local excalidash = require("assay.excalidash")
local c = excalidash.client({
  api_key = env.get("EXCALIDASH_API_KEY"),
  base_url = "https://draw.example.com",
})

excalidash.client(opts) accepts:

A wrong api_path is worth getting right: the dashboard answers any unknown path with the SPA's HTML and a 200, so a read would otherwise report an empty dashboard rather than a mistake. The module refuses a non-JSON body and says which setting to check.

Drawings

Scene writes are versioned. Passing the version you read makes the write conditional, and a drawing that moved on since then is refused rather than clobbered:

local d = c.drawings:get(id)
local ok, err = pcall(function()
  return c.drawings:update(id, { elements = edited, version = d.version })
end)
-- err names VERSION_CONFLICT; re-read and merge

Every scene write snapshots the previous state first, which is where version history comes from.

Collections

Collections are flat, owner-scoped folders. Trash is one of them, reported as the id trash whatever it is called internally.

Deleting a collection does not delete its drawings; they are moved out to no collection at all.

Version history

All session-only. Snapshots are kept for two days and swept hourly, so history is a short window rather than an archive.

Restoring snapshots the current state first, so a restore is itself reversible. version is the drawing's current version and guards the write exactly as a scene update does; servers from 0.6.0 on require it and answer 400 without one.

Sharing

All session-only.

Only one link share is active per drawing: creating another revokes the one before it. spec.expires_at is an ISO timestamp at least a minute out, or false for no expiry at all — which the server honours for view and overrides with its own ceiling for edit. Omitting it entirely gets the server's default TTL, which is a different thing from false.

Helpers

Example

#!/usr/bin/env assay
local excalidash = require("assay.excalidash")

local c = excalidash.client({ base_url = "https://draw.example.com" })

local folder = excalidash.ensure_collection(c, "Architecture")
local d = excalidash.ensure_drawing(c, {
  name = "Ingress path",
  collection_id = folder.id,
  elements = scene.elements,
  app_state = scene.appState,
})

log.info("drawing " .. d.id .. " is at v" .. d.version)

for _, old in ipairs(excalidash.all_drawings(c, { search = "draft" })) do
  excalidash.trash(c, old.id)
end