Pick transport, then client shape
Streaming confuses two independent choices:
- Transport — chunked HTTP (
TemplateStream) vs SSE (EventStream) - Client — full-page navigation vs htmx in-place swap
Only two pairings are safe for a single growing answer:
| Goal | Transport | Client wiring |
|---|---|---|
| Simplest path; user navigates to a streaming page | TemplateStream |
Plain<form method="post"> — no hx-target |
| Stay on the same page; tokens append in a div | EventStream |
POST →Fragment scaffold → parametric sse-connect |
Chirp warns on the bad pairings at freeze:
template_stream_client_shape— htmx swap into aTemplateStreamroutesse_token_swap_mode— many small SSE Fragments with replace swapssse_eager_connect— staticsse-connecton a GET page (INFO)
See Contract categories and Realtime decision tree.
Recipe 1 — TemplateStream (full page)
Reach for: one request, one growing answer, no live shell.
@app.route("/ask", methods=["POST"])
async def ask(request: Request) -> TemplateStream:
form = await request.form()
prompt = (form.get("prompt") or "").strip()
return TemplateStream("response.html", prompt=prompt, stream=get_stream(prompt))
<!-- index.html — no htmx on this form -->
<form action="/ask" method="post">
<input name="prompt" placeholder="Ask something..." autocomplete="off">
<button type="submit">Stream</button>
</form>
<!-- response.html — full page with async loop -->
<div class="response">{% async for token in stream %}{{ token }}{% end %}</div>
Canonical example: LLM Minimal (first form),
examples/standalone/llm_streaming_kida/.
Recipe 2 — EventStream (in place)
Reach for: htmx shell, parametric prompt, token append in one region.
Step A — form swaps in a panel (not the stream itself):
@app.route("/stream/start", methods=["POST"])
async def stream_start(request: Request) -> Fragment:
form = await request.form()
prompt = (form.get("prompt") or "").strip()
url = f"/stream?prompt={quote(prompt)}"
return Fragment("sse_panel.html", "sse_panel", prompt=prompt, stream_url=url)
<form hx-post="/stream/start"
hx-target="#sse-section"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful) this.reset()"
method="post">
<input name="prompt" placeholder="Ask..." autocomplete="off">
<button type="submit">Stream</button>
</form>
<div id="sse-section"></div>
Use placeholder, not value, on the input — this.reset()restores the
initial value attribute, not empty.
Step B — panel connects with a dynamic URL:
<!-- sse_panel.html -->
<div hx-ext="sse" sse-connect="{{ stream_url }}" sse-close="close"
hx-disinherit="hx-target hx-swap" style="display: contents">
<p class="prompt">Prompt: {{ prompt }}</p>
<div class="response" sse-swap="message" hx-target="this" hx-swap="beforeend"></div>
</div>
Per-token Fragments must use beforeend (append). innerHTMLreplaces the
whole answer on every token — only the last word stays visible.
Step C — stream route:
@app.route("/stream", referenced=True)
async def stream(request: Request) -> EventStream:
prompt = (request.query.get("prompt") or "").strip()
async def generate():
async for token in get_stream(prompt):
yield Fragment("response.html", "token", token=token)
yield SSEEvent(event="close", data="done")
return EventStream(generate())
Canonical example: LLM Minimal (SSE form),
chirpui/llm_playground/for a shell variant.
Recipe 3 — Scaffold a new project
chirp new mydemo --stream
cd mydemo && python app.py
Generates both recipes with simulated tokens — no API keys.
For a one-shot SSE hello (not token streaming), usechirp new myapp --sse
instead.
Testing checklist (good-first issues)
app.check()— zero ERRORs- If the form uses
hx-*, tests sendHX-Request - SSE token sinks use
hx-swap="beforeend" @pytest.mark.issue(N)acceptance when closing an issue
Seeexamples/README.md→ “AI curious”.