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.
/apps/tiles in two iframes (same Tiles app as two tabs) · paint either panel
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.
| Route | Role |
|---|---|
GET / | First paint — build HTML once |
GET /subscribe | Stay open — patch HTML over SSE when something changes |
POST /click?cellId=N | Toggle a cell — 204 immediately, updates on the subscribe stream |
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:
from pathlib import Pathfrom stario import AssetManifest, UrlPath ASSETS = AssetManifest(Path(__file__).parent / "static")STYLE_CSS = ASSETS.href("css/style.css") # fingerprinted stylesheet URLDATASTAR_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).
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.
from stario.datastar import at, datafrom stario.markup import baked, html as h @bakeddef 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:
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:
import uuid import stario.responses as responsesfrom 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 handlersubscribe — 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).
import stario.responses as responsesfrom stario import Context, Relay, Writerfrom 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 handlerSubscribe 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.
import stario.responses as responsesfrom stario import Context, Relay, Writerfrom 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 handlerUpdates 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.
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.
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
git clone https://github.com/bobowski/stario.gitcd stario/examples/tilesuv syncuv run stario watch main:bootstrapOpen http://127.0.0.1:8000. When you run this docs site locally, the same app is embedded above at /apps/tiles.
Read next
Hello world — client signals before Relay
The go-to architecture — CQRS, SSE, and Relay in depth
Chat room — SQLite, features layout, tests