HTTP API Reference

Complete route specification for expman-rs axum HTTP server.

🔗Reference — HTTP API

Hand-written from src/api/*.rs, 2026-07-27. Authoritative over src/api/README.md, which is stale (it documents a nonexistent /api/events endpoint and mentions utoipa).

Router: src/api/mod.rs:42-140. All routes below are prefixed with /api (mod.rs:153). Anything else falls through to the embedded frontend (mod.rs:155). CORS is fully permissive — Any origin/method/header (mod.rs:146-149).

Rows marked NEW are from the uncommitted TensorBoard work.

🔗Projects

MethodPathHandlerResponse
GET/projectsprojects.rs[{id, display_name, description, tags, experiments_count, created_at, generated, generated_from, generated_at}]
POST/projectsprojects.rs:53ProjectMetadata
GET/projects/{project}projects.rs{id, display_name, description, tags, created_at, generated, generated_from, generated_at, readme, experiments: [...]}
PATCH/projects/{project}projects.rs:151updated ProjectMetadata. Body: {display_name?, description?, tags?}
DELETE/projects/{project}projects.rs:178204 No Content (unassigns experiments)
GET/projects/{project}/runsprojects.rscross-experiment runs table with facets — see below
GET/projects/{project}/readmeprojects.rs{content}
PUT/projects/{project}/readmeprojects.rs:133{content}. Body: {content}

🔗Experiments

MethodPathHandlerResponse
GET/experimentsexperiments.rs:17[{id, display_name, description, tags, project, runs_count}] (ad-hoc json!)
GET/experiments/{exp}/metadataexperiments.rs:39ExperimentMetadata
PATCH/experiments/{exp}/metadataexperiments.rs:60merged ExperimentMetadata. Body: {display_name?, description?, tags?, project?}
GET/experiments/{exp}/statsstats.rs:16[{run, status, started_at, finished_at, duration_secs, last_metrics}]

🔗Runs

MethodPathHandlerNotes
GET/experiments/{exp}/runsruns.rs:26query ?metrics=a,b,c filters columns. Backfills vectors from vectors.parquet when empty (runs.rs:56-68)
GET/experiments/{exp}/runs/{run}/metadataruns.rs:88RunMetadata with latest scalars merged into vectors
PATCH/experiments/{exp}/runs/{run}/metadataruns.rs:118Body: {name?, description?, tags?}
GET/experiments/{exp}/runs/{run}/configmetrics.rs:118config.yaml as JSON

🔗Metrics and streams

MethodPathHandlerNotes
GET/experiments/{exp}/runs/{run}/metricsmetrics.rs:28query ?since_step=N. Returns Vec<HashMap<String, Value>>
GET (SSE)/run/{exp}/{run}/stream/vectorsmetrics.rs:41every 500 ms re-reads vectors.parquet past the last seen step, emits a JSON array of new rows. 15 s keep-alive.
GET (SSE)/experiments/{exp}/runs/{run}/log/streammetrics.rs:78query ?file= (default run.log). Tails by byte offset; resets on truncation (:99). 15 s keep-alive.

Both streams terminate via .take_until(shutdown.cancelled()).

Note the vectors-SSE route is the one route that breaks the naming convention — /run/{exp}/{run}/... rather than /experiments/... (mod.rs:56). It is also currently unused by the frontend.

🔗Artifacts

MethodPathHandlerNotes
GET/experiments/{exp}/runs/{run}/artifactsartifacts.rs:18Vec<ArtifactInfo>
GET/experiments/{exp}/runs/{run}/artifacts/contentartifacts.rs:34query ?path=. Raw bytes with a guessed content-type; .parquet returns {type:"parquet", data:[first 100 rows]} (:69-73). Path traversal is guarded by canonicalize + starts_with (:47-58).

🔗Global

MethodPathHandlerResponse
GET/statsstats.rs{total_experiments, total_projects, total_runs, active_runs, stale_runs, total_storage_bytes}
GET/configstats.rs{live_mode, read_only, version} — both read from AppState, so they reflect the flags exp serve was actually given

🔗Jupyter

Per-run, plus a __multi__ variant scoped to the experiment.

MethodPathHandler
GET/jupyter/availablejupyter_handlers.rs available_jupyter → {backend: "jupyter"|"python"|"none"}
POST/experiments/{exp}/runs/{run}/jupyter/startstart_jupyter → {port}
POST/experiments/{exp}/runs/{run}/jupyter/stopstop_jupyter
GET/experiments/{exp}/runs/{run}/jupyter/statusstatus_jupyter → {running, port}
GET/experiments/{exp}/runs/{run}/jupyter/notebookget_jupyter_notebook → {exists, content}
POST/experiments/{exp}/runs/{run}/jupyter/notebookcreate_jupyter_notebook → {created, content} or 409
POST/experiments/{exp}/jupyter/startstart_multi_jupyter, body {runs: [String]}
POST/experiments/{exp}/jupyter/stopstop_multi_jupyter
GET/experiments/{exp}/jupyter/statusstatus_multi_jupyter
GET/experiments/{exp}/jupyter/notebookget_multi_jupyter_notebook
POST/experiments/{exp}/jupyter/notebookcreate_multi_jupyter_notebook

Both start routes write the notebook first, and since 1.3.0 that write may replace an existing interactive.ipynb — when expman wrote it, you have not edited it, and the template has changed since. An edited notebook is never overwritten. Full rules, and the --notebook-template placeholder list: CLI reference.

GET /jupyter/available reports on the configured Jupyter command (exp serve --jupyter-command, default jupyter), not a hardcoded jupyter — so a project that reaches Jupyter through uv run is detected rather than falling back to the ipython/python copy-paste view.

409 from the notebook POSTs means "the file on disk stands" — whether because it is already current or because it was edited and left alone. The two are the same answer to the caller; the distinction is in the server log (warn for an edited file, info for a regeneration).

🔗TensorBoard — NEW / uncommitted

MethodPathHandler
GET/tensorboard/availabletensorboard_handlers.rs:14 → {available}
GET/experiments/{exp}/runs/{run}/tensorboard/has_logs:20 → {has_logs}
POST/experiments/{exp}/runs/{run}/tensorboard/start:30 → {port}
POST/experiments/{exp}/runs/{run}/tensorboard/stop:43
GET/experiments/{exp}/runs/{run}/tensorboard/status:54 → {running, port}

No multi-run variant exists, unlike Jupyter — see STATUS.md.

🔗Frontend fallback

GET /* → frontend::serve_frontend (frontend.rs:57). Serves a rust-embed asset if the path matches, else falls back to index.html for anything that looks like an SPA route, else 404.

The route/asset split is is_asset_request (frontend.rs:45): only the last path segment is examined, and only a suffix in ASSET_EXTENSIONS (:36) counts as an asset. A dot earlier in the path is part of a name, not an extension — so /experiments/dmc-cartpole.swingup and /experiments/dmc-cheetah.run/runs/latest are routes. html is excluded from the list on purpose. See architecture for why the list is wider than what the bundle embeds.

🔗Notes

  • No WebSocket endpoints exist, despite axum's ws feature being enabled.
  • No caching. Every request re-reads the filesystem and re-parses Parquet.
  • Routes the frontend never calls: /run/{exp}/{run}/stream/vectors, /experiments/{exp}/runs/{run}/config, /experiments/{exp}/stats, /config.
  • Test coverage: tests/api_test.rs covers 7 routes in-process via tower::ServiceExt::oneshot. The Jupyter and TensorBoard routes are untested.

🔗GET /projects/{project}/runs

GET /projects/{project} returns an experiment list only, which is not a view you can work in: with one project per study, the comparison worth living in cuts across experiments. This returns the flat runs table plus what a filter UI needs.

Query parameters (all optional):

parammeaning
tagsexpression, e.g. arm:tiered AND (study:1 OR study:2)
statusRUNNING, FINISHED, FAILED, CRASHED (400 otherwise)
experimentnarrow to one experiment
groupnarrow to one DDP job or sweep cohort
{
  "project": "study-1",
  "total": 2,
  "runs": [{ "run": "tiered-s2", "experiment": "...", "project": "study-1",
             "status": "FINISHED", "started_at": "...", "heartbeat_at": null,
             "tags": ["arm:tiered"], "scalars": {}, "vectors": {},
             "path": "experiments/..." }],
  "facets": {
    "tags":        { "arm:tiered": 2, "seed:1": 1, "study:1": 2 },
    "status":      { "FINISHED": 2 },
    "experiments": { "e1-drift-regret-slope": 2 },
    "groups":      { "lr-sweep": 6, "job-77001": 4 }
  },
  "metrics": ["regret"]
}

This is core::dto::ProjectRuns, so the frontend deserializes the same type the handler serializes. It backs the dashboard's Compare tab.

Facets are counted over the returned runs, so they narrow as filters are applied — the behaviour a filter UI needs to avoid offering options that lead to empty results. metrics is the union of metric names across those runs, so a caller knows what is comparable before fetching any series.

🔗Read-only mode

exp serve --read-only rejects every request that is not GET/HEAD/OPTIONS with 403 {"error": "read_only", "message": ...}. It is middleware over the whole router (enforce_read_only in api/mod.rs), not a per-handler check, so new routes are covered by default. /config reports read_only so the frontend can hide controls rather than offering writes that will fail.

🔗Generated projects

A project created by exp project sync carries generated: true. Writes to it — PATCH /projects/{p} and PUT /projects/{p}/readme — are refused with 409:

{
  "error": "generated_project",
  "message": "Project 'study-1' is generated from studies.yaml (thesis repo) and is regenerated on each sync. Edit the source and re-run `exp project sync` instead.",
  "generated_from": "studies.yaml (thesis repo)"
}

Accepting the write would be worse than refusing it: the dashboard would report success and the edit would vanish at the next sync with no trace. DELETE is refused on the same grounds.

🔗Downsampling

GET /experiments/{exp}/runs/{run}/metrics returns at most 2000 points.

parammeaning
max_pointschange the cap; 0 disables it
full=1return every row — the escape hatch for export and analysis
since_steponly rows past this step (used by the SSE stream)

The reduction is Largest-Triangle-Three-Buckets, not a stride. A stride drops the single-row loss spike or divergence, which is exactly what someone opens a chart to find; LTTB keeps whichever point in each bucket contributes the most visible area, so extremes survive. First and last rows are always kept, so endpoints are exact.

Without this, a million-step run serialises to hundreds of MB of JSON and hangs the tab before it draws anything.