Skip to main content

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.py with a FastAPI app, optionally creates frontend/index.html, registers it in platform.toml, runs enlace check, and starts serving.
  • Day-to-day operations. The agent runs enlace serve, enlace check, enlace show-config and enlace 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:

  1. 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.
  2. 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)

Source distribution for enlace 0.1.58
File Size Uploaded
enlace-0.1.58.tar.gz 358.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for enlace 0.1.58
File Interpreter ABI Platform
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}

Release history Release notifications | RSS feed

This release

0.1.58 This release

2 release files

0.1.57

2 release files

0.1.56

2 release files

0.1.55

2 release files

0.1.54

2 release files

0.1.53

2 release files

0.1.52

2 release files

0.1.51

2 release files

0.1.50

2 release files

0.1.49

2 release files

0.1.48

2 release files

0.1.47

2 release files

0.1.46

2 release files

0.1.45

2 release files

0.1.44

2 release files

0.1.42

2 release files

0.1.41

2 release files

0.1.40

2 release files

0.1.39

2 release files

0.1.38

2 release files

0.1.37

2 release files

0.1.36

2 release files

0.1.35

2 release files

0.1.34

2 release files

0.1.33

2 release files

0.1.31

2 release files

0.1.30

2 release files

0.1.29

2 release files

0.1.28

2 release files

0.1.27

2 release files

0.1.26

2 release files

0.1.25

2 release files

0.1.22

2 release files

0.1.21

2 release files

0.1.20

2 release files

0.1.19

2 release files

0.1.18

2 release files

0.1.17

2 release files

0.1.16

2 release files

0.1.15

2 release files

0.1.14

2 release files

0.1.13

2 release files

0.1.12

2 release files

0.1.11

2 release files

0.1.10

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.0.12

2 release files

0.0.11

2 release files

0.0.10

2 release files

0.0.9

2 release files

0.0.8

2 release files

0.0.7

2 release files

0.0.6

2 release files

0.0.5

2 release files

0.0.4

2 release files

0.0.3

2 release files

0.0.2

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page