Development
Project Structure
Section titled “Project Structure”stk/├── stk/ # Main application package│ ├── app.py # Application factory, blueprint registration│ ├── settings.py # Configuration│ ├── extensions.py # Async SQLAlchemy engine, session factory, Base│ ├── migrations.py # Alembic config behind `stk db`│ ├── security.py # csrf_exempt for webhooks│ ├── cli/ # CLI commands (agent, database, users, doctor, smoke, reports)│ ├── scaffold/ # Templates behind `stk new`│ ├── shell.py # Async REPL behind `stk shell`│ ├── tasks/ # Async background tasks│ ├── websocket.py # WebSocket blueprint│ ├── portal/ # Blueprint: protected dashboard│ ├── public/ # Blueprint: public routes, OAuth callbacks│ ├── user/ # Blueprint: admin user/role/activity management, models│ ├── utils/ # Email, rate limiting│ ├── static/ # Vue, Vuetify, Axios, Tabler Icons, app JS and CSS│ └── templates/ # Jinja2 templates├── alembic/ # Migration env and versions/├── tests/ # unittest suite├── checks.py # Sanity checks run by `stk verify`├── docs/ # This documentation site├── instance/ # SQLite database├── .stk/context/ # Structured agent context├── .claude/skills/ # Agent skills (also at .agents/skills)├── CLAUDE.md # Agent instructions (AGENTS.md links here)├── pyproject.toml # Dependencies and project config├── setup.sh # Setup script├── run.py # ASGI entry point└── docker-compose.yml # Docker configurationBlueprints
Section titled “Blueprints”register_blueprints() in stk/app.py registers four blueprints: users, public, portal, and the ws WebSocket blueprint. Login, registration, 2FA, and passkey pages come from quart-security itself.
Portal (portal/)
Section titled “Portal (portal/)”The protected dashboard. A blueprint-level before_request requires a session for every route:
from quart import Blueprint, render_templatefrom quart_security import auth_required
portal = Blueprint("portal", __name__, static_folder="../static")
@portal.before_request@auth_required("session")async def before_request(): pass
@portal.route("/dashboard/")async def dashboard(): return await render_template("dashboard.html")User (user/)
Section titled “User (user/)”Admin pages and JSON APIs for users, roles, and activity (/users/, /roles/, /activities/). The whole blueprint requires an admin:
bp_user = Blueprint("users", __name__, static_folder="../static")
@bp_user.before_request@auth_required("session")@roles_required("admin")async def before_request(): passIt also holds the models (stk/user/models.py) and the auth signal handlers that track sessions and log password and 2FA changes.
Public (public/)
Section titled “Public (public/)”Unauthenticated routes: the landing page, /health, robots.txt, and the Google and GitHub OAuth callbacks.
public = Blueprint("public", __name__, static_folder="../static")
@public.route("/")async def index(): return await render_template("index.html")To add a module of your own, run uv run stk new <module>. It scaffolds the blueprint, model, templates, and migration, and ends at a URL you can open.
Database Operations
Section titled “Database Operations”stk uses async SQLAlchemy with g.db_session, created per request in before_request and closed in after_request. CLI code and background tasks open their own session from stk.extensions.async_session_factory.
Model Definition
Section titled “Model Definition”from datetime import datetimefrom sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Textfrom sqlalchemy.orm import relationshipfrom stk.extensions import Base
class Post(Base): __tablename__ = "post"
id = Column(Integer, primary_key=True) title = Column(String(80), nullable=False) content = Column(Text) created_at = Column(DateTime, default=datetime.now) user_id = Column(Integer, ForeignKey("user.id")) user = relationship("User", lazy="selectin")Every relationship must use lazy="selectin". Selectin loads only on query, so reading post.user on a row you just add()ed raises MissingGreenlet. Load it with await g.db_session.get(Post, post.id) or a query first.
Queries
Section titled “Queries”from quart import gfrom sqlalchemy import select
# Createpost = Post(title="New Post", content="Content here")g.db_session.add(post)await g.db_session.commit()
# Queryresult = await g.db_session.execute(select(Post))posts = result.scalars().all()
# Get by IDpost = await g.db_session.get(Post, 1)
# Filtered queryresult = await g.db_session.execute( select(Post).where(Post.title.like("%python%")))posts = result.scalars().all()
# Updatepost.title = "Updated Title"await g.db_session.commit()
# Deleteawait g.db_session.delete(post)await g.db_session.commit()Migrations
Section titled “Migrations”Schema changes go through Alembic. After changing a model:
uv run stk db revision -m "add post" # Autogenerate a revisionuv run stk db upgrade # Apply ituv run stk db check # Fail if models drifted from migrationsuv run stk db downgrade -1 # Roll back one stepSQLite runs migrations in batch mode automatically (alembic/env.py). Three SQLite traps are worth knowing. DDL is not transactional, so a failed upgrade leaves the tables it already created: fix the revision, drop only those new empty tables, and upgrade again. A new NOT NULL column on a table with rows needs a server_default in both the migration and the model. Constraints are named by the convention on Base.metadata in stk/extensions.py; keep it, because batch mode cannot add an unnamed foreign key.
Background Tasks
Section titled “Background Tasks”Use run_in_background() for fire-and-forget async tasks. There is no Celery and no Redis requirement; the task runs on the current event loop and exceptions are logged.
from stk.tasks import run_in_background
async def send_email(user_id): ...
await run_in_background(send_email(user.id))For tasks that need their own DB session, run_with_session opens one, commits on success, and rolls back on error:
from stk.tasks import run_with_session
async def cleanup_old_records(session): await session.execute(...)
await run_with_session(cleanup_old_records)API Development
Section titled “API Development”List endpoints paginate manually and serialize with orjson. Mutations arrive as {item: {...}} from the frontend.
import orjson as jsonfrom quart import Response, g, requestfrom sqlalchemy import func, select
@bp.route("/api/posts")async def api_posts(): page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 25, type=int) total = (await g.db_session.execute(select(func.count()).select_from(Post))).scalar() result = await g.db_session.execute( select(Post).offset((page - 1) * per_page).limit(per_page) ) items = [p.to_dict() for p in result.scalars().all()] data = {"items": items, "total": total, "perPage": per_page} return Response(json.dumps(data), content_type="application/json")
@bp.post("/api/post/")async def api_post_create(): json_data = await request.json item = json_data.get("item", {}) ...Every mutation requires a CSRF token, which Axios sends from the shared layout token. A third-party webhook without a session opts out with @csrf_exempt from stk.security, placed under the route decorator, and must authenticate the caller itself. See Authentication.
Frontend
Section titled “Frontend”Pages are Vue 3 (Options API) plus Vuetify, loaded from static files with no build step. Each page script uses mixins: [layoutMixin] for the drawer, navigation, WebSocket, and notifications, sets delimiters: config.delimiters so Vue uses ${} instead of Jinja’s {{}}, and calls registerStkComponents(app) before mounting. Icons are Tabler (ti ti-pencil), not Material Design Icons. Sidebar entries live in stk/static/js/navigation.js; add role: 'admin' to restrict one.
Pass server data with <script type="application/json" id="...">{{ data|tojson|safe }}</script> inside {% block js %}. Vue strips <script> tags inside the #app mount element, so the same tag in {% block content %} comes back as null.
Vue template expressions cannot see page globals. Expose shared helpers with app.config.globalProperties.labels = labels before mount. The production Vue build fails such lookups silently, so check the rendered page rather than relying on stk smoke.
Pinned versions and the full component reference live in .stk/context/frontend.md.
Development Server
Section titled “Development Server”uv run stk run # Start server at localhost:5000uv run ruff check --fix . && uv run ruff format . # Lint and formatuv run python -m unittest discover -s tests # TestsAgent Operability
Section titled “Agent Operability”stk exposes machine-readable project state so agents and tooling can verify instead of guess:
uv run stk inspect routes --json # Route map with auth and source infouv run stk inspect context --json # Routes and models in one contractuv run stk verify --json # Lint, sanity, and migration checksuv run stk smoke --json # Real-browser smoke check (Playwright)uv run stk report # Static project review artifactuv run stk new <module> # Scaffold + migrate, ends at a URLuv run stk doctor --json # Checkout state, each problem with its fixuv run stk verify --watch # Re-run affected checks on every saveuv run stk shell -c "await count(User)" # One-shot query against the real appEvery failing check reports the command that fixes it, in the text output and in the JSON, so an agent reads the remedy rather than guessing one. See The Build Loop and The Shell.
The smoke command boots a temporary app with its own SQLite database, logs in through a test-only agent route, visits the dashboard and admin pages, captures console errors and failed requests, and writes a screenshot to .stk/smoke/. The test login route is not registered by default and refuses to start outside development or testing.
To see SQL as it runs, raise the engine logger: logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO).