enlace
enlace serves all your web apps from one gateway: it discovers them by convention in a directory, mounts Python ASGI apps in-process, runs Node/Go/anything else as supervised child processes behind a reverse proxy, and serves static sites and external services under the same domain. Your apps don't import it, don't know it's there, and keep working standalone.
🦖 Writing code by hand? Skip to For developers with opposable thumbs.
For AI agents
Skills
enlace ships three agent skills. The real files live in enlace/data/skills/, so they come with pip install enlace, and gh skill (which looks for any skills/*/SKILL.md in a repo) can install them from GitHub.
| Skill | Audience | What it does | Trigger phrases |
|---|---|---|---|
| enlace | users | Create apps, configure platform.toml/app.toml, understand conventions and modes, serve |
"add an app", "serve my apps", "configure enlace" |
| enlace-diagnose | users | Analyze an app for compatibility and suggest fixes that preserve standalone operation | "can this be enlaced?", "diagnose this app", "what needs to change?" |
| enlace-dev | developers | Modify the enlace package itself: features, bugs, middleware, process supervision | "add X to enlace", "implement Y in enlace" |
Enable them in one of these ways:
# Cross-agent and versioned, with a GitHub CLI that has `gh skill`:
gh skill install i2mint/enlace enlace
# From the installed package (offline). Links into ~/.claude/skills by default;
# pass --target .claude/skills to link into the current project instead.
pip install enlace skill
skill link-skills "$(python -c 'from enlace import skills_dir; print(skills_dir())')"
# Or symlink by hand into a project:
mkdir -p .claude/skills
ln -s "$(python -c 'from enlace import skills_dir; print(skills_dir())')"/* .claude/skills/
Working on enlace itself? Read .claude/CLAUDE.md first: it holds the two core principles, the architecture, and the critical rules (pure ASGI middleware only, loud-and-local load failures, CORS on the parent only). In a checkout, .claude/skills/ already links to the three skills.
What an agent can do with enlace
Ask in plain language:
"Add my_app to the platform"
"Can my_app be enlaced?"
"Diagnose /path/to/my_app and fix what you can"
"List my apps"
"Serve all my apps"
- Onboarding an existing app. The agent runs
enlace diagnose, reads the report, and presents findings in three tiers: enlace-side fixes (no app changes), app changes that preserve standalone operation, and warnings. It proposes specific code changes and applies them with your approval. - Creating a new app. The agent scaffolds the directory, writes
server.pywith a FastAPI app, optionally createsfrontend/index.html, registers it inplatform.toml, runsenlace check, and starts serving. - Day-to-day operations. The agent runs
enlace serve,enlace check,enlace show-configandenlace doctor, interprets the output, and explains what's happening.
A minimal runnable example
Discover an app, check it, and call it through the gateway, all in-process (pip install "enlace[dev]" provides the test client's httpx):
import tempfile
from pathlib import Path
from starlette.testclient import TestClient
from enlace import PlatformConfig, build_backend, diagnose_app, discover_apps
root = Path(tempfile.mkdtemp())
app_dir = root / "apps" / "hello"
app_dir.mkdir(parents=True)
(app_dir / "server.py").write_text(
"from fastapi import FastAPI\n"
"app = FastAPI()\n"
"@app.get('/greet')\n"
"def greet(name: str = 'world'):\n"
" return {'message': f'Hello, {name}!'}\n"
)
(root / "platform.toml").write_text('[platform]\napps_dirs = ["apps"]\n')
assert diagnose_app(app_dir).is_enlaceable
config = discover_apps(PlatformConfig.from_toml(root / "platform.toml"))
client = TestClient(build_backend(config))
print(client.get("/api/hello/greet", params={"name": "agent"}).json())
# {'message': 'Hello, agent!'}
Quick start
pip install enlace
# Create an app
mkdir -p apps/hello
cat > apps/hello/server.py << 'EOF'
from fastapi import FastAPI
app = FastAPI()
@app.get("/greet")
def greet(name: str = "world"):
return {"message": f"Hello, {name}!"}
EOF
# Serve it
enlace serve
# → http://localhost:8000/api/hello/greet?name=Thor
With no platform.toml, enlace serves the apps in ./apps.
Philosophy
Apps don't depend on enlace. enlace is an operator's tool, not a library your app imports. Your app is a standard Python module with app = FastAPI(). It runs standalone with uvicorn server:app. enlace just happens to know how to find it, mount it at a route prefix, and serve it alongside other apps. (See Zero Coupling for how enlace provides services like auth and storage without creating a dependency.)
enlace (the platform) your app (Python, Node, Go, ...)
├── fastapi ├── fastapi (or express, gin, ...)
├── uvicorn ├── pandas (or whatever you need)
├── pydantic └── ... your domain libs
├── cw
└── httpx (optional, for proxy)
← no arrow here: your app does NOT import enlace
Two principles in tension:
- Apps should not need to change. All aggregation logic lives in enlace. When an app is hard to mount, prefer enlace-side config (app.toml, env vars) over app code changes.
- Enlaced apps must still work alone. When changes are suggested, they preserve standalone operation. The pattern: env-var with current value as default — standalone uses the original, enlace overrides at build time. (See Standalone Preservation for the env-var-with-default pattern and fix classification.)
For the full rationale — including how these principles interact, where the balance sits today, and what enlace aspires to handle better — see the Design Principles document.
Under the hood
The CLI, Python API, and configuration system that the skills use internally.
CLI
enlace serve # Start backend (dev mode, hot reload); --mode prod for 2 workers
enlace show-config # Resolved config with provenance (--verbose, --json)
enlace check # Validate config, check route conflicts
enlace list-apps # Table: name, route, type, access (+ mode when mixed)
enlace app-meta # Launcher metadata (title, description, icon) per app
enlace build [--app-name NAME]
# Run declarative frontend builds (apps with a
# [build] section); explicit pre-deploy step,
# never builds at request time. --dry-run to preview.
enlace diagnose <dir> # Analyze an app for enlace compatibility
enlace doctor --base-url http://127.0.0.1:8000
# Post-deploy smoke: probe /auth/csrf and every
# mounted app; exit nonzero on any failure.
enlace analytics [--app-name kids] [--days 30] [--json]
# Page views per day and per path, for apps that
# opted in to privacy-first analytics.
Python API
from enlace import build_backend, diagnose_app, discover_apps
# Diagnose an app
report = diagnose_app("/path/to/my_app")
print(report) # Human-readable report
print(report.is_enlaceable) # True if no critical blockers
# Discover and compose
config = discover_apps() # reads ./platform.toml, or defaults to ./apps
app = build_backend(config) # FastAPI app with all sub-apps mounted
App discovery
enlace discovers apps by filesystem conventions:
apps/
├── my_tool/
│ └── server.py # has `app = FastAPI()` → mounted at /api/my_tool
├── dashboard/
│ ├── server.py # backend
│ └── frontend/
│ └── index.html # served at /dashboard/
├── calculator/
│ └── server.py # typed functions, no `app` → auto-wrapped as routes
├── blog_node/
│ ├── app.toml # mode = "process", command = ["node", "server.js"]
│ └── server.js # spawned + proxied at /api/blog_node
└── docs/
├── app.toml # mode = "static", public_dir = "dist"
└── dist/index.html # served directly
| Convention | Default | Override in app.toml |
|---|---|---|
| Serving mode | asgi |
mode (asgi, process, external, static) |
| Route prefix | /api/{dir_name} |
route |
| Entry point | First of server.py, app.py, main.py |
entry_point |
| ASGI app object | Attribute named app |
app_attr |
| Frontend assets | frontend/index.html |
frontend_dir |
Everything enlace infers is inspectable (enlace show-config --verbose) and overridable via app.toml, platform.toml, environment variables, or CLI flags.
When an app won't import
Discovering an asgi-mode app imports its entry module, so a missing dependency — or any exception raised at module scope — is a discovery failure. What happens next is a policy you choose:
discover_apps(config) # raise (default)
discover_apps(config, on_import_error="record") # record and carry on
The diagnostic verbs (doctor, check, list-apps, show-config, app-meta) record, so one broken app no longer hides the health of every other one — enlace doctor --json still exits nonzero, but now with a parseable FAIL naming the app and its exception class, on AppConfig.import_error.
The gateway (create_app, which gunicorn loads, and enlace serve) records too, and is loud and local: the broken app's route prefix gets a 500 stub naming the app and the exception class, and every other app serves. A stub is never silent: one ERROR log line with the traceback, degraded_apps in /_meta, degraded: true (and not launchable) in /_apps, and a FAIL in enlace doctor read from the live gateway's /_meta. Platform configuration errors still refuse to boot. To refuse to boot on an app's failure too:
# app.toml — this app would rather be absent than serve a 500
fail_hard = true
# platform.toml — every app, the pre-0.1.50 behaviour
[platform]
app_load_failure = "raise"
The decision and its limits are in ADR-0001.
Configuration
platform.toml (project root):
[platform]
apps_dirs = ["apps"] # Directories containing app subdirs
app_dirs = ["/path/to/standalone"] # Individual app directories
backend_port = 8000
cors_origins = ["*"] # default: any origin, never with credentials;
# or list origins ("https://host[:port]") that may
# send credentials (then only those get CORS);
# [] = no CORS. "*" can't be mixed; "null" refused.
[conventions]
entry_points = ["server.py", "app.py", "main.py"]
app_attr = "app"
frontend_dir = "frontend"
app.toml (per-app, in app directory):
# Python ASGI (default mode — just overrides)
route = "/api/custom-route"
entry_point = "backend/main.py"
access = "public"
display_name = "My App"
# Non-Python or separate process
mode = "process"
command = ["node", "server.js"]
port = 3001
route = "/api/blog"
# External upstream
mode = "external"
upstream_url = "http://192.168.1.50:3000"
# Declarative frontend build (any app with a compiled frontend)
[build]
install = "npm ci" # string or argv list
build = "npm run build"
env_vars = ["VITE_API_BASE"] # vars the build honours; deployers inject values
outputs = ["dist"] # optional hint of produced paths
enlace build runs these as an explicit pre-deploy step (enlace build --app-name my_app, or all apps with a [build] section). enlace never builds at request time and does not assume a toolchain — it just runs the argv the app declares, in the app directory, with the caller's environment plus any injected overrides (e.g. VITE_API_BASE). The app keeps a normal build whether or not enlace serves it, so the "apps don't know about enlace" principle holds.
For process/external modes: pip install enlace[process]
Override precedence (lowest → highest):
defaults → filesystem conventions → app.toml → platform.toml → env vars → CLI flags
App modes
| Mode | Description | How it works |
|---|---|---|
asgi (default) |
Python ASGI apps | Imported + mounted on gateway FastAPI |
process |
Any app as a child process | Spawned, health-checked, reverse-proxied |
external |
Pre-existing upstream | Proxied, no lifecycle management |
static |
Static file directory | Served directly |
The reverse proxy carries HTTP, including server-sent events. It does not carry websockets yet: a websocket to a process or external app is refused with a close (clients see an HTTP 403).
Within asgi mode, apps are further classified:
| Type | How detected | How mounted |
|---|---|---|
asgi_app |
Module has callable app attribute |
parent.mount(prefix, sub_app) |
functions |
No app attr, has typed public functions |
Auto-wrapped as API routes |
frontend_only |
No backend entry, has frontend/index.html |
Static file serving only |
Auth, sessions & plugins
enlace itself is auth-agnostic. Cross-cutting concerns like authentication, sessions, and per-user storage are added by plugins — callables (parent: FastAPI, config: PlatformConfig) -> None invoked once after sub-apps are mounted:
from enlace import build_backend, discover_apps
app = build_backend(discover_apps(), plugins=[my_plugin])
Plugins can also be loaded by name from the ENLACE_PLUGINS env var (comma-separated). The canonical example is enlace_auth.plugin, which adds auth, sessions, the admin dashboard, and per-user stores. Plugins reach apps without coupling: services are injected into the ASGI scope, so apps read request.state.store rather than importing anything from enlace. Apps can also condition on the ENLACE_MANAGED=1 env var (set by build_backend()), but never have to.
HTML caching
Every static mount (app frontends, the landing app, mode="static" apps, shared assets) sends Cache-Control: no-cache on HTML documents — including SPA fallbacks, 304 revalidations and an html=True 404.html page — so browsers revalidate a page (a cheap 304 via its ETag) instead of heuristically reusing an old build whose HTML still names the previous asset URLs. Non-HTML assets are untouched, and a Cache-Control already set on a response is never overridden. The class is enlace.frontend.RevalidatingStaticFiles (html_cache_control=None opts out).
App icons (tab, home screen, launcher)
Every app gets one icon, served in every form a browser or phone asks for: /_apps/{name}/icon (as-is), /_apps/{name}/icon-{32,180,192,512}.png (square PNGs, needs pip install enlace[icons]), and a generated /_apps/{name}/manifest.webmanifest. enlace adds the matching <link rel="icon">, <link rel="apple-touch-icon"> and <link rel="manifest"> to each app's HTML wherever the app's own <head> lacks them, so the launcher tile, the browser tab and "Add to Home Screen" (iOS and Android) all show the same image.
The icon is picked once per app: live launcher edit > [app_meta.apps.<name>].icon > <name>.png in [app_meta].icons_dir > the app's own icon (from its <head>, manifest or icon.png) > a letter monogram. An icon from the platform tiers replaces the page's own icon links; an app's own icon only fills gaps.
[app_meta]
icons_dir = "static/app_icons" # one folder of <app>.png, owned by the platform
manifest_display = "browser" # or "standalone"
Home-screen icons must be raster: an app whose icon is only an SVG, emoji or monogram gets a favicon but no PNG. enlace.app_icons.home_screen_gaps(config) lists those apps.
Privacy-first analytics
An app can have page views counted with no cookie, no JavaScript and no third party. It opts in from its own app.toml:
[analytics]
mode = "privacy"
The enlace server counts the app's HTML page views as it serves them, and the pages are not changed at all. It stores only daily aggregates per app: views per path (query strings dropped), referrer domain, primary language and device class (mobile/tablet/desktop). Each is kept as a separate count, never crossed with the others. No IP address is read and no visitor identifier is kept. Bots are counted separately. DNT: 1 and Sec-GPC: 1 are honoured, and /_analytics/opt-out is a page a privacy notice can link to. An app without the table records nothing.
Read the counts on the serving host with enlace analytics, or from Python with enlace.analytics_report(). Storage defaults to JSON files under ~/.local/share/enlace/analytics. It is configured in platform.toml, and build_backend(config, analytics_store=...) takes any MutableMapping (e.g. a dol store):
[analytics]
store_path = "~/.local/share/enlace/analytics"
retention_days = 395 # at most 750 (under 25 months)
timezone = "Europe/Paris" # whose midnight starts a new day
The design and how it maps onto the CNIL's consent exemption for audience measurement are in misc/docs/privacy_analytics.md. That doc also lists what a site operator still has to do: a privacy-notice entry, and handling their own access logs.
Deploy manifest (/_meta)
enlace answers "what is actually deployed?" via an always-on, cheap manifest layer. Every mounted app gets a /_meta endpoint (plus a platform-level /_meta), and every response carries X-Deploy-* headers. A deploy tool snapshots build identity (git SHA, deploy time, externals) at deploy time into {manifest_dir}/{app}.json; enlace owns the read paths and schema. This lets you tell whether the server is serving the SHA you think it is and whether the browser rendered the current build or a stale cache.
enlace_version is what the deploy recorded (or, when the manifest records none, the running version); enlace_runtime_version is always the enlace actually running, added at serve time. They differ after an engine-only upgrade (a new wheel and a restart, no redeploy), and enlace doctor warns when they do.
For developers with opposable thumbs
Everything above works for you too. This section holds what only matters when you change enlace itself.
Dev setup.
git clone https://github.com/i2mint/enlace && cd enlace
pip install -e ".[dev]" # dev carries pytest, httpx, Pillow
python -m pytest --doctest-modules # what CI runs: enlace/ (tests + doctests) and tests/
ruff check . && ruff format --check .
Where the reasoning lives. misc/docs/enlace_spec.md is the architecture. The research documents in misc/docs/ explain each subsystem (ASGI composition, auth, discovery, frontend serving, process orchestration), and misc/docs/decisions/ holds the ADRs. Read the relevant one before changing a subsystem; the table in .claude/CLAUDE.md maps modules to documents.
House rules that bite. Middleware is pure ASGI, never BaseHTTPMiddleware. Sub-apps mount on FastAPI() directly, never on an APIRouter. Any suggestion to an app author must keep the app runnable on its own (the env-var-with-default pattern). The CLI surface is the COMMANDS list at the bottom of enlace/__main__.py, and the cw dispatcher keeps its default convention on purpose.
Skills. Edit the real files under enlace/data/skills/. The entries in .claude/skills/ are symlinks to them, and a test fails if one turns back into a copy.
Releases. CI (the reusable wads workflow) runs on every push and publishes to PyPI when a change lands on main; it also bumps the version, so don't edit it by hand. Questions and proposals go to the issue tracker.
Metadata
Release files for enlace 0.1.58
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| enlace-0.1.58.tar.gz | 358.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| enlace-0.1.58-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 589.9 kB
Release files / enlace-0.1.58.tar.gz
| Download URL | enlace-0.1.58.tar.gz |
|---|---|
| Size | 358.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
812469adae085ac28d0fee1a626769d6dce5f39cba2251ec2969d59a8e18377e
|
|
BLAKE2b-256 checksum How to use checksums |
9ef2088dec75a70411b65bf8ab92038f68ecc59ce171a090748077f20c029efa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / enlace-0.1.58-py3-none-any.whl
| Download URL | enlace-0.1.58-py3-none-any.whl |
|---|---|
| Size | 231.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3775d3c5f3bb004e52ed03bde6db5ece7612eccead6c1629d200feb1c4e3d037
|
|
BLAKE2b-256 checksum How to use checksums |
1cb47808cf2b3ba5ac860f77b5c39482112d22795ff55cdb7fef7188d89704e2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|