Secrets in Plain are a type
SECRET_KEY: Secret[str]
`plain settings get` now masks them like `settings list` already did. Matters more with agents: everything a command prints lands in the transcript.
`plain settings get --reveal` is the explicit ask
ALT app/settings.py declares STRIPE_SECRET_KEY: Secret[str]. Below, labeled 'what the agent sees', an agent transcript shows Bash(plain settings get STRIPE_SECRET_KEY) returning ******** and a hint that it is a secret, pass --reveal to print its value.
Plain now requires Python 3.14. The first payoff is sql() in plain-postgres: the SQL is the literal parts of a t-string, every interpolated value binds as a parameter, and passing a plain str is a type error.
ALT Python code using Plain's new sql() API: a QueueStats dataclass, then JobRequest.query.sql() called with a t-string SELECT that interpolates {JobRequest.queue}, count(*), min({JobRequest.created_at}), FROM {JobRequest}, WHERE queue = ANY({queues}), GROUP BY 1, with result_type=QueueStats.
plain docs cache --api prints the real signatures for the version you have installed. Docs ship inside each Plain package — your agent reads them instead of guessing from training data.
ALT Terminal showing the output of `plain docs cache --api`: the Cache class from cache/core.py with its full method signatures — get, get_many, set, set_many, get_or_set, increment, decrement, touch, delete, delete_many, clear — and the cache = Cache() instance.
plain.loginlink: link expiration is now a class attribute — link_expires_in = 60 * 15 on your form, no method override needed. Docs now spell out the real semantics: links stay valid until expiry, deliberately, so mail scanners can't burn them before your user clicks.
ALT Python code before/after. Before: override a method to change the expiration — class CustomLoginLinkForm(LoginLinkForm) overriding maybe_send_link(self, request, expires_in=60 * 15) and calling super(). After: one class attribute — class CustomLoginLinkForm(LoginLinkForm) with link_expires_in = 60 * 15.
Dev secrets can now live in git. `plain env set` encrypts a value into your committed .env.dev, one key per project. A fresh clone — or a hosted agent sandbox — just needs DEV_ENV_KEY and every dev credential comes with the repo.
ALT A .env.dev file committed to git, with STRIPE_SECRET_KEY and GITHUB_APP_PRIVATE_KEY stored as encrypted: values that are decrypted locally with DEV_ENV_KEY
Response.status_code is read-only in Plain now. The status decides things everything downstream builds on — whether a body is allowed, how the response is framed on the wire — so it's fixed at construction. Renderers take it directly: self.render(form=form, status_code=422)
plain 0.160.0 makes that bug unrepresentable instead of just catching it: bodiless responses (204, 304, HEAD) refuse content at construction, and Response.status_code is read-only — so you can't build a valid response and mutate it into an invalid one.
We were seeing intermittent 503s behind Heroku's router that never showed up in our app logs. Root cause: a 204 response with a body. The body bytes go onto the keep-alive connection unframed, and the router reads them as the start of the next response.
Replaced Model.save() with create() and update() in Plain.
The old version branched on whether a primary key was set, which meant reading a save() call told you nothing about whether it wrote a new row.
Two new packages in Plain: plain.mcp and plain.oauthserver.
Together they mean an AI client can connect to your app as a real, authenticated user — PKCE, dynamic client registration, token rotation.
Plain doesn't auto-index foreign keys — you declare the indexes you want. Add a composite index starting with that FK and the single-column one is redundant anyway: Postgres uses the composite's leftmost prefix. Explicit indexes, no overlap by default.
Experimenting with a MARK comment in the Plain source code, which we can render as sections when displaying docs.
Inspired by # pragma mark in Objective-C and MARK in Swift
Plain now ships with `plain install` and `plain upgrade` commands.
In some ways these are glorified AI prompt generators! But integrating with coding agents in this way leaves API keys and control completely in your hands. Use any provider or tool you want.
From seeing a problem to fixing it, with the new opentelemetry instrumentation, plain-observer, and `plain observer diagnose`.
What else can we do if we embrace coding agents?!
Models in plain no longer support custom primary keys. Everything gets a bigint auto id field!
Time will tell whether more is really needed here, but in most cases you shouldn't need to customize primary keys in new apps and this sets a clear expectation across all of Plain.
The admin toolbar shows exception tracebacks automatically when you break something.
This appears on top of your regular error view, so you never forget what your users are seeing in these situations!
Plain now supports a single DATABASE instead of multiple DATABASES. This drastically simplifies a few areas of the framework.
Multiple db support can always come back if it proves to be a need, and by then it may take a different form then it has today!
Experimenting with a `plain help` command that lists out all available commands and options... one more step towards figuring out what makes a framework "AI-friendly"
plain.pytest 0.8.0 includes a new `testbrowser` fixture — this brings together playwright, pytest-playwright, and gunicorn to start running browser tests with no additional setup
The plain-importmap package is being removed for now. The implementation wasn't quite right, and it's not totally clear that it's actually needed.
In practice, plain-esbuild (bundling) has been more useful, and plain-vendor can be used when creating importmaps manually.
The `plain fix` command now uses @biomejs to lint and format javascript, json, and css files. Combined with @astral_sh ruff for python, it's an all-in-one preconfigured toolset for code formatting.
The plain.api package is being reworked!
Plain itself is even more JSON-friendly than before, and the new plain.api views bring it all together with API keys and (optional) OpenAPI/swagger.json generation
The first plainx community package is plainx-sentry (integration with @getsentry).
We aren't planning to maintain official, first-party integrations for other commercial services... but we could provide some kind of vetted directory that includes them!