Getting started with Stario

Stario is a Python 3.14+ framework for building web apps that scale with you — from static pages and simple forms to realtime, multiplayer experiences. The same pieces stay explicit as experiments grow into products: routes as data, handlers as plain async functions, HTML composed in Python, optional Datastar for live UI, and built-in telemetry so you can see what matters.

Below is a live view of Tiles — a collaborative painting board from the Stario repo. We use it to introduce the ideas Stario is built around: server-rendered HTML, SSE updates, short POST commands, and a small event loop that ties actions to subscribers.

Try it now: paint in Player 1 or Player 2 below — the other panel updates live. Each panel is a separate browser tab (same app, shared board). You can also open the app full screen in another window.

Live demo

/apps/tiles in two iframes · paint either panel

Player 1
Player 2

How it works

Tiles uses three routes on purpose. Together they implement a CQRS-shaped split built for realtime multiplayer: a long-lived connection keeps the UI in sync when state changes, while separate command routes signal actions (paint a cell, join, leave) without blocking on every client.

RouteRole
GET /First paint — build HTML once
GET /subscribeStay open — patch HTML over SSE when something changes
POST /click?cellId=NToggle a cell — 204 immediately, updates on the subscribe stream
text
       Tab A                         Server                         Tab B
         |                              |                              |
         |-------- GET / -------------->|                              |
         |<------- HTML ----------------|                              |
         |-------- GET /subscribe ----->|                              |
         |                              |<------- GET /subscribe ------|
         |-------- POST /click -------->|                              |
         |<------- 204 -----------------|                              |
         |                              |-------- relay.publish ------>|
         |<------- SSE patch -----------|<------- SSE patch -----------|

A command alone does not update other tabs — you need to close the loop between the action (POST) and every open subscriber (SSE). Tiles uses an in-process Relay for that fan-out. The same role is often filled by NATS, Redis pub/sub, or another event bus in production; the HTTP shape stays the same.

Static sites or single-player realtime apps use subsets of this pattern — only GET /, or GET / plus one SSE route without multiplayer fan-out. Working out those simpler shapes is a good follow-on exercise once this page clicks.

Routes and assets

Routes, static files, and fingerprinted asset URLs are declared as module-level data:

python
from pathlib import Path
from stario import AssetManifest, UrlPath
 
ASSETS = AssetManifest(Path(__file__).parent / "static")
STYLE_CSS = ASSETS.href("css/style.css")      # fingerprinted stylesheet URL
DATASTAR_JS = ASSETS.href("js/datastar.js")   # fingerprinted Datastar bundle
 
HOME = UrlPath("/")
SUBSCRIBE = UrlPath("/subscribe")
CLICK = UrlPath("/click")

AssetManifest scans and fingerprints at import time; StaticAssets serves files during bootstrap. Each route is one UrlPath constant — use it in app.get / app.post and in views via .href().

Shared state

Game holds the board and presence roster for this demo. Handlers receive the same instance through closures (see home below).

python
class Game:
    def __init__(self, *, grid_size: int = 5):
        self.board: dict[int, str] = {}        # cell index → color
        self.user_colors: dict[str, str] = {}   # tabs with an open /subscribe
 
    def join(self, user_id: str) -> None: ...
    def leave(self, user_id: str) -> None: ...
    def paint_cell(self, user_id: str, cell_id: int) -> str: ...

For real apps we strongly recommend a database — SQLite for local and small deploys, Postgres or similar when you outgrow it. A dict keeps this example in one file so the request path is easy to follow.

Views

Stario builds HTML as a tree of Python callables — import stario.markup.html as h, then nest tags like ordinary functions. A mapping argument sets element attributes (id, class, …); positional arguments are children. Lists of children are fine. The framework renders the tree when you pass it to responses.html() or SSE(w).patch_elements().

page is a @baked document shell — static <head> (CSS, Datastar) and a dynamic body. Full info_view / board_view helpers live in examples/tiles/main.py.

python
from stario.datastar import at, data
from stario.markup import baked, html as h
 
@baked
def page(body):
    return h.HtmlDocument(
        h.Head(
            h.Link({"rel": "stylesheet", "href": STYLE_CSS}),
            h.Script({"type": "module", "src": DATASTAR_JS}),
        ),
        h.Body(body),
    )
 
def home_view(user_id: str, game: Game):
    return page(
        h.Div(
            {"id": "home"},  # stable patch target for SSE
            data.signals({"user_id": user_id}, if_missing=True),
            data.init(at.get(SUBSCRIBE.href(), retry="always")),  # opens SSE
            info_view(user_id, game),
            board_view(game),  # data.on("click") → POST /click
        ),
    )

The same home_view renders the first GET and every live patch — server state and DOM stay aligned.

Handlers

Every handler shares one signature:

python
async def handler(c: Context, w: Writer) -> None:
    ...

Context (c) is the per-request bundle: the incoming Request, routing metadata, and c.span for telemetry. Writer (w) is how you send the response — HTML, SSE, redirects, empty 204, and so on. You do not return a response object; you write to w.

home — first paint

GET /. Mint a fresh user_id per tab (stored as a Datastar signal for POST and subscribe).

Passing dependencies into handlers — game state, a database pool, an HTTP client — is often done with a factory function that returns the handler and closes over what that route needs:

python
import uuid
 
import stario.responses as responses
from stario import Context, Writer
 
 
def home(game: Game):
    async def handler(c: Context, w: Writer) -> None:
        user_id = str(uuid.uuid4())[:8]
        c.span.attr("user_id", user_id)  # visible in traces
        responses.html(w, home_view(user_id, game))
 
    return handler

subscribe — player lifecycle

GET /subscribe. A long-lived SSE connection with three phases: on start (join), while connected (patch on every change), on end (leave when the tab disconnects).

python
import stario.responses as responses
from stario import Context, Relay, Writer
from stario.datastar import SSE, read_signals
 
 
def subscribe(game: Game, relay: Relay[str]):
    async def handler(c: Context, w: Writer) -> None:
        signals = await read_signals(c.req)
        user_id = str(signals.get("user_id", ""))
        if not user_id:
            responses.redirect(w, HOME.href())
            return
 
        async with relay.subscribe("*") as live:
            sse = SSE(w)
 
            # on start — register player, notify others
            game.join(user_id)
            relay.publish("join", user_id)
            sse.patch_elements(home_view(user_id, game))
 
            # while connected — re-render when relay fires (clicks, joins, leaves)
            async for _, _ in c.alive(live):
                sse.patch_elements(home_view(user_id, game))
 
        # on end — connection dropped or server shutting down
        game.leave(user_id)
        relay.publish("leave", user_id)
 
    return handler

Subscribe to the relay before publishing join so this tab's queue exists and it does not miss its own presence event.

c.alive drives the while connected loop: it yields while the client stays connected and exits when the tab closes or the server begins shutdown — then the on end block runs.

click — toggle a cell

POST /click. Validate input, respond with 204 immediately, then mutate state and notify subscribers — the same shape as a background task in other frameworks: the client is done waiting, work continues on the server.

python
import stario.responses as responses
from stario import Context, Relay, Writer
from stario.datastar import read_signals
 
 
def click(game: Game, relay: Relay[str]):
    async def handler(c: Context, w: Writer) -> None:
        signals = await read_signals(c.req)
        user_id = str(signals.get("user_id", ""))
        if user_id not in game.user_colors:
            responses.redirect(w, HOME.href())
            return
 
        cell_id_param = c.req.query.get("cellId")
        if cell_id_param is None:
            responses.redirect(w, HOME.href())
            return
        try:
            cell_id = int(cell_id_param)
        except ValueError:
            responses.redirect(w, HOME.href())
            return
        if not 0 <= cell_id < game.total_cells:
            responses.redirect(w, HOME.href())
            return
 
        responses.empty(w, 204)           # ack first — do not write to w again after this
        game.paint_cell(user_id, cell_id)
        relay.publish("click", user_id)   # SSE tabs patch home_view
 
    return handler

Updates reach every open tab on the subscribe stream, not in the POST body. After responses.empty(w, 204), the HTTP response is complete — do not call responses.* or SSE(w) on that same Writer again.

Wire it up

bootstrap is the composition root Stario calls at startup. Code before yield registers assets and routes; code after yield runs teardown when the process stops.

python
from stario import App, Relay, Span, StaticAssets
 
async def bootstrap(app: App, span: Span):
    game = Game()
    relay = Relay[str]()
 
    with span.step("static_assets") as s:
        static = StaticAssets(ASSETS)
        s.attrs(static.stats)
    static.register(app)
 
    app.get(HOME, home(game))
    app.get(SUBSCRIBE, subscribe(game, relay))
    app.post(CLICK, click(game, relay))
 
    yield
    # teardown: close pools, flush queues, …

Run with stario watch app.main:bootstrap or stario serve app.main:bootstrap. See Runtime.

Telemetry

Stario treats observability as part of the app, not an afterthought. You describe what matters in the same code that handles requests — dimensions on spans, timed steps, point-in-time events — and Tracers export it to the TTY, NDJSON, or SQLite.

A span is a unit of work with a begin and end. span.step() creates a child span around a block (startup work, a DB query). span.attr / span.attrs attach searchable dimensions. span.event marks something that happened at an instant inside a span.

python
async def bootstrap(app: App, span: Span):
    game = Game()
    relay = Relay[str]()
 
    # attrs — dimensions on this startup span (use attrs for several at once)
    span.attrs({
        "tiles.grid_size": game.grid_size,
        "tiles.total_cells": game.total_cells,
    })
 
    # step — nested span with its own begin/end (shows up indented in TTY traces)
    with span.step("static_assets") as s:
        static = StaticAssets(ASSETS)
        s.attrs(static.stats)
    ...
 
async def handler(c: Context, w: Writer) -> None:
    c.span.attr("user_id", user_id)  # one dimension on the request span
    # event — a moment in time, not a duration
    c.span.event("Cell toggled", {"cell_id": cell_id, "action": action})

Telemetry is there to give you insight into a running app like this one — see Getting insights from SQLite tracer for querying traces locally.

Run it yourself

bash
git clone https://github.com/bobowski/stario.git
cd stario/examples/tiles
uv sync
uv run stario watch main:bootstrap

Open http://127.0.0.1:8000. When you run this docs site locally, the same app is embedded above at /apps/tiles.