This post is about a small idea with large consequences: in Chirp, the return type is the response contract.
In Flask, the handler knows what it wants to return, but the framework often needs extra hints. That gap lives in make_response(), render_template(), and the if request.headers.get("HX-Request"): check you write in every htmx route.
Forget that check once and your htmx swap renders the full page inside a div.
Chirp inverts this. The handler returns a type, such as Template, Fragment, Page, Stream, Suspense, or EventStream, and the framework's content negotiator decides the response shape. The return type is the intent.
The negotiation model
When a route handler returns, Chirp's negotiate() function dispatches using structural pattern matching:
match value:
case Response(): # pass through
case Redirect(): # 302 + Location
case Template(): # full page render
case Fragment(): # block render
case Page(): # full or fragment based on request
case Action(): # empty body + HX headers
case ValidationError(): # 422 + fragment
case OOB(): # primary + hx-swap-oob
case Stream(): # progressive HTML
case Suspense(): # shell + deferred OOB
case EventStream(): # SSE
case str(): # text/html
case dict() | list(): # JSON
No if-chains. No isinstance ladders. Each case maps to exactly one response shape.
Why this matters for htmx
This is easiest to appreciate in htmx apps. The same route often needs to return a full page or a fragment depending on whether the request is a browser navigation or an htmx swap.
In Flask:
@app.route("/search")
def search():
books = find_books(request.args.get("q", ""))
if request.headers.get("HX-Request"):
return render_template("search.html", books=books, block="results")
return render_template("search.html", books=books)
In Chirp:
@app.route("/search")
def search(request: Request):
books = find_books(request.query.get("q", ""))
return Page("search.html", "results", books=books, query=request.query.get("q"))
Page encapsulates the decision. If the request has HX-Request: true and is not a history restore, the negotiator renders just the results block. Otherwise, it renders the full template.
Same data. Same template. The branch moves out of the handler and into the contract.
The return types
Real examples
Fragment for htmx CRUD
@app.route("/tasks", methods=["POST"])
def create_task(request: Request):
task = store.add(request.form["title"])
return Fragment("tasks.html", "task_item", task=task)
The handler does not think about response objects, content types, or status codes. It returns the thing it wants to happen.
OOB for multi-region updates
@app.route("/tasks/{id}/complete", methods=["POST"])
def complete_task(id: int):
task = store.complete(id)
return OOB(
Fragment("tasks.html", "task_item", task=task),
Fragment("tasks.html", "stats", stats=store.stats()),
)
One return. Two regions update. The negotiator handles the hx-swap-oob wrapping.
Suspense for instant first paint
@app.route("/dashboard")
async def dashboard():
return Suspense(
"dashboard.html",
shell="shell",
blocks={
"metrics": fetch_metrics(),
"activity": fetch_activity(),
},
)
The shell renders immediately. Deferred blocks resolve concurrently and stream in as OOB swaps. The user sees content immediately, and heavier data fills in without waiting for a full page render.
FormAction — one handler, two clients
@app.route("/tasks", methods=["POST"])
def create_task(request: Request):
task = store.add(request.form["title"])
return FormAction(
redirect="/tasks",
fragments=[Fragment("tasks.html", "task_list", tasks=store.all())],
)
- htmx request → renders the fragments, returns HTML
- Browser POST → 303 redirect to
/tasks
One handler. Both clients work correctly.
What the negotiator doesn't do
It does not guess. Every return type maps to exactly one response strategy. No content-type sniffing, no Accept header parsing, no fallback chains. If you return Fragment, you get a fragment. If you return dict, you get JSON.
Implicit negotiation, like Rails' respond_to, creates behavior that is harder to predict and harder to debug. Chirp's negotiation is explicit: the type is the contract.
The negotiator is ~250 lines of code. No inheritance, no visitor pattern, no registry. Adding a new return type means defining the dataclass, adding a case branch, and updating the contract checks. That's it.
Further reading
- Chirp documentation — full reference including all return types and HTMX patterns
- Chirp source — negotiation.py — the full negotiator in ~250 lines
- Chirp — Free-Threaded Web Framework — the threading model underneath the response types