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:
- The return type you chose —
Template,Fragment,Page, and the rest. - The
HX-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 a
Pageworks 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
How Chirp computes the render intent
ForPage, LayoutPage, and PageCompositionreturns, Chirp builds a
RenderPlanbefore any HTML is produced. The plan resolves to one of three
intents —"full_page", "page_fragment", or "local_fragment"— based on the
HX-Requestheader, whether the request is a boosted navigation or a history
restore, and theHX-Targetof the swap.
Middleware can read the plan withget_render_plan(request)to log or act on the
rendering decision. The full mechanism, including layout-chain matching, is
covered in RenderPlan Middleware.
Next steps
- Return Values — the full return-type catalog and decision tree
- Your First Fragment App — build the negotiation loop end to end
- Fragments — the working surface for HTML over the wire
- Contracts — how
app.check()validates this wiring at startup