Plain is a Python web framework.
- Always use
uv runto execute commands — never use barepythonorplaindirectly. - Plain is a Django fork but has different APIs — never assume Django patterns will work.
- When unsure about an API or something doesn't work, run
uv run plain docs <package>first. Add--apiif you need the full API surface. - Use the
/plain-installskill to add new Plain packages. - Use the
/plain-upgradeskill to upgrade Plain packages. - Use the
/plain-optimizeskill to investigate slow pages and N+1 queries.
- Painfully obvious over clever — blatant clarity, even if it means more code. You should see what is happening, not have to deduce it.
- Write code meant to be read — clear names, natural flow, obvious structure. The next person reading it should understand it immediately.
- Simplify to the present need — if it feels overcomplicated, it is. Get right to the heart of the issue.
Settings live in app/settings.py and are accessed via plain.runtime.settings.
-
Type-annotated settings can be set via
PLAIN_-prefixed environment variables (e.g.,PLAIN_SECRET_KEY,PLAIN_DEBUG=true). Env vars take highest precedence — they overridesettings.pyvalues. When suggesting how to configure a setting, mention the env var option. -
uv run plain settings list— list all settings with current values and sources -
uv run plain settings get <SETTING_NAME>— get a specific setting's value -
Never use
getattr(settings, "X", default)— all known settings have defaults registered by their packages, sosettings.Xalways works. Usinggetattrmasks typos and missing package installs.
Run uv run plain docs runtime for full details on env var syntax, .env files, custom prefixes, and package settings.
Plain is a Django fork but has different APIs. Package-specific differences are in their respective rules (plain-postgres, plain-templates) under a ## Differences from Django section. These are the core framework differences:
- URLs: Use
Routerwithurlslist, not Django'surlpatterns - Request data: Use
request.query_paramsnotrequest.GET,request.form_datanotrequest.POST,request.json_datanotjson.loads(request.body),request.filesnotrequest.FILES - Middleware: Middleware uses
before_request(self, request) -> Response | Noneandafter_response(self, request, response) -> Response— not Django's__init__(self, get_response)/__call__pattern. NoAuthMiddlewareexists — auth works through sessions + view-level checks (AuthViewMixin).
When in doubt, run uv run plain docs <package> --api to check the actual API.
- Message format: Capitalized sentence fragments —
"User logged in","Payment failed", not snake_case tokens or inline key=value - No f-strings or % formatting in log messages — pass variable data via
context={}instead - Use
context={}forapp_logger,extra={"context": {...}}for standard loggers (logging.getLogger("plain.xxx"))
Run uv run plain docs logs for full examples and anti-patterns.
OTel-based exception tooling (Datadog/NR/Honeycomb-style) attributes application errors to entry spans — the topmost span belonging to the service for a given unit of work. The convention across APM backends is:
span_kind IN (SERVER, CONSUMER, PRODUCER) AND status_code = 'ERROR' AND has(events.name, 'exception')
Only those three span kinds count for error attribution. INTERNAL and CLIENT are trace context — they explain what was happening, but they're not where the failure is recorded.
Pick the right SpanKind when adding instrumentation:
SERVER— incoming requests (HTTP, RPC handlers, etc.)CONSUMER— discrete background units of work (jobs, chores, scheduled tasks)PRODUCER— emitting work to a queue/brokerCLIENT— outgoing calls (DB, HTTP, SMTP) — never an error-attribution boundaryINTERNAL— sub-operations and inner loop cycles (worker tick, template render) — useful for trace context, not error attribution
If a failure inside an INTERNAL/CLIENT span is a real application error, the surrounding entry span should carry the failure. If there's no entry span and the failure matters, you probably need to add one.
The canonical failure signal on an entry span is status_code=ERROR + error.type attribute + a recorded exception event. Don't branch on exception.escaped — deprecated upstream, unreliable in the Python SDK.
If the surrounding code catches the exception inside the with span: block, the SDK's auto-record on context exit won't fire — stamp the canonical signal explicitly:
span.record_exception(exc)
span.set_status(trace.StatusCode.ERROR)
span.set_attribute(ERROR_TYPE, format_exception_type(exc))If the exception propagates out of the span context, the SDK auto-records and sets status — only error.type needs to be set explicitly.
Already wired entry spans:
- HTTP requests — SERVER (
plain/internal/handlers/base.py) - View 5xx attachment —
plain/views/base.py:_respond_to_exception(records on the SERVER span via_finalize_span) - Job enqueue — PRODUCER (
plain-jobs/jobs/jobs.py) - Job execute — CONSUMER (
plain-jobs/jobs/models.py), plus a fallback CONSUMER span inplain-jobs/jobs/workers.py:process_jobthat catches lookup-time failures beforerun()is reached - Worker maintenance loop — CONSUMER (
plain-jobs/jobs/workers.py), opened only on ticks where a maintenance task is due — fully idle ticks emit no spans at all - Worker claim/heartbeat failures — one-off
claim job/worker heartbeatCONSUMER error spans viaemit_error_consumer_span(plain-jobs/jobs/otel.py); the claim transaction, heartbeat writes, gauge queries, and done-callback bookkeeping are otherwise untraced (plain.postgres.otel.suppress_db_tracing) - Chore execution — CONSUMER (
plain/cli/chores.py) - MCP RPC dispatch — SERVER (
plain-mcp/mcp/views.py)
Trace-context-only (not error attribution): template render (plain-templates), DB queries / email sends (CLIENT — same role).
Discovery — find what's available and where things are:
uv run plain docs --list— all packages and core modules with descriptionsuv run plain docs --search <term>— find which modules/sections mention a term (compact, one line per section). Substring by default; add--regexfor regex patterns (alternation, anchors, etc.)
Reading — get full content:
uv run plain docs <name>— full markdown docsuv run plain docs <name> --search <term>— full content of all matching sections in that moduleuv run plain docs <name> --api— public API surface (classes, functions, signatures)
Workflow: Use --search <term> to find which module has what you need, then read the full doc, or run <name> --search <term> to print just the matching sections.
Packages: plain, plain-admin, plain-api, plain-assets, plain-auth, plain-cache, plain-code, plain-connect, plain-dev, plain-elements, plain-email, plain-flags, plain-htmx, plain-jobs, plain-loginlink, plain-mcp, plain-portal, plain-postgres, plain-oauth, plain-pages, plain-passwords, plain-pytest, plain-redirection, plain-scan, plain-sessions, plain-start, plain-tailwind, plain-templates, plain-toolbar, plain-tunnel, plain-vendor
Core modules: agents, chores, cli, csrf, forms, http, logs, packages, preflight, runtime, server, test, urls, utils, views
Online docs URL pattern: https://plainframework.com/docs/<pip-name>/<module/path>/README.md
uv run plain check— run linting, preflight, migration, and test checks (add--skip-testfor faster iteration)uv run plain pre-commit—checkplus commit-specific steps (custom commands, uv lock, build)uv run plain shell— interactive Python REPL with the app configured (-c "..."for one-off code, piped stdin works). For standalone scripts, putimport plain.runtime; plain.runtime.setup()at the top and run withuv run python script.py.uv run plain request /path— test HTTP request against the dev database (--user,--method,--data,--header,--status,--contains,--not-contains). Add--jsonfor context-frugal output — response metadata and trace analysis (query counts, N+1s, span tree), no response body.
Don't guess at errors — reproduce them first, read the traceback, then fix what it actually says.
uv run plain check— lint, preflight, migration, and test checks in one shot (add--skip-testfor faster iteration)uv run plain request /path— hit a view and see the full error/stacktrace (--user,--status,--contains,--not-contains)uv run plain shell -c "..."— run a quick snippet to test behavior in isolationuv run plain test -x -k test_name— run a specific failing test, stop on first failureprint()statements — add them, run the code, read the output, then remove before committing