Markdown Stream

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [viewer]
#| viewerHeight: 350

import asyncio

from shiny import App, reactive, ui

chunks = [
    "## Hello, **markdown stream**! \n\n",
    "This content arrives ",
    "one chunk at a time, ",
    "like the response from an LLM. \n\n",
    "- Supports *markdown* formatting \n",
    "- Auto-scrolls as content arrives \n",
]

app_ui = ui.page_fluid(
    ui.input_action_button("stream", "Stream markdown", class_="mb-3"),
    ui.output_markdown_stream("md_stream"),
    class_="px-3 pt-3",
)

def server(input, output, session):
    md = ui.MarkdownStream("md_stream")

    async def chunk_generator():
        for chunk in chunks:
            await asyncio.sleep(0.25)
            yield chunk

    @reactive.effect
    @reactive.event(input.stream)
    async def _():
        await md.stream(chunk_generator())

app = App(app_ui, server)
import asyncio

from shiny import reactive
from shiny.express import input, ui

chunks = [
    "## Hello, **markdown stream**! \n\n",
    "This content arrives ",
    "one chunk at a time, ",
    "like the response from an LLM. \n\n",
    "- Supports *markdown* formatting \n",
    "- Auto-scrolls as content arrives \n",
]

ui.input_action_button("stream", "Stream markdown", class_="mb-3")

md = ui.MarkdownStream("md_stream")  
md.ui()  

async def chunk_generator():
    for chunk in chunks:
        await asyncio.sleep(0.25)
        yield chunk

@reactive.effect
@reactive.event(input.stream)
async def _():
    await md.stream(chunk_generator())
import asyncio

from shiny import App, reactive, ui

chunks = [
    "## Hello, **markdown stream**! \n\n",
    "This content arrives ",
    "one chunk at a time, ",
    "like the response from an LLM. \n\n",
    "- Supports *markdown* formatting \n",
    "- Auto-scrolls as content arrives \n",
]

app_ui = ui.page_fluid(
    ui.input_action_button("stream", "Stream markdown", class_="mb-3"),
    ui.output_markdown_stream("md_stream"),  
)

def server(input, output, session):
    md = ui.MarkdownStream("md_stream")  

    async def chunk_generator():
        for chunk in chunks:
            await asyncio.sleep(0.25)
            yield chunk

    @reactive.effect
    @reactive.event(input.stream)
    async def _():
        await md.stream(chunk_generator())

app = App(app_ui, server)
No matching items

Relevant Functions

No matching items

Details

A markdown stream progressively renders markdown (or HTML) content as it arrives, chunk by chunk. It is designed for displaying streaming responses — most notably output generated by an LLM — without waiting for the full response to finish. Visit the article on streaming to learn more about using MarkdownStream() with an LLM provider.

To add a markdown stream to your app:

  1. In Core, add ui.output_markdown_stream() to the UI, passing it a unique id. In Express, create a ui.MarkdownStream() object and call its .ui() method where the stream should appear.

  2. In the server, create a ui.MarkdownStream() object with the same id (Core only — in Express you already have the object).

  3. Call await md.stream(...) with an iterable or async iterable of content chunks. Each chunk is appended to the display as it arrives. By default .stream() clears previous content first; pass clear=False to append to what is already there.

    Pass a sequence of strings, not a single string: a bare str is itself an iterable of characters, so .stream("Hello") streams one letter at a time.

The UI component accepts content for initial (non-streamed) content, content_type ("markdown", "html", or "text"), and auto_scroll to control whether the container scrolls as new content arrives.

Besides .stream(), a ui.MarkdownStream() object gives you .clear() to empty the display and .latest_stream to reactively read the stream itself. .latest_stream returns the underlying extended task, so you can read its final .result(), check its .status(), or .cancel() a stream in progress.

Pass on_error to ui.MarkdownStream() to control how streaming errors surface. With the default "auto", the message is sanitized if the app is set to sanitize errors and shown verbatim otherwise; "actual" and "sanitize" force those two behaviors. All three notify the user and let the app keep running. "unhandled" instead shows nothing and stops the app.

See also: Chat for a complete conversational interface.