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 addsVary: HX-Request, HX-Request-Typeso a cache never serves a fragment to a browser, replays the wrong htmx 4 fragment width, or serves a full page to a narrow target.

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.

From return intent to application contract

Return types are also compiler input. At application freeze, Chirp combines route declarations, typed return metadata, template blocks, htmx targets, and registries into one immutable internal application model.app.check()uses that model to catch detectable broken edges before a request reaches the browser; runtime traces, DevTools, and transition tests correlate observed requests with the same compiled transitions.

The compiler does not replace request-time rendering. SQL, forms, sessions, streaming, and SSE still run in the live ASGI application. Static export through chirp freezeis optional and applies only to compatible routes. See Architecture and the tested Full-Application Journey for the complete feedback loop.

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