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"
# Launcher metadata (all optional; harvested from manifest / index.html if absent)
description = "What it does, in one line"
keywords = ["music", "tools"]
icon = "icon.svg"                   # app-relative path, emoji:🎸, data: URI, or https URL
listed = false                      # link-only: served at its URL as usual, but omitted
                                    # from the launcher (/_apps and /). Launcher-metadata
                                    # editors still see it there, flagged "listed": false.
# 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"
# A page shipped inside the app's Python package (see "Pages from a package" below)
page_from_package = "mypkg"
# 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.

Pages from a package

Some apps ship their whole page inside their Python package: a script, a stylesheet and maybe vendored files in a data directory, plus a small HTML shell that tells the script where the API is. One app.toml key makes enlace serve both, so the host commits no generated frontend/index.html and a UI change ships by upgrading the package:

page_from_package = "mypkg"          # files in mypkg/data/ui; generic shell
[page_from_package]
package = "songleaf"
assets = "data/web"                  # directory inside the package (default data/ui)
shell = "songleaf.web:page_shell"    # the app's own shell function, if it has one
files = ["app.js", "style.css"]      # optional allowlist (default: every file)

enlace then serves the shell at /{name}/ and every path under it (client-side routing), and the package's files at {route}/ui/, mounted ahead of the app: what the package does not serve (a missing or non-allowlisted file, a POST, a websocket) falls through to the app, so its own /ui routes keep working. Hidden files and __pycache__ are never served from the package. Both are sent with Cache-Control: no-cache, so an upgrade reaches the browser on its next load. The shell is the app's own (shell = "module:function", called with api=, base= and title= keywords) or enlace's generic one, which sets window.<GLOBAL> = {api, base, title} (global_name, default the package name upper-cased) and loads scripts (default ["app.js"]) and stylesheets (default ["style.css"]) from {route}/ui/. The page replaces a frontend_dir if the app has both. The package is imported into the gateway, so a package, assets directory or shell that cannot load is an app-load failure: that app is stubbed with a 500 and reported, like any other (fail_hard refuses to boot). Not available for mode = "static" apps, nor as the landing_app (refused at boot).

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.

Per-client rate limits

An app with an anonymous write endpoint can have enlace rate-limit it per client, with no code in the app. It declares the limits in its own app.toml, with paths relative to its API prefix:

[rate_limit]
"POST /attempts" = "60/minute"   # method + path
"/search" = "10/second"          # any method
"POST /items/*" = "100/hour"     # fnmatch glob

A request over a limit gets 429 Too Many Requests with Retry-After. Behind a reverse proxy, list the proxy in platform.toml so the client address is read from its X-Forwarded-For (only from those peers, right to left, so a client cannot spoof it):

[platform]
trusted_proxies = ["127.0.0.1", "::1"]

Without trusted_proxies, the ASGI peer address is used as is: behind a proxy that is the proxy's own address (unless the server already resolves forwarded headers), so every client would share one bucket. Paths are normalised before matching, a GET rule also covers HEAD, CORS preflights are never counted, and a websocket handshake over the limit is closed with code 1008. Buckets live in memory per process, so N workers allow up to N times the declared rate: it bounds abuse rather than enforcing an exact quota. enlace.ratelimit.RateLimitMiddleware(make_limiter=...) is the seam for a shared limiter: make_limiter=lambda rate: RedisLimiter(client, rate) (with the enlace[redis] extra) gives one count across every worker and host, each rule keeping its own count.

The client-address and limiter pieces (client_address, bucket_key, TokenBucketLimiter, SlidingWindowLimiter, RedisLimiter) import only the standard library and are usable outside enlace's middleware; heed's public feedback endpoint uses them.

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.63

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.63
File Size Uploaded Provenance
enlace-0.1.63.tar.gz 388.6 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for enlace 0.1.63
File Interpreter ABI Platform
enlace-0.1.63-py3-none-any.whl Python 3 none any Details

Total release size: 652.6 kB

Release files / enlace-0.1.63.tar.gz

Download URL enlace-0.1.63.tar.gz
Size 388.6 kB
Tags Source
SHA-256 checksum
How to use checksums
06783831e4365d5f0186db5b8678c1e1730d0f886f000843b43876b4afcdb004
BLAKE2b-256 checksum
How to use checksums
9803eed192897e987eb16d6f2b36586b2ac50807343dc6983314efa119b031db
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","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.63-py3-none-any.whl

Download URL enlace-0.1.63-py3-none-any.whl
Size 264.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
36df03a6eff631267157f34797913b74515da71777f7b4b99e04f4accfdb70f1
BLAKE2b-256 checksum
How to use checksums
e4480b8b7df87d4325ec04f31da79a90d9de7d26541144f9a0a754c159df4bfd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","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}

Security for enlace 0.1.63

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.63 This release

2 release files No provenance

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