Every tool that touches .env eventually wants a schema. You need to know that DATABASE_URL is a URL and PORT is a number, which keys are required, and, if you care about coding agents at all, which values must never be echoed. The obvious move is to design a little format for that. We nearly did.
Then we read @env-spec.
What @env-spec is
@env-spec is the decorator vocabulary that varlock introduced: comments above a key in a dotenv-shaped file, carrying metadata the loader can act on.
# @type=url
DATABASE_URL=
# @type=port @sensitive=false
PORT=3000
That is the whole idea. The file still parses as dotenv. Every editor already highlights it. A person who has never seen the spec reads it correctly on the first try, and a person who has seen it reads it instantly.
We did not think we could improve on that, so we didn't try. penv's .env.schema is an @env-spec file.
What that bought us
Zero learning curve for the committed file. The thing penv puts in your repo is the thing people most need to trust and edit. Making it a format with an existing spec and an existing community meant we never had to write "how to read a .env.schema" docs. The decorators mean what the spec says they mean.
A guessable init. penv init reads the .env you already have, writes the schema next to it, and gitignores .env. Because the output is dotenv-shaped, the guesses it makes are visible and correctable in the same file: if it marks a key sensitive that isn't, you change one decorator. Every key is sensitive and required unless a bundler prefix (NEXT_PUBLIC_, VITE_, EXPO_PUBLIC_ and friends) or a dull value (3000, us-east-1) says otherwise, and a key named like a secret (STRIPE_SECRET_KEY, ..._ANON_KEY) stays sensitive whatever it holds and whatever prefix it carries.
Interoperability we didn't have to negotiate. A team that already uses varlock can drop the same file into penv. A team that leaves penv walks away with a file another tool understands. That last part matters to us more than it sounds; one of the principles in our design doc is "drop-in or evict", and a proprietary schema format would have quietly broken the evict half.
What we added
penv needs a few things the base vocabulary does not carry, so it reads three extra decorators, @since, @rotate and @dynamicFrom, plus two headers. @schema=1 names the grammar version the file was written in. @penv=<org>/<project> names the cloud project the schema belongs to; leave it out and penv runs in local mode, and nothing leaves your machine.
They follow the same shape as the spec's own decorators on purpose. A varlock user seeing @rotate for the first time will not know exactly what penv does with it, but will know exactly where it goes and how to read it. We'll document their semantics fully with the release candidate rather than half-document them now.
The one thing we left out, on purpose
This came up publicly this week, from the people who wrote the spec, and it deserves a straight answer.
varlock can set values in the schema and load a cascade of env files: .env, .env.local, .env.production, and so on, layered in a defined order. It is a useful model and a lot of teams rely on it.
penv does not do the cascade. It reads exactly one local file, .env, from the directory that holds the schema, and no .env.* variant is ever layered on top. There is one committed schema, one local .env (which penv init gitignores), and, once you push, environments live in the cloud, addressed as org/project/environment.
The reason is the problem penv was built for. A coding agent runs as you, and the deny rules people write by hand for their harnesses are almost always written against .env, singular. .env.local and .env.production are precisely the files those rules miss, and precisely the files that hold the interesting values. penv's own guards deny .env.* as a pattern across eight harnesses, so the cost is not enforcement. The cost is surface: every extra file in a cascade is another place a value can live and another file a grep for context can surface. We would rather have one file to keep out of reach than a well-ordered set of five.
So: non-sensitive values do live in the schema (PORT=3000 and DEBUG=true go straight in during init). Anything that could be a secret never does. And the layering that varlock does well on a laptop, penv moves to the server, where a value can be short-lived, scoped and attributed to the session that used it.
That is a real trade. It makes penv less flexible on a single machine than varlock is. If a team's workflow is built around the cascade, varlock is the better fit, and we would say so.
Why not just use varlock, then
Because they are solving adjacent problems, not the same one. varlock is the schema and the local loader, done well. penv is downstream of that: the per-harness guards, the output masking under an agent session, the typed codegen, and the path to a shared store when there is more than one laptop. We adopted the spec so that those layers sit on a format that isn't ours, and so that nobody has to choose a schema dialect in order to choose a tool.
If you're building something that touches .env, our unsolicited advice is the same thing we did: read @env-spec before designing a format. The spec is small, it's readable, and the people behind it clearly intended for others to build on it.
- penv: https://github.com/penvhq/penvhq (MIT,
1.0.0-alpha.3) - @env-spec and varlock: https://varlock.dev
Disclosure: penv and varlock are separate projects. We have no affiliation beyond having adopted their spec and having argued with them, politely, on X.