v0.13.0’s headline change looks bureaucratic, a JSON schema edit. It is actually a philosophy change about who the source of truth is. The old config described your frontend and implied your backend from imports; the new one declares both.
What changed, concretely
{
"backend": {
"framework": "flask",
"entrypoint": "main:app",
"types_module": "main"
},
"clients": [
{ "framework": "Next.js", "root": "../frontend" }
]
}- framework selects the native dev server (uvicorn / flask run / manage.py runserver).
- entrypoint is framework-specific by design: a
module[:app]target for FastAPI/Flask/ASGI, a filesystem path tomanage.pyfor Django. One name, honest semantics per framework. - types_module names where registration happens (see its own post).
- clients[] carries a framework each, killing the old
"Mixed"pseudo-framework that existed only because one field served many roots.
Sniffing demoted from oracle to default
Auto-detection still exists, it is good UX. What changed is its authority:
- Interactive: sniffing preselects the wizard’s framework choice. You press Enter to confirm or arrow away. Detection is a suggestion, never a decision.
--yes: sniff-or-error. If markers (mount_fastapi(,mount_flask(,mount_django(,PyRPCAsgiApp) are absent, the command exits nonzero with guidance instead of guessing. CI gets determinism, not vibes.- Invalid input fails before anything else:
--framework expressexits immediately with the four valid choices, before config loading, before module resolution.
Validation as a data model, not scattered ifs
Parsing lives in one place producing an immutable value:
@dataclass(frozen=True) class BackendSpec: framework: str entrypoint: str types_module: str | None = None spec = parse_backend(cfg) # None for absent/invalid/legacy
Three payoffs fell out of this shape. Bare entrypoints normalize once ("main" becomes main:app) so every consumer sees canonical form. Dataclass equality gives the live-reload watcher free, precise diffing. And legacy configs are simply parse_backend() is None, no migration code paths, no version field to maintain: an unreadable file is treated as unconfigured, rewritten in place on next run.
The trade we made on purpose
Yes, first runs now answer one more question. In exchange: no more inferring servers from import graphs, no silent wrong-framework launches, configs readable at a glance, and every flag (--framework, --module, --client) maps to exactly one schema key. Explicitness is not ceremony here, it is what makes the other features (native runners, live restarts) safe to ship.

pyRPC