What You Will Build
Build a tiny notes app — one Python file, one template — that shows Chirp's whole
hypermedia loop. A browser visit gets a full page. An htmx request gets back only
the named fragment (a template block)
it asked to swap. A form POST returns the updated fragment, not JSON. The
return type you choose —Pagevs
Fragment— is the only thing that decides which one a request gets.
You have a choice for the starting point:
- Run
chirp newwhen you want auth, sessions, CSRF, static files, and tests already wired (a batteries-included scaffold). - Follow this guide when you want the smallest complete example of the fragment model, with nothing you didn't type yourself.
Build And Run
- 1
Create the project files
Make the project directory and its template/test folders:
mkdir notes-app cd notes-app mkdir templates testsCreate
app.py. The/handler returnsPage, which auto-negotiates: a browser gets the full document, an htmx request gets just thenotes_listblock. The POST handler always returns that block as aFragment.from dataclasses import dataclass from pathlib import Path from threading import Lock from chirp import App, AppConfig, Fragment, Page, Request @dataclass(frozen=True, slots=True) class Note: id: int body: str app = App(config=AppConfig(template_dir=Path(__file__).parent / "templates")) _lock = Lock() _next_id = 3 _notes = [ Note(1, "Return types carry rendering intent."), Note(2, "One template can serve pages and fragments."), ] def _all_notes() -> list[Note]: with _lock: return list(_notes) def _add_note(body: str) -> None: global _next_id body = body.strip() if not body: return with _lock: _notes.append(Note(_next_id, body)) _next_id += 1 @app.route("/", name="notes") def notes(request: Request): return Page("notes.html", "notes_list", notes=_all_notes()) @app.route("/notes", methods=["POST"], name="notes.add") async def add_note(request: Request): form = await request.form() _add_note(form.get("body", "")) return Fragment("notes.html", "notes_list", notes=_all_notes()) if __name__ == "__main__": app.run()Create
templates/notes.html. Thenotes_listblock is the unit both handlers render — once inside the full page, once on its own for the swap.<!doctype html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Notes</title> <script src="https://unpkg.com/htmx.org@2.0.4"></script> </head> <body> <main> <h1>Notes</h1> <form hx-post="{{ url_for('notes.add') }}" hx-target="#notes-list" hx-swap="outerHTML" hx-on::after-request="if (event.detail.successful) this.reset()"> <input name="body" required> <button type="submit">Add</button> </form> {% block notes_list %} <ul id="notes-list"> {% for note in notes %} <li>{{ note.body }}</li> {% end %} </ul> {% endblock %} </main> </body> </html> - 2
Run it
Start the dev server:
python app.pyOpen
http://127.0.0.1:8000. The first request renders the full document. When the form submits, htmx sendsPOST /notesand Chirp returns only thenotes_listblock — the same block, served as a fragment because the handler returnedFragment.
Check The Contract
chirp checkvalidates that your routes and template blocks actually line up —
it is the static side of Chirp's contract
model. Run it against the import string for your app object:
chirp check app:app
The check should be clean.
For CI, promote warnings to failures so a broken swap can't merge:
chirp check app:app --warnings-as-errors
Add A Smoke Test
Install the testing extra if you did not already:
uv add "bengal-chirp[testing]"
Create tests/test_notes.py. Chirp's
TestClient is an async context manager;
sending theHX-Requestheader makes Chirp treat the call like an htmx request,
so you can assert the page-vs-fragment behavior directly:
from app import app
from chirp.testing import TestClient
async def test_notes_page_and_fragment() -> None:
async with TestClient(app) as client:
page = await client.get("/")
assert page.status == 200
assert "<html" in page.text
assert "Return types carry rendering intent." in page.text
fragment = await client.post(
"/notes",
data={"body": "Fragments keep swaps narrow."},
headers={"HX-Request": "true"},
)
assert fragment.status == 200
assert "<html" not in fragment.text
assert 'id="notes-list"' in fragment.text
assert "Fragments keep swaps narrow." in fragment.text
These are plain async def tests, so they need pytest-asynciowith
asyncio_mode = "auto" set under [tool.pytest.ini_options]in your
pyproject.toml — then pytestawaits the coroutines with no per-test marker.
Run it:
pytest
Where To Go Next
- Return Values explains when to choose
Template,Page,Fragment,OOB,Suspense,Stream, andEventStream. - Fragments covers named block rendering and htmx targeting in more detail.
- [Examples] points to larger runnable apps with validation, OOB updates, SSE, and app shells.