What you'll build
Chirp serves HTML over the wire — full pages for browser navigations and HTML fragments for htmx requests, both from one template. This page goes from an empty directory to a running app with a live search box in about five minutes.
You return values likeTemplate and Page; the return type tells Chirp what
to render. If you know Flask, the routing will feel familiar — the fragment loop
is the one new idea.
Start a project
You have two ways in. Scaffold a ready-made app withchirp new, or build one by
hand to see each piece. Both land on a running app athttp://127.0.0.1:8000.
chirp new myapp
cd myapp
python app.py
The scaffold ships with auth, sessions, CSRF, and security headers already wired.
Log in withadmin / password and open http://127.0.0.1:8000/dashboard.
Create a file calledapp.py:
from chirp import App
app = App()
@app.route("/")
def index():
return "Hello, World!"
app.run()
Run it with python app.py and open http://127.0.0.1:8000. A handler returns
a value; a plain string becomes an HTML response.
What the scaffold generates (and the secret-key story)
chirp new myappwrites an auth-ready layout:
app.pywith sessions, auth, CSRF, and security-headers middlewaremodels.pywith a demo user model and password hashingpages/filesystem routes (/,/login,/dashboard)static/style.csstests/with auth-flow tests
For a smaller starting point,chirp new myapp --minimal writes a single app.py
plustemplates/index.html — no pages/tree, models, or auth routes. The
minimalapp.pyis still not bare: it wires the secure-by-default stack
(SessionMiddleware → CSRFMiddleware → SecurityHeadersMiddleware) and reads
the secret key fromCHIRP_SECRET_KEY, so even it passes the security_stack
contract out of the box.
Every scaffold readsCHIRP_SECRET_KEYand refuses to start in production with a
placeholder secret. Generate one before you deploy:
export CHIRP_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
See auth-hardening for the secure-by-default rationale and production config for the full deployment story.
Add a template
Create atemplates/ directory and add templates/base.html:
<!DOCTYPE html>
<html>
<head><title>{{ title }}</title></head>
<body>
{% block content %}{% endblock %}
</body>
</html>
Add templates/index.html:
{% extends "base.html" %}
{% block content %}
<h1>{{ title }}</h1>
<p>Welcome to my Chirp app.</p>
{% endblock %}
Update app.py:
from chirp import App, Template
app = App()
@app.route("/")
def index():
return Template("index.html", title="Home")
app.run()
Handlers return values. Template tells Chirp to render index.htmlwith the
given context through kida templates.
Render a fragment
This is where Chirp diverges from Flask. A search route can serve a full page to a browser and just the results to an htmx request — from the same template, with no separate partials directory.
Addtemplates/search.html. Wrap the results in a named block so it can be
rendered on its own:
{% extends "base.html" %}
{% block content %}
<h1>Search</h1>
<input type="search" name="q"
hx-get="/search" hx-target="#results" hx-trigger="input changed delay:300ms">
{% block results %}
<div id="results">
{% for item in results %}
<p>{{ item }}</p>
{% endfor %}
</div>
{% endblock %}
{% endblock %}
Update app.py. Return [[docs/about/core-concepts/return-values|Page]] and
Chirp negotiates the response: a full page for browser navigations, the named
block for narrow htmx swaps.
from chirp import App, Template, Page, Request
app = App()
ITEMS = ["apple", "banana", "cherry", "date", "elderberry"]
@app.route("/")
def index():
return Template("index.html", title="Home")
@app.route("/search")
def search(request: Request):
q = request.query.get("q", "")
results = [i for i in ITEMS if q.lower() in i.lower()] if q else ITEMS
return Page("search.html", "results", title="Search", results=results)
app.run()
Page replaces the manual if request.is_htmx: return Fragment(...)branch you'd
otherwise write on every htmx-reachable route.
What `Page` desugars to
Page("search.html", "results", ...)is the auto-negotiation form. If you ever
need the branch by hand — to render different blocks per request type, say — read
the request directly:
from chirp import App, Template, Fragment, Request
@app.route("/search")
def search(request: Request):
q = request.query.get("q", "")
results = [i for i in ITEMS if q.lower() in i.lower()] if q else ITEMS
if request.is_htmx:
return Fragment("search.html", "results", results=results)
return Template("search.html", title="Search", results=results)
Use request.is_htmx for any htmx request, or request.is_narrow_fragmentto
exclude boosted navigations and history restores. PreferPage— it handles all
three cases for you.
Wire up htmx
To make the fragment swap fire, include htmx intemplates/base.html:
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"></script>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
Now the search input sends hx-get requests to /search, and Chirp responds
with only theresultsblock — no full page reload, no separate partials, no
hand-written JavaScript.
Stream live updates
For updates that arrive after the page loads — notifications, a ticker, a live
feed — use Server-Sent Events.
A route returns anEventStream that yields Fragmentswaps over a long-lived
connection.
- 1
Open an SSE scope in your template
Add
templates/feed.html. Extend the boost layout, wrap the live region in a named block, and declare where the stream connects:{% extends "chirp/layouts/boost.html" %} {% block content %} {% block live_block %} <ol id="live_block"> {% for item in items %} <li>{{ item }}</li> {% endfor %} </ol> {% endblock %} {% endblock %} {% block sse_scope %} {% from "chirp/sse.html" import sse_scope %} {{ sse_scope("/events", swap="live_block") }} {% endblock %} - 2
Stream fragments from the route
EventStreamtakes an async generator. Eachyieldre-renders thelive_blockfromfeed.htmland pushes it down the connection:from chirp import EventStream, Fragment @app.route("/events", referenced=True) async def events(): async def stream(): yield Fragment("feed.html", "live_block", items=ITEMS) return EventStream(stream()) - 3
Run chirp check
chirp check myapp:appThis catches SSE scope violations and route/template mismatches before you open the browser.
See View Transitions + OOB for the full real-time pattern.
Next steps
You now have the fragment loop running. The natural next step is to build the
same loop with a form, tests, andchirp checkend to end:
- First Fragment App — the smallest complete app, with a form POST and tests
- Return Values — every type a handler can return and what it means
- Fragments — the fragment-rendering deep dive
- Server-Sent Events — real-time updates after the page loads