The API server
The Pipelex API server, the FastAPI runner published as the pipelex/pipelex-api image and the pipelex-api package, lives in this repository as the api/ directory. It is the reference implementation of the MTHDS Protocol, and the base the hosted runner composes. Its own guide is api/CLAUDE.md, and its reference documentation is the API Server section of this site, whose pages live in docs/api-server/.
A workspace member
The repository is a uv workspace. The root project is the pipelex library, untouched, and api/ is a second member, the pipelex-api distribution, whose import package is pipelex_api. The root pyproject.toml declares it:
[tool.uv.workspace]
members = ["api"]
The member depends on the library through the workspace (pipelex = { workspace = true } in its [tool.uv.sources]), so the server always runs the library of the same commit, and the repository keeps a single uv.lock at its root. The pipelex wheel and sdist are unaffected: both restrict themselves to packages = ["pipelex"].
The two are released together, under one version number. The member declares its version dynamic and hatch reads it from the root pyproject.toml ([tool.hatch.version] in api/pyproject.toml), so a release bumps one file, and the member's [tool.uv] cache-keys name the root file so that uv notices the bump.
Its dependencies are dynamic too. They are written in [tool.hatch.metadata.hooks.custom] of api/pyproject.toml rather than in [project], and api/hatch_build.py writes them into the distribution's metadata: each requirement of lockstep-dependencies, which is pipelex with the server's extras, pinned to == the release's own version, then the other dependencies as written. In the workspace uv ignores a version specifier on a workspace source, so the pin changes nothing locally and never goes stale; in a published pipelex-api it is what makes an install take exactly the pipelex the server was released with. A server dependency is therefore added or changed in that table by hand, and the root's uv lock picks it up: uv add would write a static [project] dependencies list, which hatch refuses beside the dynamic one.
Its own environment
The server is tested with exactly the dependencies it declares, which are not the library's development set: pipelex with the server's extras and without cli, plus FastAPI, uvicorn and the server's own tools. Its Makefile therefore points every uv command at api/.venv (UV_PROJECT_ENVIRONMENT), where pipelex is an editable install of this repository's source, so a library edit reaches the server without a reinstall. The targets that need that environment provision it themselves.
The gate covers it
The root make agent-check ends with make api-agent-check, which runs make -C api agent-check: the member's install, ruff, pyright and mypy under its own configuration, then two drift checks. The root make agent-test ends with make api-agent-test, the server's unit tests. A library change that breaks the server therefore fails the gate in the change that makes it, and so does one that moves the server's wire or its inference tree:
make -C api openapi-checkcompares the committed OpenAPI artifact,docs/api-server/openapi/pipelex-api.openapi.yaml, with what the app generates. It sits among the server's pages, so the docs site publishes it beside the pages that link to it. pipelex's own models ride the server's wire (the run result, the bundle blueprint, the validation error vocabulary), so a library change can move the artifact with no change to the server's code. Regenerate it withmake -C api openapi-exportand read the diff: it is what the change did to the server's callers.make -C api kit-checkcompares the server's vendored inference tree with the kit.
Pull-request CI covers it
Every pull request runs the server's checks, with no path filter, because a change to the library alone can break the server:
Lint (api), inlint-check.yml, runs the member's ruff, pyright and mypy,openapi-checkandkit-checkin its own environment.Tests (api), intests-check.yml, runs its unit tests.Tests (packages)builds thepipelexandpipelex-apiwheels and sdists as a release would, checks the exact pin onpipelexin thepipelex-apimetadata withapi/scripts/check_lockstep_pin.py, installs the two wheels together under the lock's versions and imports the server from them.Tests (api image)builds the image from the repository root and smoke-tests it withmake -C api docker-smoke, which boots it, waits for/healthand checks that/v1/versionreports this version for both the server and the pipelex it runs.
They block through the Lint (all) and Tests (all) aggregates. Those run the floor Python version only, as they do for the library; on a pull request into main, the pre-main gates add the server to every supported version, in each Lint fresh (pyX) leg and in the Tests full (api, pyX) jobs.
The root ruff configuration excludes api/, which the member's gate lints under its own. The root plxt configuration formats and lints the member's TOML and MTHDS files like any other, except its vendored inference tree, which the sync keeps byte-identical to the kit, and its deliberately invalid sample bundles.
The vendored inference tree
The image serves the models its /root/.pipelex/inference/ tree declares, which is api/.pipelex/inference/, and pipelex never falls back to the kit in its own package once a config directory exists. api/scripts/sync_vendored_kit.py keeps that tree in step with pipelex/kit/configs/inference/: the backend files, the routing profiles, the numbered deck files and the manifests are mirrored byte for byte, and only the enabled switches in backends.toml and the x_custom_* deck overrides are the server's own choice.
make up-kit-configs (ukc), the step a kit change already takes, runs that sync after mirroring .pipelex/ into the kit, so the server's tree moves in the same change. The tree stays committed rather than generated when the image is built, because make run and the server's tests read it, and because the sync can refuse a kit entry it has no rule for, which a person has to resolve before a release rather than during one.
The image
The image is built with the repository root as its context, so it installs the pipelex of the same commit:
docker build -f api/Dockerfile .
make -C api docker-build runs exactly that, and make -C api docker-smoke boots what it built and checks the versions it reports. The root .dockerignore keeps the context down to the files the build reads. It is an allowlist, and it sits at the root rather than beside the Dockerfile because the legacy builder reads no other ignore file, and without one it would copy local override files, which can hold credentials, into the image.
How a release ships it
A pipelex release publishes three artifacts under its one version, all from .github/workflows/publish-pypi.yml, which fires when the release pull request merges into main:
pipelexon PyPI, as before:build, thenpublish-to-pypi, thengithub-release, which tags the release commit and refuses a commit an existing tag does not name.pipelex-apion PyPI:build-apibuilds the server's wheel and sdist and refuses them unlesscheck_lockstep_pin.pyfindspipelex[...]==X.Y.Zin both.publish-to-pypiwaits for it, so a server that does not build stops the release beforepipelexis uploaded, while the version can still ship from a fixed commit. Thenpublish-api-to-pypiuploads them through trusted publishing in the samepypienvironment. It waits forgithub-release, so thepipelexwheel they pin is on PyPI and the tag guard has accepted the commit they were built from before a file reaches PyPI, where it can never be replaced.pipelex/pipelex-api:X.Y.Zon Docker Hub: once both distributions are on PyPI,publish-docker-hubcalls.github/workflows/publish-docker-hub.ymlwith the version. It checks out the commit thevX.Y.Ztag names, runsmake -C api deploy-docker-hub, which builds the image, smoke-tests it and pushes it, and confirms the tags through Docker Hub's API.latestmoves only when the version is the newest stable release, so a pre-release, or a retry of an older release, leaves it where it is. The Docker Hub overview is not part of the release: it is still updated by hand, by pastingapi/README.mdinto the repository's overview on Docker Hub when it has changed.
A failed publish is retried with "Re-run failed jobs" on the release run, which re-runs only what failed. A version Docker Hub already serves is never built again, because a rebuild would be a different image under the same tag: a retry that finds it there only moves latest, when the version is the newest stable release, and confirms the tags. The image can also be published on its own with gh workflow run publish-docker-hub.yml --ref <branch> -f version=X.Y.Z, which helps when the fix is a change to that workflow file, which a re-run would not pick up: dispatched from the branch carrying the fix, it still builds the commit the tag names. Such a fix must use only actions the organization's Actions policy allows, or GitHub rejects the whole workflow at startup and nothing publishes; pull-request CI never loads these workflows, so make check-actions-allowlist checks every workflow against the policy's mirror on each pull request.
What the release needs configured on GitHub and PyPI: the DOCKER_HUB_TOKEN secret on the repository, a token for the pipelex Docker Hub account that can push the image; and a PyPI trusted publisher for the pipelex-api project with owner Pipelex, repository pipelex, workflow publish-pypi.yml and environment pypi, the same as pipelex's.