Imported from zarguell/polyglot (
docs/AGENTS.md). Install upstream withnpx skills add zarguell/polyglot --skill docs. Copyright stays with the author.
AGENTS.md — Polyglot Agent Instructions
This file is mandatory reading for any AI coding agent working on this repo. Follow these rules unless the PRD explicitly overrides a specific point.
Quick Reference (use these patterns)
New Model
# app/models/my_entity.py
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, Text, Uuid
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import AuditMixin, Base, uuid_pk
class MyEntity(AuditMixin, Base):
__tablename__ = "my_entities"
id: Mapped[uuid.UUID] = uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
New Route (HTML form handler — always use manual body parsing)
# app/api/my_routes.py
from __future__ import annotations
import uuid
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.api.deps import CurrentUser, DbDeps
from app.core.errors import NotFoundError
from app.core.templates import get_jinja_env
from app.services.my_service import create_entity, get_entities
router = APIRouter(tags=["my_entities"])
@router.get("/my-entities", response_class=HTMLResponse)
async def list_page(request: Request, user: CurrentUser, db: DbDeps):
items = await get_entities(db, user.id)
env = get_jinja_env()
return HTMLResponse(
env.get_template("my_entities/list.html").render(
request=request, user=user, items=items
)
)
New Route (form POST)
# ``request.form()`` works transparently because ``BodyCacheMiddleware``
# (outermost middleware layer) reads the ASGI body once and replays it
# for every downstream consumer. No manual body parsing needed.
from fastapi import Form
@router.post("/my-entities")
async def create_form(request: Request, user: CurrentUser, db: DbDeps):
form = await request.form()
name = form.get("name", "")
...
New Service
# app/services/my_service.py
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.my_entity import MyEntity
async def create_entity(db: AsyncSession, user_id: uuid.UUID, *, name: str) -> MyEntity:
entity = MyEntity(user_id=user_id, name=name)
db.add(entity)
await db.flush()
return entity
New Task (with periodic trigger)
# app/tasks/my_tasks.py
# No need to import this module anywhere — it is auto-discovered from app/tasks/.
from app.core.tasks import task_app
@task_app.task(name="my_app.my_action")
def my_action(param: str = "") -> None:
import asyncio
...
# Periodic trigger: wraps the already-registered task (Procrastinate 2.6+ API).
# Do NOT use @task_app.periodic() as a decorator on a plain function.
periodic_my_action = task_app.periodic(
cron="0 * * * *",
task_name="my_app.my_action",
)(my_action)
New Template (extends base.html)
{% extends "base.html" %}
{% block title %}{{ app_name }} — Page Title{% endblock %}
{% block nav %}{% include "components/nav.html" %}{% endblock %}
{% block content %}
<div class="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<h1 class="text-2xl font-bold">Page Title</h1>
...
</div>
{% endblock %}
Architecture Overview
Polyglot is an opinionated secure application boilerplate. It provides authentication, database, task processing, and a component system out of the box. An agent adds business logic by:
- Reading the PRD (
docs/PRD.md) - Reading this file for conventions
- Reading
docs/DESIGN.mdfor UI tokens (sourced fromDESIGN_TOKENS.jsonviamake generate-tokens) - Creating domain models in
app/models/ - Creating API routes in
app/api/ - Creating UI templates in
app/templates/ - Writing tests
- Creating migrations
- Running
make smoke-testto verify the app boots
Directory Responsibilities
| Directory | Purpose |
|---|---|
app/api/ |
FastAPI route handlers |
app/core/ |
Framework-level code: config, DB, auth, middleware, tasks |
app/models/ |
SQLAlchemy ORM models |
app/schemas/ |
Pydantic v2 request/response schemas |
app/services/ |
Business logic layer (no HTTP/DB-impl details) |
app/tasks/ |
Procrastinate task definitions |
app/components/ |
Auto-registered component modules (copied from boilerplate/templates/) |
app/templates/ |
Jinja2 HTML templates (HTMX mode) |
app/static/ |
Compiled CSS and static assets |
boilerplate/templates/ |
Copy-on-activate component packs |
alembic/ |
Database migrations |
tests/ |
Test suite |
docs/ |
Documentation |
Security Rules (NEVER VIOLATE)
- No wildcard CORS —
Access-Control-Allow-Origin: *is forbidden in production. - No inline scripts except the CSRF bootstrap in
base.html. - All forms must include CSRF token as hidden input and
X-CSRFTokenheader for HTMX. - No secrets in repo — use
.envor theSECRET_KEY/auth_oidc_client_secretsettings. - No user HTML rendered unescaped — Jinja autoescape is on by default.
- All external CDN assets must use fixed versions + SRI integrity hashes.
- Never add
@ts-ignore,@ts-expect-error, oras anyequivalents. - Never add wildcard
PATCH/DELETEwithout auth checks. - Procrastinate tasks must not be used for request-local operations.
- Audit log every auth-relevant event (login, logout, role change, settings change).
- Webhook endpoints must use HMAC signature verification, not CSRF.
- If implementing multi-tenancy, prefer service-layer tenant scoping first. RLS is defense-in-depth, not a replacement for application checks.
- RLS policies must be included in Alembic migrations as
op.execute()statements. - RLS requires the
postgresuser to enable, and each policy needs its own migration.
Data Modeling Rules
- Prefer normalized tables for stable business entities.
- Use JSONB only for genuinely dynamic fields or unstructured metadata.
- Use explicit foreign keys for critical integrity paths.
- Never use JSONB for ledger-like financial records by default.
- Put per-user preferences in a structured table or constrained JSONB field.
- All tables should have UUID primary keys.
- All tables should have
created_attimestamps.
Task Queue Rules
Task auto-discovery: Modules in
app/tasks/are imported automatically whentask_apploads (see_discover_task_modulesinapp/core/tasks.py). Drop a.pyfile inapp/tasks/and its@task_app.task(...)decorators register with the worker — no manualimportline needed. (Previously you had to addimport app.tasks.footoapp/tasks/__init__.py; forgetting that made the task silently vanish with no error.) Import failures now fail loudly at startup instead of silently disappearing.
-
Use Procrastinate for jobs longer than a normal request/response cycle (>200ms).
-
Name tasks with a prefix:
domain.action(e.g.,billing.send_invoice). -
Idempotency: tasks should be safe to retry. Use
taskmethod retry config. -
Don't use tasks for synchronous responses.
-
Webhook ingestion: enqueue + return 202.
-
Periodic triggers: use the wrapper pattern (see Quick Reference). Do NOT use
@task_app.periodic()as a decorator on a plain function — Procrastinate 2.6+ requires the target to be a registered task. -
Testing caveat —
AppNotOpen: In tests,task_appis never opened (no connection pool is initialized). Every.defer()call will raiseprocrastinate.exceptions.AppNotOpen. Always wrap.defer()calls in try/except when the environment may not have initialized the Procrastinate connection pool. All route-handler.defer()calls in this codebase use the pattern:try: some_task.defer(...) except Exception: logger.warning("some_task_defer_failed")See
app/services/ticket_notifications.pyfor a reference implementation. -
Worker component initialization: Component
register()hooks run in both the web process (viaapp.main._load_components) and the worker process (viaapp.core.tasks._register_components). Components guardapp.include_router()behindif app is not Nonesince the worker has no FastAPI application. Process-global side effects (registry hooks, task imports) execute in both processes.
Frontend Rules
- Default mode is HTMX + Jinja2. Use it for internal tools and CRUD.
- React mode requires the
reactcompose profile. API-first. - Consult
DESIGN_TOKENS.jsonfor colors, fonts, radius before generating UI. - Components folder:
app/templates/components/for Jinja partials. - No bare
<script>tags — use nonce or SRI.
Row-Level Scoping (beyond require_permission)
The built-in require_permission(resource, action) factory answers "can this user
perform this action?" — but not "can this user act on THIS specific entity?"
For role-scoped access (e.g., "manager approves only their direct reports' timecards"),
add a service-layer helper and call it from route handlers. The pattern:
# app/services/scope.py — create this file in your app
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.employee import Employee
from app.models.timecard import Timecard
async def get_scoped_timecards(
db: AsyncSession,
user_id: uuid.UUID,
*,
is_admin: bool = False,
can_view_all: bool = False,
) -> list[Timecard]:
"""Return timecards the user is allowed to see.
Admins and users with ``view_all`` permission see everything.
Managers see their direct reports' timecards.
Employees see only their own.
"""
if is_admin or can_view_all:
result = await db.execute(select(Timecard))
return list(result.scalars().all())
# Get the user's employee record to find their reports
emp_result = await db.execute(
select(Employee).where(Employee.user_id == user_id)
)
employee = emp_result.scalar_one_or_none()
if not employee:
return []
# Manager: show direct reports + self
report_ids = [employee.id]
if employee.is_manager:
reps = await db.execute(
select(Employee.id).where(Employee.manager_id == employee.id)
)
report_ids.extend(r[0] for r in reps.all())
result = await db.execute(
select(Timecard).where(Timecard.employee_id.in_(report_ids))
)
return list(result.scalars().all())
Then use it in route handlers instead of repeating the filter:
@router.get("/timecards")
async def list_timecards(user: CurrentUser, db: DbDeps):
can_view_all = await has_permission(db, user, "timecards", "view_all")
timecards = await get_scoped_timecards(
db, user.id, is_admin=user.is_admin, can_view_all=can_view_all,
)
...
Why not build this into the boilerplate? Scoping rules are app-specific:
hierarchy depth (direct reports vs whole org tree), ownership models (solo vs team),
and override rules (escalation, delegation) differ across every domain. A generic
scope_filter() would need to know your org structure, assignment model, and
override rules — essentially a policy engine. The recipe above is the reusable
pattern; adapt the query to your entity relationships.
Critical Traps (read these before writing any code)
Trap 1: Form body silently consumed by middleware (already fixed)
Starlette's BaseHTTPMiddleware creates a new Request object per middleware layer.
If any middleware calls request.form() or request.body(), it consumes the ASGI
receive stream — downstream handlers would get an empty body with no error or warning.
This boilerplate fixes this transparently via BodyCacheMiddleware (outermost
middleware layer). It reads the body once from the original receive stream, caches it,
and replays it for every downstream consumer. You can safely use request.form()
in route handlers without any special pattern.
Trap 2: Procrastinate periodic() decorator
@task_app.periodic(cron="...") on a plain function wrapper no longer works
in Procrastinate 2.6+. The function must be a registered task first. Always use
the wrapper pattern shown in the Quick Reference.
Trap 3: Session cookie name is derived, not hardcoded
The cookie name comes from settings.app_name via SESSION_COOKIE_NAME in
app/main.py. Never hardcode "polyglot_session" in tests or middleware.
Import SESSION_COOKIE_NAME from app.main instead.
Trap 4: Audit event listener fires for ALL Base subclasses
Models that extend Base directly (without AuditMixin) will crash on
insert/update because the audit listener tries to set created_by_user_id.
Either add AuditMixin to your model or ensure it has the audit columns.
Testing Expectations
- Each new feature needs: unit tests for business logic, integration test for API route.
- All tests run against Postgres in Docker. There is no SQLite path. The
conftest.pyconnects viaTEST_DATABASE_URL(defaults to local Docker Postgres). Rundocker compose up -d postgresbeforemake test-local, or usemake testto run inside Docker (which handles the dependency automatically). - Prefer
JSONoverJSONB/ARRAY—JSONis more portable and sufficient for most use cases. Only useJSONBif you need Postgres-specific query operators (e.g.,@>,?). Since all environments run Postgres, either works — butJSONis the safe default. - Tests must pass before PR.
Migration Workflow
alembic revision --autogenerate -m "description"- Review the generated migration.
alembic upgrade head- Commit migration alongside model changes.
Guard Rails (machine-enforced conventions)
These Makefile targets verify conventions. Run them before every push.
make pre-commit # lint + test-local + smoke-test + check-deps + verify-tasks + check-config
make smoke-test # verify the app boots, pages render, headers present
make check-deps # verify all app modules import without errors
make verify-tasks # verify all task modules register without errors
make check-config # verify no os.getenv outside core/config.py
| Guard rail | What it enforces | What it catches |
|---|---|---|---|
| make smoke-test | App boots, pages render, CSRF works, form data persists | Silent body drops, Procrastinate API drift, missing deps |
| make check-deps | All modules import | Missing transitive deps (aiopg, psycopg-binary) |
| make verify-tasks | Task registration | periodic() syntax errors |
| make check-config | os.getenv/os.environ only in core/config.py | Config inconsistency, env-var leakage |
| make lint | Code style, types | Type errors, unused imports |
| make test | Tests pass against Postgres in Docker | Logic errors, model/schema/routes broken |
Definition of Done
- Models created/updated
- Migration generated and applied
- API routes working
- Templates render (if UI)
- Tasks defined (if background work)
- Tests pass (
make test) - Lint and type-check pass (
make lint) - Guard rails pass (
make pre-commitor individuallymake smoke-test check-deps verify-tasks check-config) - Pre-commit hooks installed (
pre-commit installafter cloning) - Security rules not violated
Available Templates
These live in boilerplate/templates/. Activate via make activate-component COMPONENT=<name>:
smtp— Email sending via SMTP (requires Mailhog or real SMTP)file_storage— Local/S3 file storage (key-based:store()returns a UUID key, not a filesystem path)redis_cache— Redis cache layer and rate limiterwebsockets— WebSocket connection managementstripe— Stripe payment processingfsm_workflows— Finite state machine workflowsreporting_exports— CSV/XLSX/PDF report generationinbound_webhooks— HMAC-signed webhook receiveroutbound_webhooks— Webhook dispatch with retryldap_ad— LDAP auth and user syncdjango_upgrade— Migration guide to Django (docs-only)
Escalation Path
If requirements exceed FastAPI-first assumptions (complex admin, deep workflows, metadata-heavy systems), the path forward is documented in docs/DECISIONS.md. In short: FastAPI stays until Django's admin framework, FSM library, or permission system is genuinely needed.
