Web UI
Soup includes a built-in web dashboard for managing experiments, training, and data.
Launch
pip install "soup-cli[ui]"
soup ui
# Opens http://127.0.0.1:7860 in your browser
# Prints auth token to consoleThe tab soup ui opens carries no token in its address, and the page has no field to type one into, so its first requests are refused and the dashboard shows Error loading dashboard: Unauthorized. Open http://127.0.0.1:7860/?token=<the printed token> yourself: the page keeps the token for that tab and removes it from the address bar. soup ui --public prints this form of URL with your LAN address, and --auth-token keeps the token, and so the URL, the same across restarts. The mechanics are under Authentication.
# Custom port, don't auto-open browser
soup ui --port 8080 --no-browserPages
- Dashboard — the run table with system info; opening a run shows its configuration, loss, learning-rate, gradient-norm, speed and GPU-memory charts, and its eval results
- New Training — edit a YAML config (start from a template or a recipe), validate it, start or stop a run, and see its PID while it runs. The page also contains a progress bar and a log panel (step, elapsed time, ETA, streamed output), but at v0.75.2 nothing in the page's script opens them, so they stay hidden after Start Training; the same output is available from
GET /api/train/logsfor your own client, see the REST API reference - Data Explorer — browse and inspect datasets (JSONL, JSON, CSV, Parquet, plain text)
- Model Chat — chat with a running
soup serveinference server: temperature, max tokens, top-p and a system prompt, markdown rendering of replies, a Cancel button, and Export of the conversation as JSON - Tool Outputs — the most recent tool calls held in the server's memory, polled every three seconds. A run started from the UI does not fill that memory (see the REST API reference), so expect
No tool calls observed yet.
The chat page has an Adapter field, and it does not select an adapter on soup serve: the page sends the value as the request's model field, while the server reads a separate adapter field (see Inference Server). Leave it empty.
Security
The Web UI generates a random auth token at startup and prints it to the console. Every private endpoint requires an Authorization: Bearer <token> header, not only the mutating ones (the three event streams also accept a one-time ticket, below). Until v0.75.0 the read endpoints were open: run configurations, logs, system metrics and the event streams all answered without a token. Only /, /api/health and the page's own /static files stay open, so the dashboard can load.
Until v0.75.1 this page rendered dataset and run content as HTML. The Data Explorer and the run detail view inserted dataset field names, dataset values and stored run configuration straight into the page with no escaping (0.7.0 to 0.75.0), and the UI sent no Content-Security-Policy. Inspecting somebody else's dataset could therefore run script in the origin that holds your bearer token and can start training, which is as bad as it sounds.
Every rendered value now goes through one escaping template, inline handlers are gone, and the UI sends a policy with no unsafe-inline for scripts and no eval, alongside X-Content-Type-Options, Referrer-Policy and X-Frame-Options. Chart.js is pinned with Subresource Integrity, and the interactive API docs stay the one documented exception, served on loopback only.
One limitation upstream publishes with it rather than letting you assume otherwise: the Web UI token still lives in sessionStorage. The sinks that could read it from the page are gone and the policy forbids inline script and eval, but the storage choice itself is unchanged.
The streams authenticate differently, on purpose. An SSE connection cannot carry a header the browser does not let you set, so instead of putting a durable token in a query string, the client exchanges one over an authenticated POST for a short-lived, single-use ticket.
The interactive API docs are loopback-only. /openapi.json, /docs, /docs/oauth2-redirect and /redoc serve on a loopback bind and are absent, 404, on any other, including soup ui --public. Upstream classifies that honestly as reconnaissance rather than disclosure: the schema exposes no run data, configuration or logs, but it does describe every route, parameter and request shape, and on a LAN bind that is free reconnaissance. They are removed rather than gated because /docs is a browser navigation that cannot attach a bearer header, so gating would have broken the page for a developer while leaving /openapi.json readable by any HTTP client. Read the schema from a loopback instance of the same version if you need it.
A non-loopback bind without a valid token exits 2 rather than warning. In practice a token is always present by then: one is generated at startup, and a malformed --auth-token exits 1 first, so this is a guard the command's own options do not reach.
The chat page only forwards to endpoints it can vouch for. Model Chat sends your message through the UI server to whatever endpoint you type, so that endpoint is checked first: plain http only for loopback, https for anything else, and, since v0.75.2, never an IP address in a private, link-local or reserved range, on either scheme. A hostname is accepted and is not resolved. http://0.0.0.0 is no longer treated as local; use localhost. A URL that does not parse answers 400 rather than 500. Reaching the proxy needs a valid bearer token in the first place, and the reasoning behind the v0.75.2 change is on Private addresses refused.
- CORS is not a wildcard: on a default bind it allows only the served origin, and under a
0.0.0.0bind (--public) it allows loopback and private-network origins instead (exact rule under Authentication) - Data inspection is limited to files under the working directory, by a path check rather than a sandbox
- Config validation before writing
- Error bodies from the data and chat routes are generic (details logged server-side); the config routes return the loader's own message on purpose, so you can see which key was refused
REST API reference
Everything the pages above do goes through a JSON API under /api/. At v0.75.2 soup ui serves 24 operations there, plus GET / for the page and /static for its files. This section lists them as they behave at that release: what each takes, what it returns, what it touches on disk or in other processes, and which credential it needs. Every route was exercised against the v0.75.2 app with Starlette's test client in a scratch directory, with the training subprocess and the chat upstream replaced by stubs, and the page's own script was run offline against a stub server. What is said about a real training run, or about what a browser does, comes from reading the code at the tag and was not observed.
What this is, and what it is not
These routes are the Web UI's own backend. Upstream's documentation describes the pages, the token and the content policy; a search of the README, docs/, SECURITY.md and CONTRIBUTING.md at v0.75.2 finds no stability, versioning or compatibility statement for the routes themselves. The OpenAPI document that soup ui serves on loopback reports version: 1.0.0, which is a constant in the app factory, not a compatibility level. Nine of the routes are never reached by the bundled page's script (/api/health, /api/auth/ticket, /api/runs/compare, /api/config/schema, /api/config/from-form, /api/train/progress, /api/train/logs, /api/train/metrics/live and /api/train/stream), so nothing in the shipped UI exercises their shapes. Treat what follows as a description of one release: pin the Soup version if you script against it, and read /openapi.json from a loopback instance of the version you run. For the same jobs from a script, the documented interface is the CLI: soup train, soup runs and soup data inspect.
Authentication
- One credential, per process.
soup uigenerates a bearer token at startup (43 URL-safe characters, new on every start) and prints it in its start-up panel.--auth-tokenreplaces it with your own: 16 to 128 characters fromA-Z a-z 0-9 _ -, otherwise the command exits 1.--show-tokenprints the token and exits without serving. On its own it prints a fresh value that belongs to no running server (two runs printed two different tokens), so use it together with--auth-token, or read the token from the panel. - A header, never a query string, on the API. Send
Authorization: Bearer <token>. It is compared against the whole expected string in constant time, the scheme word is case-sensitive (beareris refused), and a missing or wrong header is401with{"detail": "Unauthorized"}(a header containing a non-ASCII byte is a bare500instead, because the comparison raises)./api/runs?token=...is also 401. (soup ui --helpdescribes--publicas requiring a token "in the query string"; that is the page URL described below, not the API.) - Open routes.
GET /, the/staticfiles andGET /api/health. Every other route under/api/needs the token, and three streaming routes also accept a ticket. - Tickets, for streams only. A browser
EventSourcecannot set a header, soPOST /api/auth/ticketwith the bearer token returns{"ticket": "..."}. A ticket is valid for 30 seconds, works once, and is accepted only as?ticket=<value>on/api/train/logs,/api/train/metrics/liveand/api/train/stream. On any other route it is a 401. A client that can set headers, such as curl, can send the bearer token to those three routes directly. - How the page gets the token. The page script reads
?token=from its own URL, orsessionStorageon later loads, then removes the parameter from the address bar.GET /ignores the parameter and sets no cookie.soup uiitself opens the address without it, which is why the Launch section above tells you to add it. - Binding. The default is
127.0.0.1.--publicbinds0.0.0.0when--hostis127.0.0.1(the default, or typed out), and--hostbinds whatever you pass, with the same token. Loopback meanslocalhost, any127.x.x.xaddress or::1; off loopback the interactive docs routes do not exist (404).
Where these checks stop.
- No TLS.
soup uiserves plain HTTP and has no certificate option. On loopback the traffic stays on the machine; under--publicthe token and every request cross the LAN unencrypted, and the phone URL carries the token in a query string. - The token is the only server-side gate. The server does not check the
Hostheader (a request withHost: evil.examplewas served) and does not reject a foreignOrigin. CORS is a browser rule: for a page on another origin theAuthorizationheader forces a preflight request, which the server refuses on a default bind, so the browser never sends the real request. It does not apply to curl, a script or any other non-browser client that holds the token. - CORS rule. Methods
GET,POSTandDELETE, headersAuthorizationandContent-Type. By default exactly one origin is allowed,http://<host>:<port>as the server was started. Under a0.0.0.0bind (--public, or--host 0.0.0.0) anylocalhost,127.0.0.1,10.x.x.x,192.168.x.xor172.16.x.xto172.31.x.xorigin is allowed, on any port, overhttporhttps. - No rate limit, no lockout, no rotation. The middleware stack is CORS plus the security headers, nothing else. The token changes only when the process restarts.
- One token, every power. There are no scopes and no read-only token. Holding it lets a client read every run's configuration, logs and metrics, delete tracker rows, read any supported data file under the working directory, start a training run from any soup.yaml as the user running
soup ui, and send requests through the chat proxy. - The content policy is the other half of the story. Every response the app itself produces, 401s, 404s and 422s included, carries
X-Content-Type-Options,Referrer-PolicyandX-Frame-Options, and theContent-Security-Policyheader too except on the loopback-only/docs,/redocand/docs/oauth2-redirect. The bare500 Internal Server Errorthat an unhandled exception produces carries none of them. Scripts are limited to the page's own origin and the pinned Chart.js URL;style-srcstill allows inline styles. - The interactive docs cannot authenticate. The OpenAPI document declares no security scheme, so the Swagger page offers no way to enter the token, and its Try it out calls to protected routes are refused with 401. Use curl.
Start the server with a token you chose, then call it from a second terminal, after setting the same value there (TOKEN=<the value you chose>).
TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(24))')"
soup ui --no-browser --auth-token "$TOKEN"curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:7860/api/runs?limit=5"
curl -N -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:7860/api/train/logs"
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"yaml": "base: x"}' "http://127.0.0.1:7860/api/config/validate"The last call answers 200 with valid set to false and the loader's message, because an invalid config is an answer, not an error.
Conventions
- Bodies and errors. Requests and responses are JSON. A refusal is
{"detail": "<message>"}. A parameter or body of the wrong shape or range is422with FastAPI's list-valueddetail. An unexpected exception is a bare500 Internal Server Error. - No size caps, no rate limits. No route limits its request body (a 5 MB YAML body was accepted by
/api/config/validate), and none limits how often you call it. The v0.75.0 release page lists the same gap for the YAML endpoints. - Whose permissions. Every route acts as the user who started
soup ui, in the directory where it was started. Run data comes from~/.soup/experiments.db, or the file named bySOUP_DB_PATH.
Page, health and tickets
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| GET | / | The single-page UI | none | Serves index.html. A ?token= in the URL is read by the page script, not by the server. |
| GET | /static/* | The page's script, styles and logo | none | Plain static files. |
| GET | /api/health | Liveness | none | Returns {"status": "ok"}. |
| POST | /api/auth/ticket | Exchange the bearer token for a stream ticket | Bearer | No body. Returns {"ticket": "..."}; valid 30 seconds, single use, stream routes only (see Authentication). |
Runs
These read and write the experiment tracker, the same SQLite database that soup runs uses.
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| GET | /api/runs | List runs, newest first | Bearer | Query limit: 1 to 500, default 50, otherwise 422. Returns {"runs": [...]}; each item is the whole tracker row, config_json included. |
| GET | /api/runs/compare | Configs and metrics for several runs | Bearer | Query ids: 1 to 5 comma-separated run ids, otherwise 400. Returns {"runs": [{"run_id", "config", "metrics"}]}. An unknown id is not an error: it comes back with an empty config and empty metrics. A unique prefix finds the config but not the metrics, which are read by exact id, so pass full ids. |
| GET | /api/runs/{run_id} | One run | Bearer | The whole row. Accepts an exact id or a prefix that matches exactly one run; no match, or an ambiguous prefix, is 404. |
| GET | /api/runs/{run_id}/metrics | Metric rows for a run | Bearer | Returns {"run_id", "metrics": [...]} ordered by step. The 404 check accepts a unique prefix, but the rows are looked up by the string you sent, so a prefix gives 200 with empty metrics. Use the full id. |
| GET | /api/runs/{run_id}/eval | Eval results for a run | Bearer | Returns {"run_id", "eval_results": [...]}. Exact id only, and an unknown id is 200 with an empty list. |
| DELETE | /api/runs/{run_id} | Delete a run's records | Bearer | Exact id only (a prefix is 404). Returns {"deleted": true, "run_id": "..."}. See below for what it removes. |
- What DELETE removes. The run's rows in
runs,metricsandeval_results. Rows incheckpoint_qualityandforgetting_evalare left. Nothing on disk is touched: the output directory, adapters and checkpoints stay. There is no confirmation step and no undo. - A GET can write. Listing runs, opening one, reading its metrics or comparing runs rewrites a row whose status is
runningand whose recordedpidis no longer alive toterminated, in the database, as it reads it. Only rows that carry apidare affected. - Metric rows carry
id,run_id,step,epoch,loss,val_loss,lr,grad_norm,speed,gpu_memandtimestamp.val_lossis null on steps where no evaluation ran.
System, templates, recipes and config
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| GET | /api/system | Soup version and device | Bearer | Returns version, device, device_name, gpu_info (memory totals and GPU count) and python_version. |
| GET | /api/templates | The built-in config templates | Bearer | Returns {"templates": {"<name>": "<yaml text>"}}: 17 templates at v0.75.2. The four compliance templates that soup init --template offers are not in this list. |
| GET | /api/recipes | The recipe catalog | Bearer | Returns {"recipes": [{"name", "model", "task", "description", "tags", "yaml"}]}, every recipe with its full YAML. |
| GET | /api/config/schema | Field metadata for building a form | Bearer | The top-level fields, with data, training and training.lora as nested objects. Each field is {"type", "required"} plus default, options, ge and le where they apply. |
| POST | /api/config/validate | Validate a soup.yaml | Bearer | Body {"yaml": "<text>"}. Empty or missing yaml is 400. Otherwise 200 with {"valid": true, "config": {...}} or {"valid": false, "error": "..."}. Nothing is written or run. Any exception the loader raises comes back as its text, so a non-string yaml returns a Python error message such as 'int' object has no attribute 'read'. |
| POST | /api/config/from-form | Turn form values into validated YAML | Bearer | Body is an object of top-level soup.yaml keys. Returns 200 with {"yaml": "..."} or 200 with {"error": "Invalid configuration: ..."}. |
/api/config/schematypes are hints. Thetypeis guessed from the annotation's text, so it can be wrong:training.batch_sizeis reported asintegeralthough its default isauto,data.trainasstringalthough a list is accepted, and the nestedevalandadviseblocks asstring. The authority is/api/config/validate./api/config/validatereturns the whole config. On success,configis the validated config with every default filled in. A YAML syntax error and an unknown key both come back asvalid: falsewith the message; since v0.75.0 an unknown key is refused by name rather than ignored. Note the field isyamlhere, while/api/train/starttakesconfig_yaml./api/config/from-formdrops empty values. Top-level values that arenull,""or{}are removed (nested ones are not), the rest is dumped as YAML in the order given and checked by the same loader as validate. An invalid result is still HTTP 200.
Training
The UI server keeps a handle to at most one training child process, the one it launched itself. The routes below know nothing about a soup train you started from a shell, or from another soup ui.
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| POST | /api/train/start | Validate a config and launch soup train | Bearer | Body {"config_yaml": "<text>"}. 409 if the child it launched is still alive; 400 Invalid training configuration: <message> if the loader refuses the config. Returns {"started": true, "pid": N}. |
| GET | /api/train/status | Is the launched child alive | Bearer | Returns {"running", "pid", "config_path"}. config_path is the temp file and is set only while running. |
| POST | /api/train/stop | Terminate the launched child | Bearer | Returns {"stopped": true}, or {"stopped": false, "detail": "No training in progress"}. Returns without waiting for the process to exit. |
| GET | /api/train/progress | Last logged step | Bearer | Optional run_id. Returns {"running", "current_step", "run_id"}: the highest step in the tracker for that exact id, and always 0 without run_id. No total and no ETA. |
| GET | /api/train/logs | The child's output (SSE) | Bearer or ticket | Frames id: N plus data: {"line": "...", "id": N}, ending with event: done. |
| GET | /api/train/metrics/live | New metric rows for a run (SSE) | Bearer or ticket | Query run_id. Sends each new tracker row as a data: frame, then closes. See below: it does not follow a run for long. |
| GET | /api/train/stream | Trainer events (SSE) | Bearer or ticket | Frames data: {"type": "metric", ...} from an in-memory buffer. See below: a run started from the UI does not fill it. |
What /api/train/start runs.
- A fixed command line.
<python> -m soup_cli train --config <file> --yes, with the interpreter that is runningsoup ui. Nothing in the request adds, removes or changes a flag, so--trust-remote-codestays at its default (refused).--yesskips the CLI's confirmation prompts. - A temp file. The validated YAML is written to
soup_ui_config_*.yamlin the system temp directory, created withtempfile.mkstemp, so it is a new file with an unpredictable name rather than a fixed path someone could pre-place a link at. Nothing deletes it afterwards, not even/api/train/stop, so these files accumulate and hold whatever you put in the YAML. - No sandbox of its own. The child inherits the server's whole environment, API keys included, and its working directory, so relative paths in the config resolve where
soup uiwas started. The config is processed exactly assoup train --configwould process it; the Web UI adds validation before the launch and nothing around it. - A normal run. The child records itself in the experiment tracker, which is why it then appears in
/api/runs. Its stdout and stderr are merged and drained by a background thread into a ring buffer of the last 10,000 lines. - Validation is the loader's. The same check as
/api/config/validate, so an unknown key is a 400 that names it. A YAML syntax error answers only the genericInvalid training configuration, without the parser's message.
The three streams.
/api/train/logsreplays the retained lines, then follows the child live.Last-Event-IDresumes after that id. If nothing has been launched since the server started, it answersevent: doneat once, and launching another run replaces the buffer./api/train/metrics/livepolls the tracker every 0.1 second. With norun_idit sendsdoneat once. It also closes withevent: doneafter three polls (about 0.3 second) that found no new row, even while the run is alive, so it replays what exists and then stops; a new connection, with a new ticket, starts again from the first row, because the route has no resume point. Frames have no SSEid:line; theidinside the JSON is the metrics row's./api/train/streamsendsTrainEventframes with atypeofmetric,status,logoreval, and thents,step,epoch,loss,val_loss,lr,grad_norm,tokens_per_s,ema_loss,p95_step_ms,p99_step_ms,eta_secondsandmessage, each only when set. It reads a buffer of the last 1,000 events inside thesoup uiprocess. The callbacks that fill that buffer run inside the trainer, and/api/train/startruns the trainer as a separate process, so for a run started from the UI no events arrive: expect:heartbeatlines, then astatusframe whosemessageisdoneonce no launched child is alive, ortimeoutafter about 20 seconds if one is.
Data
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| POST | /api/data/inspect | Read a dataset file and return a sample | Bearer | Body {"path": "...", "limit": 50}; limit is 1 to 500, default 50, otherwise 422. Returns {"path", "total", "format", "keys", "sample"}. 403 outside the working directory, 404 if missing, 400 if the file cannot be loaded. |
- What the path check covers. The path is resolved to its real location, and that location must be inside the working directory of the
soup uiprocess...traversal, an absolute path elsewhere, a sibling directory that shares the prefix (project-secretsnext toproject) and a symlink inside the directory that points outside are all 403. Where it stops: it is a check made before the file is opened, not a sandbox, and it does not hold the file open between the check and the read. An empty path, a directory, or a path with a NUL byte is a 400. - What it reads.
.jsonl,.json(a list of objects),.csv,.parquet(needs pandas) and.txt(one{"text": ...}row per non-empty line). Any other extension is 400. The whole file is loaded into memory first;limitonly trimssample, andtotalis the full row count, so there is no size cap. Malformed JSONL lines are skipped with a warning in the server console. - What it returns.
pathis the resolved absolute path.formatis detected from the first row only (unknownfor an empty file), andkeysis the union of keys across the returned sample. A file whose first row matches no known format, or whose returned sample contains a row that is not an object, answers 500 rather than 400, because the detector or the key scan raises.
Chat
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| POST | /api/chat/send | Stream a chat completion from an inference server (SSE) | Bearer | Body below. Frames data: {"delta": "..."}, then data: {"done": true}. |
- Body.
messagesis a non-empty list of{"role": "...", "content": "..."}(empty is 400; there is no role allowlist and no limit on count or size).endpointis the server URL. Optional:temperature0 to 2 (default 0.7),max_tokens1 to 16384 (default 512),top_p0 to 1 (default 0.9),adapter(a string or null). A number out of range is 422. - Which endpoints are accepted is described under Security above, and the reasoning is on Private addresses refused. A refused endpoint is a 400 before anything is sent; the response never echoes the URL back.
- What is sent.
POST <endpoint>/v1/chat/completionswithstream: true, andContent-Type: application/jsonis the only header the route sets: noAuthorizationor API key is forwarded, so an endpoint that needs a key cannot be reached through this route. The timeout is 120 seconds for each connect, read, write and pool wait, not a limit on the whole reply. Redirects are not followed (the httpx default). A non-emptyadaptergoes upstream as themodelfield, which is why the Adapter box on the chat page does not select an adapter. - What comes back. Any failure while talking to the endpoint arrives as
data: {"error": "Connection failed"}inside a200response. The upstream's HTTP status is not checked, so a 4xx or 5xx from the inference server arrives as an empty reply followed bydone.
Tool outputs
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| GET | /api/tool-outputs | Recent tool-call records | Bearer | Query limit: 1 to 1000, default 100, otherwise 422. Returns {"count", "records": [{"name", "started_ts", "duration_ms", "success", "output_preview", "error"}]}. |
The records live in an in-memory buffer of the soup ui process (at most 1,000, previews cut to 4,096 characters). They are written by the trainer's callback for tool-calling runs, which runs inside the soup train child, so a run started from the UI leaves this list empty: {"count": 0, "records": []}. While the Tool Outputs page is open the page polls it every three seconds, and it stops when you leave.
Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.