# PyWeb guide for AI assistants PyWeb (`pip install pyweb-stack`, `import pyweb`, command `pyweb`) builds a full-stack web app from ONE `.pyweb` file: Python plus HTML-like markup. Pages render on the server; event handlers compile to JavaScript; functions marked `@server` run on the server and are called from the browser over RPC. Follow these rules exactly. When unsure, run the `pyweb_check` tool (or `pyweb check app.pyweb`) and fix what it reports. ## 1. Skeleton ```pyweb from pyweb import App, server app = App(title="My app", stylesheets=["/static/app.css"]) @server def save_item(text: str) -> list: # runs on the server; called over RPC ITEMS.append(text) return ITEMS ITEMS = [] @app.page("/") def Home(): items = list(ITEMS) # computed on the server per request draft = "" # browser state (bound below) def add(): # event handler -> compiled to JavaScript items = save_item(draft) # server call: awaited automatically draft = ""

Items ({len(items)})

``` Run: `pyweb dev app.pyweb` (http://localhost:8000, live reload). Static files go in `static/` next to `app.pyweb`, served at `/static/...`. ## 2. What runs where (most important rules) | Code | Runs | |---|---| | imports, classes, DB connections, module objects | server only | | `NAME = ` at module level | both (inlined into browser JS) | | `@server` functions | server; browser calls become RPC | | undecorated module functions | server; compiled to JS only if browser code calls them | | page function body (top to the markup) | server, every request | | nested `def` inside a page (handlers) | browser (JavaScript) | | `{expressions}` in markup | server (first render) and browser (updates) | Consequences: - Handlers must NOT use imports, DB handles, files, `os`, models, or other server-only names. Put that work in an `@server` function and call it. - Markup expressions must not call `@server` functions. Load data into a page variable instead (`rows = load_rows()` at the top of the page). - Never put secrets in page variables read by markup or handlers; the compiler rejects names like `token`, `secret`, `password`, `api_key` that would reach the browser (an empty `password = ""` bound to an input is fine). - Everything the browser reads (markup, JS, page state) is public. Authorize inside `@server` functions with `session.require(...)`. ## 3. State Plain local variables in a page are the state. No `useState`, no `nonlocal`. - A variable assigned/mutated in a handler, or used with `bind={x}`, is a **signal**: reactive, updating only the DOM that reads it. - A variable computed from signals and never assigned in a handler is **computed**: `total = price * quantity`. - Everything else is a constant. - Inside handlers, assigning a page variable updates the page: `count += 1`, `draft = ""`, `items.append(x)`, `items[i]["done"] = True`, `del items[i]`, `items = [x for x in items if ...]` all work. - You cannot assign to a computed value or constant from a handler. - Initial values that call functions, read the session, or depend on page-level logic are computed on the server; only values browser code reads are sent to the browser. ## 4. Markup - A line starting with `` works: `oninput`, `onchange`, `onkeydown`. - Binding: `bind={name}` on input/textarea/select; checkbox binds a bool; `type="number"` with a numeric initial value binds a number. - Control flow lines inside markup: `for x in xs:`, `if c:`, `elif c:`, `else:` with markup bodies indented below. - Whitespace between separate lines is dropped (like JSX); keep text that needs a space on one line. - Literal braces: `{"{"}`. ## 5. Components ```pyweb from pyweb import App, component app = App() @component def Card(title, subtitle="", children=None):

{title}

if subtitle:

{subtitle}

{children}
@app.page("/") def Home():

Body

``` Props are parameters (defaults = optional). Pass callbacks as props (`on_delete={lambda: delete(i)}`) and use them as handlers inside (`onclick={on_delete}`). A component's initial state must be computable in the browser (pass server data as props). Bigger apps can split into files: put components, `@server` functions and constants in e.g. `widgets.pyweb` (or `ui/cards.pyweb`) next to `app.pyweb` and `from widgets import Card, save`. Pages stay in `app.pyweb`; import server functions without `as`; each file keeps its own constants. ## 6. Server functions, sessions, routing ```python from pyweb import App, RPCError, NotFound, redirect, request, server, session @server def update(item_id: int, title: str) -> dict: # annotations validate/coerce args user = session.require() # 401 if not signed in if not title.strip(): raise RPCError("validation_error", "Title is required.") # browser: except RPCError as e: str(e) ... @app.page("/items/{item_id}") # typed route param; bad int -> 404 def Item(item_id: int): if not session.user(): return redirect("/login") row = find(item_id) if row is None: raise NotFound() ... ``` - `session.login(user_id, **claims)`, `session.user()`, `session.logout()`, `session.require("admin")`. - `pyweb.auth.hash_password` / `verify_password` for passwords. - Database: `from pyweb.db import connect; db = connect("sqlite:///app.db")`; `db.execute("select ... where id = ?", (x,)).dicts()`; always use `?` parameters; `with db.transaction(): ...`. - Run code after load with a handler named `on_mount`; stop timers in `on_unmount` (runs when the user navigates away). React to a value changing with `watch(lambda: value, handler)` in `on_mount` (`from pyweb.browser import watch`). - Live updates: in a server function `publish("room:1", data)`; in the page `feed = channel("room:1")` (runs on the server); in `on_mount` `subscribe(feed, handler)`, where `handler(message)` assigns page variables. Import all three from `pyweb`. Never poll with `setInterval` when `publish` fits. ## 7. Python that compiles to the browser Supported in handlers/markup: literals, f-strings (with format specs), arithmetic with Python semantics, comparisons, `in`, `and/or/not` with Python truthiness, comprehensions, lambdas, slicing/negative indexes, `if/for/while/try/except/raise/return/del`, builtins (`len str int float bool abs min max sum round range sorted reversed enumerate zip list dict set tuple any all isinstance print`), common str/list/dict/set methods. JS globals are available directly: `window`, `document`, `localStorage`, `console`, `setTimeout`, `setInterval`, `fetch`, `Math`, `JSON`, `Date`. Not supported in browser code: classes, imports, `with`, generators, walrus, `*args/**kwargs` parameters, slice assignment, keyword arguments to browser globals, server-only names. Move such code into `@server` functions. ## 8. Errors and fixes | Error text contains | Fix | |---|---| | `only exists on the server` | Move that logic into an `@server` function and call it from the handler. | | `is not defined in browser code` | Define it at module level (literal or helper function), pass it in, or use an `@server` function. | | `markup expressions must be synchronous` | Assign the server call's result to a page variable or call it in a handler. | | `cannot assign to ... derived/read-only` | Assign to a variable the handler owns (make it state), not a computed/constant. | | `server secret ... would be sent to the browser` | Keep the value inside `@server` functions; don't read it in markup/handlers. | | `bind={x} must name a local variable` | Declare `x = ""` (or a number/bool) in the page before the markup. | | `unknown component ` | Define `def X(...)` with markup (capitalized) in this file, or import it: `from widgets import X`. | | `mismatched ` / `is never closed` | Close every tag; void tags (`input`, `img`, `br`) need no close (``). | | `... is not supported in browser code` | Rewrite with supported constructs or move it to `@server`. | | `npm package 'x' isn't installed` | Run `pyweb add x` in the app folder (the folder with `pyweb.lock`). | | `uses X from npm(...), which only exists in the browser` | Use the package in a handler or `on_mount` and store the result in a page variable that markup shows. | | `layout ... has no {children}` | Put `{children}` exactly once in the layout's markup where pages go. | | `isn't a valid int` / `missing ?name=` (400) | Give the query parameter a default, or link with a valid value. | | `pyweb add`: `is CommonJS` / `imports the Node.js module` | Pick an ES-module browser package (e.g. `lodash-es`), or do the work in an `@server` function. | ## 9. Multi-page apps ```pyweb from pyweb import App, NotFound, head app = App(title="Shop", base_url="https://shop.example") # base_url: canonical URLs @app.layout # wraps every page; @app.layout("/admin") for a prefix def Shell(children): cart = 0 # layout state survives page changes def add(): cart += 1 {children} @app.page("/products", title="Products", description="Everything we sell") def Products(q: str = "", page: int = 1): # not in the route -> query string, typed; bad -> 400

Results for {q}, page {page}

@app.page("/products/{pid}") def Product(pid: int): if pid != 1: raise NotFound() head(title="Lamp - Shop", description="A lamp") # per-page head tags, on the server

Lamp

@app.error(404) def Missing(path):

Nothing at {path}

``` - Put `{children}` exactly once in a layout. `@app.page(..., layout=None)` opts out; `layout="Admin"` picks one. - Plain `` links load without a full reload; layouts keep their state; links to the current page get `aria-current="page"` automatically (style `a[aria-current]`). Don't add active-link logic. - From handlers: `navigate("/path")` (`from pyweb.browser import navigate`). - A layout that shows per-page server data (like `request.path`) is re-rendered on every navigation; keep layout data stable. ## 10. Streaming and AI features ```pyweb import os from pyweb import App, Markdown, RPCError, server app = App() @server def reply(question: str): # a generator: each yield reaches the browser at once if not question.strip(): raise RPCError("validation_error", "Ask something") for word in ("**Hello**", " from", " the", " server"): yield word # call your model API here (key from os.environ) @app.page("/") def Chat(): question = "" answer = "" busy = False stream = None async def ask(): busy = True answer = "" stream = reply(question) try: async for piece in stream: answer += piece except RPCError as e: answer = str(e) busy = False def stop(): if stream: stream.cancel() # aborts the request and closes the generator def on_unmount(): stop() ``` - Handlers that use `async for` are `async def`. Calling a streaming function returns a stream; `break` or `.cancel()` stops it. - `` is safe on model output (no raw HTML, no `javascript:` links) and renders as text streams in. - API keys: only in `@server` code via `os.environ`. Validate and trim the conversation the browser sends (roles, length). - Full example: `pyweb new NAME --template ai-chat` (Anthropic, OpenAI-compatible servers such as Ollama, or a built-in demo model). - `TestClient.rpc("reply", question="hi")` returns the list of yielded values. ## 11. Live data ```pyweb from pyweb import App, live, server from pyweb.db import connect app = App() db = connect("sqlite:///app.db") @server def add(title: str) -> None: db.execute("insert into todos (title) values (?)", (title,)) # announces "todos" after commit @app.page("/") def Todos(): todos = live(db, "select id, title from todos order by id desc limit 50")
    for t in todos:
  • {t["title"]}
``` - Every open page showing `todos` updates when the table is written through `pyweb.db` (including `pyweb.models`). Don't add publish / subscribe or polling for this. - Live variables are reactive in the browser; use `watch(lambda: todos, fn)` for side effects (redrawing a chart). - Filter per user in SQL (`where owner = ?`), keep queries small (`LIMIT`), and call `db.notify("table")` after writes made elsewhere. - Several processes: `realtime.use_bus(RedisBus(url))`. ## 12. npm packages - Add with `pyweb add chart.js/auto` (MCP: `pyweb_packages`) in the app folder; it writes `pyweb.lock` and `static/vendor/` (commit both; no Node.js needed). Remove with `pyweb remove NAME`. - Bind at module level with literal strings: `Chart = npm("chart.js/auto")` (default export), `Gauge = npm("pkg", "Gauge")` (named export), `lib = npm("pkg", "*")` (whole module). `from pyweb import npm`. - Calling a class constructs it (`new`); keyword arguments become one options object: `confetti(particleCount=80)`. - npm names work only in browser code (handlers, `on_mount`, lambdas), never directly in markup: store results in page variables. - Give libraries an element with `ref={el}` (declare `el = None`; it is set before `on_mount`). ## 13. Workflow for agents 1. Start from a template: `pyweb new NAME --template todo` (or the `pyweb_new_app` MCP tool). Templates: blank, counter, todo, blog, auth, chat, ai-chat. 2. Edit `app.pyweb`. After every edit run `pyweb check app.pyweb` (MCP: `pyweb_check`) and fix errors by line number. 3. Need a JavaScript library? `pyweb add NAME` (MCP: `pyweb_packages`). Use `pyweb inspect` (MCP: `pyweb_inspect`) to confirm what runs in the browser vs server and what is sent to the browser. 4. Verify behaviour: list pages, layouts and parameters (`pyweb_routes`), render pages (`pyweb_render`) and call server functions (`pyweb_call`). See the page and try interactions in a real browser with `pyweb_screenshot` (steps: click, fill, press, ...). 5. Test: new apps include `test_app.py` (`pyweb.testing.TestClient`); add tests for what you change and run `pytest` (MCP: `pyweb_test`). 6. Ship: `pyweb build app.pyweb --out dist --production` then `pyweb serve dist` (set `PYWEB_AUTH_SECRET` in production). Full docs: https://maanavkrishna.github.io/PyWeb/ --- # Introduction PyWeb lets you build a complete web application in Python, in one file. You write pages as Python functions with HTML-like markup, keep interactive state in ordinary local variables, and mark the functions that must run on the server with `@server`. The compiler works out what runs where and produces: - **server-rendered HTML** for every page, so the first paint is complete and works for search engines and slow devices; - **a small JavaScript module per interactive page**, compiled from your Python event handlers and expressions (no Python runtime in the browser); - **typed RPC endpoints** for your `@server` functions, with generated browser calls, argument validation and structured errors. ```pyweb from pyweb import App, server app = App(title="Guestbook") ENTRIES = [] @server def sign(name: str) -> list: ENTRIES.append(name.strip() or "anonymous") return ENTRIES @app.page("/") def Home(): entries = list(ENTRIES) # runs on the server for each request name = "" # bound to the input below: browser state def submit(): # compiled to JavaScript entries = sign(name) # calls the server over RPC name = ""

Guestbook ({len(entries)})

    for entry in entries:
  • {entry}
``` Run it with `pyweb dev app.pyweb` and open . ## The problem it solves Python developers who need a real web UI usually pick one of these: | Approach | What you write | The cost | |---|---|---| | Django / Flask / FastAPI + a JS framework | Python API **and** a React/Vue/Svelte app | Two languages, two builds, a hand-written API layer between them | | Server templates + htmx | Python views, HTML templates, endpoints per interaction | Every interaction is a server round trip; client state is awkward | | Server-driven Python UI (Reflex, NiceGUI, Streamlit) | Python only | UI state lives on the server; each click is a network round trip over a WebSocket, and servers hold per-user sessions | | Python in the browser (PyScript / Pyodide) | Python only | A multi-megabyte runtime download before anything is interactive | PyWeb takes the architecture of modern JavaScript meta-frameworks (server rendering + fine-grained client reactivity + server functions) and makes Python the source language for all of it: - **Interactions run in the browser.** Typing, toggling and filtering update the DOM locally. The network is used only when your code calls a `@server` function. - **Servers stay stateless.** Pages render per request and RPC calls are plain JSON over HTTP, so any number of processes can sit behind an ordinary load balancer. No sticky sessions or WebSocket fan-out. - **What ships is small.** The shared runtime is about 14 KB gzipped and cached; a typical page adds well under 1 KB. Pages without interactivity ship no JavaScript at all. - **Boundaries are explicit and checked.** Code that can't run in a browser (database access, imports, secrets) is a compile error with a file and line, not a runtime surprise. `pyweb inspect` explains where every name runs and why. ## When PyWeb is a good fit - Internal tools, admin panels, dashboards and CRUD apps, including ones that update live as the database changes. - AI features: chat, summarising, drafting, with replies streamed from your server and keys kept there. - Product sites and small SaaS apps where pages should be fast and indexable but still interactive. - Teams that know Python and want a web UI without adopting a separate JavaScript stack. ## When to choose something else - **Large single-page apps** built around a JavaScript component framework (React/Svelte/Vue component libraries): use that framework directly. Plain npm libraries (charts, maps, editors) work in PyWeb; see [npm packages](18-npm-packages.md). - **Scientific Python in the browser** (NumPy, pandas client-side): use Pyodide/PyScript. PyWeb compiles a defined subset of Python to JavaScript; it does not run CPython in the browser. - **Notebook-style data apps** where a script re-running on every interaction is the desired model: Streamlit is purpose-built for that. ## How it works, briefly 1. **Parse.** A `.pyweb` file is Python plus markup statements. The parser separates the two, keeping line numbers exact for both. 2. **Classify.** For each page, every local variable is classified: *signal* (changed by an event handler or bound to an input), *computed* (derived from signals), or *constant*; and by where its first value comes from: a literal, the browser, or the server. 3. **Place.** Event handlers and markup expressions are compiled to JavaScript. Calls to `@server` functions become awaited RPC calls. Everything else stays on the server. 4. **Render.** On each request the server runs the page function, renders HTML, and embeds only the values the browser code reads as JSON. 5. **Hydrate.** The page's module adopts the server-rendered DOM and attaches live bindings to it; from then on, each signal update touches only the nodes that depend on it. Read on: [Quickstart](02-quickstart.md) · [Tutorial](03-tutorial.md) · [The .pyweb language](04-pyweb-files.md). --- # Quickstart To try PyWeb without installing anything, open the [playground](https://maanavkrishna.github.io/PyWeb/playground.html): it runs the compiler, server rendering and your `@server` functions in the browser. ## Install The package is published as `pyweb-stack`; you import it as `pyweb` and the command is `pyweb`. ```bash pip install pyweb-stack # Python 3.10+; no other dependencies pip install "pyweb-stack[all]" # optional: Postgres, MySQL, Redis, cryptography, uvicorn ``` ## Create and run an app ```bash pyweb new hello cd hello pyweb dev app.pyweb # http://localhost:8000, reloads on save ``` `pyweb new` writes `app.pyweb`, a starter test (`test_app.py`) and `AGENTS.md`/`CLAUDE.md` (instructions for AI coding agents). Add `--template todo` (or `blog`, `auth`, `chat`, `blank`) to start from a bigger example. The default counter app is: ```pyweb from pyweb import App app = App(title="Hello") @app.page("/") def Home(): count = 0 def increment(): count += 1

Counter

``` What happened: - `count` is read by the markup and changed by `increment`, so it became a **signal**. Clicking the button updates only that button's text. - `increment` was compiled to a JavaScript function. Nothing is sent to the server when you click. - The first response was complete HTML (`Count: 0`), rendered on the server. Edit the file and save: the dev server recompiles and the browser reloads. If you introduce an error, the page shows it with the line. ## See what the compiler decided ```bash pyweb inspect app.pyweb ``` ```text page Home("/") signals ['count'] rpc [] place count: browser # reactive state: assigned in increment(); literal initial value place increment: browser # event handler (compiled to JavaScript) ``` ## Check, build and serve ```bash pyweb check app.pyweb # compile + security checks (CI-friendly) pyweb build app.pyweb --out dist --production # hashed, minified, deployable dist/ pyweb serve dist # production server with /healthz ``` `dist/` contains your app source, static assets and a `Dockerfile`; see [Deployment](12-deployment.md). ## Next The [tutorial](03-tutorial.md) builds a notes app with a database, server functions and login. Then, depending on what you're building: - several pages with a shared header: [Layouts & navigation](19-layouts-navigation.md); - pages that update when data changes: [Live data](21-live-data.md); - chat or other AI features: [Building AI apps](20-ai-apps.md), or start from `pyweb new myapp --template ai-chat`; - charts, maps or editors from npm: [npm packages](18-npm-packages.md); - working with an AI assistant: [AI assistants & MCP](17-ai-assistants.md). --- # Tutorial: a notes app We'll build a small multi-user notes app in one file: a page with browser-side state, a reusable component, a SQLite database behind server functions, and sign-in with sessions. The finished app is about 90 lines. ## 1. A page with state ```pyweb from pyweb import App app = App(title="Notes") @app.page("/") def Home(): notes = ["Try PyWeb"] draft = "" def save(): if draft.strip(): notes.append(draft.strip()) draft = ""

Notes ({len(notes)})

    for note in notes:
  • {note}
``` - `bind={draft}` keeps the input and the variable in sync both ways. - `onsubmit={save}` runs `save` and prevents the browser's default form submission. - `notes.append(...)` mutates a list that markup reads. PyWeb makes the update copy-on-write, so the `