Hypermedia Model

The mental model: HTML over the wire, content negotiation, and why the return type is the intent.

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

What "hypermedia model" means

Chirp sends HTML over the wire — full pages, page fragments, and streamed HTML — instead of JSON that a client framework turns into HTML. The server owns rendering. The browser (often with htmx) swaps the HTML it receives into the page. There is no client-side state store, no serialization layer, and no separate API to keep in sync with the UI.

That leaves one question for every route: which slice of HTML should this request get? Chirp answers it from the value your handler returns. The return type is the intent.

@app.route("/")
def index():
    return "Hello, World!"          # -> 200 text/html

@app.route("/api/status")
def status():
    return {"status": "ok"}         # -> 200 application/json

You never call make_response() or jsonify(). You return a value; Chirp turns it into the right response. If you have ever returnedrender_template(...)in Flask and reached forjsonify(...)on the next line, this is the same idea with the wiring removed.

Three things a request can ask for

A hypermedia app serves the same template at different scopes depending on who is asking. Chirp recognises three render intents.

Intent The request looks like Chirp sends
Full page A browser navigation — no htmx The whole template, layout and all
Page fragment An htmx navigation (hx-boost, history restore) A self-contained page region, no outer chrome
Local fragment A narrow htmx swap (hx-getinto a target) A single named template block

A template block is a named region of a template ({% block results %}…{% endblock %}). The novelty of the hypermedia model is that one template, with named blocks, serves all three intents — there is no separate "partials" directory and no API view to maintain alongside the page.

Content negotiation: how Chirp decides

Chirp picks the intent from the request, so most handlers never branch on it themselves. Two signals drive the decision:

  1. The return type you chose —Template, Fragment, Page, and the rest.
  2. TheHX-Requestheader htmx sends on every request it issues.

The clearest expression of this is thePagereturn type. You hand it one template and one block name; it renders the full page for a browser and just the block for htmx — noifstatement in your handler.

from chirp import App, Page, Request

app = App()

@app.route("/search")
def search(request: Request):
    books = run_search(request.query.get("q", ""))
    return Page("search.html", "results", books=books)

A browser hitting /search gets search.htmlrendered in full. An htmx request swapping#results gets only the resultsblock — same handler, same template, same data. Chirp adds aVary: HX-Requestresponse header so a cache never serves a fragment to a browser or a full page to htmx.

The return-type vocabulary

Every render intent and transport has a return type. You pick the type that names your intent; Chirp handles status codes, headers, and content negotiation.

You want You return
A full page rendered from a template Template("page.html", **ctx)
One named block, no full page Fragment("page.html", "block", **ctx)
Full page for browsers, fragment for htmx Page("page.html", "block", **ctx)
Several swap targets in one response OOB(main, *oob_fragments)
A shell now, slow sections streamed in Suspense("page.html", stats=load())
A live channel after the page loads EventStream(generator())
Plain HTML or JSON astr or a dict

This is the heart of the framework, and it has its own page. The full catalog — every type, its signature, and a decision tree for choosing between them — lives in Return Values.

Why HTML over the wire

The mental model pays off because the parts that usually drift cannot drift here.

  • One source of truth for the UI. The HTML a browser sees and the HTML an htmx swap receives come from the same template and the same handler. There is no second JSON representation to keep aligned with the markup.
  • State lives on the server. The page reflects server state directly, so you do not reconcile a client store against the database on every interaction.
  • Progressive enhancement is the default. A route that returns aPageworks as a plain HTML form without JavaScript; htmx upgrades it to a partial swap when present.

No client-config blob to keep in sync

The clearest cost of the JSON/SPA model is the hand-maintained client-config contract. A SPA cannot render until it knows what the server enabled, so the server hand-builds a config blob the client fetches on boot — and then re-states every one of those flags again on the admin side so they can be read and written over the wire.

open-webui is a representative example. ItsGET /api/confighandler returns a role-gated dictionary of server flags the SPA fetches before it can render (backend/open_webui/main.py:2367, returning a ~140-line dict through main.py:2539). Editing those same flags needs a second surface: backend/open_webui/routers/configs.py declares Pydantic *ConfigFormmodels and pairedGET/POSTadmin handlers — ~20 route handlers restating the same field names (configs.py:71 onward). A single flag like enable_channelsis typed in the Pydantic model, read in the GET handler, written in the POST handler, and emitted in the boot blob — four hand-aligned restatements that must not drift.

In Chirp the server-known flag is a value in the render context, consumed directly in the template at render time. There is no boot blob and no client-config contract — the flag and the markup that depends on it live in the same place:

import os

from chirp import App

app = App()

# Your own server-side flag — env var, settings object, feature service, etc.
FEATURE_CHANNELS = os.environ.get("FEATURE_CHANNELS") == "1"

# Make it available to every template (or pass it per-route as render context,
# or via a pages `_context.py` provider).
@app.template_global("enable_channels")
def enable_channels() -> bool:
    return FEATURE_CHANNELS
{% if enable_channels %}
  <a href="/channels" class="nav-link">Channels</a>
{% endif %}

The flag is read where it is used. There is no /api/configendpoint to write, no Pydantic form to keep aligned with it, and no client store to reconcile — the contract the SPA had to maintain by hand simply does not exist. This is the concrete shape of "no serialization layer." For the trade-offs of the JSON bet as a whole, see Comparison.

For the design reasoning behind these choices, see Philosophy; for the trade-offs versus a JSON/SPA stack, see Comparison.

Gotchas

Going deeper

Next steps