Skip to content
v0.0.42 Python 3.11+ Apache 2.0

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"
QuadKit: Start Building
0.0.42 public release
590+ protocols
13 published packages
240+ error codes
3.11+ Python, 100% async
Apache 2.0 license

Quickstart

Running in five minutes

Three commands from zero to a live API with generated /docs. No containers, no services to babysit first.

  1. uv add "quadkit-cli>=0.0.4,<0.1"
    Install the CLI
  2. quadkit new project my-app # --template api|minimal
    Scaffold a web API
quadkit · application boot boot trace
app.py
# 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()
Provider · CONFIGURATION ConfigProvider
Provider · CORE LoggingProvider
Provider · DOMAIN OrdersProvider
Provider · PRESENTATION WebProvider
APP READY

Application boot

0.42s · 4 providers

One command. The container wires everything — no config files, no magic, just typed contracts and providers.

zsh — quadkit run
$ quadkit run
Provider ConfigProvider · CORE
Provider LoggingProvider · CORE
Provider OrdersProvider · DOMAIN
Provider WebProvider · PRESENTATION
Application ready in 0.42s · 4 providers
my-app http://127.0.0.1:8000
openapi http://127.0.0.1:8000/docs
$

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.

controllers/orders.py Python 3.11+
# @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)
Build this app, step by step

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.

01
register()

Modules bind protocols to implementations. register() never resolves anything — no I/O, no hidden work at import time.

02
boot()

The container resolves the whole graph in dependency order and hands back an app that's actually ready.

03
shutdown()

Connections, pools, and clients tear down in reverse order. Clean exits — in dev, tests, and production.

project tree
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.

01

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
02

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

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
04

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
05

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

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.

quadkit · contract wiring 4 steps
app.py
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
    )
STEP 1 · PICK A CONTRACT CacheBackendProtocol
STEP 2 · IMPLEMENT IT class RedisCache(CacheBackendProtocol)
STEP 3 · CREATE A PROVIDER CacheProvider.register()
STEP 4 · BOOT THE APP Application.boot(providers=[...])
DI CONTAINER · READY

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.

FastAPI QuadKit
What it is A focused web framework — routes, OpenAPI, Pydantic That HTTP layer plus the rest of the application
Dependencies Depends() on the path operation The same idea, on the constructor
Lifecycle Startup and shutdown hooks on the app Providers: register, boot, shutdown
Backends You pick and import the client Protocols in quadkit-contracts; swap in config
Expected errors HTTPException at the handler Result[T, E] in the domain, HTTP at the edge
A friendly map from FastAPI

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 docs

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

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"