Streaming answers — transport × client

Safe recipes for TemplateStream and EventStream LLM answers in Chirp

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

Pick transport, then client shape

Streaming confuses two independent choices:

  1. Transport — chunked HTTP (TemplateStream) vs SSE (EventStream)
  2. 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 a TemplateStreamroute
  • sse_token_swap_mode— many small SSE Fragments with replace swaps
  • sse_eager_connect — static sse-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 useshx-*, tests send HX-Request
  • SSE token sinks usehx-swap="beforeend"
  • @pytest.mark.issue(N)acceptance when closing an issue

Seeexamples/README.md→ “AI curious”.