dw.serve runs the workflow engine as a local HTTP server with a full web
UI: browse and run workflows, build them in a form-based editor with
introspection-driven autocomplete, watch jobs stream live progress, review
past generations in a gallery, and manage the models on disk.
python -m dw.serve # http://127.0.0.1:8765
python -m dw.serve --port 8000 --workflow-dir ./workflows --output-dir ./outputs --prompt-dir ./prompts
# or point it at a workspace, which supplies all four directories
python -m dw.serve --workspace ~/studio
# your own workflows, with a checkout's examples alongside them read-only
python -m dw.serve --workspace ~/studio --examples-dir ~/src/diffusers-workflow/workflows
python -m dw.serve --host 0.0.0.0 --token "some-long-random-string" # reachable off this machine
python -m dw.serve --host 0.0.0.0 --token "..." --mcp # ...and drivable by an agent on another machine
python -m dw.serve --trust-workflows # only if nothing untrusted can reach POST /api/jobs - see Security modelInstalled as a package, the same server is dw-serve. Interactive API docs
(OpenAPI) are at /docs.
The server keeps the REPL's persistent GPU worker underneath: models stay loaded between runs, so re-running a workflow with a new prompt skips the load entirely.
- Workflows — every workflow on the search path (the workspace's own
--workflow-dirfirst, then any--examples-dir, read-only), as cards with descriptions, output kinds, and variable counts. Folders one level deep become sections. Click through to a run form generated from the workflow's variables, with the raw JSON alongside. When the server holds more than one workspace, a picker here chooses which one's workflows are listed and where a save lands. - Prompts — the prompt library under
--prompt-dir(default: discovered the way a CLI run discovers it, then pinned for every job, so the page andprompt:resolution always agree), plus the read-onlyprompts/beside each--examples-dir, so an example'sprompt:references resolve: stored prompts as cards with descriptions, intended-model badges, and tags, foldered the same way workflows are. Each opens in an editor with form, split, and schema-aware JSON views, and an Enhance with AI panel that expands an idea into a full prompt with a local language model (a preset per target model family; the model runs as an ordinary queued job). A workflow argument written asprompt:nameloads the stored text at run time, and deleting a prompt warns which workflows reference it. - Jobs — the queue and full run history (persisted in
~/.diffusers_helper/jobs.sqlite), spanning every workspace with a filter to narrow to one; each job says which workspace it ran in, and keeps it through a rerun. A running job streams step-by-step progress, per-step denoising ticks, what each step is doing when it is not denoising (loading a model, decoding, saving), and its result files as they land. Jobs can be cancelled mid-denoise and re-run with one click. - Editor — build or modify workflows without writing JSON by hand.
Forms are generated from the live pipeline signatures (see
introspection), references
(
variable:/previous_result:) autocomplete from the workflow itself, and three views — form, split, and raw JSON — edit the same definition. The split view puts the form beside the JSON with both sides editable; changes apply when a side loses focus. Validate, save, and run from the same screen. A Monaco editor with the workflow JSON schema backs the JSON views. A fourth view, flow, renders the workflow's data-flow graph read-only: one box per step, arrows for eachprevious_resultreference labeled with the argument it feeds, entry-point steps marked apart from steps that depend on earlier ones, and fan-in points - steps combining more than one upstream producer - flagged with the cartesian-product multiplier where it's known statically (e.g. a literalnum_images_per_prompton both producers). It's a diagram of the JSON, not a second way to edit it; clicking a step jumps to it in the form view. - Gallery — everything in the selected workspace's output directory,
which the engine lays out as
<workflow>/<run id>/. The folder filter groups a workflow's runs together rather than listing each run separately, and each run directory also holds amanifest.jsondescribing what produced it (see Workspaces). Images generated withembed_metadatacarry their full workflow definition and seed; open as workflow loads that definition into the editor with the seed pinned, so any image can be reproduced or riffed on. Each tile carries a checkbox (shift-click extends a range, Select all takes whatever the filter leaves showing); a selection can be downloaded as one zip or deleted in bulk, which is how a directory that fills up over a few hundred runs gets cleared out. Anything that fails to delete stays selected. Keep as asset promotes one generated file into the workspace's asset library under a stable name, so a later workflow can reference it asasset:<name>instead of a run id that pruning would break. - Models — the Hugging Face hub cache: every cached repo with sizes, revisions, and last-used dates, plus free disk space. Download a repo by id with live progress (cancellable; partial files resume on retry), and delete to free disk (refused while a job is running; the next workflow that needs the model downloads it again). The page also shows the installed diffusers version (with its commit for a git install) and can upgrade it to GitHub HEAD - new model pipelines usually land there before a PyPI release. The idle worker restarts on success so the next job imports the new version; the upgrade is refused while a job runs.
- Server — what this server is and how to reach it: device, version,
bind address and LAN addresses, whether a token is required, whether
/mcpis mounted (with theclaude mcp addline to connect to it), the directories in use, and the workspaces on this server — created and deleted from here. - Schema — the workflow JSON schema the running server validates
against, as a browsable tree: the document root plus every definition,
with types, required markers, defaults, enums, and descriptions.
$reflabels jump to their definition; a filter narrows the list.
One server can hold several workspaces — each with its own workflows/,
assets/ and outputs/, all sharing the root's one prompt library. The
root's own folders are the workspace named default.
Every scoped route takes an optional ?workspace=<name>; omitting it means
default, so nothing written against a single-workspace server changes
meaning. POST /api/jobs also accepts "workspace" in the body, and a job
holds onto the directories it was submitted with — through the run, a rerun,
and when history serves its files back. GET /api/jobs spans every workspace
unless one is named.
A workspace is a namespace, not a security boundary: the API token is all-or-nothing. See Workspaces.
The Server page lists them, creates them, and deletes them - beside the
directories it resolved and the claude mcp add line for connecting an agent
from another machine:
| Route | What it does |
|---|---|
POST /api/jobs |
Queue a run: {"workflow_path": ...} or an inline {"workflow": {...}, "base_dir": ...}, plus arguments for variable overrides. workflow_path accepts a stored workflow name as listed by /api/workflows (with or without .json, nested names included), or a relative/absolute path that still resolves under --workflow-dir - confined the same way the /api/workflows CRUD routes are; a path that names a real file outside that directory is rejected with 400, not opened. Answers with argument warnings from signature checking. |
GET /api/jobs |
Queue + history summaries |
GET /api/jobs/{id} |
Full detail: spec, events, manifest, error. A manifest entry for a step served from the step cache carries reused: true |
GET /api/jobs/{id}/workflow |
The workflow the job ran: {id, definition, realized}. realized: true is the copy the run itself wrote (workflow.json in its run directory), with arguments, seed, prompts and output:latest pinned; false falls back to the submitted definition, which is what a job from before run tracking has. 404 means neither is readable - the job itself still is |
POST /api/jobs/{id}/export?workspace=&overwrite= |
Gather one finished job into <workspace>/exports/<job id>/: workflow.json, manifest.json, job.json, README.md, assets/, inputs/, outputs/. 201 with the file list, total bytes, anything it could not find, a zip_url, and the three JSON files inline. 404 unknown job, 409 for a job still running or an existing export without overwrite |
GET /exports/{id}.zip?workspace= |
The same tree as one archive, built on request rather than kept as a second copy. Entries are named <job id>/<relative path>. Ungated exactly as /outputs is |
GET /api/jobs/{id}/events |
Server-sent events stream; ?after=N / Last-Event-ID replay missed events, so reconnects are lossless |
GET /api/jobs/{id}/event-log?after=-1&limit=200 |
The same events as the SSE stream, as one JSON page: {id, status, events, last_seq, truncated, note}. after is exclusive; page by passing back the previous last_seq. A job restored from history serves the bounded event tail persisted with it; a job that finished before events were retained returns an empty list and a note saying so. |
POST /api/jobs/{id}/cancel |
Cooperative cancel (takes effect at the next step boundary or denoise step) |
POST /api/jobs/{id}/rerun |
Re-queue a finished job's spec |
POST /api/jobs/{id}/move |
Reorder a queued job: {"direction": "up"|"down"|"front"|"back"}. Job listings carry each waiting job's queue_position. |
One job runs at a time (it is one GPU); submissions queue in order, and the waiting portion of the queue can be reordered.
Every event in the stream carries a seq and an event name:
| event | when | payload |
|---|---|---|
job_status |
queued/running/terminal transitions | status |
log |
worker output lines | message |
memory |
device memory after a run | info |
run_start |
the run directory is chosen, before the first step | run_id, identity, run_dir |
workflow_start |
the run begins | workflow, total_steps, steps, seed |
step_start / step_end |
each step | step, index, total_steps; files at the end. A step served from the step cache adds reused: true to step_end, and its files are the earlier run's files rather than newly written ones |
iteration_start |
each argument combination in a step | step, iteration, total_iterations |
pipeline_step |
each denoise step | step, total_steps |
phase |
the step changes what it is doing | phase, detail |
workflow_end |
the run finishes | manifest |
A step spends most of its wall clock outside the denoise loop, and
pipeline_step cannot see any of it. phase is what fills that silence:
loading (with the model or component in detail), cached (the same
pipeline as a previous run - milliseconds, not minutes), generating
(the denoise loop, or a chain's segment N/M - which is why the counter
restarts), decoding (latents, after the last denoise step), saving
(writing files, including video encode) and task (a task step, named in
detail). Emits are a handful per step, not per denoise tick.
The editor's forms come from these; they are just as usable from scripts:
GET /api/pipelines,GET /api/pipelines/{name}— diffusers pipeline classes and their call signaturesGET /api/classes?kind=...,GET /api/classes/{name}?target=call|init|load— any allowed class (diffusers + registered extension modules), described for calling, constructing, orfrom_pretrainedloadingGET /api/tasks— the task commands and processorsGET /api/tasks/{command}— a task's argument schema, read from its registered implementation's real signatureGET /api/schema— the workflow JSON schemaGET /api/guides— the documentation that bears on choosing a capability: each guide's name, what it covers, and its section headingsGET /api/guides/{name}?section=— one guide whole, or one section of it; section names match loosely. Served by the engine so an MCP client at another version reads the guides for the server it is driving, not its own. A checkout serves the repo'sdocs/; an install the copybuild_dist.shputs underdw/docs/POST /api/validate— schema validation plus signature-level argument warnings for pipeline and task steps (catches the typo before the model loads). Acceptsworkflow_path(same resolution and confinement as/api/jobs, above) as an alternative to inlineworkflow- exactly one of the two, or a 400. Every schema violation is returned inerrors([{path, message}], sorted by path, capped at 25), and joined one per line inerror.
-
GET /api/workflows— the stored workflow names, plus adetailsentry per workflow:description,kinds(the output content types' top-level halves),steps,variables(a count) andvariable_names, andprompt_refsnaming the stored prompts it leans on, andconfigures- for a workflow undermodels/, thetemplates/name it is a tuned configuration of, empty when it is a template itself or when the name does not resolve (thenconfigures_missingcarries what was written). Four more say what the workflow makes, read off its definition (a top-levelshape,traitsorsummaryin the file overrides):shape, one ofimage,image-set,image-edit,shot,sequence,audio,text,utility;traits, a sorted subset ofhas-audio,chained,image-conditioned,identity-referenced,needs-input-media,composes-workflows;summary, the first sentence of the description, capped at 120 characters; andcost, the maintainer-measured{device, name, vram_gb, minutes}runs, ornullwhen nobody has measured it. Amodels/entry takes itsshapeandtraitsfrom the template it configures and keeps its owncost. Enough to choose a workflow and know what to pass it without reading each one; the variable defaults are deliberately left out, being an order of magnitude more payload on a listing the UI reloads. Cached by file mtimeOptional query params narrow and shrink it:
?shape=&traits=&configures=&include_models=&view=compact.shapekeeps entries of that shape andtraits(comma-separated) those carrying all of them - an unknown value in either is a 400 whosedetaillists the vocabulary.configures=<template>keeps that template's model configs.view=compactis the agent's projection: it dropsdescription,origin,writable,prompt_refs,stepsandvariables, keepssummary,shape,traits,cost,kindsandvariable_names, and lists templates only unlessinclude_models=trueor aconfiguresasks otherwise. With no params the response is what it always was, plus the four new fields -
GET/PUT/DELETE /api/workflows/{name}— read, save, delete workflow files (confined to--workflow-dir) -
GET /api/workflows/{name:path}/download— download a workflow file as JSON -
GET /api/prompts,GET/PUT/DELETE /api/prompts/{name}— the prompt library (confined to--prompt-dir, names held to what aprompt:reference can load); saves are validated against the prompt schema, served atGET /api/prompt-schema -
GET /api/prompts/{name:path}/download— download a prompt file as text -
GET /api/enhancers,POST /api/enhance— prompt-enhancement presets, and{"idea": ..., "preset": ..., "model_name": ..., "device": ...}to queue an enhancement as an ordinary job whose saved text file is the result -
GET /api/gallery,GET /api/gallery/{name}/metadata,DELETE /api/gallery/{name}— outputs and their embedded metadata -
GET /api/gallery/{name:path}/download— download an output file -
POST /api/gallery/archive—{"names": [...]}(1-1000) bundles a multi-file selection into one zip, named by each file's gallery-relative path so output subfolders survive. A browser cannot zip on its own and throttles a burst of single downloads, so the gallery's bulk download goes through here; an unknown or out-of-directory name 404s the whole request rather than yielding a partial archive -
GET /api/workspaces,POST /api/workspaces({"name": ...}),DELETE /api/workspaces/{name}?acknowledged=true— the workspaces on this server. The workspace root's ownworkflows/assets/outputsare thedefaultworkspace and a named one is a subdirectory beside them, sharing the root's one prompt library. Delete answers with what it would remove and refuses until acknowledged, refuses the default, and refuses a workspace with jobs still queued. A workspace is a namespace, not a security boundary: the API token is all-or-nothingexports/sits beside the workspace's own folders, holding one directory per exported job. It is a reserved name: no workspace can be calledexports, and the folder is never listed as one. -
GET /api/assets— the asset library: input media, each with theasset:reference a workflow carries rather than a path, since a path only means something on the server's own machine. Empty rather than an error when no library is configured -
POST /api/assets/keep({"name": ..., "asset_name": ..., "overwrite": false}) — keep a generated file as an input asset under a stable name, returning itsasset:reference. A run's files are named by the run that made them, which is the wrong thing for a later workflow to depend on:latestmoves and a pinned run id breaks when outputs are pruned. The copy happens inside the workspace and is a hard link where the filesystem allows one, so keeping one frame of a large render costs no second copy of it. Refuses an existing name unlessoverwrite -
POST /api/uploads?filename=...— the raw bytes of one image, video or audio file (200MB ceiling, checked fromContent-Lengthbefore a byte is read, and again on the body; extension held to the allowed image/video list), saved into the asset library'suploads/subfolder under a generated name. Answers 201 withpath-asset:uploads/<name>, the reference a saved workflow can carry and still resolve on a later run - andurl, the same file under the/inputsmount, for the editor's preview. A server started without an asset library falls back to the output directory'suploads/and an absolute path. This is how the UI's file pickers get a local file onto the machine that will run the workflow. The body is the file itself, so no multipart parser is needed for a single-file upload -
GET /api/models,DELETE /api/models?repo={repo_id}— hub cache inventory and deletion -
POST /api/models/download({"repo_id": ...}),GET /api/models/downloads,POST /api/models/downloads/{id}/cancel— background snapshot downloads with byte-level progress -
GET /api/system/diffusers,POST /api/system/diffusers/update— installed diffusers version/commit, and a background diffusers install/ update (refused while a job is running or queued). The POST body is optional JSON,{"commit": ..., "revert": ...}: with neither, itpip install --upgrades from GitHub HEAD;commit(7-40 hex characters, validated before it reaches the command line) pins the git install to that commit instead of HEAD;revert: truepins back to the known-good published release instead of installing from git - the diffusers floor version read frompyproject.toml(pip install diffusers==<floor>).commitandrevertare mutually exclusive. The status response includesbefore(the version/commit that was installed when the update started) alongside the liveversion/commit, so a revert has a concrete before/after to compare -
GET /api/memory,GET /api/health— worker VRAM/RAM stats and liveness; health also reportshostname,deviceand whethermcpis mounted, so a remote client can tell which machine answered -
GET /api/server— connection details for the Server page:hostname,version,device, thebind_host/port/wildcard_bindthe server was started with,auth_required(whether a token is configured - never the token itself),mcp(mountedplus itspath), the machine's non-loopbackaddresses, and thedirectoriesin use; a client composes its URLs from an address, the port and the MCP path
The server is built to serve your own GPU to your own browser, not the network:
- Binds to
127.0.0.1by default.--host 0.0.0.0(or any other non-loopback address) is possible; without a token configured (see Authentication, below) the server logs a startup warning, since anything that can reach that address can queue jobs and browse/delete files. - Requests carrying an
Originheader are rejected (403) unless its hostname is a loopback name, the configured--host, or the hostname the request itself was addressed to (Host). The last clause lets a browser on another machine use a--host 0.0.0.0server by LAN IP or hostname; it still blocks cross-site pages and DNS rebinding, where the attacker's page carries its ownOriginwhileHostis whatever resolved. Scheme and port are ignored, so a TLS-terminating proxy that forwardsHostunchanged needs no configuration. - Requests carrying a
Hostheader that names neither a loopback address nor the configured--hostare rejected (400). A wildcard bind (--host 0.0.0.0or::) skips this check - clients reach such a server by the machine's LAN IP or hostname, never by the bind address, so there is no allowlist to build from it. This is defense-in-depth, not the DNS-rebinding fix by itself - theOrigincheck above already covers browser requests, since a browser'sOriginreflects the real requesting origin regardless of what DNS name resolved to this address. TheHostcheck closes the remaining gap: a non-browser client (curl, a script, the MCP client) that never sendsOriginat all. - Every path from HTTP input goes through
dw/security.pyvalidation; workflow files (both the/api/workflowsCRUD routes and aworkflow_pathgiven to/api/jobsor/api/validate) are confined to the workflow directory, prompt files to the prompt directory, outputs to the output directory, and traversal (../) is blocked throughout. - Inline workflow definitions are schema-validated before queueing, and
their
base_diris validated like any other path input. - A workflow JSON file can execute arbitrary Python (
pre_load_modules, dotted*_type/config_typevalues - see Trust model).dw-serverefuses that surface by default for every job it runs, inline or from a file, MCP-submitted or not;--trust-workflowslifts the refusal for the whole server and should only be passed when nothing untrusted can reachPOST /api/jobs. --mcpmounts the MCP tool surface at/mcp(Streamable HTTP) behind the same token as/api, for an agent on another machine with no local install. Both/mcpand/mcp/are answered, and the token is accepted only as anAuthorization: Bearerheader there - never as?token=. It is refused on a non-loopback bind without a token. See REMOTE.md.
There is no authentication by default - the checks above assume a trusted local machine or LAN. An optional static bearer token closes that gap:
python -m dw.serve --token "some-long-random-string"
# or
export DW_API_TOKEN="some-long-random-string"
python -m dw.serveWhen a token is configured, every /api/* request must carry
Authorization: Bearer <token> or gets a 401. The UI's own static files and
/outputs (generated media) stay reachable without it - the page has to
load far enough for a user to enter the token, and an <img>/<script>
tag cannot attach a header anyway. A few GET API routes additionally accept the
token as a ?token=... query parameter, because the browser loads them
without being able to set headers: the SSE stream,
GET /api/jobs/{id}/events (EventSource), and the gallery grid's
GET /api/gallery/{name}/thumbnail (an <img> tag). The three /download
routes (gallery output, workflow, prompt) accept ?token=... the same way,
since a download button is a plain <a href download> navigation that
cannot set a header either. That is a deliberate,
narrower trade-off (a token that can leak into logs or browser history for
those URLs) rather than a general alternative to the header - every other
route accepts the header only.
The web UI has a one-time token field (next to the theme toggle) that
stores the token in localStorage and attaches it to every API call,
including the two query-parameter routes above. The MCP server reads the
same DW_API_TOKEN variable (or dw-mcp --token), so one export
configures both ends - see MCP.md. It is a convenience, not a
credential vault - anyone with access to the browser profile can read it
back out of localStorage.
A token configured this way is a single shared static secret, not a login
system: there is one token, checked with a constant-time comparison, and no
notion of separate users or sessions. It raises the bar for exposing the
server on a LAN or beyond; it is not a substitute for a real network
boundary (a firewall, a VPN, or simply binding to 127.0.0.1) for anything
more exposed than that.
Running on another machine: REMOTE.md is the end-to-end recipe
- token, firewall, systemd unit, the browser,
--mcp, and what to do beyond the LAN.
