What You Will Build
This guide builds a tiny notes app with one Python file and one template. It shows Chirp's core loop:
- A normal browser visit gets a full page.
- An htmx request gets only the named block it asked to swap.
- A form POST returns the updated fragment, not JSON.
chirp checkvalidates the route and template wiring before a user sees it.
Use the generated scaffold when you want auth, sessions, CSRF, static files, and tests already wired. Use this guide when you want the smallest complete example of Chirp's fragment model.
Create The Files
mkdir notes-app
cd notes-app
mkdir templates tests
Create app.py:
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:
<!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>
Run It
python app.py
Open http://127.0.0.1:8000. The first request renders the full document. When
the form submits, htmx sendsPOST /notesand Chirp returns only the
notes_listblock.
Check The Contract
chirp check app:app
The check should be clean. If you rename the notes_listblock but forget to
updatePage("notes.html", "notes_list", ...), Chirp reports the missing block
at startup instead of letting htmx swap an empty or wrong response.
For CI, promote warnings to failures:
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:
import pytest
from app import app
from chirp.testing import TestClient
@pytest.mark.anyio
async def test_notes_page_and_fragment() -> None:
async with TestClient(app) as client:
page = await client.get("/")
assert page.status_code == 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_code == 200
assert "<html" not in fragment.text
assert 'id="notes-list"' in fragment.text
assert "Fragments keep swaps narrow." in fragment.text
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.