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. A run whose steps chose aresult.subfoldershows its results underfinal/andintermediate/headings (or whatever the step named), the deliverable first; one that chose none shows them as before. Jobs can be cancelled mid-denoise and re-run with one click. A finished job's Export button gathers the run into the workspace'sexports/on the server (POST /api/jobs/{id}/export) and downloads it as one zip - the same bundle MCP'sexport_jobmakes; an export that already exists asks before it is replaced. - 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 a subfolder pick beside the text filter - offered once any output landed in one - narrows the grid tofinal/,intermediate/or whatever a step'sresult.subfoldernamed; 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?workspace=&status=&limit= |
Queue + history summaries, oldest first, with total beside them. status narrows to one state or a comma-separated set (queued, running, succeeded, failed, cancelled; anything else is a 400); limit keeps the newest N, and total still reports how many matched, so a bounded answer cannot be mistaken for a complete one. No parameters means every job, which is what the web UI polls |
GET /api/jobs/{id} |
Full detail: spec, events, manifest, error. A manifest entry for a step served from the step cache carries reused: true. Every entry carries subfolder - the in-run subfolder the step's result.subfolder chose, '' for none. A for_each step appears in the manifest as its members (shot@wide_open, shot@closeup), because the manifest records what ran; the run's workflow.json keeps the for_each form, because it records what was asked |
GET /api/jobs/{id}/workflow |
The workflow the job ran: {id, definition, realized, seed_variable}. seed_variable names the variable a new_seed rerun would draw into (null when the workflow has none), read from the workflow as written rather than the realized copy, whose seed is pinned. 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. Every event carries seq and at - seconds since the job started (since it was queued, for the events before that) - so a step's cost is a subtraction: step_start to generating is what a reused pipeline still pays before it runs, generating to the first pipeline_step is the prompt and reference encoding |
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. Body {"new_seed": true} draws a fresh seed into the workflow's seed variable instead of repeating the original arguments; 400 when the workflow pins its seed to a literal or names none. A plain rerun of a seeded workflow is served whole from the step cache — the earlier run's files, republished with reused: true, generating nothing |
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 and subfolder 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. Emitted for a pipeline that takes a callback_on_step_end, and for a ModularPipeline (H3, LTX-2, Qwen-Image), which takes none - there the denoise block's own progress bar is what reports |
phase |
the step changes what it is doing | phase, detail |
pipeline_released |
a step with release_pipeline drops its pipeline |
step, index, gpu_memory_allocated_mb and gpu_memory_allocated_before_mb (both null where the backend cannot say). Emitted between the step's generation and its files being written, which is where the release happens - so the ordering is readable off the event stream rather than by trying to poll memory through a sub-second write |
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.
GET /api/jobs/{id} carries a progress block while a job is running
(null before it starts and once it is terminal, where the manifest is the
better answer). It is the same information the event log holds, kept as the
events arrive so a caller does not have to page back through a trimmed log
to learn where a long render is:
| field | |
|---|---|
step, step_index, total_steps |
the workflow step being run |
phase, phase_detail |
the latest phase and what it named |
seconds_in_phase |
how long it has been in it |
seconds_since_event |
how long since anything at all happened - the number that separates a slow run from a stuck one |
denoise_step, denoise_total_steps |
the denoise loop's counter, null until it starts |
A null denoise_step under generating is the pipeline's lead-in - encoding
the prompt and any reference image or audio - which emits nothing and runs
well over a minute on a large video model (~90 s on MiniMax H3). The keys are
always present so that lead-in can be told from a loop that has stopped
advancing: seconds_since_event is a stall signal once denoise_step is a
number, or in any phase other than generating.
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);warningsalso names an entry key of a list-driven variable that no step reads, at the entry's path (variables.shots[0]or, when the caller's ownargumentssupplied the list,arguments.shots[0]). 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. It also takes theargumentsa caller is about to run with, and checks them the way the run would: a name the workflow does not declare, a value that will not coerce to the declared type, and anasset:,prompt:oroutput:reference that names nothing this workspace can reach - each reported atarguments.<name>.POST /api/jobsmakes the same check and answers 400 rather than queuing a job that would fail on its first step;checked_argumentson a valid answer names what was covered, since without arguments the verdict is about the stored defaults only.
-
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 - a list-driven workflow'scostentry may also carry a measuredper_entry({variable, minutes, entries}), the cost of one entry of the list it was measured against. Amodels/entry takes itsshapeandtraitsfrom the template it configures and keeps its owncost. A list-driven workflow (one with afor_eachstep) also carrieslists: per list variable, the fields an entry takes, the steps run over it and the default's length. 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,kinds,variable_namesandlists(carried only when the workflow has a list-driven step, likeconfigures), and lists templates only unlessinclude_models=trueor aconfiguresasks otherwise. With no params the response is what it always was, plus the 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/workflows/{name:path}/variables— a workflow's variables and what they default to, without the definition around them. Long string defaults are cut to 200 characters and named intruncated, including strings inside a list default, named likeshots[0].prompt;full=truereturns them whole -
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. Each gallery entry carriesfolder(the workflow identity, the run id dropped) andsubfolder(what followed the run id - thefinal/intermediatea step'sresult.subfolderchose,''when it chose none);?folder=and?subfolder=filter independently, and the reply'sfoldersandsubfolderslist every distinct value over the whole tree,''always a member of each so root-level files stay selectable -
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. Each listed workspace carries ausage({files, bytes}) — roughly how much disk its own folders hold, walked at most once a minute per workspace and deliberately approximate; the shared prompt library counts against thedefaultworkspace alone rather than once per workspace. 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, "shared": 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.asset_namemay name a folder and takes the kept file's extension when it carries none (a contradicting one is a 400) — the same rule the upload route follows, and what keeps a kept asset from landing under an extensionless name the library listing never shows."shared": truekeeps it in<root>/common/assetsinstead — the library every workspace under the root shares, which is where a recurring cast belongs -
DELETE /api/assets/{name}— remove one file from the asset library, deleting from whichever library on the search path holds it (the workspace's own before the shared one, the orderasset:resolves in). An asset from a read-only examples library answers 403, the same as a read-only prompt or workflow; a name nothing holds answers 404 -
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 - the shared library at<root>/common/assetswhenshared=true, this workspace's own otherwise - under a generated name, or underasset_namewhen one is given (cast/priya-voice.wav, folders allowed, the uploaded file's extension assumed, confined to the library the waykeep_output's name is). 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; memory answerslive(whetherinfowas measured by this call),stale,reason(job_running,worker_stopped,worker_busy,worker_unreachable) andage_seconds, so a cached reading is never mistaken for the worker's memory now -info: nullmeans nothing has been measured because nothing is resident. 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.
