The AI-friendly
Python framework
Contracts and protocols coding agents can reason about, and a backend they can assemble — HTTP, configuration, generators, and tests wired through one container.
uv add "quadkit-cli>=0.0.4,<0.1" Quickstart
Running in five minutes
Three commands from zero to a live API with generated /docs.
No containers, no services to babysit first.
- Install the CLI
uv add "quadkit-cli>=0.0.4,<0.1" - Scaffold a web API
quadkit new project my-app # --template api|minimal - Run it, inside my-app/
quadkit run
# composition root — quadkit run boots this
from quadkit import Application, QuadKitConfig
from quadkit.web import WebModule
from my_app.di.orders_provider import OrdersProvider
def create_app(
config: QuadKitConfig | None = None
) -> Application:
app = Application(name="my-app", config=config)
app.add_modules([
WebModule.configure(
discover=["my_app.controllers"],
),
])
app.add_providers([OrdersProvider()])
return app
app = create_app() Application boot
0.42s · 4 providers
One command. The container wires everything — no config files, no magic, just typed contracts and providers.
How it feels
An orders API, in four files
A controller, a service that returns Result, a provider that binds a protocol, then create_app().
Swap the implementation in the container — the controller never notices.
# @get marks the route; returning a Result renders as JSON or a problem response
from quadkit.web import Controller, get
from my_app.services.orders import OrderService
class OrdersController(Controller):
prefix = "/orders"
def __init__(self, orders: OrderService) -> None:
self._orders = orders
@get("/{order_id}")
async def show(self, order_id: str):
return await self._orders.find(order_id) # Expected failures are values; infrastructure failures still raise
from quadkit.result import Result, Ok, Err
class OrderService:
def __init__(self, repo: OrderRepository) -> None:
self._repo = repo
async def find(self, order_id: str) -> Result[Order, OrderNotFound]:
order = await self._repo.get(order_id)
if order is None:
return Err(OrderNotFound(order_id))
return Ok(order) # register() binds, boot() resolves — the two never share a method
from quadkit.di.provider import Provider
from quadkit.contracts.core import ProviderPriority
from quadkit.contracts.core.di import (
ContainerRegistrarProtocol,
ContainerResolverProtocol,
)
class OrdersProvider(Provider):
name = "orders"
priority = ProviderPriority.DOMAIN
async def register(self, container: ContainerRegistrarProtocol) -> None:
container.singleton(OrderRepository, InMemoryOrderRepository)
async def boot(self, container: ContainerResolverProtocol) -> None:
self._repo = await container.resolve(OrderRepository) # composition root — list the modules this app actually uses
from quadkit import Application, QuadKitConfig
from quadkit.web import WebModule
from my_app.di.orders_provider import OrdersProvider
def create_app(config: QuadKitConfig | None = None) -> Application:
app = Application(name="my-app", config=config)
app.add_modules([
WebModule.configure(discover=["my_app.controllers", "my_app.modules"]),
])
app.add_providers([OrdersProvider()])
return app The stack
Built so agents don't guess
The same seams that keep a human codebase composable are the ones a coding agent can inspect, type-check, and test against.
Contracts, not coupling
Every package talks through protocols in quadkit-contracts. Agents code to interfaces, not implementations. Swap a backend behind your own protocol — same contract, one config line.
Providers with a boot order
Lifecycle and wiring live in one place. Register, boot, shut down. Priority is explicit — infrastructure providers boot before the web layer mounts.
Result, not exceptions
Domain failures are Ok / Err. Agents can follow the error path without guessing which exception flies out of a handler.
Import linter
Architectural boundaries are enforced. Cross-package leaks fail CI before they ship — agents can verify their own imports.
Web & OpenAPI
ASGI controllers, routing, middleware, CORS, rate limiting. Docs generate themselves at /docs.
Typed end to end
Python 3.11+, 100% async, full type hints. Your IDE and your agent see the same signatures.
Ecosystem
Install only what you use
Every extension depends on quadkit and quadkit-contracts — never on each other. The boundary is what keeps the graph composable.
The shape of an app
One composition root.
Three moves.
Every QuadKit app boots the same way. Deterministic startup means an agent can trace — and safely change — the whole system from a single file.
register() Modules bind protocols to implementations. register() never resolves anything — no I/O, no hidden work at import time.
boot() The container resolves the whole graph in dependency order and hands back an app that's actually ready.
shutdown() Connections, pools, and clients tear down in reverse order. Clean exits — in dev, tests, and production.
my_app/
├── domains/ # feature types — no models/
├── controllers/ # discovered by WebModule
├── di/ # app providers at the root
├── shared/ # cross-cutting pieces
├── modules/ # one provider.py each
└── app.py # the composition root
Templates add packages, not a second layout — every app keeps this tree. Even logging stays grep-able: one core config key, json_format.
ARCHITECTURE
Architecture decisions
Six design decisions that solve real problems. Each one prevents a bug category that convention-based frameworks can't catch.
The container has two faces — by design
Most DI containers give you the whole object. QuadKit splits it into `ContainerRegistrar` during setup and `ContainerResolver` during runtime — two separate interfaces on the same object. You literally cannot resolve a dependency during registration because the type system won't let you. The bug "tried to resolve a service that hasn't been registered yet" is caught at decoration time, not runtime.
# During register() — only registrar visible
class UserProvider(Provider):
async def register(self, c: ContainerRegistrarProtocol):
c.singleton(UserService, UserService(c.resolve(UserRepo)))
# ^^^
# Wait — c is a Registrar, resolve() doesn't exist yet.
# This is a compile-time error, not a runtime surprise.
# During boot() — resolver unlocked
class UserProvider(Provider):
async def boot(self, c: BootContainerProtocol):
service = c.resolve(UserService) # ✓ now it works Modules hide their internals from each other
The resolver uses a `ContextVar` to track which module is currently booting, then checks a compiled visibility graph on every resolution. Module A cannot accidentally depend on an internal service of Module B — the resolver rejects it. This is genuine encapsulation at the DI level, not a convention you hope everyone follows.
# Module A exports UserService, hides UserRepo
# Module B tries to reach into Module A's internals
class ModuleB(Module):
async def boot(self, c: BootContainerProtocol):
repo = c.resolve(UserRepo) # ✗ rejected at runtime
# The resolver checks: is UserRepo visible to ModuleB?
# The compiled graph says no. Hard boundary. A six-phase compiler for your dependency graph
The module compiler runs a pipeline: collect → cycle detection → validation → re-export expansion → visibility → provider ordering. Phase 4 transitively expands re-exports so Module B can see Module A's public types without re-declaring them. Phase 6 topologically sorts providers into parallel boot levels. It's a tiny static analysis tool for your dependency graph, and it's extensible.
# The compiler pipeline
Collect → CycleDetection → Validation → ReexportExpansion
→ Visibility → ProviderOrdering
# Phase 4: Module A exports UserService
# Module B imports Module A
# → Module B can see UserService transitively
# Phase 6: Providers sorted into boot levels
# Level 1: [UserRepo, CacheProvider]
# Level 2: [UserService] ← depends on Level 1
# Level 3: [UserController] ← depends on Level 2 Parallel boot with automatic rollback
Providers within the same topological level boot concurrently via `asyncio.gather`. If any provider fails, all previously booted providers shut down in reverse order — a full transactional rollback. Required providers trigger the rollback; optional ones fail gracefully with a warning.
# Boot levels execute in parallel
Level 1: [CacheProvider, UserRepo] ← async.gather
Level 2: [UserService] ← async.gather
Level 3: [UserController] ← async.gather
# If UserService fails in Level 2:
# → UserController never boots
# → UserRepo.shutdown() called
# → CacheProvider.shutdown() called
# → Clean state, no leaked resources Lock-free concurrent resolution
When multiple async tasks request the same scoped service, the first creates an `asyncio.Future` and stores it. Subsequent requests await that same future instead of racing to create their own instance. No locks, no event loop blocking — cooperative deduplication. The result is cached on success; the future is cancelled on failure.
# Two coroutines request the same scoped service
async def handler_a(scope):
user = await scope.resolve(UserService) # creates instance
async def handler_b(scope):
user = await scope.resolve(UserService) # joins in-flight
# handler_b doesn't create a second instance.
# It awaits handler_a's future. Lock-free. SSRF protection that defeats DNS rebinding
The SSRF guard doesn't just check if a URL looks safe. It resolves the hostname to IP addresses, validates every resolved address against private ranges, and returns the validated set. The caller pins its connection to exactly those addresses. An attacker who makes DNS resolve to a public IP at validation time and a private IP at connect time gets caught.
# collect_safe_addresses() in quadkit-contracts
# 1. Resolve hostname → [IP, IP, ...]
# 2. Check ALL resolved addresses against private ranges
# 3. Return validated address set
# 4. Caller pins HTTP connection to those exact IPs
# DNS rebinding attack: DNS → 8.8.8.8 at validation,
# DNS → 192.168.1.1 at connect. Caught.
addrs = collect_safe_addresses("https://example.com")
# addrs = [IPv4Address('93.184.216.34')] # pinned CONTRACTS
Build anything with composable contracts
QuadKit ships typed contracts for AI/LLM, web, auth, storage, tasks, resilience, and multimedia — ready to wire into your app. Each contract is a protocol with error codes, providers, and generators. Mix them to build backends that scale.
from quadkit import Application, Provider
from quadkit.contracts.infra.cache import CacheBackendProtocol
# 1. implement the contract
class RedisCache(CacheBackendProtocol):
async def get(self, key: str):
return await self.redis.get(key)
# 2. bind it in a provider
class CacheProvider(Provider):
async def register(self, container):
container.singleton(
CacheBackendProtocol, RedisCache
)
# 3. boot the app
async with Application.boot(
providers=[CacheProvider()]
) as app:
cache = await app.container.resolve(
CacheBackendProtocol
) Coming from FastAPI
Keep the HTTP instincts. Add a composition root.
Starlette routing, Pydantic request shapes, OpenAPI — you already know this layer. QuadKit wraps it in a container, providers, and a contract-first ecosystem so services, configuration, and tooling feel as designed as the routes. Feature types live in domains/, not a models/ directory.
FAQ
Answers, for humans and agents.
The honest version: what this is, what stage it's at, and where the real documentation lives.
Open the docsAn async-first, contract-driven Python application framework. The core gives you a DI container, providers, modules, YAML config, and the Result type. Extensions add HTTP; tooling scaffolds and tests. Five packages are published, and each talks through protocols — never through another package.
No. Starlette routing, Pydantic request shapes, and OpenAPI remain the HTTP layer; QuadKit adds a composition root around them — constructor injection instead of Depends(), providers instead of ad-hoc startup hooks, and contracts so a backend can be swapped in config. Adopt it one service at a time.
Coding agents need stable interfaces, typed errors, and docs they can load. QuadKit ships 590+ protocols, 240+ error codes, /llms.txt, /agents.md, /SKILL.md, the quadkit-skills pack, and fail-vs-fix guidance. Import boundaries are linted so generated code cannot quietly couple packages.
Python 3.11 or newer. The stack is 100% async/await. Install with uv add quadkit-cli (or pip install quadkit-cli), then quadkit new project my-app --template web-api.
QuadKit is alpha (0.0.x) — 0.0.42 today. Public APIs may change before 1.0 — pin versions in production and follow the changelog. Treat it as early on purpose: the architecture is stable; the surface is still moving.
No. uv add quadkit-cli, then scaffold. The foundation is quadkit plus quadkit-contracts; quadkit-web, quadkit-cli, and quadkit-testing are the other three. Extensions never depend on other extensions.
Updates
What's new
The 0.0.x line, plus writing from the last few weeks. Not a git log.
Alpha 0.0.x — what is stable
The architecture is the pin. Package names and experimental surfaces will still move. Here is what we will not casually rewrite.
Sep 15, 2026
BlogHow coding agents should read QuadKit
The shortest path from a fresh clone to a change that will pass the import linter — llms.txt, agents.md, contracts, then an example.
Sep 6, 2026
BlogComing from FastAPI
FastAPI is a great HTTP layer. QuadKit keeps that instinct and puts a composition root around it — you are not starting over.
Sep 1, 2026
Start building
Ship a backend your agent can keep working on
Install the CLI, scaffold with quadkit new project, and add packages as you need them. The contracts stay put.
uv add "quadkit-cli>=0.0.4,<0.1"