How resolution works
A reference is a pointer into your vault. This page follows one from the .env file to process.env.
References and scopes
A reference looks like this:
kerstel://<scope>/<KEY>
The scope is either global or a project name. A reference names exactly one scope and resolves there or fails. There is no fallback chain: kerstel://myapp/API_KEY never quietly picks up global/API_KEY. If you want a project to use a shared value, point the project's .env at the global reference directly.
The vault
Values live in ~/.kerstel/vault.db, encrypted per value with AES-256-GCM. The data key lives in your OS credential store. The security model covers this in detail.
The daemon
A per-user resolver daemon unlocks the vault once, using the credential store, and then answers lookups over a local socket (~/.kerstel/kerstel.sock). Each request carries a session token, so only processes running as you can ask. The daemon mints a fresh token every time it starts, keeps it in a 0600 file in the Kerstel home, and deletes it when it stops; the token never appears in any process's environment, only the file's path does, and the hook reads the file when it needs it. The daemon records an audit row for each resolution.
kerstel run never talks to the daemon: it opens the vault directly and resolves references itself. The hook does need the daemon, and kerstel exec (which init writes into your scripts) starts it on demand, as does kerstel resolve; you never start it by hand. If the hook can't reach it, the error names the reference and tells you to run kerstel doctor. kerstel daemon status shows whether it is up at any time, and kerstel daemon start is there if you want to start it yourself, or to read why it won't start.
The daemon is started with a small environment of its own: PATH, HOME, locale, the session bus and display on Linux, and Kerstel's own KERSTEL_* settings. It never inherits the environment of the script that needed it, and a kerstel daemon serve you run yourself re-executes itself through the same list before opening the vault. That keeps your project's variables, an outer script's already-resolved values, and runtime flags such as BUN_OPTIONS or NODE_OPTIONS out of the process that holds the vault key.
Two ways to resolve
kerstel run
kerstel run -- next build
run reads the current environment, resolves every reference it finds, and starts the command with plaintext values in place. It is the universal path. It works for IDE run configurations, other languages, and any command that cannot load a Node preload.
The runtime hook
The hook is a small, dependency-free preload that runs before your app code. It replaces process.env with a proxy. When code reads a key whose value starts with kerstel://, the hook asks the daemon, memoizes the answer for the life of the process, and returns the real value. Nothing on disk changes, and the hook does not care how the reference got into the environment: dotenv, Bun's native .env loader, Next.js env loading, or your shell.
kerstel init wires this up for you. Every script in package.json becomes kerstel exec -- <your original command> — npm lifecycle scripts such as postinstall and prepare are never wrapped, because that would make npm install itself depend on Kerstel . A Bun project is wired the same way: kerstel exec passes --preload to bun itself, so nothing per-machine is written into the project. Kerstel writes the hook's files to ~/.kerstel/hook the first time any kerstel command runs, and the file everything points at is ~/.kerstel/hook/preload.cjs.
What kerstel exec does
Your scripts do not call kerstel exec directly. init writes a small launcher, .kerstel/exec.cjs, which you commit, and each command becomes node .kerstel/exec.cjs -- <command>. The launcher looks for kerstel on PATH (skipping any node_modules/.bin, where a dependency could plant one) and runs kerstel exec -- <command> when it finds it. When it does not, as on a deploy host, it prints kerstel: not installed here, running without it: <command> to stderr and runs the command as written, so the same script works with and without Kerstel. kerstel exec -- <command> is the wrapper underneath, and it resolves nothing itself. It sets KERSTEL_SOCKET, KERSTEL_TOKEN_FILE and KERSTEL_HOOK_DIR for the child (three paths, no secret), appends --require <the hook> to NODE_OPTIONS, and starts the command. When the command it is about to run is bun or bunx, it also inserts --preload=<the hook> directly after the executable, because Bun does not honour NODE_OPTIONS=--require. Then the hook takes over and resolves each reference lazily, on the read. Under Bun it also resolves every reference in the environment as the process starts, because Bun.env is the raw environment object and cannot be wrapped: Bun.env.KEY then holds the value, while process.env keeps resolving on the read.
One check comes first. npm, pnpm, yarn, and bun put node_modules/.bin in front of PATH when they run a script, so a dependency that declared a bin named kerstel would be what npm run dev executes, with the full command line and access to the vault. Kerstel is not an npm package, so nothing legitimate installs one. kerstel exec and kerstel init refuse when such a file exists in the project or any parent directory, before the vault is opened, and name it; kerstel doctor reports it as a problem. The check looks for files, so it does not see a Yarn Plug'n'Play install, which has no node_modules/.bin; in a PnP project, check yarn bin kerstel yourself. Be clear about what each check can see: init and doctor run from your shell, where node_modules/.bin is not on PATH, so they are the authoritative ones. The check inside exec runs after the package manager has already picked a kerstel, so it catches a dependency that leaves its bin in place, not one that removes it before delegating; that is the general malicious-dependency case the security model puts outside the boundary today.
Wiring it by hand
A project with no package.json scripts to wrap runs its command through the wrapper directly:
kerstel exec -- <your command>
That is the same wiring init writes into a script, and it works for Node and Bun alike. The hook only activates when KERSTEL_SOCKET and KERSTEL_TOKEN_FILE are in the environment, and kerstel exec is what sets them — preloading the hook by hand, through bunfig.toml or NODE_OPTIONS, resolves nothing on its own. For a command the hook cannot reach at all, such as an IDE run configuration, kerstel run -- <your command> resolves every reference up front and passes plaintext values to the child.
Child processes are covered: the hook injects itself into the environment it exposes, so a node or bun child resolves its own references. Variables handed to any child are handed already resolved. A child that is not Node or Bun, such as python or git, could not resolve a reference anyway.
When resolution fails
If the daemon is unreachable, the vault is locked, or the key is missing, the hook throws an error that names the reference and points at kerstel doctor. It never returns the reference string to your code as if it were the value.
Edge cases worth knowing
- Build-time snapshots. Frameworks that inline environment variables into a client bundle, such as
NEXT_PUBLIC_*, only see real values when the build itself runs under the hook or throughkerstel run. - Scrubbed environments. A process launched with a clean environment, or by an absolute-path exec that drops
NODE_OPTIONS, cannot load the hook. Usekerstel runfor those. - One spawn resolves everything. Building a child's environment enumerates every variable, so a single spawn resolves every reference in scope, whether or not the child reads it. Expect one audit row per reference per spawn.