Release 0.10 with cached query properties and HTML documentation

Commit 8c19ec7 · patx · 2026-10-18T21:33:18-04:00

Changeset
8c19ec7952053c20b8cbd09ac9f8730b0a812f72
Parents
09052e90674dca70b8bb13a7fd4cace56f4fd2e0

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index f0b7d58..3409f20 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,11 @@
 
 Your API needs to return some JSON. It probably doesn't need a goddamn cathedral.
 
+The [documentation](docs/tutorial/index.html) includes an
+[API reference](docs/tutorial/api-reference.html), installation and configuration
+guides, and a tutorial covering the notebook app, middleware, frontend serving,
+and deployment with Caddy or Nginx.
+
 ## What you get
 
 - Async handlers, explicit HTTP methods, and fixed or parameterized routes.
@@ -103,18 +108,21 @@ with pretty much every ASGI application you can think of.
 
 ## Requests
 
+In Wred 0.10, `request.query` is a cached property, matching `request.headers`.
+Wred 0.9 uses `request.query()`; remove the parentheses when upgrading.
+
 * `request.path` -> Path without the query string
 * `request.method` -> HTTP method
 * `request.headers` -> Header dict decoded and cached on first access, with lowercase names and Latin-1 values; repeated Cookie fields join with `; `, other repeated names keep their last value
 * `request.scope` -> Original ASGI scope, including client, scheme, and middleware state
-* `request.query()` -> Cached dict of string keys and values. No `await`!
+* `request.query` -> Dict of string keys and values, parsed and cached on first access. No `()` or `await`!
 * `await request.body()` -> Cached body raw bytes
 * `await request.json()` -> Parsed JSON body; an empty body becomes `{}`
 
 ```python
 @app.route('/search')
 async def search(request):
-    return {'q': request.query().get('q', '')}
+    return {'q': request.query.get('q', '')}
 ```
 
 Query parameters preserve blank values; repeated keys keep the last value.
diff --git a/docs/README.md b/docs/README.md
index f891645..2d7fe3b 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,12 +5,9 @@
 ## Installation
 
 ```bash
-$ pip install wred
+$ pip install 'wred[server]'
 ```
 
-Only `orjson` is required. To include the optional Weft ASGI server, use
-`pip install 'wred[server]'`. You can also install and use another ASGI server.
-
 ## Quick Start
 
 ```python
@@ -18,7 +15,9 @@ from wred import App
 
 app = App()
 
[email protected]("/")
[email protected]('/')
 async def index(request):
-    return {"hello": "world"}
+    return {'hello': 'world'}
+
+# run with `weft app:app`
 ```
diff --git a/docs/tutorial/api-reference.html b/docs/tutorial/api-reference.html
new file mode 100644
index 0000000..9be6121
--- /dev/null
+++ b/docs/tutorial/api-reference.html
@@ -0,0 +1,136 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="The signatures, return values, and edge cases in one place. The API is small enough that this page does not require its own executive summary.">
+  <title>API reference · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html" aria-current="page"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html" aria-current="page">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+<h1>API reference</h1>
+<p class="lead">The signatures, return values, and edge cases in one place. The API is small enough that this page does not require its own executive summary.</p>
+<aside class="note"><p>These docs cover Wred 0.10, where <code>request.query</code> is a cached property. Upgrading from 0.9? Remove the parentheses; see <a href="releases.html#version-010">migration notes</a>.</p></aside>
+<nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#imports">Imports and scope</a></li><li><a href="#app">App()</a></li><li><a href="#route">App.route(path, methods=("GET",))</a></li><li><a href="#mounted-paths">Mounted paths and root_path</a></li><li><a href="#request">Request(scope, receive, send)</a></li><li><a href="#headers">request.headers → dict[str, str]</a></li><li><a href="#query">request.query → dict[str, str]</a></li><li><a href="#body">await request.body() → bytes</a></li><li><a href="#json">await request.json() → Any</a></li><li><a href="#responses">Handler return values</a></li><li><a href="#low-level">Low-level App helpers</a></li></ul></nav>
+<h2 id="imports">Imports and scope</h2>
+<div class="code-block"><div class="code-heading"><span class="code-label">Python</span><span class="code-actions"></span></div><pre tabindex="0"><code>from wred import App, Request</code></pre></div>
+<p>This reference describes the implementation in <code>wred.py</code> for Wred 0.10. Handlers normally use <code>App</code> and the request they receive. The low-level dispatch helpers are documented at the end for readers working directly with ASGI.</p>
+<p>Related guides: <a href="first-api.html">first API</a>, <a href="errors.html">errors and troubleshooting</a>, <a href="asgi-middleware.html">ASGI middleware</a>.</p>
+
+<h2 id="app">App()</h2>
+
+<p>Create an HTTP-only ASGI application. The constructor takes no configuration arguments.</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Python</span><span class="code-actions"></span></div><pre tabindex="0"><code>app = App()</code></pre></div>
+<p>Export the instance, or a middleware wrapper around it, to your server. The ASGI entry point is <code>await app(scope, receive, send)</code>; its return value is <code>None</code>. It creates a Request, finds a handler, awaits the handler, and sends the response.</p>
+<p>Scope types other than <code>http</code> raise <code>RuntimeError</code> before receiving or sending messages. Use an outer application for lifespan, WebSockets, or other protocol handling. See the <a href="https://asgi.readthedocs.io/en/latest/specs/main.html">ASGI specification</a> for the callable interface.</p>
+
+<h2 id="route">App.route(path, methods=("GET",))</h2>
+<div class="code-block"><div class="code-heading"><span class="code-label">Python</span><span class="code-actions"></span></div><pre tabindex="0"><code>@app.route(&#x27;/users/{user_id}&#x27;, methods=[&#x27;GET&#x27;, &#x27;PATCH&#x27;])
+async def user(request, user_id):
+    return {&#x27;id&#x27;: user_id, &#x27;method&#x27;: request.method}</code></pre></div><div class="table-wrap"><table><thead><tr><th scope="col">Argument</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>path: str</code></td><td>A route such as <code>/users</code> or <code>/users/{user_id}</code>. Use a leading slash and valid Python parameter names.</td></tr><tr><td><code>methods: list[str] | tuple[str, ...]</code></td><td>Defaults to <code>("GET",)</code>. Registration uppercases each method name.</td></tr><tr><td>Decorated handler</td><td>An async callable accepting the request and any path parameters as keyword arguments. The decorator returns the original handler.</td></tr></tbody></table></div>
+<ul><li>Parameters occupy a whole path segment and arrive as strings. Convert and validate them in the handler. There are no typed converters or catch-all path parameters.</li>
+<li>Trailing slashes are ignored at registration and dispatch. <code>/users</code> and <code>/users/</code> reach the same route without a redirect.</li>
+<li>A fixed route wins when it has a handler for the incoming method. If it does not, a matching dynamic route with that method may handle the request.</li>
+<li>Dynamic routes are tried in registration order. Keep overlapping patterns intentional.</li>
+<li>Registering the same path and method again replaces that handler. Other registered methods remain.</li>
+<li>No matching path/method returns <code>{"error":"Not Found"}</code> with 404. HEAD, OPTIONS, redirects, and 405 responses are not synthesized.</li></ul>
+<p>Routes are case-sensitive. Wred does not implement named routes, URL generation, subrouters, or an application <code>mount()</code> method. Use an outer ASGI router for composition.</p>
+
+<h2 id="mounted-paths">Mounted paths and root_path</h2>
+
+<p>When the incoming path starts with the scope’s <code>root_path</code> on a segment boundary, Wred removes that prefix for route matching. The original scope and <code>request.path</code> retain the incoming path.</p>
+<div class="table-wrap"><table><thead><tr><th scope="col">Registered route</th><th scope="col">Incoming scope</th><th scope="col">Result</th></tr></thead><tbody><tr><td><code>/users</code></td><td><code>path="/api/users", root_path="/api"</code></td><td>Matches <code>/users</code>; request.path remains <code>/api/users</code>.</td></tr><tr><td><code>/users/{user_id}</code></td><td><code>path="/api/users/42", root_path="/api"</code></td><td>Passes <code>user_id="42"</code>.</td></tr></tbody></table></div><p>The prefix and the server/router configuration must agree. See <a href="frontend.html#router">the Starlette mount example</a>. Arbitrarily trimming slashes is not a deployment strategy, although it is a traditional source of one.</p>
+<h2 id="request">Request(scope, receive, send)</h2>
+
+<p>Wred creates this object for each HTTP request. Direct construction is mainly useful when working at the ASGI boundary. The constructor stores the supplied scope and callables; it does not read the body.</p>
+<div class="table-wrap"><table><thead><tr><th scope="col">Attribute</th><th scope="col">Value</th></tr></thead><tbody><tr><td><code>request.path: str</code></td><td>The original <code>scope["path"]</code>, without the query string. It is not rewritten to the mount-relative route.</td></tr><tr><td><code>request.method: str</code></td><td>The incoming <code>scope["method"]</code>.</td></tr><tr><td><code>request.scope: dict</code></td><td>The supplied ASGI scope, including client, scheme, root_path, and any middleware state.</td></tr><tr><td><code>request.receive</code></td><td>The ASGI receive callable. Consuming events yourself affects subsequent body reads.</td></tr><tr><td><code>request.send</code></td><td>The ASGI send callable. Normally let the return-value handling or a callable response send the response.</td></tr></tbody></table></div><p>Wred has no built-in <code>request.user</code>, <code>request.session</code>, <code>request.cookies</code>, <code>request.form()</code>, or <code>request.is_disconnected()</code> interface. Middleware or your own decorators can add context. The notebook’s <code>request.user</code> is added by its authentication decorator.</p>
+<h2 id="headers">request.headers → dict[str, str]</h2>
+
+<p>A property, not a coroutine. On first access it decodes ASGI header bytes with Latin-1, lowercases names, and caches the resulting dict. Repeated Cookie fields are joined with <code>; </code>; repeated headers with other names keep their last value.</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Inside a handler</span><span class="code-actions"></span></div><pre tabindex="0"><code>authorization = request.headers.get(&#x27;authorization&#x27;, &#x27;&#x27;)
+content_type = request.headers.get(&#x27;content-type&#x27;, &#x27;&#x27;)</code></pre></div><p>This is a convenience view, not a multi-value header container. Use <code>request.scope.get("headers", [])</code> for the original byte pairs when duplicate fields matter. Changing the cached dict does not rewrite those original ASGI header pairs.</p>
+<h2 id="query">request.query → dict[str, str]</h2>
+
+<p>A <code>cached_property</code>, like <code>headers</code>. Parse the query string on first attribute access and cache the resulting dict. Blank values are preserved; repeated keys keep the last value. The parser strictly decodes UTF-8, including percent-encoded values. Invalid UTF-8 raises <code>UnicodeDecodeError</code>.</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Inside a handler</span><span class="code-actions"></span></div><pre tabindex="0"><code># /search?q=first&amp;q=last&amp;empty=
+query = request.query
+# {&#x27;q&#x27;: &#x27;last&#x27;, &#x27;empty&#x27;: &#x27;&#x27;}</code></pre></div><p>No <code>await</code> is needed. Results are strings. Apply your own numeric ranges, field-count limits, and repeated-key policy. Repeated accesses return the cached dict, so mutate a copy if you need a separate working view.</p>
+<h2 id="body">await request.body() → bytes</h2>
+
+<p>Read successive <code>http.request</code> messages until <code>more_body</code> is false, join their bytes, and cache the complete body. Later calls return the cached bytes.</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Inside a handler</span><span class="code-actions"></span></div><pre tabindex="0"><code>body = await request.body()</code></pre></div><p>The body is buffered in memory. Wred imposes no body-size limit; configure a limit at the server, proxy, or a suitable middleware layer. An incoming event other than <code>http.request</code>, including <code>http.disconnect</code>, raises <code>ConnectionError</code>.</p>
+<h2 id="json">await request.json() → Any</h2>
+
+<p>Read the cached body and parse it with <code>orjson.loads()</code>. An empty body returns <code>{}</code>. The result may be an object, array, string, number, boolean, or <code>None</code>; Wred does not enforce an object schema.</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Inside a handler</span><span class="code-actions"></span></div><pre tabindex="0"><code>data = await request.json()
+if not isinstance(data, dict):
+    return {&#x27;error&#x27;: &#x27;Send a JSON object&#x27;}, &#x27;application/json&#x27;, 400</code></pre></div><p>The body is cached; the parsed JSON value is not. Each <code>json()</code> call parses those bytes again. Malformed JSON, including invalid UTF-8 JSON input, raises <code>orjson.JSONDecodeError</code>. Parsing does not depend on the Content-Type header. See <a href="https://github.com/ijl/orjson">orjson’s reference</a> for its supported JSON behavior.</p>
+<h2 id="responses">Handler return values</h2>
+<div class="table-wrap"><table><thead><tr><th scope="col">Return</th><th scope="col">Body / content type / status</th></tr></thead><tbody><tr><td><code>dict</code> or <code>list</code></td><td>Serialized by orjson; <code>application/json</code>; 200.</td></tr><tr><td><code>bytes</code></td><td>Sent directly; <code>application/octet-stream</code>; 200.</td></tr><tr><td><code>(body, content_type, status)</code></td><td>Explicit media type and status. A dict/list body always uses <code>application/json</code>.</td></tr><tr><td><code>(body, content_type, status, headers)</code></td><td>As above, with an iterable of string header pairs.</td></tr><tr><td>Callable ASGI response</td><td>Wred awaits <code>response(scope, receive, send)</code>; the response owns the outgoing messages.</td></tr></tbody></table></div><div class="code-block"><div class="code-heading"><span class="code-label">Alternative returns inside handlers</span><span class="code-actions"></span></div><pre tabindex="0"><code># JSON creation response with a Location header.
+return {&#x27;id&#x27;: &#x27;n1&#x27;}, &#x27;application/json&#x27;, 201, [(&#x27;Location&#x27;, &#x27;/notes/n1&#x27;)]
+
+# Empty deletion response.
+return b&#x27;&#x27;, &#x27;application/json&#x27;, 204
+
+# Text: encode it yourself.
+return &#x27;Hello&#x27;.encode(&#x27;utf-8&#x27;), &#x27;text/plain; charset=utf-8&#x27;, 200</code></pre></div>
+<p>Tuple bodies must be bytes, dicts, or lists. Bare strings, integers, booleans, and <code>None</code> are unsupported response bodies. If you need a JSON scalar, use a callable response such as Starlette’s <code>JSONResponse</code>.</p>
+<p>Use the tuple’s media type field to set Content-Type. Additional headers are lowercased and encoded with ASCII names and Latin-1 values. A list of pairs preserves repeated <code>Set-Cookie</code> headers.</p>
+<p>Wred sends one response-start message followed by one body message for its own return-value handling; it does not add a Content-Length header itself. Your ASGI server handles HTTP transport details. A callable response can stream instead; see <a href="frontend.html#responses">files and SSE</a>.</p>
+
+<h2 id="low-level">Low-level App helpers</h2>
+
+<p>Most handlers should return a response value. The methods below support dispatch and explicit ASGI integrations; use them when you actually own the message flow. Sending a response yourself and then returning another one is not a two-for-one offer.</p>
+<h3 id="send-response">await app.send_response(send, response, status=200) → None</h3>
+<p>Send a supported body or response tuple through an ASGI send callable. A tuple’s status takes precedence over the <code>status</code> argument. This helper handles serialization and the two outgoing messages; it does not invoke callable response objects. Normal app dispatch invokes callable responses separately.</p>
+<h3 id="match-path">app.match_path(pattern, parts) → dict[str, str] | None</h3>
+<p>Compare a tuple of pattern segments with a list of incoming segments. Return string parameters when segment counts and fixed segments match, otherwise <code>None</code>. Inputs are already split; this helper does not normalize trailing slashes or remove a root path.</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Python</span><span class="code-actions"></span></div><pre tabindex="0"><code>app.match_path((&#x27;&#x27;, &#x27;users&#x27;, &#x27;{user_id}&#x27;), [&#x27;&#x27;, &#x27;users&#x27;, &#x27;42&#x27;])
+# {&#x27;user_id&#x27;: &#x27;42&#x27;}</code></pre></div><p><code>app.routes</code> and <code>app.pattern_routes</code> hold dispatch tables. Register handlers with <code>route()</code> rather than editing these tables directly.</p>
+<nav class="chapter-nav" aria-label="Related documentation"><a href="index.html">← Documentation overview</a><a href="errors.html">Errors &amp; troubleshooting →</a></nav>
+<footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+</main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/asgi-middleware.html b/docs/tutorial/asgi-middleware.html
new file mode 100644
index 0000000..b413023
--- /dev/null
+++ b/docs/tutorial/asgi-middleware.html
@@ -0,0 +1,120 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="Sometimes you need a request ID, a timing header, or a place to attach context. This does not require founding another framework.">
+  <title>Write a little ASGI middleware · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html" aria-current="page"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html" aria-current="page">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+      <h1>Write a little ASGI middleware</h1>
+      <p class="lead">Sometimes you need a request ID, a timing header, or a place to attach context. This does not require founding another framework.</p>
+      <nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#contract">Three arguments, two directions</a></li><li><a href="#context">Add request context and timing</a></li><li><a href="#rules">Keep the wrapper well behaved</a></li><li><a href="#proxy">Use Uvicorn’s proxy-header middleware with another server</a></li><li><a href="#auth-context">Authentication middleware can use the same scope</a></li></ul></nav>
+      <h2 id="contract">Three arguments, two directions</h2>
+    <p>An ASGI app is called with <code>(scope, receive, send)</code>. The scope describes the connection. <code>receive()</code> supplies request events; <code>send()</code> emits response events. Middleware can wrap either callable and then delegate to the inner app.</p>
+    <p>This example assigns a request ID, makes it available to handlers, and adds response headers when <code>http.response.start</code> is sent. It measures time until response headers, not the complete download time. Names are useful when they mean things.</p>
+    <h2 id="context">Add request context and timing</h2><p>Save as <code>context_app.py</code>, then run <code>weft context_app:app</code>.</p><div class="code-block"><div class="code-heading"><span class="code-label">context_app.py</span><span class="code-actions"><a href="examples/context_app.py" download>Download</a></span></div><pre tabindex="0"><code>from time import perf_counter
+from uuid import uuid4
+
+from wred import App
+
+
+class RequestContext:
+    def __init__(self, app):
+        self.app = app
+
+    async def __call__(self, scope, receive, send):
+        if scope[&#x27;type&#x27;] != &#x27;http&#x27;:
+            return await self.app(scope, receive, send)
+
+        request_id = uuid4().hex
+        started = perf_counter()
+        scope = {**scope, &#x27;state&#x27;: {**scope.get(&#x27;state&#x27;, {}), &#x27;request_id&#x27;: request_id}}
+
+        async def send_with_context(message):
+            if message[&#x27;type&#x27;] == &#x27;http.response.start&#x27;:
+                elapsed_ms = (perf_counter() - started) * 1000
+                message = {
+                    **message,
+                    &#x27;headers&#x27;: [
+                        *message.get(&#x27;headers&#x27;, []),
+                        (b&#x27;x-request-id&#x27;, request_id.encode(&#x27;ascii&#x27;)),
+                        (b&#x27;server-timing&#x27;, f&#x27;app;dur={elapsed_ms:.2f}&#x27;.encode(&#x27;ascii&#x27;)),
+                    ],
+                }
+            await send(message)
+
+        await self.app(scope, receive, send_with_context)
+
+
+api = App()
+
+
[email protected](&#x27;/api/hello&#x27;)
+async def hello(request):
+    return {&#x27;request_id&#x27;: request.scope[&#x27;state&#x27;][&#x27;request_id&#x27;]}
+
+
+app = RequestContext(api)</code></pre></div><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>curl -i http://127.0.0.1:8000/api/hello</code></pre></div><p>The body’s <code>request_id</code> matches <code>X-Request-ID</code>. <code>Server-Timing</code> reports the elapsed milliseconds up to response start. Each request keeps its own variables inside <code>__call__</code>; shared instance attributes would invite concurrent requests to exchange identities.</p><h2 id="rules">Keep the wrapper well behaved</h2>
+    <ul><li>Pass through protocols you don’t handle. That lets a lifespan-aware outer app keep doing its job.</li><li>Copy the scope when adding request-local data. Preserve existing <code>state</code> entries.</li><li>Preserve existing response headers, including repeated Set-Cookie fields.</li><li>If you modify the response body, update or remove Content-Length as appropriate.</li><li>Don’t consume the request body unless you also arrange to replay it to the inner app.</li></ul>
+    <p>Our wrapper leaves the body alone. It also passes lifespan through rather than implementing it; wrapping a bare Wred app still leaves you with an HTTP-only app.</p>
+    <p>The <a href="https://starlette.dev/middleware/#pure-asgi-middleware">pure ASGI middleware guide</a> describes this pattern. Middleware can also come from other packages: the interoperability is in the ASGI call, not in matching logos.</p>
+    <h2 id="proxy">Use Uvicorn’s proxy-header middleware with another server</h2>
+    <p>You can reuse Uvicorn’s <code>ProxyHeadersMiddleware</code> while serving with Weft. Install <code>uvicorn</code> in addition to the notebook’s dependencies, save this as <code>proxy_app.py</code> at the repository root, and run <code>weft proxy_app:app --host 127.0.0.1 --lifespan on</code>.</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">proxy_app.py</span><span class="code-actions"><a href="examples/proxy_app.py" download>Download</a></span></div><pre tabindex="0"><code>from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
+
+from example_app.app import create_app
+
+# Use only behind a trusted proxy connecting from IPv4 loopback.
+app = ProxyHeadersMiddleware(create_app(), trusted_hosts=[&#x27;127.0.0.1&#x27;])</code></pre></div><p>It accepts forwarded scheme and client information only from the listed proxy peers. For this example, the reverse proxy must connect over IPv4 loopback. Keep the backend private; a client that reaches it directly from a trusted address can supply those headers too.</p><p>This is an alternative to Weft’s <code>--forwarded-allow-ips</code> option used in the deployment chapters. Pick one owner for proxy-header processing. See the <a href="https://github.com/Kludex/uvicorn/blob/main/uvicorn/middleware/proxy_headers.py">Uvicorn implementation</a>.</p><h2 id="auth-context">Authentication middleware can use the same scope</h2>
+    <p><a href="https://starlette.dev/authentication/">Starlette’s authentication middleware</a> places authentication information in <code>scope["user"]</code> and <code>scope["auth"]</code>. A Wred handler can read those keys directly. The middleware’s authentication backend and your authorization rules remain application choices.</p>
+    <p>The notebook uses its own <code>@protected</code> decorator instead. That decorator adds <code>request.user</code> itself. Don’t assume the property exists in unrelated Wred apps, or that wrapping with authentication middleware automatically protects every route.</p>
+
+      <nav class="chapter-nav" aria-label="Previous and next chapters"><a href="starlette-middleware.html">← Borrow Starlette’s middleware</a><a href="frontend.html">Serve a frontend and other responses →</a></nav>
+      <footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+    </main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/build_search.py b/docs/tutorial/build_search.py
new file mode 100644
index 0000000..be00db8
--- /dev/null
+++ b/docs/tutorial/build_search.py
@@ -0,0 +1,79 @@
+"""Rebuild the static documentation search index using only the standard library.
+
+Run from any directory with: python docs/tutorial/build_search.py
+The HTML pages remain directly usable without running a build or a web server.
+"""
+
+from html.parser import HTMLParser
+import json
+from pathlib import Path
+import re
+
+
+ROOT = Path(__file__).resolve().parent
+
+
+class PageText(HTMLParser):
+    def __init__(self):
+        super().__init__(convert_charrefs=True)
+        self.in_main = False
+        self.in_h1 = False
+        self.text = []
+        self.title = []
+        self.headings = []
+        self.heading = None
+
+    def handle_starttag(self, tag, attrs):
+        attrs = dict(attrs)
+        if tag == 'main':
+            self.in_main = True
+        if not self.in_main:
+            return
+        if tag == 'h1':
+            self.in_h1 = True
+        if tag in ('h2', 'h3') and attrs.get('id'):
+            self.heading = {'anchor': attrs.get('id', ''), 'parts': []}
+
+    def handle_endtag(self, tag):
+        if tag == 'main':
+            self.in_main = False
+        if tag == 'h1':
+            self.in_h1 = False
+        if tag in ('h2', 'h3') and self.heading:
+            self.headings.append({
+                'anchor': self.heading['anchor'],
+                'title': ''.join(self.heading['parts']),
+            })
+            self.heading = None
+
+    def handle_data(self, data):
+        if not self.in_main:
+            return
+        self.text.append(data)
+        if self.in_h1:
+            self.title.append(data)
+        if self.heading:
+            self.heading['parts'].append(data)
+
+
+def main():
+    pages = []
+    for path in sorted(ROOT.glob('*.html')):
+        parser = PageText()
+        parser.feed(path.read_text())
+        pages.append({
+            'url': path.name,
+            'title': ''.join(parser.title),
+            'headings': parser.headings,
+            'text': re.sub(r'\s+', ' ', ' '.join(parser.text)).strip(),
+        })
+    payload = json.dumps(pages, ensure_ascii=False, separators=(',', ':'))
+    (ROOT / 'search-index.js').write_text(
+        '// Generated by build_search.py; edit the HTML pages, then rebuild.\n'
+        f'const DOCS_SEARCH_INDEX = {payload};\n'
+    )
+    print(f'Indexed {len(pages)} documentation pages.')
+
+
+if __name__ == '__main__':
+    main()
diff --git a/docs/tutorial/caddy.html b/docs/tutorial/caddy.html
new file mode 100644
index 0000000..d7d64b1
--- /dev/null
+++ b/docs/tutorial/caddy.html
@@ -0,0 +1,82 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="HTTPS, a reverse proxy, and a configuration file you can read before your coffee gets cold. Caddy is annoyingly good at this.">
+  <title>Put Caddy in front · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html" aria-current="page"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html" aria-current="page">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+      <h1>Put Caddy in front</h1>
+      <p class="lead">HTTPS, a reverse proxy, and a configuration file you can read before your coffee gets cold. Caddy is annoyingly good at this.</p>
+      <nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#backend">Keep the Python listener private</a></li><li><a href="#caddyfile">Give your domain a Caddyfile</a></li><li><a href="#validate">Validate, reload, and check</a></li><li><a href="#local">Try the proxy locally</a></li><li><a href="#boundaries">Keep the responsibilities obvious</a></li></ul></nav>
+      <h2 id="backend">Keep the Python listener private</h2>
+    <p>These deployment chapters use the notebook at the site root, including its static files. Use a host with Caddy installed and your app dependencies in a virtualenv. First run the backend on IPv4 loopback:</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code># Run from the repository root with the notebook dependencies installed.
+weft example_app.asgi:app --host 127.0.0.1 --port 8000 \
+  --lifespan on --forwarded-allow-ips 127.0.0.1</code></pre></div><p>Accept forwarded headers only from the loopback proxy. That gives the notebook the external HTTPS scheme for cookie flags and same-origin checks, and the actual client address for its rate limiter. Use your supervisor to keep this process running; <a href="operations.html">the operations chapter</a> includes systemd.</p><h2 id="caddyfile">Give your domain a Caddyfile</h2>
+    <p>Replace <code>notes.example.com</code> with your domain. Point its DNS records at this host and make ports 80 and 443 reachable. Keep Caddy’s certificate storage writable and persistent. With those prerequisites, <a href="https://caddyserver.com/docs/automatic-https">automatic HTTPS</a> handles certificate issuance, renewal, and HTTP redirects.</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">Caddyfile</span><span class="code-actions"><a href="examples/Caddyfile" download>Download</a></span></div><pre tabindex="0"><code>notes.example.com {
+    request_body {
+        max_size 1MB
+    }
+    encode zstd gzip
+    reverse_proxy 127.0.0.1:8000
+}</code></pre></div><p><a href="https://caddyserver.com/docs/caddyfile/directives/request_body">request_body</a> caps uploads at about one megabyte. <a href="https://caddyserver.com/docs/caddyfile/directives/reverse_proxy">reverse_proxy</a> preserves the request path and forwards the Host and proxy headers. The notebook still sees <code>/api/notes</code>, which is fortunate because that’s where we put it.</p><h2 id="validate">Validate, reload, and check</h2>
+    <p>Save the site configuration in the Caddyfile used by your installation, commonly <code>/etc/caddy/Caddyfile</code>. Validate it before applying it:</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
+sudo systemctl reload caddy
+curl -i https://notes.example.com/api/health</code></pre></div><p>Use the <a href="https://caddyserver.com/docs/command-line">Caddy CLI</a> reload workflow if your installation isn’t managed by systemd. Expect JSON 200 from health. Visit the site, log in, and check that the refresh cookie has Secure under HTTPS. A green health check cannot inspect your cookies on your behalf.</p><h2 id="local">Try the proxy locally</h2>
+    <p>For an HTTP-only local smoke check, replace the domain line with <code>http://localhost:8080</code>, save as <code>Caddyfile.local</code>, and run:</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>caddy run --config Caddyfile.local --adapter caddyfile</code></pre></div><p>Open <code>http://localhost:8080/</code>. This explicitly disables automatic HTTPS for that local site. Use the domain configuration for public HTTPS.</p><h2 id="boundaries">Keep the responsibilities obvious</h2>
+    <p>Caddy terminates TLS and limits request bodies. Weft runs the ASGI app. The notebook handles its database lifecycle and authorization. You still need a shared rate-limit strategy if you want one limit across workers; this Caddyfile does not add one.</p>
+    <p>Choose whether compression belongs in Caddy or an application middleware layer. For streams, check proxy timeouts and disconnect behavior with a real client. For a separate frontend build, use explicit static and API routing while preserving the API prefix.</p>
+
+      <nav class="chapter-nav" aria-label="Previous and next chapters"><a href="frontend.html">← Serve a frontend and other responses</a><a href="nginx.html">Put Nginx in front →</a></nav>
+      <footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+    </main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/configuration.html b/docs/tutorial/configuration.html
new file mode 100644
index 0000000..66622d2
--- /dev/null
+++ b/docs/tutorial/configuration.html
@@ -0,0 +1,82 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="There is no Wred settings object to memorize. Configure the component that actually owns the behavior. A small victory for both readability and your remaining memory.">
+  <title>Configuration &amp; architecture · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html" aria-current="page"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html" aria-current="page">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+<h1>Configuration &amp; architecture</h1>
+<p class="lead">There is no Wred settings object to memorize. Configure the component that actually owns the behavior. A small victory for both readability and your remaining memory.</p>
+<nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#ownership">Who configures what?</a></li><li><a href="#notebook-settings">Notebook environment variables</a></li><li><a href="#recipe-settings">Recipe-only settings</a></li><li><a href="#protocols">Separate HTTP from lifespan</a></li><li><a href="#proxy-settings">Configure forwarding at one layer</a></li></ul></nav>
+<h2 id="ownership">Who configures what?</h2>
+<div class="table-wrap"><table><thead><tr><th scope="col">Concern</th><th scope="col">Owner and documentation</th></tr></thead><tbody><tr><td>Routes, methods, validation, error responses</td><td>Your Wred application. See the <a href="api-reference.html">API reference</a>.</td></tr><tr><td>Host, port, workers, reload, transport timeouts</td><td>Your ASGI server. Wred has no server configuration or CLI.</td></tr><tr><td>CORS, signed sessions, request context</td><td>The middleware stack. See <a href="starlette-middleware.html">Starlette middleware</a> and <a href="asgi-middleware.html">custom ASGI middleware</a>.</td></tr><tr><td>Database connections and credentials</td><td>Your application and database client. The notebook’s settings are below.</td></tr><tr><td>HTTPS, body limits, proxy routing</td><td>Your edge server. See <a href="caddy.html">Caddy</a> or <a href="nginx.html">Nginx</a>.</td></tr><tr><td>Process supervision and environment files</td><td>Your deployment system. See <a href="operations.html">operations</a>.</td></tr></tbody></table></div><p><code>App()</code> takes no settings and Wred reads no environment variables. A variable called <code>WRED_DEBUG</code> will do exactly what an unread environment variable usually does: occupy a small amount of memory.</p>
+<h2 id="notebook-settings">Notebook environment variables</h2>
+<div class="table-wrap"><table><thead><tr><th scope="col">Variable</th><th scope="col">Default</th><th scope="col">Read by</th></tr></thead><tbody><tr><td><code>MONGODB_URI</code></td><td><code>mongodb://127.0.0.1:27017</code></td><td><code>example_app.app.create_app()</code></td></tr><tr><td><code>MONGODB_DATABASE</code></td><td><code>wred_notebook</code></td><td><code>example_app.app.create_app()</code></td></tr><tr><td><code>WRED_TEST_MONGODB_URI</code></td><td>Unset: database integration tests are skipped.</td><td><code>tests/test_notebook.py</code>; not the running app.</td></tr></tbody></table></div><p>Explicit <code>mongodb_uri</code> and <code>database_name</code> arguments override the two app environment values. Passing <code>database=...</code> overrides both by supplying an application-owned database object.</p><div class="code-block"><div class="code-heading"><span class="code-label">A notebook entry point in the repository root</span><span class="code-actions"></span></div><pre tabindex="0"><code>from example_app.app import create_app
+
+app = create_app(
+    mongodb_uri=&#x27;mongodb://127.0.0.1:27017&#x27;,
+    database_name=&#x27;my_notebook&#x27;,
+)</code></pre></div><p>The factory and its environment variables belong to the example application, not the Wred package. Its wrapper calls the database’s async <code>initialize()</code> and <code>close()</code> methods through lifespan.</p>
+<h2 id="recipe-settings">Recipe-only settings</h2>
+<div class="table-wrap"><table><thead><tr><th scope="col">Setting</th><th scope="col">Used by</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>SESSION_SECRET</code></td><td><code>examples/session_app.py</code></td><td>Required signing secret, shared by all workers. No built-in default.</td></tr><tr><td><code>COOKIE_SECURE</code></td><td><code>examples/session_app.py</code></td><td>Defaults to <code>1</code>. Set <code>0</code> only for the local HTTP recipe.</td></tr></tbody></table></div><p>These two settings configure the standalone Starlette session example. The notebook’s refresh cookie uses the incoming ASGI scheme instead. The similar-looking cookies are under different management.</p>
+<h2 id="protocols">Separate HTTP from lifespan</h2>
+
+<p>Wred’s inner app handles HTTP. The notebook’s outer app handles startup and shutdown; Starlette can also be the outer lifecycle owner when composing apps. Pass unhandled protocols through middleware to the component that owns them.</p>
+<p>A bare Wred app should use your server’s unsupported-lifespan detection or an explicit lifespan-off setting. The notebook should run with lifespan enabled so database startup failures are visible. <code>--lifespan on</code> is shown in its Weft commands.</p>
+<p>Register routes before serving traffic. One worker’s in-memory objects do not become shared state merely because another worker was launched with the same command.</p>
+
+<h2 id="proxy-settings">Configure forwarding at one layer</h2>
+
+<p>The deployment examples bind Weft to <code>127.0.0.1</code> and use <code>--forwarded-allow-ips 127.0.0.1</code>. Their proxies preserve the public Host and supply the scheme and client address. If you use the alternative Uvicorn middleware recipe, let that wrapper own this processing instead.</p>
+<p>Correct scheme information matters for Secure cookies, redirects, and the notebook’s same-origin checks. A proxy configuration is part of application behavior, not a decorative YAML-adjacent accessory.</p>
+
+<nav class="chapter-nav" aria-label="Related documentation"><a href="index.html">← Documentation overview</a><a href="api-reference.html">API reference →</a></nav>
+<footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+</main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/data-and-auth.html b/docs/tutorial/data-and-auth.html
new file mode 100644
index 0000000..29ebbf7
--- /dev/null
+++ b/docs/tutorial/data-and-auth.html
@@ -0,0 +1,119 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="The notebook’s auth is application code you can read. A radical alternative to discovering your security model through decorators with five undocumented arguments.">
+  <title>Notes, accounts, and ownership · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html" aria-current="page"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html" aria-current="page">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+      <h1>Notes, accounts, and ownership</h1>
+      <p class="lead">The notebook’s auth is application code you can read. A radical alternative to discovering your security model through decorators with five undocumented arguments.</p>
+      <nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#request">Follow one create request</a></li><li><a href="#api">The notebook’s API</a></li><li><a href="#tokens">Access tokens and refresh cookies</a></li><li><a href="#curl">Try the auth flow with curl</a></li><li><a href="#ownership">Check ownership where the data changes</a></li><li><a href="#limits">Know what is local to a worker</a></li></ul></nav>
+      <h2 id="request">Follow one create request</h2>
+    <p>The browser posts JSON to <code>/api/notes</code> with an <code>Authorization: Bearer …</code> header. The <code>@protected(database)</code> decorator checks the token before the handler runs and sets <code>request.user</code> and <code>request.login_id</code>.</p>
+    <p><code>read_note()</code> rejects unknown fields and validates the title, body, and pinned flag. The handler generates an ID and timestamps, adds the owner from the authenticated user, and calls <code>database.create_note()</code>. A successful create returns JSON 201 with a <code>Location</code> header.</p>
+    <p>The owner comes from the authenticated user, never from a client-supplied <code>owner</code> field. Otherwise, congratulations: you have built an impersonation API.</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">example_app/notes.py · create route excerpt</span><span class="code-actions"></span></div><pre tabindex="0"><code>@app.route(&#x27;/api/notes&#x27;, methods=[&#x27;POST&#x27;])
+@protected(database)
+async def create_note(request):
+    try:
+        data = await read_note(request)
+    except ValueError as error:
+        return {&#x27;error&#x27;: str(error)}, &#x27;application/json&#x27;, 400
+    note_id = token_urlsafe(9)
+    now = datetime.now(UTC).isoformat(timespec=&#x27;microseconds&#x27;)
+    note = {
+        &#x27;id&#x27;: note_id,
+        &#x27;owner&#x27;: request.user[&#x27;id&#x27;],
+        **data,
+        &#x27;created_at&#x27;: now,
+        &#x27;updated_at&#x27;: now,
+    }
+    await database.create_note(note)
+    return (
+        note,
+        &#x27;application/json&#x27;,
+        201,
+        [(&#x27;Location&#x27;, f&#x27;/api/notes/{note_id}&#x27;)],
+    )</code></pre></div><h2 id="api">The notebook’s API</h2><div class="table-wrap"><table><thead><tr><th scope="col">Method and path</th><th scope="col">Behavior</th></tr></thead><tbody><tr><td>POST <code>/api/signup</code></td><td>Create an account; 201, or 409 for an existing username.</td></tr><tr><td>POST <code>/api/login</code></td><td>Return an access token and set the refresh cookie.</td></tr><tr><td>POST <code>/api/refresh</code></td><td>Use the refresh cookie to issue another access token.</td></tr><tr><td>GET <code>/api/me</code></td><td>Return the authenticated user.</td></tr><tr><td>POST <code>/api/logout</code></td><td>Revoke the login and expire its refresh cookie.</td></tr><tr><td>GET / POST <code>/api/notes</code></td><td>List private notes or create a note.</td></tr><tr><td>GET / PUT / PATCH / DELETE <code>/api/notes/{note_id}</code></td><td>Read, replace editable fields, partially update, or delete a note.</td></tr><tr><td>GET <code>/api/notes/export</code></td><td>Download the authenticated user’s notes as JSON.</td></tr></tbody></table></div><p>The fixed <code>/api/notes/export</code> route wins over <code>/api/notes/{note_id}</code>, even though it is registered afterward. Listing accepts <code>q</code>, <code>pinned=true|false</code>, <code>limit</code> from 1 to 50, and a nonnegative <code>offset</code>.</p><h2 id="tokens">Access tokens and refresh cookies</h2>
+    <p>Passwords use PBKDF2-HMAC-SHA256 with a random salt and 600,000 iterations. Hashing runs through <code>asyncio.to_thread()</code> so the event loop can keep serving other requests. Unknown usernames still perform a dummy hash.</p>
+    <p>Access and refresh tokens are random opaque strings. MongoDB stores SHA-256 digests of those tokens. Access tokens last one hour; the login’s refresh window lasts seven days. The database checks expiry during authentication instead of waiting for MongoDB’s TTL cleanup to get around to it.</p>
+    <p>The browser keeps the access token in memory. The refresh token lives in an HttpOnly, SameSite=Strict cookie scoped to <code>/api</code>, with Secure added when the ASGI scheme is HTTPS. Refresh reuses the cookie while issuing new access tokens; it does not rotate the refresh token on each call.</p>
+    <p>MongoDB stores each login and its access credentials in one document. Logout deletes that document, revoking the login’s access tokens as well. The browser’s <code>renewLogin()</code> shares one refresh promise across concurrent requests and refuses to replay an old account’s pending request under a newly logged-in account.</p>
+    <h2 id="curl">Try the auth flow with curl</h2>
+    <p>With the notebook running, use a separate shell. The password here is demonstration data. Use something you haven’t published in a tutorial for your real account.</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>curl -sS http://127.0.0.1:8000/api/signup \
+  -H &#x27;Content-Type: application/json&#x27; \
+  -d &#x27;{&quot;username&quot;:&quot;ada&quot;,&quot;password&quot;:&quot;a sufficiently long example password&quot;}&#x27;
+
+curl -sS -c notebook.cookies http://127.0.0.1:8000/api/login \
+  -H &#x27;Content-Type: application/json&#x27; \
+  -H &#x27;X-Notebook-Request: 1&#x27; \
+  -d &#x27;{&quot;username&quot;:&quot;ada&quot;,&quot;password&quot;:&quot;a sufficiently long example password&quot;}&#x27;</code></pre></div><p>Copy the <code>access_token</code> from the login JSON into a shell variable, then create and list notes:</p><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>read -r -s NOTEBOOK_TOKEN
+# Paste the access token, then press Enter.
+
+curl -i http://127.0.0.1:8000/api/notes \
+  -H &quot;Authorization: Bearer $NOTEBOOK_TOKEN&quot; \
+  -H &#x27;Content-Type: application/json&#x27; \
+  -d &#x27;{&quot;title&quot;:&quot;Ship it&quot;,&quot;body&quot;:&quot;After reading the deployment chapter.&quot;,&quot;pinned&quot;:true}&#x27;
+
+curl -sS &#x27;http://127.0.0.1:8000/api/notes?limit=5&amp;offset=0&#x27; \
+  -H &quot;Authorization: Bearer $NOTEBOOK_TOKEN&quot;
+
+curl -sS -b notebook.cookies -X POST http://127.0.0.1:8000/api/refresh \
+  -H &#x27;X-Notebook-Request: 1&#x27;</code></pre></div><p>Login and refresh require <code>X-Notebook-Request: 1</code>. Browser requests also face the app’s same-origin checks. A cookie jar contains credentials; keep it private and remove it when you finish.</p><h2 id="ownership">Check ownership where the data changes</h2>
+    <p>List and export operations filter by owner. Reading another user’s existing note returns 403; a missing ID returns 404. Updates and deletes include both the note ID and owner in the MongoDB write filter, so an earlier ownership check isn’t the sole line of defense.</p>
+    <p>The application limits titles to 100 characters and bodies to 10,000 characters. PATCH preserves omitted fields; PUT requires a title and fills omitted body and pinned values with their defaults. Both reject fields outside <code>title</code>, <code>body</code>, and <code>pinned</code>.</p>
+    <h2 id="limits">Know what is local to a worker</h2>
+    <p>The rate limiter stores timestamps in memory per application instance. A client can get three successful calls to the demo endpoint per ten-second window <em>per worker</em>. Multiple workers don’t magically share a Python dict, however enthusiastically you deploy them.</p>
+    <p>Database-backed sessions survive app restarts; rate-limit buckets do not. For shared throttling, use a gateway or a shared store. Before deploying an adapted auth system, decide how you will handle account recovery, token abuse, CSRF, and operational monitoring. The notebook demonstrates a design; it isn’t an identity provider hiding in a trench coat.</p>
+
+      <nav class="chapter-nav" aria-label="Previous and next chapters"><a href="notebook.html">← Run the notebook</a><a href="starlette-middleware.html">Borrow Starlette’s middleware →</a></nav>
+      <footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+    </main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/development.html b/docs/tutorial/development.html
new file mode 100644
index 0000000..807860f
--- /dev/null
+++ b/docs/tutorial/development.html
@@ -0,0 +1,98 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="Make a small change, demonstrate the behavior, and explain it clearly. The framework is tiny; the pull request does not need a cinematic universe.">
+  <title>Development &amp; contributing · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html" aria-current="page"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html" aria-current="page">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+<h1>Development &amp; contributing</h1>
+<p class="lead">Make a small change, demonstrate the behavior, and explain it clearly. The framework is tiny; the pull request does not need a cinematic universe.</p>
+<nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#setup">Set up a checkout</a></li><li><a href="#checks">Run the existing checks</a></li><li><a href="#changes">Keep changes reviewable</a></li><li><a href="#docs">Edit and preview these docs</a></li><li><a href="#reporting">Report a bug with a reproduction</a></li><li><a href="#license">License</a></li></ul></nav>
+<h2 id="setup">Set up a checkout</h2>
+<div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>git clone https://gitman.io/git/patx/wred
+cd wred
+python -m venv .venv
+. .venv/bin/activate
+python -m pip install -e &#x27;.[server]&#x27; pymongo servestatic</code></pre></div><p>For the core framework alone, <code>python -m pip install -e .</code> installs Wred and orjson. Starlette and itsdangerous are only needed for the tutorial recipes using them.</p>
+<h2 id="checks">Run the existing checks</h2>
+
+<p>The repository uses Python’s <code>unittest</code> runner. From the repository root:</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>python -m unittest tests.test_wred
+python -m unittest tests.test_example_app
+python -m unittest discover -s tests</code></pre></div>
+<p>The core suite exercises request parsing, routes, responses, mounted paths, and protocol boundaries. The example-app suite needs PyMongo and ServeStatic installed. The database integration suite also imports those dependencies and is skipped unless its MongoDB URI is set.</p>
+<p>Use a disposable MongoDB instance for integration checks:</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>WRED_TEST_MONGODB_URI=&#x27;mongodb://127.0.0.1:27017&#x27;   python -m unittest tests.test_notebook</code></pre></div><p>The integration suite creates uniquely named databases beginning with <code>wred_test_</code> and drops them during cleanup. The supplied credentials need permission for that work. Use a test database server, not production with a hopeful expression.</p>
+<h2 id="changes">Keep changes reviewable</h2>
+
+<p>Start with the observable behavior you want to change. Keep Wred’s core focused on HTTP routing and responses; middleware and application modules are good homes for optional behavior. Explain any API or compatibility changes in the accompanying docs.</p>
+<p>When adding a regression check, exercise the actual affected boundary and describe what would fail without the fix. Prefer a small meaningful case to a second copy of the implementation dressed as a test.</p>
+
+<h2 id="docs">Edit and preview these docs</h2>
+
+<p>The documentation is plain HTML with shared CSS and JavaScript in <code>docs/tutorial/</code>. There is no framework build. Serve the docs directory to browse it locally:</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>python -m http.server 8001 --bind 127.0.0.1 --directory docs</code></pre></div>
+<p>Open <a href="http://127.0.0.1:8001/tutorial/">http://127.0.0.1:8001/tutorial/</a>. The downloadable frontend example needs its ASGI server for its API; serving the documentation itself does not start that application.</p>
+<p>After changing page text or adding a page, rebuild the offline search index:</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>python docs/tutorial/build_search.py
+node --check docs/tutorial/tutorial.js
+git diff --check</code></pre></div><p>Pages and navigation still work without JavaScript. Code-copy buttons and full-text search use the shared script; the search index is a local JavaScript file, so it also works without fetching a service.</p>
+<h2 id="reporting">Report a bug with a reproduction</h2>
+
+<p>Include the Wred and Python versions, the ASGI server and relevant options, a minimal app, the request you sent, the expected and actual responses, and the traceback if there is one. For proxy issues, say whether the same request works against the backend directly.</p>
+<p>Remove passwords, tokens, cookies, database credentials, and personal data from examples. Use the <a href="https://gitman.io/patx/wred">project repository</a> for ordinary bug reports and contributions. See <a href="security.html#reporting">deployment boundaries</a> for vulnerability-reporting guidance.</p>
+
+<h2 id="license">License</h2>
+
+<p>Wred is distributed under the BSD 3-Clause license. The complete terms are in the repository’s <code>LICENSE</code> file; retain that file and the applicable notices when redistributing. This paragraph is a signpost to the license, not a replacement for it.</p>
+
+<nav class="chapter-nav" aria-label="Related documentation"><a href="index.html">← Documentation overview</a><a href="api-reference.html">API reference →</a></nav>
+<footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+</main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/errors.html b/docs/tutorial/errors.html
new file mode 100644
index 0000000..e505b35
--- /dev/null
+++ b/docs/tutorial/errors.html
@@ -0,0 +1,91 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="An exception and an HTTP error response are different things. Your logs would appreciate being invited to this distinction.">
+  <title>Errors &amp; troubleshooting · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html" aria-current="page"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html" aria-current="page">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+<h1>Errors &amp; troubleshooting</h1>
+<p class="lead">An exception and an HTTP error response are different things. Your logs would appreciate being invited to this distinction.</p>
+<nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#exceptions">Exceptions Wred can raise</a></li><li><a href="#expected-errors">Return expected errors explicitly</a></li><li><a href="#statuses">Where HTTP statuses come from</a></li><li><a href="#debugging">A short debugging sequence</a></li><li><a href="#streaming">After a response has started</a></li></ul></nav>
+<h2 id="exceptions">Exceptions Wred can raise</h2>
+<div class="table-wrap"><table><thead><tr><th scope="col">Exception</th><th scope="col">Typical trigger</th><th scope="col">What to do</th></tr></thead><tbody><tr><td><code>orjson.JSONDecodeError</code></td><td>Invalid JSON in <code>await request.json()</code>.</td><td>Catch at the input boundary and return a 400 response.</td></tr><tr><td><code>UnicodeDecodeError</code></td><td>Invalid UTF-8 in the raw or percent-decoded query string.</td><td>Reject the query with 400 if it is part of your public input handling.</td></tr><tr><td><code>ConnectionError</code></td><td>A body read receives a disconnect or another non-request event.</td><td>Handle cancellation/disconnection where relevant; do not assume a response can still reach the client.</td></tr><tr><td><code>TypeError</code></td><td>An unsupported response body, or a serialization/type error.</td><td>Return bytes, a dict/list, a supported tuple, or a callable ASGI response.</td></tr><tr><td><code>ValueError</code></td><td>For example, unpacking a tuple with fewer than three response fields.</td><td>Use exactly the documented three- or four-item response tuple.</td></tr><tr><td><code>UnicodeEncodeError</code></td><td>A custom header name is not ASCII or a value cannot be encoded as Latin-1.</td><td>Use HTTP-compatible headers and encode internationalized values with the appropriate header format.</td></tr><tr><td><code>RuntimeError</code></td><td>A non-HTTP scope reaches a bare Wred app.</td><td>Route that protocol to an outer app or configure lifespan appropriately.</td></tr></tbody></table></div><p>Wred lets these exceptions propagate. Your middleware or ASGI server decides how to log and respond to an unhandled exception. Do not depend on a bare Wred app to generate a JSON 500 response.</p>
+<h2 id="expected-errors">Return expected errors explicitly</h2>
+<div class="code-block"><div class="code-heading"><span class="code-label">Add to an existing app</span><span class="code-actions"></span></div><pre tabindex="0"><code>import orjson
+
+
[email protected](&#x27;/api/items&#x27;, methods=[&#x27;POST&#x27;])
+async def create_item(request):
+    try:
+        data = await request.json()
+    except orjson.JSONDecodeError:
+        return {&#x27;error&#x27;: &#x27;Invalid JSON&#x27;}, &#x27;application/json&#x27;, 400
+    if not isinstance(data, dict):
+        return {&#x27;error&#x27;: &#x27;Send a JSON object&#x27;}, &#x27;application/json&#x27;, 400
+    return {&#x27;accepted&#x27;: True}</code></pre></div><p>Catch the exception you expect at the boundary you understand. A blanket <code>except Exception: return {"ok": True}</code> makes dashboards look lovely and everything else substantially worse.</p>
+<h2 id="statuses">Where HTTP statuses come from</h2>
+<div class="table-wrap"><table><thead><tr><th scope="col">Status</th><th scope="col">Wred behavior</th></tr></thead><tbody><tr><td>200</td><td>Default for supported handler return values.</td></tr><tr><td>400 / 401 / 403 / 409 / 429</td><td>Application or middleware decisions. The notebook provides examples.</td></tr><tr><td>404</td><td>Wred’s fallback for an unmatched path or method.</td></tr><tr><td>405</td><td>Not generated automatically. Register a handler or use another routing layer if you need it.</td></tr><tr><td>500</td><td>Unhandled-error behavior is supplied by the server or middleware. See the <a href="starlette-middleware.html#stack">error middleware example</a>.</td></tr></tbody></table></div><p>HTTP status is part of the response, not a declaration made by throwing a Starlette exception. Wred does not install Starlette’s exception handlers for you.</p>
+<h2 id="debugging">A short debugging sequence</h2>
+
+<ol><li>Call the backend directly from its host and inspect the exact status, headers, and body with <code>curl -i</code>.</li>
+<li>Confirm the server imports the wrapper you intended, not just the inner Wred instance.</li>
+<li>Check the route path and method, then the proxy or mount prefix.</li>
+<li>Inspect the server traceback. Turn off public debug tracebacks before deployment.</li>
+<li>Repeat through the public proxy to isolate scheme, Host, cookie, and forwarding issues.</li></ol>
+<p>The <a href="operations.html#debugging">operations troubleshooting table</a> covers 502s, database startup failures, login problems, and buffered streams.</p>
+
+<h2 id="streaming">After a response has started</h2>
+
+<p>A callable response owns its send operations. If it raises after sending headers or part of a stream, Wred propagates the exception without attempting a second response. Validate inputs and permissions before sending the first message.</p>
+<p>An error handler cannot change a status code already sent to the client. Time travel remains an optional dependency that nobody has successfully published.</p>
+
+<nav class="chapter-nav" aria-label="Related documentation"><a href="index.html">← Documentation overview</a><a href="api-reference.html">API reference →</a></nav>
+<footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+</main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/examples/Caddyfile b/docs/tutorial/examples/Caddyfile
new file mode 100644
index 0000000..efd44db
--- /dev/null
+++ b/docs/tutorial/examples/Caddyfile
@@ -0,0 +1,7 @@
+notes.example.com {
+    request_body {
+        max_size 1MB
+    }
+    encode zstd gzip
+    reverse_proxy 127.0.0.1:8000
+}
diff --git a/docs/tutorial/examples/context_app.py b/docs/tutorial/examples/context_app.py
new file mode 100644
index 0000000..792d313
--- /dev/null
+++ b/docs/tutorial/examples/context_app.py
@@ -0,0 +1,43 @@
+from time import perf_counter
+from uuid import uuid4
+
+from wred import App
+
+
+class RequestContext:
+    def __init__(self, app):
+        self.app = app
+
+    async def __call__(self, scope, receive, send):
+        if scope['type'] != 'http':
+            return await self.app(scope, receive, send)
+
+        request_id = uuid4().hex
+        started = perf_counter()
+        scope = {**scope, 'state': {**scope.get('state', {}), 'request_id': request_id}}
+
+        async def send_with_context(message):
+            if message['type'] == 'http.response.start':
+                elapsed_ms = (perf_counter() - started) * 1000
+                message = {
+                    **message,
+                    'headers': [
+                        *message.get('headers', []),
+                        (b'x-request-id', request_id.encode('ascii')),
+                        (b'server-timing', f'app;dur={elapsed_ms:.2f}'.encode('ascii')),
+                    ],
+                }
+            await send(message)
+
+        await self.app(scope, receive, send_with_context)
+
+
+api = App()
+
+
[email protected]('/api/hello')
+async def hello(request):
+    return {'request_id': request.scope['state']['request_id']}
+
+
+app = RequestContext(api)
diff --git a/docs/tutorial/examples/data/notes.json b/docs/tutorial/examples/data/notes.json
new file mode 100644
index 0000000..1a71c9e
--- /dev/null
+++ b/docs/tutorial/examples/data/notes.json
@@ -0,0 +1 @@
+[{"title": "First note", "body": "A sample download from Wred."}]
diff --git a/docs/tutorial/examples/first_api.py b/docs/tutorial/examples/first_api.py
new file mode 100644
index 0000000..95d0e6a
--- /dev/null
+++ b/docs/tutorial/examples/first_api.py
@@ -0,0 +1,18 @@
+from wred import App
+
+app = App()
+
+
[email protected]('/')
+async def hello(request):
+    return {'hello': 'world'}
+
+
[email protected]('/api/health')
+async def health(request):
+    return {'status': 'ok'}
+
+
[email protected]('/api/hello/{name}')
+async def greet(request, name):
+    return {'hello': name, 'language': request.query.get('lang', 'en')}
diff --git a/docs/tutorial/examples/frontend/index.html b/docs/tutorial/examples/frontend/index.html
new file mode 100644
index 0000000..cccef7e
--- /dev/null
+++ b/docs/tutorial/examples/frontend/index.html
@@ -0,0 +1,25 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Wred frontend example</title>
+</head>
+<body>
+  <h1>Hello from Wred</h1>
+  <p id="result" role="status">Loading…</p>
+  <script>
+    async function load() {
+      const result = document.getElementById('result');
+      try {
+        const response = await fetch('/api/hello');
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+        result.textContent = JSON.stringify(await response.json());
+      } catch (error) {
+        result.textContent = `Could not load the API: ${error.message}`;
+      }
+    }
+    load();
+  </script>
+</body>
+</html>
diff --git a/docs/tutorial/examples/frontend_app.py b/docs/tutorial/examples/frontend_app.py
new file mode 100644
index 0000000..3d44882
--- /dev/null
+++ b/docs/tutorial/examples/frontend_app.py
@@ -0,0 +1,19 @@
+from pathlib import Path
+
+from starlette.applications import Starlette
+from starlette.routing import Mount
+from starlette.staticfiles import StaticFiles
+from wred import App
+
+api = App()
+
+
[email protected]('/hello')
+async def hello(request):
+    return {'hello': 'world'}
+
+
+app = Starlette(routes=[
+    Mount('/api', app=api),
+    Mount('/', app=StaticFiles(directory=Path(__file__).with_name('frontend'), html=True)),
+])
diff --git a/docs/tutorial/examples/nginx.conf b/docs/tutorial/examples/nginx.conf
new file mode 100644
index 0000000..e350268
--- /dev/null
+++ b/docs/tutorial/examples/nginx.conf
@@ -0,0 +1,30 @@
+# Include these server blocks inside nginx's http context.
+# Replace the domain and certificate paths before enabling.
+server {
+    listen 80;
+    server_name notes.example.com;
+    return 308 https://$host$request_uri;
+}
+
+server {
+    listen 443 ssl;
+    server_name notes.example.com;
+
+    ssl_certificate /etc/letsencrypt/live/notes.example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/notes.example.com/privkey.pem;
+
+    client_max_body_size 1m;
+    client_body_timeout 15s;
+
+    location / {
+        proxy_pass http://127.0.0.1:8000;
+        proxy_http_version 1.1;
+        proxy_set_header Connection "";
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-For $remote_addr;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_connect_timeout 5s;
+        proxy_read_timeout 60s;
+        proxy_send_timeout 60s;
+    }
+}
diff --git a/docs/tutorial/examples/proxy_app.py b/docs/tutorial/examples/proxy_app.py
new file mode 100644
index 0000000..81cd235
--- /dev/null
+++ b/docs/tutorial/examples/proxy_app.py
@@ -0,0 +1,6 @@
+from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
+
+from example_app.app import create_app
+
+# Use only behind a trusted proxy connecting from IPv4 loopback.
+app = ProxyHeadersMiddleware(create_app(), trusted_hosts=['127.0.0.1'])
diff --git a/docs/tutorial/examples/responses_app.py b/docs/tutorial/examples/responses_app.py
new file mode 100644
index 0000000..218bf37
--- /dev/null
+++ b/docs/tutorial/examples/responses_app.py
@@ -0,0 +1,30 @@
+import asyncio
+from pathlib import Path
+
+from starlette.responses import FileResponse, StreamingResponse
+from wred import App
+
+app = App()
+
+
[email protected]('/download')
+async def download(request):
+    return FileResponse(
+        Path(__file__).with_name('data') / 'notes.json',
+        filename='notes.json',
+        media_type='application/json',
+    )
+
+
[email protected]('/events')
+async def events(request):
+    async def messages():
+        for number in range(5):
+            yield f'data: {number}\n\n'
+            await asyncio.sleep(1)
+
+    return StreamingResponse(
+        messages(),
+        media_type='text/event-stream',
+        headers={'Cache-Control': 'no-cache'},
+    )
diff --git a/docs/tutorial/examples/session_app.py b/docs/tutorial/examples/session_app.py
new file mode 100644
index 0000000..7933c20
--- /dev/null
+++ b/docs/tutorial/examples/session_app.py
@@ -0,0 +1,21 @@
+import os
+
+from starlette.middleware.sessions import SessionMiddleware
+from wred import App
+
+api = App()
+
+
[email protected]('/visits')
+async def visits(request):
+    session = request.scope['session']
+    session['visits'] = session.get('visits', 0) + 1
+    return {'visits': session['visits']}
+
+
+app = SessionMiddleware(
+    api,
+    secret_key=os.environ['SESSION_SECRET'],
+    same_site='lax',
+    https_only=os.environ.get('COOKIE_SECURE', '1') == '1',
+)
diff --git a/docs/tutorial/examples/starlette_app.py b/docs/tutorial/examples/starlette_app.py
new file mode 100644
index 0000000..540f5d8
--- /dev/null
+++ b/docs/tutorial/examples/starlette_app.py
@@ -0,0 +1,30 @@
+from starlette.middleware.cors import CORSMiddleware
+from starlette.middleware.errors import ServerErrorMiddleware
+from starlette.middleware.gzip import GZipMiddleware
+from starlette.middleware.trustedhost import TrustedHostMiddleware
+from starlette.responses import JSONResponse
+from wred import App
+
+api = App()
+
+
[email protected]('/api/hello')
+async def hello(request):
+    return {'hello': 'world'}
+
+
+async def server_error(request, exc):
+    return JSONResponse({'error': 'Internal server error'}, status_code=500)
+
+
+# Requests enter the last wrapper first.
+app = GZipMiddleware(api, minimum_size=1000)
+app = TrustedHostMiddleware(app, allowed_hosts=['localhost', '127.0.0.1', 'api.example.com'])
+app = ServerErrorMiddleware(app, handler=server_error, debug=False)
+app = CORSMiddleware(
+    app,
+    allow_origins=['http://localhost:5173'],
+    allow_methods=['GET', 'POST', 'PATCH', 'DELETE'],
+    allow_headers=['Authorization', 'Content-Type'],
+    expose_headers=['Location'],
+)
diff --git a/docs/tutorial/examples/validation.py b/docs/tutorial/examples/validation.py
new file mode 100644
index 0000000..00b06ed
--- /dev/null
+++ b/docs/tutorial/examples/validation.py
@@ -0,0 +1,18 @@
+import orjson
+from wred import App
+
+app = App()
+
+
[email protected]('/api/echo', methods=['POST'])
+async def echo(request):
+    try:
+        data = await request.json()
+    except orjson.JSONDecodeError:
+        return {'error': 'Invalid JSON'}, 'application/json', 400
+    if not isinstance(data, dict):
+        return {'error': 'Send a JSON object'}, 'application/json', 400
+    title = data.get('title')
+    if not isinstance(title, str) or not 1 <= len(title.strip()) <= 100:
+        return {'error': 'Title must contain 1–100 characters'}, 'application/json', 400
+    return {'title': title.strip()}
diff --git a/docs/tutorial/examples/wred.service b/docs/tutorial/examples/wred.service
new file mode 100644
index 0000000..df63705
--- /dev/null
+++ b/docs/tutorial/examples/wred.service
@@ -0,0 +1,21 @@
+[Unit]
+Description=Wred notebook
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=exec
+User=wred
+Group=wred
+WorkingDirectory=/srv/wred
+EnvironmentFile=/etc/wred/notebook.env
+Environment=PYTHONUNBUFFERED=1
+ExecStart=/srv/wred/.venv/bin/weft example_app.asgi:app --host 127.0.0.1 --port 8000 --workers 2 --lifespan on --forwarded-allow-ips 127.0.0.1
+Restart=on-failure
+RestartSec=3
+TimeoutStopSec=45
+NoNewPrivileges=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/tutorial/faq.html b/docs/tutorial/faq.html
new file mode 100644
index 0000000..eb351ee
--- /dev/null
+++ b/docs/tutorial/faq.html
@@ -0,0 +1,107 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="The questions that usually arrive just after the first successful curl request, when optimism starts acquiring requirements.">
+  <title>Frequently asked questions · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html" aria-current="page"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html" aria-current="page">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+<h1>Frequently asked questions</h1>
+<p class="lead">The questions that usually arrive just after the first successful curl request, when optimism starts acquiring requirements.</p>
+<nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#scope">What does Wred deliberately leave out?</a></li><li><a href="#server">Do I have to use Weft?</a></li><li><a href="#requests">Can I use Starlette’s Request APIs on a Wred request?</a></li><li><a href="#methods">Why does POST or HEAD return 404?</a></li><li><a href="#responses">Why can I parse JSON null but not return None?</a></li><li><a href="#forms">How do I handle forms or uploaded files?</a></li><li><a href="#lifespan">Where do startup tasks and database cleanup go?</a></li><li><a href="#streams">Can Wred stream or handle WebSockets?</a></li><li><a href="#concurrency">Can handlers call blocking code or launch background jobs?</a></li><li><a href="#limits">Are the notebook’s rate limits global?</a></li><li><a href="#performance">Will my app get the README’s benchmark numbers?</a></li></ul></nav>
+<h2 id="scope">What does Wred deliberately leave out?</h2>
+
+<p>Authentication, sessions, database integration, schemas, templates, multipart/form parsing, CORS, CSRF protection, dependency injection, generated API docs, background jobs, WebSockets, and built-in static or streaming responses. The tutorial shows how to compose several of these from application code and ASGI libraries.</p>
+<p>The <a href="api-reference.html">API reference</a> describes what the core does. A feature’s absence is permission to choose a component, not an invitation to pretend the feature is unnecessary.</p>
+
+<h2 id="server">Do I have to use Weft?</h2>
+
+<p>No. <code>wred[server]</code> is a convenience extra for Weft. Install the base package and your preferred ASGI server if you want something else. Wred is ASGI, not a WSGI application; use the correct interface when a server supports both.</p>
+
+<h2 id="requests">Can I use Starlette’s Request APIs on a Wred request?</h2>
+
+<p>Wrapping an application with Starlette middleware does not replace the request object Wred creates. Read middleware data through <code>request.scope</code>, or add your own application adapter. See <a href="api-reference.html#request">Request’s attributes</a>.</p>
+<p><code>request.query</code> is a synchronous cached property. <code>request.body()</code> and <code>request.json()</code> must be awaited. Python will not reinterpret this distinction as a stylistic preference.</p>
+
+<h2 id="methods">Why does POST or HEAD return 404?</h2>
+
+<p>A route defaults to GET. Register methods explicitly. Wred uses the same 404 fallback for unknown paths and unsupported methods; it does not supply automatic HEAD, OPTIONS, or 405 behavior. CORS middleware can handle browser preflights.</p>
+
+<h2 id="responses">Why can I parse JSON null but not return None?</h2>
+
+<p>Input parsing accepts any JSON value. Wred’s direct response handling supports dicts, lists, and bytes, plus the documented tuple forms. Return a callable JSON response object when you need a JSON scalar, or return <code>b"", "application/json", 204</code> for no content.</p>
+
+<h2 id="forms">How do I handle forms or uploaded files?</h2>
+
+<p>Wred has no form or multipart parser. Choose a parser or ASGI component deliberately, install its dependencies, and arrange who reads the receive stream. A second consumer cannot reread events already consumed by the first. Enforce body and file limits before buffering uploads.</p>
+
+<h2 id="lifespan">Where do startup tasks and database cleanup go?</h2>
+
+<p>In a lifespan-aware outer ASGI app. The notebook’s <code>NotebookApp</code> initializes and closes its database there. A bare Wred instance rejects lifespan. See <a href="notebook.html#lifespan">the actual wrapper</a> and <a href="configuration.html#protocols">protocol configuration</a>.</p>
+
+<h2 id="streams">Can Wred stream or handle WebSockets?</h2>
+
+<p>A handler can return a callable ASGI response such as Starlette’s StreamingResponse for HTTP streaming or SSE. WebSocket scopes are rejected by Wred itself; route them to a separate ASGI app with an outer router if you need them.</p>
+
+<h2 id="concurrency">Can handlers call blocking code or launch background jobs?</h2>
+
+<p>Handlers are awaited on the event loop. Calling a blocking database client or doing expensive CPU work directly there blocks other requests in that worker. Use an async client, an appropriate thread for blocking I/O, or separate workers for expensive work.</p>
+<p>A detached task is not a durable job queue. If work must survive a restart, use a durable queue and workers that own it. The operating system is unimpressed by how important your untracked task felt.</p>
+
+<h2 id="limits">Are the notebook’s rate limits global?</h2>
+
+<p>No. They are per app instance and per worker. Database sessions are persistent; the in-memory timestamp buckets are not. Use shared application storage or an edge limiter for a shared policy. See <a href="nginx.html#throttle">the Nginx example</a>.</p>
+
+<h2 id="performance">Will my app get the README’s benchmark numbers?</h2>
+
+<p>Those numbers describe one workload on one machine with a specific server configuration. Database calls, passwords, middleware, payload size, concurrency, and client behavior all change throughput and latency. Measure the work your application actually does. A hello-world benchmark remains impressively good at saying hello.</p>
+
+<nav class="chapter-nav" aria-label="Related documentation"><a href="index.html">← Documentation overview</a><a href="api-reference.html">API reference →</a></nav>
+<footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+</main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/first-api.html b/docs/tutorial/first-api.html
new file mode 100644
index 0000000..f430cb1
--- /dev/null
+++ b/docs/tutorial/first-api.html
@@ -0,0 +1,110 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="Install a server, return a dict, and make a request. This is the part where a larger framework might ask you to generate a project, three directories, and a personal philosophy.">
+  <title>Your first API · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html" aria-current="page"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html" aria-current="page">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+      <h1>Your first API</h1>
+      <p class="lead">Install a server, return a dict, and make a request. This is the part where a larger framework might ask you to generate a project, three directories, and a personal philosophy.</p>
+      <nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#install">Make a small project</a></li><li><a href="#routes">Give it some routes</a></li><li><a href="#run">Run it and ask it something</a></li><li><a href="#validation">Accept JSON without accepting absolutely anything</a></li><li><a href="#response-shapes">Choose the response you mean</a></li></ul></nav>
+      <h2 id="install">Make a small project</h2>
+    <p>Start in a new directory and install Wred 0.10 or newer. The <code>server</code> extra installs Weft.</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>mkdir hello-wred
+cd hello-wred
+python -m venv .venv
+. .venv/bin/activate
+python -m pip install 'wred[server]>=0.10'</code></pre></div><h2 id="routes">Give it some routes</h2><p>Save this as <code>first_api.py</code>. An async handler receives a Wred request. Returning a dict or list produces JSON with status 200.</p><div class="code-block"><div class="code-heading"><span class="code-label">first_api.py</span><span class="code-actions"><a href="examples/first_api.py" download>Download</a></span></div><pre tabindex="0"><code>from wred import App
+
+app = App()
+
+
[email protected](&#x27;/&#x27;)
+async def hello(request):
+    return {&#x27;hello&#x27;: &#x27;world&#x27;}
+
+
[email protected](&#x27;/api/health&#x27;)
+async def health(request):
+    return {&#x27;status&#x27;: &#x27;ok&#x27;}
+
+
[email protected](&#x27;/api/hello/{name}&#x27;)
+async def greet(request, name):
+    return {&#x27;hello&#x27;: name, &#x27;language&#x27;: request.query.get(&#x27;lang&#x27;, &#x27;en&#x27;)}</code></pre></div>
+    <p><code>name</code> is a string, passed by keyword. <code>request.query</code> is a synchronous cached property and returns strings too. Wred will not infer that a query parameter is an integer because you looked at it meaningfully.</p>
+    <p>Methods default to GET. Fixed paths win over parameterized paths for the same method; trailing slashes are ignored. Unknown paths and unsupported methods return JSON 404. HEAD, OPTIONS, and 405 handling aren’t added automatically.</p>
+    <h2 id="run">Run it and ask it something</h2><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>weft first_api:app</code></pre></div><p>Leave the server running. In a second terminal:</p><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>curl -sS http://127.0.0.1:8000/
+# {&quot;hello&quot;:&quot;world&quot;}
+
+curl -sS &#x27;http://127.0.0.1:8000/api/hello/Ada?lang=en&#x27;
+# {&quot;hello&quot;:&quot;Ada&quot;,&quot;language&quot;:&quot;en&quot;}</code></pre></div><p>The Python module is <code>first_api</code>; the exported ASGI application is <code>app</code>. That’s what the colon means. We have now exhausted the mystery of <code>first_api:app</code>.</p><h2 id="validation">Accept JSON without accepting absolutely anything</h2><p>Save the next example as <code>validation.py</code>, stop the first server, and run <code>weft validation:app</code>. This endpoint echoes a validated title; it does not store anything.</p><div class="code-block"><div class="code-heading"><span class="code-label">validation.py</span><span class="code-actions"><a href="examples/validation.py" download>Download</a></span></div><pre tabindex="0"><code>import orjson
+from wred import App
+
+app = App()
+
+
[email protected](&#x27;/api/echo&#x27;, methods=[&#x27;POST&#x27;])
+async def echo(request):
+    try:
+        data = await request.json()
+    except orjson.JSONDecodeError:
+        return {&#x27;error&#x27;: &#x27;Invalid JSON&#x27;}, &#x27;application/json&#x27;, 400
+    if not isinstance(data, dict):
+        return {&#x27;error&#x27;: &#x27;Send a JSON object&#x27;}, &#x27;application/json&#x27;, 400
+    title = data.get(&#x27;title&#x27;)
+    if not isinstance(title, str) or not 1 &lt;= len(title.strip()) &lt;= 100:
+        return {&#x27;error&#x27;: &#x27;Title must contain 1–100 characters&#x27;}, &#x27;application/json&#x27;, 400
+    return {&#x27;title&#x27;: title.strip()}</code></pre></div><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>curl -i http://127.0.0.1:8000/api/echo \
+  -H &#x27;Content-Type: application/json&#x27; \
+  -d &#x27;{&quot;title&quot;:&quot;My first note&quot;}&#x27;</code></pre></div><p>The response body is <code>{"title":"My first note"}</code>. A JSON array, missing title, or invalid JSON gets 400. Schema validation is your application’s decision; a few explicit checks are perfectly respectable.</p><h2 id="response-shapes">Choose the response you mean</h2><div class="table-wrap"><table><thead><tr><th scope="col">Return value</th><th scope="col">Result</th></tr></thead><tbody><tr><td><code>{"ok": True}</code></td><td>JSON, 200</td></tr><tr><td><code>{"id": "n1"}, "application/json", 201</code></td><td>JSON, 201</td></tr><tr><td><code>b"", "text/plain", 303, [("Location", "/")]</code></td><td>Redirect</td></tr><tr><td><code>b"", "application/json", 204</code></td><td>Empty response</td></tr><tr><td>A callable ASGI response</td><td>The response object sends its own messages</td></tr></tbody></table></div><p>Custom tuple bodies can be bytes, dicts, or lists. Dicts and lists use JSON even if you give them another content type. Exceptions propagate to the server or your middleware; Wred doesn’t convert them into a reassuringly vague success.</p>
+      <nav class="chapter-nav" aria-label="Previous and next chapters"><a href="index.html">← Build something with Wred.</a><a href="notebook.html">Run the notebook →</a></nav>
+      <footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+    </main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/frontend.html b/docs/tutorial/frontend.html
new file mode 100644
index 0000000..52ec8a1
--- /dev/null
+++ b/docs/tutorial/frontend.html
@@ -0,0 +1,146 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="Your JavaScript needs somewhere to live. Your API needs somewhere to return JSON. They can share an origin without sharing an existential crisis.">
+  <title>Serve a frontend and other responses · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html" aria-current="page"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html" aria-current="page">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+      <h1>Serve a frontend and other responses</h1>
+      <p class="lead">Your JavaScript needs somewhere to live. Your API needs somewhere to return JSON. They can share an origin without sharing an existential crisis.</p>
+      <nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#same-origin">Start with one origin</a></li><li><a href="#router">Use Starlette for the outer routing</a></li><li><a href="#servestatic">Or use the notebook’s ServeStatic wrapper</a></li><li><a href="#responses">Return a file or stream</a></li><li><a href="#browser">Borrow the notebook’s fetch habits</a></li></ul></nav>
+      <h2 id="same-origin">Start with one origin</h2>
+    <p>The notebook serves its frontend and API together, so browser code can use <code>fetch("/api/notes")</code> and same-origin cookies. A frontend on port 5173 and an API on port 8000 are different origins, even on the same machine.</p>
+    <p>During development, use your frontend tool’s API proxy or configure a deliberately cross-origin API. The notebook’s auth checks expect the same-origin arrangement. Opening an HTML file through <code>file://</code> is not an ingenious CORS workaround.</p>
+    <h2 id="router">Use Starlette for the outer routing</h2>
+    <p>Install <code>starlette</code>. Save this as <code>frontend_app.py</code> and create a sibling <code>frontend/</code> directory containing the HTML below. Run <code>weft frontend_app:app</code>, then open <code>http://127.0.0.1:8000/</code>.</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">frontend_app.py</span><span class="code-actions"><a href="examples/frontend_app.py" download>Download</a></span></div><pre tabindex="0"><code>from pathlib import Path
+
+from starlette.applications import Starlette
+from starlette.routing import Mount
+from starlette.staticfiles import StaticFiles
+from wred import App
+
+api = App()
+
+
[email protected](&#x27;/hello&#x27;)
+async def hello(request):
+    return {&#x27;hello&#x27;: &#x27;world&#x27;}
+
+
+app = Starlette(routes=[
+    Mount(&#x27;/api&#x27;, app=api),
+    Mount(&#x27;/&#x27;, app=StaticFiles(directory=Path(__file__).with_name(&#x27;frontend&#x27;), html=True)),
+])</code></pre></div><div class="code-block"><div class="code-heading"><span class="code-label">frontend/index.html</span><span class="code-actions"><a href="examples/frontend/index.html" download>Download</a></span></div><pre tabindex="0"><code>&lt;!doctype html&gt;
+&lt;html lang=&quot;en&quot;&gt;
+&lt;head&gt;
+  &lt;meta charset=&quot;utf-8&quot;&gt;
+  &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&gt;
+  &lt;title&gt;Wred frontend example&lt;/title&gt;
+&lt;/head&gt;
+&lt;body&gt;
+  &lt;h1&gt;Hello from Wred&lt;/h1&gt;
+  &lt;p id=&quot;result&quot; role=&quot;status&quot;&gt;Loading…&lt;/p&gt;
+  &lt;script&gt;
+    async function load() {
+      const result = document.getElementById(&#x27;result&#x27;);
+      try {
+        const response = await fetch(&#x27;/api/hello&#x27;);
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+        result.textContent = JSON.stringify(await response.json());
+      } catch (error) {
+        result.textContent = `Could not load the API: ${error.message}`;
+      }
+    }
+    load();
+  &lt;/script&gt;
+&lt;/body&gt;
+&lt;/html&gt;</code></pre></div><p>The outer <a href="https://starlette.dev/routing/#submounting-routes">Starlette router</a> owns composition and lifespan. Wred registers <code>/hello</code>; the mount makes it <code>/api/hello</code>. Wred respects the ASGI root path. The final <code>/</code> mount catches static paths, so keep it last.</p><p><a href="https://starlette.dev/staticfiles/">StaticFiles</a> handles the directory. Its HTML mode can serve an index; it does not automatically make every unknown URL an SPA route. Configure an explicit fallback if your client router needs one.</p><h2 id="servestatic">Or use the notebook’s ServeStatic wrapper</h2>
+    <p>The notebook already wraps its Wred app with <code>ServeStaticASGI</code> in <code>example_app/app.py</code>. You don’t need to add the Starlette router above to run that example. Both approaches delegate static files to a library built for them.</p>
+    <p>For a built frontend, Caddy or Nginx can serve the build directory and proxy <code>/api/</code> to Python. Preserve the prefix when the Wred routes already include <code>/api</code>. Strip it only when the app is intentionally mounted with matching root-path handling. Random slash removal is a surprisingly effective 404 generator.</p>
+    <h2 id="responses">Return a file or stream</h2>
+    <p>Wred also accepts callable ASGI response objects. This independent sample uses Starlette’s <code>FileResponse</code> and <code>StreamingResponse</code>. Save it as <code>responses_app.py</code> and place the sample <a href="examples/data/notes.json" download>notes.json</a> in a sibling <code>data/</code> directory.</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">responses_app.py</span><span class="code-actions"><a href="examples/responses_app.py" download>Download</a></span></div><pre tabindex="0"><code>import asyncio
+from pathlib import Path
+
+from starlette.responses import FileResponse, StreamingResponse
+from wred import App
+
+app = App()
+
+
[email protected](&#x27;/download&#x27;)
+async def download(request):
+    return FileResponse(
+        Path(__file__).with_name(&#x27;data&#x27;) / &#x27;notes.json&#x27;,
+        filename=&#x27;notes.json&#x27;,
+        media_type=&#x27;application/json&#x27;,
+    )
+
+
[email protected](&#x27;/events&#x27;)
+async def events(request):
+    async def messages():
+        for number in range(5):
+            yield f&#x27;data: {number}\n\n&#x27;
+            await asyncio.sleep(1)
+
+    return StreamingResponse(
+        messages(),
+        media_type=&#x27;text/event-stream&#x27;,
+        headers={&#x27;Cache-Control&#x27;: &#x27;no-cache&#x27;},
+    )</code></pre></div><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>weft responses_app:app</code></pre></div><p>From another terminal:</p><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>curl -i http://127.0.0.1:8000/download
+curl -N http://127.0.0.1:8000/events</code></pre></div><p><code>FileResponse</code> streams the known file and sets download headers. The events route sends five SSE messages. <code>curl -N</code> disables client output buffering. Authenticate and validate before starting a response; once headers are sent, an exception can’t retroactively turn it into a JSON 400.</p><p>See the <a href="https://starlette.dev/responses/">Starlette response reference</a>. Keep large downloads and long-lived streams in mind when choosing proxy buffering and timeout settings.</p><h2 id="browser">Borrow the notebook’s fetch habits</h2>
+    <p><code>example_app/static/app.js</code> has one fetch helper for JSON, auth headers, refresh, errors, and empty 204 responses. It checks <code>response.ok</code>: fetch resolves normally for HTTP 400 and 500, which is considerate of HTTP and occasionally inconsiderate of your expectations.</p>
+    <p>Render user text with <code>textContent</code>, keep credentials out of URLs, and handle empty responses before trying to parse JSON. The notebook also tracks request sequences so a slow search result cannot overwrite a newer one.</p>
+
+      <nav class="chapter-nav" aria-label="Previous and next chapters"><a href="asgi-middleware.html">← Write a little ASGI middleware</a><a href="caddy.html">Put Caddy in front →</a></nav>
+      <footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+    </main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/index.html b/docs/tutorial/index.html
new file mode 100644
index 0000000..70b7939
--- /dev/null
+++ b/docs/tutorial/index.html
@@ -0,0 +1,92 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="Build an app, look up an API, or find out which component owns the configuration. A tutorial and reference for a framework that still fits in one Python file.">
+  <title>Wred documentation · Tutorial and API reference</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html" aria-current="page">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html" aria-current="page">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+      <h1>Wred documentation</h1>
+      <p class="lead">Build an app, look up an API, or find out which component owns the configuration. A tutorial and reference for a framework that still fits in one Python file.</p>
+<aside class="note"><p>These docs cover Wred 0.10, where <code>request.query</code> is a cached property. Upgrading from 0.9? Remove the parentheses; see <a href="releases.html#version-010">migration notes</a>.</p></aside>
+
+      <h2 id="reference">Look something up</h2>
+<p>Following a lesson is useful. Finding the exact method signature without rereading a lesson is also useful. We have bravely decided to provide both.</p>
+<ul>
+  <li><a href="installation.html">Installation &amp; quick start</a> — Python requirements, extras, servers, and editable installs.</li>
+  <li><a href="api-reference.html">API reference</a> — App, routes, Request, response formats, and ASGI helpers.</li>
+  <li><a href="configuration.html">Configuration &amp; architecture</a> — settings ownership and the notebook’s environment variables.</li>
+  <li><a href="errors.html">Errors &amp; troubleshooting</a> — exceptions, HTTP statuses, and debugging.</li>
+  <li><a href="faq.html">Frequently asked questions</a> — the usual surprises, catalogued.</li>
+  <li><a href="security.html">Deployment boundaries</a> — body limits, proxy trust, credentials, and input handling.</li>
+  <li><a href="development.html">Development &amp; contributing</a> — checks, source changes, docs, and bug reports.</li>
+  <li><a href="releases.html">Release notes &amp; upgrading</a> — the 0.10 query-property change and the optional server extra.</li>
+</ul>
+<p>The search box looks through these pages and the tutorial. Everything runs locally in the browser; no search account, index service, or ritual sacrifice required.</p>
+<h2 id="what-you-build">The app we’re actually building</h2>
+    <p>The repository includes a notebook with accounts, private notes, search, pagination, JSON export, a browser interface, and MongoDB storage. We’ll run it, follow a request through its files, then explore the bits you might want to borrow for your own app.</p>
+    <p>Wred handles HTTP routes and responses. Your application chooses the database, authentication, middleware, and frontend. It’s a small framework with a very healthy relationship with the word “elsewhere.”</p>
+    <div class="request-path" aria-label="Request flow"><span>Browser</span><b aria-hidden="true">→</b><span>Caddy or Nginx</span><b aria-hidden="true">→</b><span>ASGI server</span><b aria-hidden="true">→</b><span>Middleware</span><b aria-hidden="true">→</b><span>Wred + your code</span></div>
+    <p>The chapters move from local development to deployment. The middleware and frontend recipes are independent examples; they don’t silently rewrite the notebook app while you’re looking away.</p>
+    <h2 id="before-starting">Bring these things</h2>
+    <ul><li>Python 3.11 or newer, a terminal, and enough JavaScript to recognize <code>fetch()</code>.</li>
+    <li>A running MongoDB instance for the notebook chapters. The first API and most middleware examples work without it.</li>
+    <li>A Linux or macOS shell for the commands shown. On Windows, use WSL or adapt virtualenv activation to PowerShell.</li></ul>
+    <p>Examples use Weft through <code>wred[server]</code>. Wred itself only requires <code>orjson</code>. Starlette, PyMongo, ServeStatic, and the other extras belong to the examples that use them.</p>
+    <h2 id="chapters">Work through the tutorial</h2><ol class="reading-order"><li><a href="first-api.html">1. Your first API</a><p>Routes, requests, validation, JSON, and response tuples.</p></li><li><a href="notebook.html">2. Run the notebook</a><p>Start MongoDB, run the example, and follow the source files.</p></li><li><a href="data-and-auth.html">3. Notes, accounts, and ownership</a><p>Trace note creation, bearer tokens, refresh cookies, and ownership.</p></li><li><a href="starlette-middleware.html">4. Borrow Starlette’s middleware</a><p>Add CORS, host checks, compression, errors, and signed sessions.</p></li><li><a href="asgi-middleware.html">5. Write a little ASGI middleware</a><p>Build request context and reuse proxy-header middleware.</p></li><li><a href="frontend.html">6. Serve a frontend and other responses</a><p>Compose static files, API mounts, downloads, and event streams.</p></li><li><a href="caddy.html">7. Put Caddy in front</a><p>Deploy with automatic HTTPS and a private Python backend.</p></li><li><a href="nginx.html">8. Put Nginx in front</a><p>Configure TLS, forwarding, body limits, and throttling.</p></li><li><a href="operations.html">9. Keep it running</a><p>Supervise workers, manage configuration, and diagnose failures.</p></li></ol><h2 id="using-examples">Copy the bits you need</h2>
+    <p>Python and configuration examples have download links. Save each example with the filename shown. Run the standalone Python examples from the directory you saved them in; stop one server with Ctrl+C before starting another on port 8000.</p>
+    <p>From a checkout of this repository, you can also run the standalone examples directly:</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>python -m venv .venv
+. .venv/bin/activate
+python -m pip install -e &#x27;.[server]&#x27; starlette itsdangerous
+cd docs/tutorial/examples
+weft first_api:app</code></pre></div><p>The notebook chapters use the repository root instead. Each chapter says which directory and dependencies it expects, because scavenger hunts are a terrible package manager.</p>
+      <nav class="chapter-nav" aria-label="Previous and next chapters"><a href="first-api.html">Your first API →</a></nav>
+      <footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+    </main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/installation.html b/docs/tutorial/installation.html
new file mode 100644
index 0000000..9407dcc
--- /dev/null
+++ b/docs/tutorial/installation.html
@@ -0,0 +1,105 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="Pick a Python, make a virtualenv, and install the parts you need. Your dependency tree is allowed to have sunlight reaching the bottom.">
+  <title>Installation &amp; quick start · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html" aria-current="page"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html" aria-current="page">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+<h1>Installation &amp; quick start</h1>
+<p class="lead">Pick a Python, make a virtualenv, and install the parts you need. Your dependency tree is allowed to have sunlight reaching the bottom.</p>
+<aside class="note"><p>These docs cover Wred 0.10, where <code>request.query</code> is a cached property. Upgrading from 0.9? Remove the parentheses; see <a href="releases.html#version-010">migration notes</a>.</p></aside>
+<nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#requirements">Requirements</a></li><li><a href="#virtualenv">Create an isolated environment</a></li><li><a href="#quick-start">Save an app and run it</a></li><li><a href="#extras">Choose your dependencies</a></li><li><a href="#another-server">Use another ASGI server</a></li><li><a href="#checkout">Work from a checkout</a></li><li><a href="#versions">Check what you installed</a></li></ul></nav>
+<h2 id="requirements">Requirements</h2>
+
+<p>Wred requires Python 3.11 or newer and <code>orjson</code>. It is an ASGI application framework; an ASGI server runs it. The optional <code>server</code> extra installs <code>weft-server</code>, whose executable is <code>weft</code>.</p>
+<p>Wred is packaged as a single Python module. There is no project generator, settings module, or Wred CLI. You can spend the time saved choosing a name for your app. That should comfortably consume the afternoon.</p>
+
+<h2 id="virtualenv">Create an isolated environment</h2>
+<div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>mkdir hello-wred
+cd hello-wred
+python -m venv .venv
+. .venv/bin/activate
+python -m pip install &#x27;wred[server]&#x27;</code></pre></div>
+<p>Commands assume a POSIX shell and a <code>python</code> command pointing to Python 3.11+. Use <code>python3</code> if that is how your system names it. In Windows PowerShell, activate with <code>.venv\Scripts\Activate.ps1</code>.</p>
+<p>A virtualenv also avoids modifying an externally managed system Python. See the <a href="https://docs.python.org/3/library/venv.html">Python virtualenv guide</a> and <a href="https://pip.pypa.io/en/stable/cli/pip_install/">pip install reference</a>.</p>
+
+<h2 id="quick-start">Save an app and run it</h2>
+<div class="code-block"><div class="code-heading"><span class="code-label">app.py</span><span class="code-actions"></span></div><pre tabindex="0"><code>from wred import App
+
+app = App()
+
+
[email protected](&#x27;/&#x27;)
+async def hello(request):
+    return {&#x27;hello&#x27;: &#x27;world&#x27;}</code></pre></div><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>weft app:app</code></pre></div>
+<p>Leave that process running. In a second terminal:</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>curl -sS http://127.0.0.1:8000/
+# {&quot;hello&quot;:&quot;world&quot;}</code></pre></div><p><code>app:app</code> means the <code>app</code> object in <code>app.py</code>. Continue with <a href="first-api.html">Your first API</a> for routes and validation.</p>
+<h2 id="extras">Choose your dependencies</h2>
+<div class="table-wrap"><table><thead><tr><th scope="col">Install</th><th scope="col">What it adds</th></tr></thead><tbody><tr><td><code>python -m pip install wred</code></td><td>Wred and orjson. Bring an ASGI server yourself.</td></tr><tr><td><code>python -m pip install "wred[server]"</code></td><td>Wred, orjson, and the Weft server.</td></tr><tr><td><code>python -m pip install starlette</code></td><td>The optional middleware, routing, and response classes used in several chapters.</td></tr><tr><td><code>python -m pip install itsdangerous</code></td><td>Needed for the Starlette signed-cookie session example.</td></tr><tr><td><code>python -m pip install pymongo servestatic</code></td><td>The notebook example’s database client and static-file wrapper.</td></tr></tbody></table></div><p>These examples are separate application choices. Installing Starlette does not transform Wred’s request into a Starlette request, despite the excellent opportunities for confusion.</p>
+<h2 id="another-server">Use another ASGI server</h2>
+
+<p>You can install another ASGI server alongside the base package. For example, with Uvicorn:</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>python -m pip install wred uvicorn
+uvicorn app:app --host 127.0.0.1 --port 8000 --lifespan off</code></pre></div>
+<p>The bare Wred app handles HTTP only, so this command disables Uvicorn’s lifespan handling. The <a href="notebook.html#lifespan">notebook wrapper</a> implements lifespan and should run with it enabled. See your server’s own options for workers, reload, TLS, and forwarding.</p>
+
+<h2 id="checkout">Work from a checkout</h2>
+<div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>git clone https://gitman.io/git/patx/wred
+cd wred
+python -m venv .venv
+. .venv/bin/activate
+python -m pip install -e &#x27;.[server]&#x27; pymongo servestatic</code></pre></div><p>Editable installation points Python at your checkout. The notebook is in <code>example_app/</code>; installing the Wred package alone does not install that application. Follow <a href="notebook.html">Run the notebook</a> for MongoDB and startup.</p>
+<h2 id="versions">Check what you installed</h2>
+<div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>python -m pip show wred
+python -c &#x27;import wred; print(wred.__file__)&#x27;</code></pre></div><p>The second command helps detect a local <code>wred.py</code> shadowing the installed module. Python imports the file it finds, not the file you had strong feelings about. See <a href="releases.html">release notes and upgrading</a> for the 0.9 server-extra change.</p>
+<nav class="chapter-nav" aria-label="Related documentation"><a href="index.html">← Documentation overview</a><a href="api-reference.html">API reference →</a></nav>
+<footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+</main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/nginx.html b/docs/tutorial/nginx.html
new file mode 100644
index 0000000..f36ecb0
--- /dev/null
+++ b/docs/tutorial/nginx.html
@@ -0,0 +1,102 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="The other perfectly reasonable reverse proxy. It offers precise control and enough semicolons to make a JavaScript formatter feel underemployed.">
+  <title>Put Nginx in front · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html" aria-current="page"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html" aria-current="page">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+      <h1>Put Nginx in front</h1>
+      <p class="lead">The other perfectly reasonable reverse proxy. It offers precise control and enough semicolons to make a JavaScript formatter feel underemployed.</p>
+      <nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#backend">Use the same private backend</a></li><li><a href="#config">Configure HTTPS and the proxy</a></li><li><a href="#reload">Check the config before reloading</a></li><li><a href="#throttle">Add a proxy rate limit when you need one</a></li><li><a href="#streams">Let event streams reach the client</a></li></ul></nav>
+      <h2 id="backend">Use the same private backend</h2><p>Use this instead of the Caddy setup. The notebook listens on loopback; Nginx owns the public ports. Run from the repository root:</p><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code># Run from the repository root with the notebook dependencies installed.
+weft example_app.asgi:app --host 127.0.0.1 --port 8000 \
+  --lifespan on --forwarded-allow-ips 127.0.0.1</code></pre></div><p>The app trusts forwarding information from <code>127.0.0.1</code>, not from arbitrary clients. Keep port 8000 private and forward the external scheme correctly.</p><h2 id="config">Configure HTTPS and the proxy</h2>
+    <p>Put these server blocks in a file included by Nginx’s <code>http</code> context. Replace the hostname and provision real certificate and key files at the configured paths first. The <a href="https://nginx.org/en/docs/http/configuring_https_servers.html">Nginx HTTPS guide</a> covers TLS configuration. Unlike the Caddy example, these certificate paths do not cause certificates to appear by force of optimism.</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">nginx.conf</span><span class="code-actions"><a href="examples/nginx.conf" download>Download</a></span></div><pre tabindex="0"><code># Include these server blocks inside nginx&#x27;s http context.
+# Replace the domain and certificate paths before enabling.
+server {
+    listen 80;
+    server_name notes.example.com;
+    return 308 https://$host$request_uri;
+}
+
+server {
+    listen 443 ssl;
+    server_name notes.example.com;
+
+    ssl_certificate /etc/letsencrypt/live/notes.example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/notes.example.com/privkey.pem;
+
+    client_max_body_size 1m;
+    client_body_timeout 15s;
+
+    location / {
+        proxy_pass http://127.0.0.1:8000;
+        proxy_http_version 1.1;
+        proxy_set_header Connection &quot;&quot;;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-For $remote_addr;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_connect_timeout 5s;
+        proxy_read_timeout 60s;
+        proxy_send_timeout 60s;
+    }
+}</code></pre></div><p><a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass">proxy_pass</a> has no URI suffix, so it preserves paths such as <code>/api/notes</code>. The forwarded client header is overwritten with the peer address for this single-proxy setup. Host and scheme match the public request.</p><p><a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size">client_max_body_size</a> limits uploads before the application buffers them. The timeout values are starting points; choose values that match your workload.</p><h2 id="reload">Check the config before reloading</h2><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>sudo nginx -t
+sudo systemctl reload nginx
+curl -i https://notes.example.com/api/health</code></pre></div><p>Check login through the public domain too. A 403 from the notebook’s origin check often means the forwarded scheme or Host is wrong. Don’t fix it by deleting the origin check and declaring victory.</p><h2 id="throttle">Add a proxy rate limit when you need one</h2>
+    <p>Nginx’s request limiter can share its zone across Nginx workers on one host. For an optional general API limit, add this declaration once in the <code>http</code> context:</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">Nginx · http context</span><span class="code-actions"></span></div><pre tabindex="0"><code>limit_req_zone $binary_remote_addr zone=wred_clients:10m rate=10r/s;</code></pre></div><p>Then add the following inside the existing <code>location /</code> block, alongside the proxy directives:</p><div class="code-block"><div class="code-heading"><span class="code-label">Nginx · location context</span><span class="code-actions"></span></div><pre tabindex="0"><code>limit_req zone=wred_clients burst=20 nodelay;
+limit_req_status 429;</code></pre></div><p>These numbers are illustrative, not a capacity plan delivered by a stranger’s HTML page. This general limit covers static files too; split locations for API-only or tighter login limits and preserve the required proxy settings. See the <a href="https://nginx.org/en/docs/http/ngx_http_limit_req_module.html">request-limit module</a>.</p><h2 id="streams">Let event streams reach the client</h2>
+    <p>For the independent <a href="frontend.html#responses">events example</a>, buffering can hide incremental output. In a dedicated <code>location /events</code>, include your normal proxy settings and add:</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">Nginx · streaming location additions</span><span class="code-actions"></span></div><pre tabindex="0"><code>proxy_buffering off;
+proxy_read_timeout 300s;</code></pre></div><p>Choose a timeout longer than the interval between stream messages. Don’t disable buffering globally just because one endpoint needs it; ordinary responses have done nothing to deserve being dragged into this.</p>
+      <nav class="chapter-nav" aria-label="Previous and next chapters"><a href="caddy.html">← Put Caddy in front</a><a href="operations.html">Keep it running →</a></nav>
+      <footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+    </main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/notebook.html b/docs/tutorial/notebook.html
new file mode 100644
index 0000000..8ea6f41
--- /dev/null
+++ b/docs/tutorial/notebook.html
@@ -0,0 +1,118 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="A real little app: accounts, a notes editor, MongoDB, and just enough moving parts to explain why people invent architecture diagrams.">
+  <title>Run the notebook · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html" aria-current="page"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html" aria-current="page">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+      <h1>Run the notebook</h1>
+      <p class="lead">A real little app: accounts, a notes editor, MongoDB, and just enough moving parts to explain why people invent architecture diagrams.</p>
+      <nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#checkout">Run from the repository root</a></li><li><a href="#mongodb">Give it a database</a></li><li><a href="#try-it">Take it for a spin</a></li><li><a href="#source-map">Follow the files</a></li><li><a href="#lifespan">The wrapper does the housekeeping</a></li><li><a href="#static">How the browser gets its files</a></li></ul></nav>
+      <h2 id="checkout">Run from the repository root</h2>
+    <p>The notebook source lives in <code>example_app/</code>; it is not installed by <code>pip install wred</code>. Clone the repository, or use your existing checkout.</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>git clone https://gitman.io/git/patx/wred
+cd wred
+python -m venv .venv
+. .venv/bin/activate
+python -m pip install -e &#x27;.[server]&#x27; pymongo servestatic</code></pre></div><p>Use a current PyMongo release that provides <a href="https://www.mongodb.com/docs/languages/python/pymongo-driver/current/connect/mongoclient/"><code>AsyncMongoClient</code></a>. The app imports the native async client, not Motor and not a synchronous client wearing an async function as a disguise.</p><h2 id="mongodb">Give it a database</h2>
+    <p>Have MongoDB listening on <code>127.0.0.1:27017</code>. An existing local instance is fine. If you already use Docker, this starts a local development database with a persistent volume. The <a href="https://docs.docker.com/engine/network/port-publishing/">published port</a> binds to loopback:</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>docker run -d --name wred-mongo \
+  -p 127.0.0.1:27017:27017 \
+  -v wred-mongo-data:/data/db mongo:8</code></pre></div><p>This local example has no database credentials and binds the published port to loopback. Use your own authenticated URI for a remote database. Don’t promote the development database to the internet because the command was conveniently short.</p><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>export MONGODB_URI=&#x27;mongodb://127.0.0.1:27017&#x27;
+export MONGODB_DATABASE=&#x27;wred_notebook&#x27;
+weft example_app.asgi:app --lifespan on</code></pre></div><p>Open <a href="http://127.0.0.1:8000/">http://127.0.0.1:8000/</a>. These environment values are the defaults; setting them explicitly makes the configuration visible. <code>--lifespan on</code> asks the server to require the example’s startup and shutdown protocol.</p><h2 id="try-it">Take it for a spin</h2>
+    <ol><li>Create an account. Usernames accept 1–64 characters; passwords accept 15–128 characters.</li><li>Write two notes, pin one, search for a word, and try the pagination controls.</li><li>Export your notes. Log out and log back in to see the stored data.</li><li>Click the rate-limit demonstration. A fresh client gets three accepted requests in ten seconds; the extra requests return 429.</li></ol>
+    <p>For a quick HTTP check, use a second terminal:</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>curl -i http://127.0.0.1:8000/api/health
+# HTTP 200, {&quot;status&quot;:&quot;ok&quot;}
+
+curl -i http://127.0.0.1:8000/api/notes
+# HTTP 401: notes require a bearer token</code></pre></div><h2 id="source-map">Follow the files</h2><div class="table-wrap"><table><thead><tr><th scope="col">File</th><th scope="col">What to read for</th></tr></thead><tbody><tr><td><code>example_app/asgi.py</code></td><td>Exports the server entry point: <code>app = create_app()</code>.</td></tr><tr><td><code>example_app/app.py</code></td><td>Builds Wred, registers routes, wraps static files, and owns lifespan.</td></tr><tr><td><code>example_app/storage.py</code></td><td>Async MongoDB operations, indexes, note ownership filters, and session persistence.</td></tr><tr><td><code>example_app/auth.py</code></td><td>Password checks, bearer tokens, refresh cookies, and authentication decorators.</td></tr><tr><td><code>example_app/notes.py</code></td><td>Validation, CRUD, filtering, pagination, and export.</td></tr><tr><td><code>example_app/pages.py</code></td><td>Health, redirect, and rate-limit demonstration routes.</td></tr><tr><td><code>example_app/rate_limit.py</code></td><td>The application-owned sliding-window decorator.</td></tr><tr><td><code>example_app/static/app.js</code></td><td>Fetch calls, refresh coordination, editor state, and rendering.</td></tr></tbody></table></div><p>Open these files in your checkout alongside the tutorial. The <a href="https://gitman.io/patx/wred">source repository</a> has the same layout.</p><p>The constructor in <code>example_app/app.py</code> assembles the HTTP app:</p><div class="code-block"><div class="code-heading"><span class="code-label">example_app/app.py · constructor excerpt</span><span class="code-actions"></span></div><pre tabindex="0"><code>self.http = App()
+rate_limit = RateLimiter()
+pages.register_routes(self.http, rate_limit)
+auth.register_routes(self.http, self.database, rate_limit)
+notes.register_routes(self.http, self.database)
+self.http = ServeStaticASGI(
+    self.http,
+    root=str(STATIC_ROOT),
+    index_file=True,
+    autorefresh=True,
+    max_age=0,
+)
+self.http.add_files(str(STATIC_ROOT), prefix=&#x27;static/&#x27;)</code></pre></div><h2 id="lifespan">The wrapper does the housekeeping</h2>
+    <p><code>NotebookApp</code> handles <code>lifespan.startup</code> by initializing the database and creating indexes. It reports startup failure if that fails. Shutdown closes the client. Ordinary requests go to its <code>self.http</code> application.</p>
+    <p>Wred itself handles HTTP only. Sending lifespan directly to a bare Wred app raises <code>RuntimeError</code>; the notebook wrapper is the part that implements it. This is deliberate composition, which is a fancy way of saying the code that opens the database also closes it.</p>
+    <p><code>create_app(database=...)</code> also accepts an application-owned database object with the methods used by the routes, including async <code>initialize()</code> and <code>close()</code>. Each app has independent rate-limit state.</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">example_app/app.py · lifespan excerpt</span><span class="code-actions"></span></div><pre tabindex="0"><code>async def __call__(self, scope, receive, send):
+    if scope[&#x27;type&#x27;] != &#x27;lifespan&#x27;:
+        return await self.http(scope, receive, send)
+    while True:
+        message = await receive()
+        if message[&#x27;type&#x27;] == &#x27;lifespan.startup&#x27;:
+            try:
+                await self.database.initialize()
+            except Exception as error:
+                await self.database.close()
+                await send({
+                    &#x27;type&#x27;: &#x27;lifespan.startup.failed&#x27;,
+                    &#x27;message&#x27;: str(error),
+                })
+                return
+            await send({&#x27;type&#x27;: &#x27;lifespan.startup.complete&#x27;})
+        elif message[&#x27;type&#x27;] == &#x27;lifespan.shutdown&#x27;:
+            await self.database.close()
+            await send({&#x27;type&#x27;: &#x27;lifespan.shutdown.complete&#x27;})
+            return</code></pre></div><h2 id="static">How the browser gets its files</h2>
+    <p>The wrapper places <code>ServeStaticASGI</code> around Wred. It serves the static directory with an index file and also adds the <code>/static/</code> asset prefix. Unmatched requests fall through to the API.</p>
+    <p>The example enables <code>autorefresh=True</code> and <code>max_age=0</code> for editing. For deployment, choose intentional cache settings or move built assets to your reverse proxy. The <a href="frontend.html">frontend chapter</a> covers alternatives.</p>
+
+      <nav class="chapter-nav" aria-label="Previous and next chapters"><a href="first-api.html">← Your first API</a><a href="data-and-auth.html">Notes, accounts, and ownership →</a></nav>
+      <footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+    </main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/operations.html b/docs/tutorial/operations.html
new file mode 100644
index 0000000..920894d
--- /dev/null
+++ b/docs/tutorial/operations.html
@@ -0,0 +1,98 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="The app works in your terminal. Now comes the traditional next step: making it work when your terminal is closed.">
+  <title>Keep it running · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html" aria-current="page"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html" aria-current="page">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+      <h1>Keep it running</h1>
+      <p class="lead">The app works in your terminal. Now comes the traditional next step: making it work when your terminal is closed.</p>
+      <nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#service">Run the notebook under systemd</a></li><li><a href="#environment">Keep environment settings outside the checkout</a></li><li><a href="#workers">Choose workers with the database in mind</a></li><li><a href="#debugging">When it doesn’t work</a></li><li><a href="#next">Make it your application</a></li></ul></nav>
+      <h2 id="service">Run the notebook under systemd</h2>
+    <p>This Linux example assumes the repository is deployed at <code>/srv/wred</code>, dependencies are installed in <code>/srv/wred/.venv</code>, and a dedicated <code>wred</code> user and group can read the application. Create those resources before enabling the service. Run behind either proxy from the previous chapters.</p>
+    <p>Put this unit at <code>/etc/systemd/system/wred.service</code>. Its <code>ExecStart</code> uses Weft’s explicit loopback forwarding trust.</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">wred.service</span><span class="code-actions"><a href="examples/wred.service" download>Download</a></span></div><pre tabindex="0"><code>[Unit]
+Description=Wred notebook
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=exec
+User=wred
+Group=wred
+WorkingDirectory=/srv/wred
+EnvironmentFile=/etc/wred/notebook.env
+Environment=PYTHONUNBUFFERED=1
+ExecStart=/srv/wred/.venv/bin/weft example_app.asgi:app --host 127.0.0.1 --port 8000 --workers 2 --lifespan on --forwarded-allow-ips 127.0.0.1
+Restart=on-failure
+RestartSec=3
+TimeoutStopSec=45
+NoNewPrivileges=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target</code></pre></div><p><code>Restart=on-failure</code> restarts a failed process. Systemd supervises it; it doesn’t check whether your database credentials are good or your application is emotionally ready. See <a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html">systemd.service</a>.</p><h2 id="environment">Keep environment settings outside the checkout</h2>
+    <p>Create <code>/etc/wred/notebook.env</code> with your database settings. The values below target the local development database; replace them for your deployment. Restrict access if the URI contains credentials.</p>
+    <div class="code-block"><div class="code-heading"><span class="code-label">/etc/wred/notebook.env</span><span class="code-actions"></span></div><pre tabindex="0"><code>MONGODB_URI=mongodb://127.0.0.1:27017
+MONGODB_DATABASE=wred_notebook</code></pre></div><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>sudo systemctl daemon-reload
+sudo systemctl enable --now wred
+sudo systemctl status wred
+sudo journalctl -u wred -f</code></pre></div><p>Keep production credentials out of Git and shell history. The service manager reads the environment file; this example does not require a dotenv package.</p><h2 id="workers">Choose workers with the database in mind</h2>
+    <p>The unit starts two workers as an example. Each worker gets its own MongoDB client and connection pool. More workers mean more pools and separate in-memory rate-limit buckets. Benchmark your workload, including database calls and middleware, before multiplying workers because the CPU graph had room for more colors.</p>
+    <p>Keep MongoDB reachable and authenticated appropriately, back up the data, and verify that you can restore it. A volume is persistence; it is not a backup with a more reassuring name.</p>
+    <p>The notebook’s health endpoint returns a simple application response. It does not ping MongoDB on every request. Use startup checks, database monitoring, and a deliberately designed readiness check for your own deployment.</p>
+    <h2 id="debugging">When it doesn’t work</h2><div class="table-wrap"><table><thead><tr><th scope="col">Symptom</th><th scope="col">Check first</th></tr></thead><tbody><tr><td>502 from the proxy</td><td>Is the backend running on the same host and port? Check the service log and request the backend’s health endpoint locally.</td></tr><tr><td>Startup failure</td><td>Can the service reach MongoDB? Did its virtualenv install PyMongo and ServeStatic? Is the working directory the repository root?</td></tr><tr><td>403 at login or refresh</td><td>The custom header, same-origin browser request, public Host, and forwarded HTTPS scheme.</td></tr><tr><td>401 on note requests</td><td>The access token, its expiry, and the login’s existence. The refresh cookie alone does not authorize CRUD routes.</td></tr><tr><td>Static page instead of JSON</td><td>A static catch-all or SPA fallback caught an API path. Put API routing first and inspect prefix stripping.</td></tr><tr><td>Secure cookie missing over HTTPS</td><td>The ASGI scheme is probably still HTTP. Fix trusted proxy handling.</td></tr><tr><td>429 changes between requests</td><td>The notebook limiter is per worker. Use a shared limiter for a shared policy.</td></tr><tr><td>SSE arrives all at once</td><td>Client and proxy buffering. Use curl -N and inspect the streaming location.</td></tr><tr><td>Different frontend port fails</td><td>It is a different origin. Use a development proxy or design CORS and auth together.</td></tr></tbody></table></div><h2 id="next">Make it your application</h2>
+    <p>Add routes in a module that exposes <code>register_routes(app, ...)</code>. Pass database and service objects explicitly, as the notebook does. Put shared HTTP behavior in middleware and resource lifecycle in the outer app.</p>
+    <p>For blocking libraries, use an appropriate worker thread or replace them with an async client. Don’t start untracked tasks inside request handlers and call it a job queue. Durable work needs a durable queue and workers that own it.</p>
+    <p>You now have routes, data, auth, a frontend, middleware, HTTPS, and a supervised process. Wred is still small. Your app was allowed to grow without filing a feature request.</p>
+
+      <nav class="chapter-nav" aria-label="Previous and next chapters"><a href="nginx.html">← Put Nginx in front</a><a href="index.html">Back to the overview →</a></nav>
+      <footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+    </main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/releases.html b/docs/tutorial/releases.html
new file mode 100644
index 0000000..d86e6a0
--- /dev/null
+++ b/docs/tutorial/releases.html
@@ -0,0 +1,85 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="The changes that matter when updating an app. Version numbers are identifiers, even when Python packaging gives your leading zero a stern talking-to.">
+  <title>Release notes &amp; upgrading · Wred docs</title>
+  <link rel="icon" type="image/png" href="../wred-logo.png">
+  <link rel="stylesheet" href="tutorial.css">
+  <script src="search-index.js" defer></script>
+  <script src="tutorial.js" defer></script>
+</head>
+<body>
+  <a class="skip" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="index.html"><img src="../wred-logo.png" width="108" alt="Wred"><span>Docs</span></a>
+    <div class="docs-search" role="search" aria-label="Documentation search">
+      <label for="docs-query">Search docs</label>
+      <input id="docs-query" type="search" autocomplete="off" placeholder="Routes, headers, middleware…" aria-controls="search-results" maxlength="200" disabled>
+      <div id="search-results" class="search-results" hidden><p id="search-status" role="status"></p><ol id="search-matches"></ol></div>
+    </div>
+    <nav aria-label="Project"><a href="../index.html">Home</a><a href="api-reference.html">API</a><a href="https://gitman.io/patx/wred">Source</a></nav>
+  </header>
+  <div class="layout">
+    <nav class="contents" aria-label="Documentation"><a class="overview" href="index.html">Documentation overview</a>
+<h2>Tutorial</h2><ol>
+<li><a href="first-api.html"><span class="num">1.</span><span>Your first API</span></a></li>
+<li><a href="notebook.html"><span class="num">2.</span><span>Run the notebook</span></a></li>
+<li><a href="data-and-auth.html"><span class="num">3.</span><span>Notes, accounts, and ownership</span></a></li>
+<li><a href="starlette-middleware.html"><span class="num">4.</span><span>Starlette middleware</span></a></li>
+<li><a href="asgi-middleware.html"><span class="num">5.</span><span>ASGI middleware</span></a></li>
+<li><a href="frontend.html"><span class="num">6.</span><span>Frontend &amp; responses</span></a></li>
+<li><a href="caddy.html"><span class="num">7.</span><span>Deploy with Caddy</span></a></li>
+<li><a href="nginx.html"><span class="num">8.</span><span>Deploy with Nginx</span></a></li>
+<li><a href="operations.html"><span class="num">9.</span><span>Operations</span></a></li>
+</ol>
+<h2>Reference</h2><ol>
+<li><a class="reference-link" href="installation.html"><span>Installation &amp; quick start</span></a></li>
+<li><a class="reference-link" href="api-reference.html"><span>API reference</span></a></li>
+<li><a class="reference-link" href="configuration.html"><span>Configuration &amp; architecture</span></a></li>
+<li><a class="reference-link" href="errors.html"><span>Errors &amp; troubleshooting</span></a></li>
+<li><a class="reference-link" href="faq.html"><span>Frequently asked questions</span></a></li>
+<li><a class="reference-link" href="security.html"><span>Deployment boundaries</span></a></li>
+</ol>
+<h2>Project</h2><ol>
+<li><a class="reference-link" href="development.html"><span>Development &amp; contributing</span></a></li>
+<li><a class="reference-link" href="releases.html" aria-current="page"><span>Release notes &amp; upgrading</span></a></li>
+</ol></nav>
+    <details class="mobile-contents"><summary>Browse documentation</summary><p><a href="index.html">Documentation overview</a></p><h3>Tutorial</h3><ul><li><a href="first-api.html">Your first API</a></li><li><a href="notebook.html">Run the notebook</a></li><li><a href="data-and-auth.html">Notes, accounts, and ownership</a></li><li><a href="starlette-middleware.html">Starlette middleware</a></li><li><a href="asgi-middleware.html">ASGI middleware</a></li><li><a href="frontend.html">Frontend &amp; responses</a></li><li><a href="caddy.html">Deploy with Caddy</a></li><li><a href="nginx.html">Deploy with Nginx</a></li><li><a href="operations.html">Operations</a></li></ul><h3>Reference</h3><ul><li><a href="installation.html">Installation &amp; quick start</a></li><li><a href="api-reference.html">API reference</a></li><li><a href="configuration.html">Configuration &amp; architecture</a></li><li><a href="errors.html">Errors &amp; troubleshooting</a></li><li><a href="faq.html">Frequently asked questions</a></li><li><a href="security.html">Deployment boundaries</a></li></ul><h3>Project</h3><ul><li><a href="development.html">Development &amp; contributing</a></li><li><a href="releases.html" aria-current="page">Release notes &amp; upgrading</a></li></ul></details>
+    <main id="main">
+<h1>Release notes &amp; upgrading</h1>
+<p class="lead">The changes that matter when updating an app. Version numbers are identifiers, even when Python packaging gives your leading zero a stern talking-to.</p>
+<nav class="on-page" aria-label="On this page"><p>On this page</p><ul><li><a href="#version-010">0.10: query is a cached property</a></li><li><a href="#version-09">0.9: the server is optional</a></li><li><a href="#normalization">Why v0.09 corresponds to package version 0.9</a></li><li><a href="#upgrade">Check an upgrade</a></li></ul></nav>
+<h2 id="version-010">0.10: query is a cached property</h2>
+<p><code>Request.query</code> now uses <code>cached_property</code>, matching <code>Request.headers</code>. It parses on first access and returns the same cached dict on later accesses. Blank values, repeated-key handling, strict UTF-8 decoding, and body-reading behavior are unchanged.</p>
+<p>This is a breaking API change from published Wred 0.9: remove the call parentheses in handlers. No <code>await</code> is needed. The old call form tries to call a dict and raises <code>TypeError</code>, which is the dictionary's entirely reasonable objection to being treated as a function.</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Migration from Wred 0.9</span><span class="code-actions"></span></div><pre tabindex="0"><code># Wred 0.9
+query = request.query()
+
+# Wred 0.10
+query = request.query
+name = request.query.get('name', 'world')</code></pre></div>
+<p>Upgrade with <code>python -m pip install --upgrade "wred[server]==0.10"</code>, or use <code>wred==0.10</code> with your own ASGI server. Version 0.10 also adds this HTML tutorial, API reference, and browser-local documentation search.</p>
+<h2 id="version-09">0.9: the server is optional</h2>
+
+<p>Wred 0.9 moves <code>weft-server</code> out of the required dependencies and into the <code>server</code> extra. The only required runtime dependency is <code>orjson</code>. The Python API remains imported from <code>wred</code>.</p>
+<div class="table-wrap"><table><thead><tr><th scope="col">Your setup</th><th scope="col">What to install</th></tr></thead><tbody><tr><td>You run the <code>weft</code> command.</td><td><code>python -m pip install "wred[server]==0.9"</code></td></tr><tr><td>You already install another ASGI server.</td><td><code>python -m pip install "wred==0.9"</code>, plus that server in your application dependencies.</td></tr><tr><td>You run the notebook from a checkout.</td><td>Install the editable server extra plus PyMongo and ServeStatic, as shown in <a href="notebook.html">the notebook chapter</a>.</td></tr></tbody></table></div><p>Record the extra in your dependency file if your app relies on Weft. Upgrading an existing environment may leave an already installed Weft package in place; a clean environment is the useful check for missing dependencies. Stale packages make excellent camouflage.</p>
+<h2 id="normalization">Why v0.09 corresponds to package version 0.9</h2>
+
+<p>The <code>v0.09</code> Git tag identifies the release whose source version was <code>0.09</code>. Flit normalizes that to package version <code>0.9</code> under Python’s version rules. The new <code>v0.10</code> tag and source version <code>0.10</code> correspond to package version <code>0.10</code>. Use the package versions in dependency requirements.</p>
+<div class="code-block"><div class="code-heading"><span class="code-label">Python</span><span class="code-actions"></span></div><pre tabindex="0"><code>from importlib.metadata import version
+
+print(version(&#x27;wred&#x27;))  # Installed distribution metadata</code></pre></div><p>See the <a href="https://packaging.python.org/en/latest/specifications/version-specifiers/">Python packaging version specification</a>. These are version components, not decimal fractions.</p>
+<h2 id="upgrade">Check an upgrade</h2>
+
+<ol><li>Install your intended version and extras in a fresh virtualenv.</li><li>Run your app’s checks with its actual server and middleware stack.</li><li>Exercise startup, routing, body parsing, expected errors, and protected operations.</li><li>Verify proxy scheme and cookie behavior if the deployment configuration changed.</li></ol>
+<p>The <a href="https://pypi.org/project/wred/0.10/">0.10 PyPI page</a> provides distribution metadata. The <a href="https://gitman.io/patx/wred">repository history</a> contains the source changes; this page does not invent summaries for older releases.</p>
+
+<nav class="chapter-nav" aria-label="Related documentation"><a href="index.html">← Documentation overview</a><a href="api-reference.html">API reference →</a></nav>
+<footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
+</main>
+  </div>
+  <p id="copy-status" class="sr-only" role="status" aria-live="polite"></p>
+</body>
+</html>
diff --git a/docs/tutorial/search-index.js b/docs/tutorial/search-index.js
new file mode 100644
index 0000000..7b497b5
--- /dev/null
+++ b/docs/tutorial/search-index.js
@@ -0,0 +1,2 @@
+// Generated by build_search.py; edit the HTML pages, then rebuild.
+const DOCS_SEARCH_INDEX = [{"url":"api-reference.html","title":"API reference","headings":[{"anchor":"imports","title":"Imports and scope"},{"anchor":"app","title":"App()"},{"anchor":"route","title":"App.route(path, methods=(\"GET\",))"},{"anchor":"mounted-paths","title":"Mounted paths and root_path"},{"anchor":"request","title":"Request(scope, receive, send)"},{"anchor":"headers","title":"request.headers → dict[str, str]"},{"anchor":"query","title":"request.query → dict[str, str]"},{"anchor":"body","title":"await request.body() → bytes"},{"anchor":"json","title":"await request.json() → Any"},{"anchor":"responses","title":"Handler return values"},{"anchor":"low-level","title":"Low-level App helpers"},{"anchor":"send-response","title":"await app.send_response(send, response, status=200) → None"},{"anchor":"match-path","title":"app.match_path(pattern, parts) → dict[str, str] | None"}],"text":"API reference The signatures, return values, and edge cases in one place. The API is small enough that this page does not require its own executive summary. These docs cover Wred 0.10, where request.query is a cached property. Upgrading from 0.9? Remove the parentheses; see migration notes . On this page Imports and scope App() App.route(path, methods=(\"GET\",)) Mounted paths and root_path Request(scope, receive, send) request.headers → dict[str, str] request.query → dict[str, str] await request.body() → bytes await request.json() → Any Handler return values Low-level App helpers Imports and scope Python from wred import App, Request This reference describes the implementation in wred.py for Wred 0.10. Handlers normally use App and the request they receive. The low-level dispatch helpers are documented at the end for readers working directly with ASGI. Related guides: first API , errors and troubleshooting , ASGI middleware . App() Create an HTTP-only ASGI application. The constructor takes no configuration arguments. Python app = App() Export the instance, or a middleware wrapper around it, to your server. The ASGI entry point is await app(scope, receive, send) ; its return value is None . It creates a Request, finds a handler, awaits the handler, and sends the response. Scope types other than http raise RuntimeError before receiving or sending messages. Use an outer application for lifespan, WebSockets, or other protocol handling. See the ASGI specification for the callable interface. App.route(path, methods=(\"GET\",)) Python @app.route('/users/{user_id}', methods=['GET', 'PATCH']) async def user(request, user_id): return {'id': user_id, 'method': request.method} Argument Meaning path: str A route such as /users or /users/{user_id} . Use a leading slash and valid Python parameter names. methods: list[str] | tuple[str, ...] Defaults to (\"GET\",) . Registration uppercases each method name. Decorated handler An async callable accepting the request and any path parameters as keyword arguments. The decorator returns the original handler. Parameters occupy a whole path segment and arrive as strings. Convert and validate them in the handler. There are no typed converters or catch-all path parameters. Trailing slashes are ignored at registration and dispatch. /users and /users/ reach the same route without a redirect. A fixed route wins when it has a handler for the incoming method. If it does not, a matching dynamic route with that method may handle the request. Dynamic routes are tried in registration order. Keep overlapping patterns intentional. Registering the same path and method again replaces that handler. Other registered methods remain. No matching path/method returns {\"error\":\"Not Found\"} with 404. HEAD, OPTIONS, redirects, and 405 responses are not synthesized. Routes are case-sensitive. Wred does not implement named routes, URL generation, subrouters, or an application mount() method. Use an outer ASGI router for composition. Mounted paths and root_path When the incoming path starts with the scope’s root_path on a segment boundary, Wred removes that prefix for route matching. The original scope and request.path retain the incoming path. Registered route Incoming scope Result /users path=\"/api/users\", root_path=\"/api\" Matches /users ; request.path remains /api/users . /users/{user_id} path=\"/api/users/42\", root_path=\"/api\" Passes user_id=\"42\" . The prefix and the server/router configuration must agree. See the Starlette mount example . Arbitrarily trimming slashes is not a deployment strategy, although it is a traditional source of one. Request(scope, receive, send) Wred creates this object for each HTTP request. Direct construction is mainly useful when working at the ASGI boundary. The constructor stores the supplied scope and callables; it does not read the body. Attribute Value request.path: str The original scope[\"path\"] , without the query string. It is not rewritten to the mount-relative route. request.method: str The incoming scope[\"method\"] . request.scope: dict The supplied ASGI scope, including client, scheme, root_path, and any middleware state. request.receive The ASGI receive callable. Consuming events yourself affects subsequent body reads. request.send The ASGI send callable. Normally let the return-value handling or a callable response send the response. Wred has no built-in request.user , request.session , request.cookies , request.form() , or request.is_disconnected() interface. Middleware or your own decorators can add context. The notebook’s request.user is added by its authentication decorator. request.headers → dict[str, str] A property, not a coroutine. On first access it decodes ASGI header bytes with Latin-1, lowercases names, and caches the resulting dict. Repeated Cookie fields are joined with ; ; repeated headers with other names keep their last value. Inside a handler authorization = request.headers.get('authorization', '') content_type = request.headers.get('content-type', '') This is a convenience view, not a multi-value header container. Use request.scope.get(\"headers\", []) for the original byte pairs when duplicate fields matter. Changing the cached dict does not rewrite those original ASGI header pairs. request.query → dict[str, str] A cached_property , like headers . Parse the query string on first attribute access and cache the resulting dict. Blank values are preserved; repeated keys keep the last value. The parser strictly decodes UTF-8, including percent-encoded values. Invalid UTF-8 raises UnicodeDecodeError . Inside a handler # /search?q=first&q=last&empty= query = request.query # {'q': 'last', 'empty': ''} No await is needed. Results are strings. Apply your own numeric ranges, field-count limits, and repeated-key policy. Repeated accesses return the cached dict, so mutate a copy if you need a separate working view. await request.body() → bytes Read successive http.request messages until more_body is false, join their bytes, and cache the complete body. Later calls return the cached bytes. Inside a handler body = await request.body() The body is buffered in memory. Wred imposes no body-size limit; configure a limit at the server, proxy, or a suitable middleware layer. An incoming event other than http.request , including http.disconnect , raises ConnectionError . await request.json() → Any Read the cached body and parse it with orjson.loads() . An empty body returns {} . The result may be an object, array, string, number, boolean, or None ; Wred does not enforce an object schema. Inside a handler data = await request.json() if not isinstance(data, dict): return {'error': 'Send a JSON object'}, 'application/json', 400 The body is cached; the parsed JSON value is not. Each json() call parses those bytes again. Malformed JSON, including invalid UTF-8 JSON input, raises orjson.JSONDecodeError . Parsing does not depend on the Content-Type header. See orjson’s reference for its supported JSON behavior. Handler return values Return Body / content type / status dict or list Serialized by orjson; application/json ; 200. bytes Sent directly; application/octet-stream ; 200. (body, content_type, status) Explicit media type and status. A dict/list body always uses application/json . (body, content_type, status, headers) As above, with an iterable of string header pairs. Callable ASGI response Wred awaits response(scope, receive, send) ; the response owns the outgoing messages. Alternative returns inside handlers # JSON creation response with a Location header. return {'id': 'n1'}, 'application/json', 201, [('Location', '/notes/n1')] # Empty deletion response. return b'', 'application/json', 204 # Text: encode it yourself. return 'Hello'.encode('utf-8'), 'text/plain; charset=utf-8', 200 Tuple bodies must be bytes, dicts, or lists. Bare strings, integers, booleans, and None are unsupported response bodies. If you need a JSON scalar, use a callable response such as Starlette’s JSONResponse . Use the tuple’s media type field to set Content-Type. Additional headers are lowercased and encoded with ASCII names and Latin-1 values. A list of pairs preserves repeated Set-Cookie headers. Wred sends one response-start message followed by one body message for its own return-value handling; it does not add a Content-Length header itself. Your ASGI server handles HTTP transport details. A callable response can stream instead; see files and SSE . Low-level App helpers Most handlers should return a response value. The methods below support dispatch and explicit ASGI integrations; use them when you actually own the message flow. Sending a response yourself and then returning another one is not a two-for-one offer. await app.send_response(send, response, status=200) → None Send a supported body or response tuple through an ASGI send callable. A tuple’s status takes precedence over the status argument. This helper handles serialization and the two outgoing messages; it does not invoke callable response objects. Normal app dispatch invokes callable responses separately. app.match_path(pattern, parts) → dict[str, str] | None Compare a tuple of pattern segments with a list of incoming segments. Return string parameters when segment counts and fixed segments match, otherwise None . Inputs are already split; this helper does not normalize trailing slashes or remove a root path. Python app.match_path(('', 'users', '{user_id}'), ['', 'users', '42']) # {'user_id': '42'} app.routes and app.pattern_routes hold dispatch tables. Register handlers with route() rather than editing these tables directly. ← Documentation overview Errors & troubleshooting → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"asgi-middleware.html","title":"Write a little ASGI middleware","headings":[{"anchor":"contract","title":"Three arguments, two directions"},{"anchor":"context","title":"Add request context and timing"},{"anchor":"rules","title":"Keep the wrapper well behaved"},{"anchor":"proxy","title":"Use Uvicorn’s proxy-header middleware with another server"},{"anchor":"auth-context","title":"Authentication middleware can use the same scope"}],"text":"Write a little ASGI middleware Sometimes you need a request ID, a timing header, or a place to attach context. This does not require founding another framework. On this page Three arguments, two directions Add request context and timing Keep the wrapper well behaved Use Uvicorn’s proxy-header middleware with another server Authentication middleware can use the same scope Three arguments, two directions An ASGI app is called with (scope, receive, send) . The scope describes the connection. receive() supplies request events; send() emits response events. Middleware can wrap either callable and then delegate to the inner app. This example assigns a request ID, makes it available to handlers, and adds response headers when http.response.start is sent. It measures time until response headers, not the complete download time. Names are useful when they mean things. Add request context and timing Save as context_app.py , then run weft context_app:app . context_app.py Download from time import perf_counter from uuid import uuid4 from wred import App class RequestContext: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope['type'] != 'http': return await self.app(scope, receive, send) request_id = uuid4().hex started = perf_counter() scope = {**scope, 'state': {**scope.get('state', {}), 'request_id': request_id}} async def send_with_context(message): if message['type'] == 'http.response.start': elapsed_ms = (perf_counter() - started) * 1000 message = { **message, 'headers': [ *message.get('headers', []), (b'x-request-id', request_id.encode('ascii')), (b'server-timing', f'app;dur={elapsed_ms:.2f}'.encode('ascii')), ], } await send(message) await self.app(scope, receive, send_with_context) api = App() @api.route('/api/hello') async def hello(request): return {'request_id': request.scope['state']['request_id']} app = RequestContext(api) Terminal curl -i http://127.0.0.1:8000/api/hello The body’s request_id matches X-Request-ID . Server-Timing reports the elapsed milliseconds up to response start. Each request keeps its own variables inside __call__ ; shared instance attributes would invite concurrent requests to exchange identities. Keep the wrapper well behaved Pass through protocols you don’t handle. That lets a lifespan-aware outer app keep doing its job. Copy the scope when adding request-local data. Preserve existing state entries. Preserve existing response headers, including repeated Set-Cookie fields. If you modify the response body, update or remove Content-Length as appropriate. Don’t consume the request body unless you also arrange to replay it to the inner app. Our wrapper leaves the body alone. It also passes lifespan through rather than implementing it; wrapping a bare Wred app still leaves you with an HTTP-only app. The pure ASGI middleware guide describes this pattern. Middleware can also come from other packages: the interoperability is in the ASGI call, not in matching logos. Use Uvicorn’s proxy-header middleware with another server You can reuse Uvicorn’s ProxyHeadersMiddleware while serving with Weft. Install uvicorn in addition to the notebook’s dependencies, save this as proxy_app.py at the repository root, and run weft proxy_app:app --host 127.0.0.1 --lifespan on . proxy_app.py Download from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from example_app.app import create_app # Use only behind a trusted proxy connecting from IPv4 loopback. app = ProxyHeadersMiddleware(create_app(), trusted_hosts=['127.0.0.1']) It accepts forwarded scheme and client information only from the listed proxy peers. For this example, the reverse proxy must connect over IPv4 loopback. Keep the backend private; a client that reaches it directly from a trusted address can supply those headers too. This is an alternative to Weft’s --forwarded-allow-ips option used in the deployment chapters. Pick one owner for proxy-header processing. See the Uvicorn implementation . Authentication middleware can use the same scope Starlette’s authentication middleware places authentication information in scope[\"user\"] and scope[\"auth\"] . A Wred handler can read those keys directly. The middleware’s authentication backend and your authorization rules remain application choices. The notebook uses its own @protected decorator instead. That decorator adds request.user itself. Don’t assume the property exists in unrelated Wred apps, or that wrapping with authentication middleware automatically protects every route. ← Borrow Starlette’s middleware Serve a frontend and other responses → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"caddy.html","title":"Put Caddy in front","headings":[{"anchor":"backend","title":"Keep the Python listener private"},{"anchor":"caddyfile","title":"Give your domain a Caddyfile"},{"anchor":"validate","title":"Validate, reload, and check"},{"anchor":"local","title":"Try the proxy locally"},{"anchor":"boundaries","title":"Keep the responsibilities obvious"}],"text":"Put Caddy in front HTTPS, a reverse proxy, and a configuration file you can read before your coffee gets cold. Caddy is annoyingly good at this. On this page Keep the Python listener private Give your domain a Caddyfile Validate, reload, and check Try the proxy locally Keep the responsibilities obvious Keep the Python listener private These deployment chapters use the notebook at the site root, including its static files. Use a host with Caddy installed and your app dependencies in a virtualenv. First run the backend on IPv4 loopback: Terminal # Run from the repository root with the notebook dependencies installed. weft example_app.asgi:app --host 127.0.0.1 --port 8000 \\ --lifespan on --forwarded-allow-ips 127.0.0.1 Accept forwarded headers only from the loopback proxy. That gives the notebook the external HTTPS scheme for cookie flags and same-origin checks, and the actual client address for its rate limiter. Use your supervisor to keep this process running; the operations chapter includes systemd. Give your domain a Caddyfile Replace notes.example.com with your domain. Point its DNS records at this host and make ports 80 and 443 reachable. Keep Caddy’s certificate storage writable and persistent. With those prerequisites, automatic HTTPS handles certificate issuance, renewal, and HTTP redirects. Caddyfile Download notes.example.com { request_body { max_size 1MB } encode zstd gzip reverse_proxy 127.0.0.1:8000 } request_body caps uploads at about one megabyte. reverse_proxy preserves the request path and forwards the Host and proxy headers. The notebook still sees /api/notes , which is fortunate because that’s where we put it. Validate, reload, and check Save the site configuration in the Caddyfile used by your installation, commonly /etc/caddy/Caddyfile . Validate it before applying it: Terminal sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile sudo systemctl reload caddy curl -i https://notes.example.com/api/health Use the Caddy CLI reload workflow if your installation isn’t managed by systemd. Expect JSON 200 from health. Visit the site, log in, and check that the refresh cookie has Secure under HTTPS. A green health check cannot inspect your cookies on your behalf. Try the proxy locally For an HTTP-only local smoke check, replace the domain line with http://localhost:8080 , save as Caddyfile.local , and run: Terminal caddy run --config Caddyfile.local --adapter caddyfile Open http://localhost:8080/ . This explicitly disables automatic HTTPS for that local site. Use the domain configuration for public HTTPS. Keep the responsibilities obvious Caddy terminates TLS and limits request bodies. Weft runs the ASGI app. The notebook handles its database lifecycle and authorization. You still need a shared rate-limit strategy if you want one limit across workers; this Caddyfile does not add one. Choose whether compression belongs in Caddy or an application middleware layer. For streams, check proxy timeouts and disconnect behavior with a real client. For a separate frontend build, use explicit static and API routing while preserving the API prefix. ← Serve a frontend and other responses Put Nginx in front → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"configuration.html","title":"Configuration & architecture","headings":[{"anchor":"ownership","title":"Who configures what?"},{"anchor":"notebook-settings","title":"Notebook environment variables"},{"anchor":"recipe-settings","title":"Recipe-only settings"},{"anchor":"protocols","title":"Separate HTTP from lifespan"},{"anchor":"proxy-settings","title":"Configure forwarding at one layer"}],"text":"Configuration & architecture There is no Wred settings object to memorize. Configure the component that actually owns the behavior. A small victory for both readability and your remaining memory. On this page Who configures what? Notebook environment variables Recipe-only settings Separate HTTP from lifespan Configure forwarding at one layer Who configures what? Concern Owner and documentation Routes, methods, validation, error responses Your Wred application. See the API reference . Host, port, workers, reload, transport timeouts Your ASGI server. Wred has no server configuration or CLI. CORS, signed sessions, request context The middleware stack. See Starlette middleware and custom ASGI middleware . Database connections and credentials Your application and database client. The notebook’s settings are below. HTTPS, body limits, proxy routing Your edge server. See Caddy or Nginx . Process supervision and environment files Your deployment system. See operations . App() takes no settings and Wred reads no environment variables. A variable called WRED_DEBUG will do exactly what an unread environment variable usually does: occupy a small amount of memory. Notebook environment variables Variable Default Read by MONGODB_URI mongodb://127.0.0.1:27017 example_app.app.create_app() MONGODB_DATABASE wred_notebook example_app.app.create_app() WRED_TEST_MONGODB_URI Unset: database integration tests are skipped. tests/test_notebook.py ; not the running app. Explicit mongodb_uri and database_name arguments override the two app environment values. Passing database=... overrides both by supplying an application-owned database object. A notebook entry point in the repository root from example_app.app import create_app app = create_app( mongodb_uri='mongodb://127.0.0.1:27017', database_name='my_notebook', ) The factory and its environment variables belong to the example application, not the Wred package. Its wrapper calls the database’s async initialize() and close() methods through lifespan. Recipe-only settings Setting Used by Meaning SESSION_SECRET examples/session_app.py Required signing secret, shared by all workers. No built-in default. COOKIE_SECURE examples/session_app.py Defaults to 1 . Set 0 only for the local HTTP recipe. These two settings configure the standalone Starlette session example. The notebook’s refresh cookie uses the incoming ASGI scheme instead. The similar-looking cookies are under different management. Separate HTTP from lifespan Wred’s inner app handles HTTP. The notebook’s outer app handles startup and shutdown; Starlette can also be the outer lifecycle owner when composing apps. Pass unhandled protocols through middleware to the component that owns them. A bare Wred app should use your server’s unsupported-lifespan detection or an explicit lifespan-off setting. The notebook should run with lifespan enabled so database startup failures are visible. --lifespan on is shown in its Weft commands. Register routes before serving traffic. One worker’s in-memory objects do not become shared state merely because another worker was launched with the same command. Configure forwarding at one layer The deployment examples bind Weft to 127.0.0.1 and use --forwarded-allow-ips 127.0.0.1 . Their proxies preserve the public Host and supply the scheme and client address. If you use the alternative Uvicorn middleware recipe, let that wrapper own this processing instead. Correct scheme information matters for Secure cookies, redirects, and the notebook’s same-origin checks. A proxy configuration is part of application behavior, not a decorative YAML-adjacent accessory. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"data-and-auth.html","title":"Notes, accounts, and ownership","headings":[{"anchor":"request","title":"Follow one create request"},{"anchor":"api","title":"The notebook’s API"},{"anchor":"tokens","title":"Access tokens and refresh cookies"},{"anchor":"curl","title":"Try the auth flow with curl"},{"anchor":"ownership","title":"Check ownership where the data changes"},{"anchor":"limits","title":"Know what is local to a worker"}],"text":"Notes, accounts, and ownership The notebook’s auth is application code you can read. A radical alternative to discovering your security model through decorators with five undocumented arguments. On this page Follow one create request The notebook’s API Access tokens and refresh cookies Try the auth flow with curl Check ownership where the data changes Know what is local to a worker Follow one create request The browser posts JSON to /api/notes with an Authorization: Bearer … header. The @protected(database) decorator checks the token before the handler runs and sets request.user and request.login_id . read_note() rejects unknown fields and validates the title, body, and pinned flag. The handler generates an ID and timestamps, adds the owner from the authenticated user, and calls database.create_note() . A successful create returns JSON 201 with a Location header. The owner comes from the authenticated user, never from a client-supplied owner field. Otherwise, congratulations: you have built an impersonation API. example_app/notes.py · create route excerpt @app.route('/api/notes', methods=['POST']) @protected(database) async def create_note(request): try: data = await read_note(request) except ValueError as error: return {'error': str(error)}, 'application/json', 400 note_id = token_urlsafe(9) now = datetime.now(UTC).isoformat(timespec='microseconds') note = { 'id': note_id, 'owner': request.user['id'], **data, 'created_at': now, 'updated_at': now, } await database.create_note(note) return ( note, 'application/json', 201, [('Location', f'/api/notes/{note_id}')], ) The notebook’s API Method and path Behavior POST /api/signup Create an account; 201, or 409 for an existing username. POST /api/login Return an access token and set the refresh cookie. POST /api/refresh Use the refresh cookie to issue another access token. GET /api/me Return the authenticated user. POST /api/logout Revoke the login and expire its refresh cookie. GET / POST /api/notes List private notes or create a note. GET / PUT / PATCH / DELETE /api/notes/{note_id} Read, replace editable fields, partially update, or delete a note. GET /api/notes/export Download the authenticated user’s notes as JSON. The fixed /api/notes/export route wins over /api/notes/{note_id} , even though it is registered afterward. Listing accepts q , pinned=true|false , limit from 1 to 50, and a nonnegative offset . Access tokens and refresh cookies Passwords use PBKDF2-HMAC-SHA256 with a random salt and 600,000 iterations. Hashing runs through asyncio.to_thread() so the event loop can keep serving other requests. Unknown usernames still perform a dummy hash. Access and refresh tokens are random opaque strings. MongoDB stores SHA-256 digests of those tokens. Access tokens last one hour; the login’s refresh window lasts seven days. The database checks expiry during authentication instead of waiting for MongoDB’s TTL cleanup to get around to it. The browser keeps the access token in memory. The refresh token lives in an HttpOnly, SameSite=Strict cookie scoped to /api , with Secure added when the ASGI scheme is HTTPS. Refresh reuses the cookie while issuing new access tokens; it does not rotate the refresh token on each call. MongoDB stores each login and its access credentials in one document. Logout deletes that document, revoking the login’s access tokens as well. The browser’s renewLogin() shares one refresh promise across concurrent requests and refuses to replay an old account’s pending request under a newly logged-in account. Try the auth flow with curl With the notebook running, use a separate shell. The password here is demonstration data. Use something you haven’t published in a tutorial for your real account. Terminal curl -sS http://127.0.0.1:8000/api/signup \\ -H 'Content-Type: application/json' \\ -d '{\"username\":\"ada\",\"password\":\"a sufficiently long example password\"}' curl -sS -c notebook.cookies http://127.0.0.1:8000/api/login \\ -H 'Content-Type: application/json' \\ -H 'X-Notebook-Request: 1' \\ -d '{\"username\":\"ada\",\"password\":\"a sufficiently long example password\"}' Copy the access_token from the login JSON into a shell variable, then create and list notes: Terminal read -r -s NOTEBOOK_TOKEN # Paste the access token, then press Enter. curl -i http://127.0.0.1:8000/api/notes \\ -H \"Authorization: Bearer $NOTEBOOK_TOKEN\" \\ -H 'Content-Type: application/json' \\ -d '{\"title\":\"Ship it\",\"body\":\"After reading the deployment chapter.\",\"pinned\":true}' curl -sS 'http://127.0.0.1:8000/api/notes?limit=5&offset=0' \\ -H \"Authorization: Bearer $NOTEBOOK_TOKEN\" curl -sS -b notebook.cookies -X POST http://127.0.0.1:8000/api/refresh \\ -H 'X-Notebook-Request: 1' Login and refresh require X-Notebook-Request: 1 . Browser requests also face the app’s same-origin checks. A cookie jar contains credentials; keep it private and remove it when you finish. Check ownership where the data changes List and export operations filter by owner. Reading another user’s existing note returns 403; a missing ID returns 404. Updates and deletes include both the note ID and owner in the MongoDB write filter, so an earlier ownership check isn’t the sole line of defense. The application limits titles to 100 characters and bodies to 10,000 characters. PATCH preserves omitted fields; PUT requires a title and fills omitted body and pinned values with their defaults. Both reject fields outside title , body , and pinned . Know what is local to a worker The rate limiter stores timestamps in memory per application instance. A client can get three successful calls to the demo endpoint per ten-second window per worker . Multiple workers don’t magically share a Python dict, however enthusiastically you deploy them. Database-backed sessions survive app restarts; rate-limit buckets do not. For shared throttling, use a gateway or a shared store. Before deploying an adapted auth system, decide how you will handle account recovery, token abuse, CSRF, and operational monitoring. The notebook demonstrates a design; it isn’t an identity provider hiding in a trench coat. ← Run the notebook Borrow Starlette’s middleware → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"development.html","title":"Development & contributing","headings":[{"anchor":"setup","title":"Set up a checkout"},{"anchor":"checks","title":"Run the existing checks"},{"anchor":"changes","title":"Keep changes reviewable"},{"anchor":"docs","title":"Edit and preview these docs"},{"anchor":"reporting","title":"Report a bug with a reproduction"},{"anchor":"license","title":"License"}],"text":"Development & contributing Make a small change, demonstrate the behavior, and explain it clearly. The framework is tiny; the pull request does not need a cinematic universe. On this page Set up a checkout Run the existing checks Keep changes reviewable Edit and preview these docs Report a bug with a reproduction License Set up a checkout Terminal git clone https://gitman.io/git/patx/wred cd wred python -m venv .venv . .venv/bin/activate python -m pip install -e '.[server]' pymongo servestatic For the core framework alone, python -m pip install -e . installs Wred and orjson. Starlette and itsdangerous are only needed for the tutorial recipes using them. Run the existing checks The repository uses Python’s unittest runner. From the repository root: Terminal python -m unittest tests.test_wred python -m unittest tests.test_example_app python -m unittest discover -s tests The core suite exercises request parsing, routes, responses, mounted paths, and protocol boundaries. The example-app suite needs PyMongo and ServeStatic installed. The database integration suite also imports those dependencies and is skipped unless its MongoDB URI is set. Use a disposable MongoDB instance for integration checks: Terminal WRED_TEST_MONGODB_URI='mongodb://127.0.0.1:27017' python -m unittest tests.test_notebook The integration suite creates uniquely named databases beginning with wred_test_ and drops them during cleanup. The supplied credentials need permission for that work. Use a test database server, not production with a hopeful expression. Keep changes reviewable Start with the observable behavior you want to change. Keep Wred’s core focused on HTTP routing and responses; middleware and application modules are good homes for optional behavior. Explain any API or compatibility changes in the accompanying docs. When adding a regression check, exercise the actual affected boundary and describe what would fail without the fix. Prefer a small meaningful case to a second copy of the implementation dressed as a test. Edit and preview these docs The documentation is plain HTML with shared CSS and JavaScript in docs/tutorial/ . There is no framework build. Serve the docs directory to browse it locally: Terminal python -m http.server 8001 --bind 127.0.0.1 --directory docs Open http://127.0.0.1:8001/tutorial/ . The downloadable frontend example needs its ASGI server for its API; serving the documentation itself does not start that application. After changing page text or adding a page, rebuild the offline search index: Terminal python docs/tutorial/build_search.py node --check docs/tutorial/tutorial.js git diff --check Pages and navigation still work without JavaScript. Code-copy buttons and full-text search use the shared script; the search index is a local JavaScript file, so it also works without fetching a service. Report a bug with a reproduction Include the Wred and Python versions, the ASGI server and relevant options, a minimal app, the request you sent, the expected and actual responses, and the traceback if there is one. For proxy issues, say whether the same request works against the backend directly. Remove passwords, tokens, cookies, database credentials, and personal data from examples. Use the project repository for ordinary bug reports and contributions. See deployment boundaries for vulnerability-reporting guidance. License Wred is distributed under the BSD 3-Clause license. The complete terms are in the repository’s LICENSE file; retain that file and the applicable notices when redistributing. This paragraph is a signpost to the license, not a replacement for it. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"errors.html","title":"Errors & troubleshooting","headings":[{"anchor":"exceptions","title":"Exceptions Wred can raise"},{"anchor":"expected-errors","title":"Return expected errors explicitly"},{"anchor":"statuses","title":"Where HTTP statuses come from"},{"anchor":"debugging","title":"A short debugging sequence"},{"anchor":"streaming","title":"After a response has started"}],"text":"Errors & troubleshooting An exception and an HTTP error response are different things. Your logs would appreciate being invited to this distinction. On this page Exceptions Wred can raise Return expected errors explicitly Where HTTP statuses come from A short debugging sequence After a response has started Exceptions Wred can raise Exception Typical trigger What to do orjson.JSONDecodeError Invalid JSON in await request.json() . Catch at the input boundary and return a 400 response. UnicodeDecodeError Invalid UTF-8 in the raw or percent-decoded query string. Reject the query with 400 if it is part of your public input handling. ConnectionError A body read receives a disconnect or another non-request event. Handle cancellation/disconnection where relevant; do not assume a response can still reach the client. TypeError An unsupported response body, or a serialization/type error. Return bytes, a dict/list, a supported tuple, or a callable ASGI response. ValueError For example, unpacking a tuple with fewer than three response fields. Use exactly the documented three- or four-item response tuple. UnicodeEncodeError A custom header name is not ASCII or a value cannot be encoded as Latin-1. Use HTTP-compatible headers and encode internationalized values with the appropriate header format. RuntimeError A non-HTTP scope reaches a bare Wred app. Route that protocol to an outer app or configure lifespan appropriately. Wred lets these exceptions propagate. Your middleware or ASGI server decides how to log and respond to an unhandled exception. Do not depend on a bare Wred app to generate a JSON 500 response. Return expected errors explicitly Add to an existing app import orjson @app.route('/api/items', methods=['POST']) async def create_item(request): try: data = await request.json() except orjson.JSONDecodeError: return {'error': 'Invalid JSON'}, 'application/json', 400 if not isinstance(data, dict): return {'error': 'Send a JSON object'}, 'application/json', 400 return {'accepted': True} Catch the exception you expect at the boundary you understand. A blanket except Exception: return {\"ok\": True} makes dashboards look lovely and everything else substantially worse. Where HTTP statuses come from Status Wred behavior 200 Default for supported handler return values. 400 / 401 / 403 / 409 / 429 Application or middleware decisions. The notebook provides examples. 404 Wred’s fallback for an unmatched path or method. 405 Not generated automatically. Register a handler or use another routing layer if you need it. 500 Unhandled-error behavior is supplied by the server or middleware. See the error middleware example . HTTP status is part of the response, not a declaration made by throwing a Starlette exception. Wred does not install Starlette’s exception handlers for you. A short debugging sequence Call the backend directly from its host and inspect the exact status, headers, and body with curl -i . Confirm the server imports the wrapper you intended, not just the inner Wred instance. Check the route path and method, then the proxy or mount prefix. Inspect the server traceback. Turn off public debug tracebacks before deployment. Repeat through the public proxy to isolate scheme, Host, cookie, and forwarding issues. The operations troubleshooting table covers 502s, database startup failures, login problems, and buffered streams. After a response has started A callable response owns its send operations. If it raises after sending headers or part of a stream, Wred propagates the exception without attempting a second response. Validate inputs and permissions before sending the first message. An error handler cannot change a status code already sent to the client. Time travel remains an optional dependency that nobody has successfully published. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"faq.html","title":"Frequently asked questions","headings":[{"anchor":"scope","title":"What does Wred deliberately leave out?"},{"anchor":"server","title":"Do I have to use Weft?"},{"anchor":"requests","title":"Can I use Starlette’s Request APIs on a Wred request?"},{"anchor":"methods","title":"Why does POST or HEAD return 404?"},{"anchor":"responses","title":"Why can I parse JSON null but not return None?"},{"anchor":"forms","title":"How do I handle forms or uploaded files?"},{"anchor":"lifespan","title":"Where do startup tasks and database cleanup go?"},{"anchor":"streams","title":"Can Wred stream or handle WebSockets?"},{"anchor":"concurrency","title":"Can handlers call blocking code or launch background jobs?"},{"anchor":"limits","title":"Are the notebook’s rate limits global?"},{"anchor":"performance","title":"Will my app get the README’s benchmark numbers?"}],"text":"Frequently asked questions The questions that usually arrive just after the first successful curl request, when optimism starts acquiring requirements. On this page What does Wred deliberately leave out? Do I have to use Weft? Can I use Starlette’s Request APIs on a Wred request? Why does POST or HEAD return 404? Why can I parse JSON null but not return None? How do I handle forms or uploaded files? Where do startup tasks and database cleanup go? Can Wred stream or handle WebSockets? Can handlers call blocking code or launch background jobs? Are the notebook’s rate limits global? Will my app get the README’s benchmark numbers? What does Wred deliberately leave out? Authentication, sessions, database integration, schemas, templates, multipart/form parsing, CORS, CSRF protection, dependency injection, generated API docs, background jobs, WebSockets, and built-in static or streaming responses. The tutorial shows how to compose several of these from application code and ASGI libraries. The API reference describes what the core does. A feature’s absence is permission to choose a component, not an invitation to pretend the feature is unnecessary. Do I have to use Weft? No. wred[server] is a convenience extra for Weft. Install the base package and your preferred ASGI server if you want something else. Wred is ASGI, not a WSGI application; use the correct interface when a server supports both. Can I use Starlette’s Request APIs on a Wred request? Wrapping an application with Starlette middleware does not replace the request object Wred creates. Read middleware data through request.scope , or add your own application adapter. See Request’s attributes . request.query is a synchronous cached property. request.body() and request.json() must be awaited. Python will not reinterpret this distinction as a stylistic preference. Why does POST or HEAD return 404? A route defaults to GET. Register methods explicitly. Wred uses the same 404 fallback for unknown paths and unsupported methods; it does not supply automatic HEAD, OPTIONS, or 405 behavior. CORS middleware can handle browser preflights. Why can I parse JSON null but not return None? Input parsing accepts any JSON value. Wred’s direct response handling supports dicts, lists, and bytes, plus the documented tuple forms. Return a callable JSON response object when you need a JSON scalar, or return b\"\", \"application/json\", 204 for no content. How do I handle forms or uploaded files? Wred has no form or multipart parser. Choose a parser or ASGI component deliberately, install its dependencies, and arrange who reads the receive stream. A second consumer cannot reread events already consumed by the first. Enforce body and file limits before buffering uploads. Where do startup tasks and database cleanup go? In a lifespan-aware outer ASGI app. The notebook’s NotebookApp initializes and closes its database there. A bare Wred instance rejects lifespan. See the actual wrapper and protocol configuration . Can Wred stream or handle WebSockets? A handler can return a callable ASGI response such as Starlette’s StreamingResponse for HTTP streaming or SSE. WebSocket scopes are rejected by Wred itself; route them to a separate ASGI app with an outer router if you need them. Can handlers call blocking code or launch background jobs? Handlers are awaited on the event loop. Calling a blocking database client or doing expensive CPU work directly there blocks other requests in that worker. Use an async client, an appropriate thread for blocking I/O, or separate workers for expensive work. A detached task is not a durable job queue. If work must survive a restart, use a durable queue and workers that own it. The operating system is unimpressed by how important your untracked task felt. Are the notebook’s rate limits global? No. They are per app instance and per worker. Database sessions are persistent; the in-memory timestamp buckets are not. Use shared application storage or an edge limiter for a shared policy. See the Nginx example . Will my app get the README’s benchmark numbers? Those numbers describe one workload on one machine with a specific server configuration. Database calls, passwords, middleware, payload size, concurrency, and client behavior all change throughput and latency. Measure the work your application actually does. A hello-world benchmark remains impressively good at saying hello. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"first-api.html","title":"Your first API","headings":[{"anchor":"install","title":"Make a small project"},{"anchor":"routes","title":"Give it some routes"},{"anchor":"run","title":"Run it and ask it something"},{"anchor":"validation","title":"Accept JSON without accepting absolutely anything"},{"anchor":"response-shapes","title":"Choose the response you mean"}],"text":"Your first API Install a server, return a dict, and make a request. This is the part where a larger framework might ask you to generate a project, three directories, and a personal philosophy. On this page Make a small project Give it some routes Run it and ask it something Accept JSON without accepting absolutely anything Choose the response you mean Make a small project Start in a new directory and install Wred 0.10 or newer. The server extra installs Weft. Terminal mkdir hello-wred cd hello-wred python -m venv .venv . .venv/bin/activate python -m pip install 'wred[server]>=0.10' Give it some routes Save this as first_api.py . An async handler receives a Wred request. Returning a dict or list produces JSON with status 200. first_api.py Download from wred import App app = App() @app.route('/') async def hello(request): return {'hello': 'world'} @app.route('/api/health') async def health(request): return {'status': 'ok'} @app.route('/api/hello/{name}') async def greet(request, name): return {'hello': name, 'language': request.query.get('lang', 'en')} name is a string, passed by keyword. request.query is a synchronous cached property and returns strings too. Wred will not infer that a query parameter is an integer because you looked at it meaningfully. Methods default to GET. Fixed paths win over parameterized paths for the same method; trailing slashes are ignored. Unknown paths and unsupported methods return JSON 404. HEAD, OPTIONS, and 405 handling aren’t added automatically. Run it and ask it something Terminal weft first_api:app Leave the server running. In a second terminal: Terminal curl -sS http://127.0.0.1:8000/ # {\"hello\":\"world\"} curl -sS 'http://127.0.0.1:8000/api/hello/Ada?lang=en' # {\"hello\":\"Ada\",\"language\":\"en\"} The Python module is first_api ; the exported ASGI application is app . That’s what the colon means. We have now exhausted the mystery of first_api:app . Accept JSON without accepting absolutely anything Save the next example as validation.py , stop the first server, and run weft validation:app . This endpoint echoes a validated title; it does not store anything. validation.py Download import orjson from wred import App app = App() @app.route('/api/echo', methods=['POST']) async def echo(request): try: data = await request.json() except orjson.JSONDecodeError: return {'error': 'Invalid JSON'}, 'application/json', 400 if not isinstance(data, dict): return {'error': 'Send a JSON object'}, 'application/json', 400 title = data.get('title') if not isinstance(title, str) or not 1 <= len(title.strip()) <= 100: return {'error': 'Title must contain 1–100 characters'}, 'application/json', 400 return {'title': title.strip()} Terminal curl -i http://127.0.0.1:8000/api/echo \\ -H 'Content-Type: application/json' \\ -d '{\"title\":\"My first note\"}' The response body is {\"title\":\"My first note\"} . A JSON array, missing title, or invalid JSON gets 400. Schema validation is your application’s decision; a few explicit checks are perfectly respectable. Choose the response you mean Return value Result {\"ok\": True} JSON, 200 {\"id\": \"n1\"}, \"application/json\", 201 JSON, 201 b\"\", \"text/plain\", 303, [(\"Location\", \"/\")] Redirect b\"\", \"application/json\", 204 Empty response A callable ASGI response The response object sends its own messages Custom tuple bodies can be bytes, dicts, or lists. Dicts and lists use JSON even if you give them another content type. Exceptions propagate to the server or your middleware; Wred doesn’t convert them into a reassuringly vague success. ← Build something with Wred. Run the notebook → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"frontend.html","title":"Serve a frontend and other responses","headings":[{"anchor":"same-origin","title":"Start with one origin"},{"anchor":"router","title":"Use Starlette for the outer routing"},{"anchor":"servestatic","title":"Or use the notebook’s ServeStatic wrapper"},{"anchor":"responses","title":"Return a file or stream"},{"anchor":"browser","title":"Borrow the notebook’s fetch habits"}],"text":"Serve a frontend and other responses Your JavaScript needs somewhere to live. Your API needs somewhere to return JSON. They can share an origin without sharing an existential crisis. On this page Start with one origin Use Starlette for the outer routing Or use the notebook’s ServeStatic wrapper Return a file or stream Borrow the notebook’s fetch habits Start with one origin The notebook serves its frontend and API together, so browser code can use fetch(\"/api/notes\") and same-origin cookies. A frontend on port 5173 and an API on port 8000 are different origins, even on the same machine. During development, use your frontend tool’s API proxy or configure a deliberately cross-origin API. The notebook’s auth checks expect the same-origin arrangement. Opening an HTML file through file:// is not an ingenious CORS workaround. Use Starlette for the outer routing Install starlette . Save this as frontend_app.py and create a sibling frontend/ directory containing the HTML below. Run weft frontend_app:app , then open http://127.0.0.1:8000/ . frontend_app.py Download from pathlib import Path from starlette.applications import Starlette from starlette.routing import Mount from starlette.staticfiles import StaticFiles from wred import App api = App() @api.route('/hello') async def hello(request): return {'hello': 'world'} app = Starlette(routes=[ Mount('/api', app=api), Mount('/', app=StaticFiles(directory=Path(__file__).with_name('frontend'), html=True)), ]) frontend/index.html Download <!doctype html> <html lang=\"en\"> <head> <meta charset=\"utf-8\"> <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> <title>Wred frontend example</title> </head> <body> <h1>Hello from Wred</h1> <p id=\"result\" role=\"status\">Loading…</p> <script> async function load() { const result = document.getElementById('result'); try { const response = await fetch('/api/hello'); if (!response.ok) throw new Error(`HTTP ${response.status}`); result.textContent = JSON.stringify(await response.json()); } catch (error) { result.textContent = `Could not load the API: ${error.message}`; } } load(); </script> </body> </html> The outer Starlette router owns composition and lifespan. Wred registers /hello ; the mount makes it /api/hello . Wred respects the ASGI root path. The final / mount catches static paths, so keep it last. StaticFiles handles the directory. Its HTML mode can serve an index; it does not automatically make every unknown URL an SPA route. Configure an explicit fallback if your client router needs one. Or use the notebook’s ServeStatic wrapper The notebook already wraps its Wred app with ServeStaticASGI in example_app/app.py . You don’t need to add the Starlette router above to run that example. Both approaches delegate static files to a library built for them. For a built frontend, Caddy or Nginx can serve the build directory and proxy /api/ to Python. Preserve the prefix when the Wred routes already include /api . Strip it only when the app is intentionally mounted with matching root-path handling. Random slash removal is a surprisingly effective 404 generator. Return a file or stream Wred also accepts callable ASGI response objects. This independent sample uses Starlette’s FileResponse and StreamingResponse . Save it as responses_app.py and place the sample notes.json in a sibling data/ directory. responses_app.py Download import asyncio from pathlib import Path from starlette.responses import FileResponse, StreamingResponse from wred import App app = App() @app.route('/download') async def download(request): return FileResponse( Path(__file__).with_name('data') / 'notes.json', filename='notes.json', media_type='application/json', ) @app.route('/events') async def events(request): async def messages(): for number in range(5): yield f'data: {number}\\n\\n' await asyncio.sleep(1) return StreamingResponse( messages(), media_type='text/event-stream', headers={'Cache-Control': 'no-cache'}, ) Terminal weft responses_app:app From another terminal: Terminal curl -i http://127.0.0.1:8000/download curl -N http://127.0.0.1:8000/events FileResponse streams the known file and sets download headers. The events route sends five SSE messages. curl -N disables client output buffering. Authenticate and validate before starting a response; once headers are sent, an exception can’t retroactively turn it into a JSON 400. See the Starlette response reference . Keep large downloads and long-lived streams in mind when choosing proxy buffering and timeout settings. Borrow the notebook’s fetch habits example_app/static/app.js has one fetch helper for JSON, auth headers, refresh, errors, and empty 204 responses. It checks response.ok : fetch resolves normally for HTTP 400 and 500, which is considerate of HTTP and occasionally inconsiderate of your expectations. Render user text with textContent , keep credentials out of URLs, and handle empty responses before trying to parse JSON. The notebook also tracks request sequences so a slow search result cannot overwrite a newer one. ← Write a little ASGI middleware Put Caddy in front → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"index.html","title":"Wred documentation","headings":[{"anchor":"reference","title":"Look something up"},{"anchor":"what-you-build","title":"The app we’re actually building"},{"anchor":"before-starting","title":"Bring these things"},{"anchor":"chapters","title":"Work through the tutorial"},{"anchor":"using-examples","title":"Copy the bits you need"}],"text":"Wred documentation Build an app, look up an API, or find out which component owns the configuration. A tutorial and reference for a framework that still fits in one Python file. These docs cover Wred 0.10, where request.query is a cached property. Upgrading from 0.9? Remove the parentheses; see migration notes . Look something up Following a lesson is useful. Finding the exact method signature without rereading a lesson is also useful. We have bravely decided to provide both. Installation & quick start — Python requirements, extras, servers, and editable installs. API reference — App, routes, Request, response formats, and ASGI helpers. Configuration & architecture — settings ownership and the note

[Diff truncated. Use the raw view or local clone for the full content.]