diff --git a/.devcontainer/building-comfort/devcontainer-lock.json b/.devcontainer/building-comfort/devcontainer-lock.json
new file mode 100644
index 0000000..895e5a2
--- /dev/null
+++ b/.devcontainer/building-comfort/devcontainer-lock.json
@@ -0,0 +1,24 @@
+{
+ "features": {
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "version": "2.5.9",
+ "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a",
+ "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a"
+ },
+ "ghcr.io/devcontainers/features/docker-in-docker:2": {
+ "version": "2.17.0",
+ "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c",
+ "integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c"
+ },
+ "ghcr.io/devcontainers/features/git:1": {
+ "version": "1.3.8",
+ "resolved": "ghcr.io/devcontainers/features/git@sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2",
+ "integrity": "sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2"
+ },
+ "ghcr.io/devcontainers/features/python:1": {
+ "version": "1.8.0",
+ "resolved": "ghcr.io/devcontainers/features/python@sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511",
+ "integrity": "sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511"
+ }
+ }
+}
diff --git a/.devcontainer/building-comfort/devcontainer.json b/.devcontainer/building-comfort/devcontainer.json
new file mode 100644
index 0000000..349dab7
--- /dev/null
+++ b/.devcontainer/building-comfort/devcontainer.json
@@ -0,0 +1,60 @@
+{
+ "name": "Building Comfort Tutorial (Python)",
+ "image": "mcr.microsoft.com/devcontainers/base:bookworm@sha256:9e35ebf156350ba25b117da6869907034b6e2437b1891e3a43395af0b7d3eda4",
+ "features": {
+ "ghcr.io/devcontainers/features/docker-in-docker:2": {
+ "version": "29",
+ "moby": false
+ },
+ "ghcr.io/devcontainers/features/python:1": {
+ "version": "3.12",
+ "installTools": true
+ },
+ "ghcr.io/devcontainers/features/git:1": {},
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "installZsh": true,
+ "configureZshAsDefaultShell": true
+ }
+ },
+ "postCreateCommand": "bash ../../.devcontainer/building-comfort/post-create.sh",
+ "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/tutorials/building-comfort",
+ "runArgs": [
+ "--privileged",
+ "--init",
+ "--ipc=host"
+ ],
+ "forwardPorts": [
+ 8501,
+ 5732
+ ],
+ "portsAttributes": {
+ "8501": {
+ "label": "Streamlit UI",
+ "onAutoForward": "notify"
+ },
+ "5732": {
+ "label": "PostgreSQL",
+ "onAutoForward": "silent"
+ }
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "ms-python.python"
+ ],
+ "settings": {
+ "editor.formatOnSave": true
+ }
+ }
+ },
+ "remoteUser": "vscode",
+ "remoteEnv": {
+ "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}"
+ },
+ "containerEnv": {
+ "POSTGRES_HOST": "localhost",
+ "POSTGRES_PORT": "5732",
+ "POSTGRES_HOST_PORT": "5732",
+ "STREAMLIT_PORT": "8501"
+ }
+}
diff --git a/.devcontainer/building-comfort/post-create.sh b/.devcontainer/building-comfort/post-create.sh
new file mode 100755
index 0000000..fd64fb1
--- /dev/null
+++ b/.devcontainer/building-comfort/post-create.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+# Post-create script for the Building Comfort tutorial (Python).
+
+set -e
+
+echo "🔧 Initializing the Building Comfort (Python) tutorial environment..."
+
+# Resolve the tutorial directory from this script's location so the script works
+# regardless of the current working directory.
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+TUTORIAL_DIR="$REPO_ROOT/tutorials/building-comfort"
+
+# Install the tutorial's Python dependencies (drasi-lib, streamlit, psycopg).
+# The devcontainer's Python feature provides a pip that is not externally
+# managed, so a plain install works and puts `streamlit` on PATH.
+echo "🐍 Installing Python dependencies..."
+cd "$TUTORIAL_DIR"
+python -m pip install --upgrade pip
+python -m pip install -r requirements.txt
+
+echo ""
+echo "✅ Building Comfort (Python) tutorial environment is ready!"
+echo " Next: run 'bash scripts/start-demo.sh' (you are already in tutorials/building-comfort)"
diff --git a/.devcontainer/curbside-pickup/devcontainer-lock.json b/.devcontainer/curbside-pickup/devcontainer-lock.json
new file mode 100644
index 0000000..895e5a2
--- /dev/null
+++ b/.devcontainer/curbside-pickup/devcontainer-lock.json
@@ -0,0 +1,24 @@
+{
+ "features": {
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "version": "2.5.9",
+ "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a",
+ "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a"
+ },
+ "ghcr.io/devcontainers/features/docker-in-docker:2": {
+ "version": "2.17.0",
+ "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c",
+ "integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c"
+ },
+ "ghcr.io/devcontainers/features/git:1": {
+ "version": "1.3.8",
+ "resolved": "ghcr.io/devcontainers/features/git@sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2",
+ "integrity": "sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2"
+ },
+ "ghcr.io/devcontainers/features/python:1": {
+ "version": "1.8.0",
+ "resolved": "ghcr.io/devcontainers/features/python@sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511",
+ "integrity": "sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511"
+ }
+ }
+}
diff --git a/.devcontainer/curbside-pickup/devcontainer.json b/.devcontainer/curbside-pickup/devcontainer.json
new file mode 100644
index 0000000..cd95247
--- /dev/null
+++ b/.devcontainer/curbside-pickup/devcontainer.json
@@ -0,0 +1,68 @@
+{
+ "name": "Curbside Pickup Tutorial (Python)",
+ "image": "mcr.microsoft.com/devcontainers/base:bookworm@sha256:9e35ebf156350ba25b117da6869907034b6e2437b1891e3a43395af0b7d3eda4",
+ "features": {
+ "ghcr.io/devcontainers/features/docker-in-docker:2": {
+ "version": "29",
+ "moby": false
+ },
+ "ghcr.io/devcontainers/features/python:1": {
+ "version": "3.12",
+ "installTools": true
+ },
+ "ghcr.io/devcontainers/features/git:1": {},
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "installZsh": true,
+ "configureZshAsDefaultShell": true
+ }
+ },
+ "postCreateCommand": "bash ../../.devcontainer/curbside-pickup/post-create.sh",
+ "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/tutorials/curbside-pickup",
+ "runArgs": [
+ "--privileged",
+ "--init",
+ "--ipc=host"
+ ],
+ "forwardPorts": [
+ 8501,
+ 5742,
+ 3309
+ ],
+ "portsAttributes": {
+ "8501": {
+ "label": "Streamlit UI",
+ "onAutoForward": "notify"
+ },
+ "5742": {
+ "label": "PostgreSQL (orders)",
+ "onAutoForward": "silent"
+ },
+ "3309": {
+ "label": "MySQL (vehicles)",
+ "onAutoForward": "silent"
+ }
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "ms-python.python"
+ ],
+ "settings": {
+ "editor.formatOnSave": true
+ }
+ }
+ },
+ "remoteUser": "vscode",
+ "remoteEnv": {
+ "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}"
+ },
+ "containerEnv": {
+ "POSTGRES_HOST": "localhost",
+ "POSTGRES_PORT": "5742",
+ "POSTGRES_HOST_PORT": "5742",
+ "MYSQL_HOST": "localhost",
+ "MYSQL_PORT": "3309",
+ "MYSQL_HOST_PORT": "3309",
+ "STREAMLIT_PORT": "8501"
+ }
+}
diff --git a/.devcontainer/curbside-pickup/post-create.sh b/.devcontainer/curbside-pickup/post-create.sh
new file mode 100755
index 0000000..3dac6b6
--- /dev/null
+++ b/.devcontainer/curbside-pickup/post-create.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+# Post-create script for the Curbside Pickup tutorial (Python).
+
+set -e
+
+echo "🔧 Initializing the Curbside Pickup (Python) tutorial environment..."
+
+# Resolve the tutorial directory from this script's location so the script works
+# regardless of the current working directory.
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+TUTORIAL_DIR="$REPO_ROOT/tutorials/curbside-pickup"
+
+# Install the tutorial's Python dependencies (drasi-lib, streamlit, psycopg,
+# PyMySQL). The devcontainer's Python feature provides a pip that is not
+# externally managed, so a plain install works and puts `streamlit` on PATH.
+echo "🐍 Installing Python dependencies..."
+cd "$TUTORIAL_DIR"
+python -m pip install --upgrade pip
+python -m pip install -r requirements.txt
+
+echo ""
+echo "✅ Curbside Pickup (Python) tutorial environment is ready!"
+echo " Next: run 'bash scripts/start-demo.sh' (you are already in tutorials/curbside-pickup)"
diff --git a/.devcontainer/getting-started/devcontainer-lock.json b/.devcontainer/getting-started/devcontainer-lock.json
new file mode 100644
index 0000000..895e5a2
--- /dev/null
+++ b/.devcontainer/getting-started/devcontainer-lock.json
@@ -0,0 +1,24 @@
+{
+ "features": {
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "version": "2.5.9",
+ "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a",
+ "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a"
+ },
+ "ghcr.io/devcontainers/features/docker-in-docker:2": {
+ "version": "2.17.0",
+ "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c",
+ "integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c"
+ },
+ "ghcr.io/devcontainers/features/git:1": {
+ "version": "1.3.8",
+ "resolved": "ghcr.io/devcontainers/features/git@sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2",
+ "integrity": "sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2"
+ },
+ "ghcr.io/devcontainers/features/python:1": {
+ "version": "1.8.0",
+ "resolved": "ghcr.io/devcontainers/features/python@sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511",
+ "integrity": "sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511"
+ }
+ }
+}
diff --git a/.devcontainer/getting-started/devcontainer.json b/.devcontainer/getting-started/devcontainer.json
new file mode 100644
index 0000000..801b278
--- /dev/null
+++ b/.devcontainer/getting-started/devcontainer.json
@@ -0,0 +1,54 @@
+{
+ "name": "Getting Started Tutorial (Python)",
+ "image": "mcr.microsoft.com/devcontainers/base:bookworm@sha256:9e35ebf156350ba25b117da6869907034b6e2437b1891e3a43395af0b7d3eda4",
+ "features": {
+ "ghcr.io/devcontainers/features/docker-in-docker:2": {
+ "version": "29",
+ "moby": false
+ },
+ "ghcr.io/devcontainers/features/python:1": {
+ "version": "3.12",
+ "installTools": true
+ },
+ "ghcr.io/devcontainers/features/git:1": {},
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "installZsh": true,
+ "configureZshAsDefaultShell": true
+ }
+ },
+ "postCreateCommand": "bash ../../.devcontainer/getting-started/post-create.sh",
+ "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/tutorials/getting-started",
+ "runArgs": [
+ "--privileged",
+ "--init",
+ "--ipc=host"
+ ],
+ "forwardPorts": [
+ 5752
+ ],
+ "portsAttributes": {
+ "5752": {
+ "label": "PostgreSQL",
+ "onAutoForward": "silent"
+ }
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "ms-python.python"
+ ],
+ "settings": {
+ "editor.formatOnSave": true
+ }
+ }
+ },
+ "remoteUser": "vscode",
+ "remoteEnv": {
+ "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}"
+ },
+ "containerEnv": {
+ "POSTGRES_HOST": "localhost",
+ "POSTGRES_PORT": "5752",
+ "POSTGRES_HOST_PORT": "5752"
+ }
+}
diff --git a/.devcontainer/getting-started/post-create.sh b/.devcontainer/getting-started/post-create.sh
new file mode 100755
index 0000000..701dc00
--- /dev/null
+++ b/.devcontainer/getting-started/post-create.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+# Post-create script for the Getting Started tutorial (Python).
+
+set -e
+
+echo "🔧 Initializing the Getting Started (Python) tutorial environment..."
+
+# Resolve the tutorial directory from this script's location so the script works
+# regardless of the current working directory.
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+TUTORIAL_DIR="$REPO_ROOT/tutorials/getting-started"
+
+# Install the tutorial's Python dependency (drasi-lib). The devcontainer's Python
+# feature provides a pip that is not externally managed, so a plain install works.
+echo "🐍 Installing Python dependencies..."
+cd "$TUTORIAL_DIR"
+python -m pip install --upgrade pip
+python -m pip install -r requirements.txt
+
+echo ""
+echo "✅ Getting Started (Python) tutorial environment is ready!"
+echo " Next: run 'bash scripts/start-demo.sh' (you are already in tutorials/getting-started)"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 11f753f..537437e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -71,6 +71,9 @@ jobs:
- name: Python formatting
run: .venv/bin/ruff format --check .
+ - name: Tutorial READMEs are current
+ run: python3 scripts/render-tutorials.py --check
+
# This went unrun for long enough to accumulate 1653 errors from a single
# misconfiguration, which is exactly the kind of rot a gate is meant to
# prevent. pyright resolves `drasi` from the source tree, so the
diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml
index 9774484..cb4bd6c 100644
--- a/.github/workflows/website.yml
+++ b/.github/workflows/website.yml
@@ -12,11 +12,13 @@ on:
branches: [main]
paths:
- "website/**"
+ - "tutorials/**"
- ".github/workflows/website.yml"
pull_request:
branches: [main]
paths:
- "website/**"
+ - "tutorials/**"
- ".github/workflows/website.yml"
# Allow the deploy job to publish to GitHub Pages via OIDC.
diff --git a/README.md b/README.md
index d6d5422..34f5728 100644
--- a/README.md
+++ b/README.md
@@ -193,6 +193,14 @@ make venv && make develop
.venv/bin/python examples/python_source.py
```
+## Tutorials
+
+Longer, hands-on walkthroughs live in [`tutorials/`](https://github.com/drasi-project/drasi-python/tree/main/tutorials).
+The [Building Comfort](https://github.com/drasi-project/drasi-python/tree/main/tutorials/building-comfort)
+tutorial builds a smart-building monitor from PostgreSQL CDC, six continuous
+queries with synthetic joins, and a Python reaction that drives a live Streamlit
+UI. The tutorials also render on the [documentation site](https://drasi-project.github.io/drasi-python/docs/tutorials/).
+
## Development
```bash
diff --git a/scripts/render-tutorials.py b/scripts/render-tutorials.py
new file mode 100644
index 0000000..838ce77
--- /dev/null
+++ b/scripts/render-tutorials.py
@@ -0,0 +1,268 @@
+#!/usr/bin/env python3
+# Copyright 2026 The Drasi Authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Render Docsy/Hugo tutorial sources into plain Markdown READMEs.
+
+Each tutorial is authored once in an ``_index.md`` that may use Docsy/Hugo
+shortcodes (``{{< tabpane >}}``, ``{{% alert %}}``, ...). The doc site consumes
+``_index.md`` directly (mounted into the Hugo content tree) so the tab widgets
+and styled alerts render. GitHub and plain Markdown viewers cannot process
+shortcodes, so this script generates a sibling ``README.md`` with the shortcodes
+converted to equivalent plain Markdown.
+
+Source of truth: tutorials//_index.md (shortcodes, used by doc site)
+Generated output: tutorials//README.md (plain Markdown, shown on GitHub)
+
+Usage:
+ python3 scripts/render-tutorials.py # write README.md files
+ python3 scripts/render-tutorials.py --check # fail if any are stale
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+TUTORIALS_DIR = REPO_ROOT / "tutorials"
+
+GENERATED_BANNER = (
+ "\n\n"
+)
+
+_ATTR_RE = re.compile(r'(\w+)\s*=\s*"([^"]*)"')
+
+_FRONT_MATTER_RE = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)
+
+_TABPANE_RE = re.compile(
+ r"\{\{<\s*tabpane[^}]*>\}\}(?P.*?)\{\{<\s*/\s*tabpane\s*>\}\}",
+ re.DOTALL,
+)
+_CODE_TAB_RE = re.compile(
+ r"\{\{<\s*tab\s+(?P[^}]*?)>\}\}(?P.*?)\{\{<\s*/\s*tab\s*>\}\}",
+ re.DOTALL,
+)
+_MD_TAB_RE = re.compile(
+ r"\{\{%\s*tab\s+(?P[^}]*?)%\}\}(?P.*?)\{\{%\s*/\s*tab\s*%\}\}",
+ re.DOTALL,
+)
+_ALERT_RE = re.compile(
+ r"\{\{%\s*alert\s+(?P[^}]*?)%\}\}(?P.*?)\{\{%\s*/\s*alert\s*%\}\}",
+ re.DOTALL,
+)
+_CARD_GRID_RE = re.compile(
+ r'^\n(?P.*?)\n
[ \t]*$',
+ re.DOTALL | re.MULTILINE,
+)
+_CARD_RE = re.compile(
+ r'[^"]+)">.*?'
+ r'unified-card-title">(?P.*?).*?'
+ r'unified-card-summary">(?P.*?)
.*?',
+ re.DOTALL,
+)
+_FLOW_DIAGRAM_RE = re.compile(
+ r'^\n(?P.*?)\n
[ \t]*$',
+ re.DOTALL | re.MULTILINE,
+)
+_FLOW_STEP_RE = re.compile(
+ r'flow-step__label">(?P