Every secret OpenStation needs comes from the environment. Nothing goes in
openstation.yaml — the manifest is committed, the environment isn't. What the manifest
does declare is where that environment comes from: an env: block naming one provider,
resolved into a bag that belongs to this workspace and no other. Nothing is written into
process.env, so two workspaces in one host process cannot see each other's credentials.
Workspace location
| Variable | Notes |
|---|---|
OPENSTATION_HOME |
Where a bare workspace name resolves. Default ~/openstation. Absolute, or relative to the process cwd — ~ is not expanded, since only a shell does that. |
OPENSTATION_WORKSPACE |
Which workspace a command acts on when nothing else says. A bare name resolves under OPENSTATION_HOME; anything with a separator is a path. Outranked by the workspace you are standing in or under — the CLI searches the current directory and its parents first; see the CLI reference for why. Also exported to every turn and inbound stage, naming their own workspace root. |
Neither is a secret, just a path. See the CLI reference for
how -d, a name, the directory you are standing in or under, and the variable rank against each
other.
Claude authentication
The executor spawns Claude Code, so Claude's own auth applies. Either works:
| Variable | Notes |
|---|---|
ANTHROPIC_API_KEY |
API-key auth |
CLAUDE_CODE_OAUTH_TOKEN |
subscription auth, as an alternative to logging in interactively |
Or run claude login once on the machine and set neither. Both are read from the
workspace's bag, so a key its own provider supplies authenticates that workspace's child
and nothing else — it does not have to be exported in the shell that starts the daemon.
An API key reaches the child only when the platform owns the Claude config dir. Either
variable being present in the bag is what authorizes the platform to own var/claude/ for
this workspace, and the key is handed to the child only in that case. Both executor kinds get
the same answer, decided once in wire.
The other branch — a key in the bag withheld from the child rather than silently billed past
a subscription login sitting in the operator's own dir — is reachable only by an embedder that
pins wire({ claudeConfigDir: false }). No CLI path sets it: under dev and serve a key in
the bag is exactly what moves the dir, so the key always goes down with it. The rule still
matters, because both adapters have to agree on it — and once they didn't.
That coupling is a fix, not a nicety. The two halves used to be decided separately, and
executor: claude-cli plus an ambient key was the pairing that exposed it: the key
relocated the config dir to var/claude/ — seeded with trust and onboarding, and no
credentials — while the CLI adapter stripped that same key from the child. Every turn failed
Not logged in with an empty stderr, and the banner claimed claude auth: API key. The
banner now says API key only when a key actually reaches the child, and claude home: names
the dir the wiring chose rather than re-reading CLAUDE_CONFIG_DIR — so neither line can
disagree with the child any more.
Connector credentials
With no connections: block, serve decides which connectors to start purely from which
variable sets are present in the workspace's environment — a connector with an incomplete set
is simply not started. Declare
connections: and that block is the
whole truth instead: it names the instances and the ${VAR} each credential reads, and
ambient credentials add nothing. The variables below are the names the block-less path looks
for.
Slack
| Variable | Required |
|---|---|
SLACK_BOT_TOKEN |
yes |
SLACK_APP_TOKEN |
yes |
Socket Mode needs both; one alone would build a client that fails at connect, so the detection requires the pair. See the Slack guide.
Telegram
| Variable | Required |
|---|---|
TELEGRAM_BOT_TOKEN |
yes |
From BotFather. See the Telegram guide.
All five are required — the connector needs a mailbox to poll and an address to answer from:
| Variable | Required | Default |
|---|---|---|
EMAIL_IMAP_HOST |
yes | — |
EMAIL_SMTP_HOST |
yes | — |
EMAIL_USER |
yes | — |
EMAIL_PASS |
yes | — |
EMAIL_FROM |
yes | — |
EMAIL_IMAP_PORT |
no | 993 |
EMAIL_SMTP_PORT |
no | 465 |
Both connections are TLS, not negotiable per-variable. IMAP and SMTP share one credential pair
(EMAIL_USER / EMAIL_PASS). See the Email guide.
With no credentials set
serve refuses to start rather than sitting idle. With no connections: block, nothing was
detected:
openstation serve: no connector credentials found in the environment (set SLACK_BOT_TOKEN +
SLACK_APP_TOKEN, declare a `connections:` block, or use `openstation dev` for a local REPL)
With one, the refusal names each connection and the variables it wanted — the block said what should start, so the failure can be specific:
openstation serve: every declared connection is unusable — connection "work-slack" is declared
but WORK_SLACK_BOT_TOKEN, WORK_SLACK_APP_TOKEN is not set
A declared connection that can start makes the others warnings instead, so one broken bot doesn't hold up the rest.
dev needs no connector credentials at all — only Claude auth.
Repository test harness only
These belong to OpenStation's own test suite. Don't set them in a deployment.
| Variable | Effect |
|---|---|
OPENSTATION_LIVE |
Opts into the live test tier, which spends real tokens and reaches real services |
OPENSTATION_GATE_WORKSPACE |
Overrides the workspace path the hn-agent gate test runs against |
Where they come from — the env: provider
One provider per workspace, declared in the manifest. Absent, it is dotenv over
<workspaceRoot>/.env — which is what every manifest written before the block already did,
so nothing existing changes.
| Provider | Declares | Reads |
|---|---|---|
dotenv |
path: — optional, relative to the workspace root or absolute. Default .env |
one file, at boot |
dotenvx |
path: — optional, same rules. Default .env |
one committed, encrypted file, decrypted at boot with a key that never enters the repo |
aws-secrets |
secretId: — a name or ARN; region: — optional |
one GetSecretValue, at boot |
Exactly one, not a chain: one unambiguous origin per variable is worth more than the convenience of layering, and the host environment is already the base underneath whichever provider is named. Shimming a single variable locally is what a shell export is for.
The block's fields and its load errors are in the manifest reference.
For a service: an environment file read by systemd (EnvironmentFile=) or container
environment is the host environment — the base — and needs no env: block. A workspace whose
secrets live in AWS declares aws-secrets instead; one whose secrets travel encrypted in its
own repo declares dotenvx. See deploying.
For local development: a .env in the workspace root. dev and serve read it from the
resolved workspace, so openstation notes serve picks up ~/openstation/notes/.env no matter
which directory you ran it from — not the caller's cwd.
# ~/openstation/notes/.env
TELEGRAM_BOT_TOKEN=123456:abc
$ cd /tmp && openstation notes serve
2026-07-29T23:43:31.735Z INFO [startup] env: dotenv /Users/you/openstation/notes/.env (1 applied)
2026-07-29T23:43:31.735Z INFO [startup] connectors: telegram
The provider, its origin, how many variables were new, and — when there were any — the names
it overrode: env: dotenv /path/.env (5 applied, replaced SLACK_BOT_TOKEN). A provider's own
warnings print under the same prefix, as WARN … env: <warning>.
Rules worth knowing:
- The provider wins over an ambient value, and says so. A workspace's own declaration is
the most specific statement of that workspace's identity, so it overrides a shell export or
a container variable of the same name — and the boot banner names what it replaced
(
replaced SLACK_BOT_TOKEN), never just a count. The earlier rule was the reverse, and it cost an afternoon: a staleSLACK_BOT_TOKENleft in a~/.zshrcshadowed the workspace's own valid token, and the daemon reported only that 5 names were "already set". If you want the environment to win, give the provider nothing to read — fordotenv, ship no.env. - An identical ambient value is neither applied nor replaced. It changed nothing, and counting it would inflate both numbers on every boot of a container that already exports what the file holds.
- Boot fails closed. A provider that errors aborts the boot, naming the origin and never
a value. A daemon that booted credential-less would start connectors that fail at connect,
which is strictly harder to diagnose than a refusal at the point of cause.
devfails closed too.
dotenv
env:
provider: dotenv
path: .env # optional; relative to the workspace root, or absolute
- A missing file is a no-op, not an error — most workspaces need no secrets at all.
Everything but a missing file fails closed, naming the path: an unreadable file, a
directory where a file was expected (
docker run -v $PWD/.env:/app/.envcreates one when the host has no.env). Absence is decided by the read itself rather than by a prior existence check, which once answered "absent" for a readable file behind an untraversable directory and booted silently with every credential missing. - The last assignment of a repeated key wins, as in a shell. A file with a
REPLACE_MEplaceholder above the real value uses the real one. - Quote a value containing
#. Unquoted,#starts a comment and the value is cut there — the same rule docker compose, direnv and python-dotenv apply to the same file.SECRET="abc#def"keeps it whole, and a cut value is named in a boot warning rather than being silently short. - No interpolation.
A=$HOMEsets the literal$HOME. A secrets file that rewrites its own values is worse than one that doesn't. - A multiline value works when quoted — a PEM key pasted across lines inside
"…"survives. - The scaffold gitignores it, alongside
var/. Commit automation already stages only the artifact zone, so.envcould never be auto-committed — but a workspace gets pushed to a remote for backup, and that guards against your owngit add -A.
dotenvx
env:
provider: dotenvx
path: .env # optional; the committed, encrypted file
dotenv's shape over a file that is committed to the workspace repo, because its values
are dotenvx-encrypted: a DOTENV_PUBLIC_KEY header plus encrypted: ciphertexts. Cloning the
workspace clones its secrets; only the private key has to reach each machine separately. The
migration recipe is in sharing secrets with dotenvx.
The private key comes from one of two places, tried in this order:
| Source | Notes |
|---|---|
DOTENV_PRIVATE_KEY |
in the resolved base environment — the same base the provider layers over: the host environment, unless an embedder named another. For a .env.<name> file the variable is DOTENV_PRIVATE_KEY_<NAME> — dotenvx's own convention |
.env.keys |
a sibling of the env file, holding the same variable. Gitignored by the scaffold; an older workspace's .gitignore gets the entry backfilled when it is served. The agent's settings deny reading it |
- A missing file fails the boot, unlike
dotenv's no-op: declaring this provider declares a file committed to the repo, so an absent one means a broken clone, not a workspace that never needed secrets. - Encrypted values with no key fail the boot, naming the variable to set and the keys path that was tried. A value that cannot be decrypted — wrong key, damaged ciphertext — fails the boot naming the key, never a value.
- Plaintext values boot, with a warning per key — "run
dotenvx encryptbefore committing". A file with no encrypted values at all gets one migration hint instead of one warning per key. Mixed files are the normal mid-migration state; the warnings are what keep the migration finishing. - A
DOTENV_PRIVATE_KEY*found inside the committed.envis stripped and warned about loudly — remove it and rotate the keypair. That is the exact incident this provider exists to prevent. - Keyring metadata never enters the bag.
DOTENV_PUBLIC_KEY*andDOTENV_PRIVATE_KEY*entries are file plumbing, not workspace variables. - No interpolation, no command substitution. Values are decrypted one by one;
$VARin a decrypted value stays literal and$(cmd)never runs — the same stance asdotenv, and the reason the provider does not use dotenvx's own parser, which expands both. - Encrypting and rotating is the dotenvx CLI's job:
bunx @dotenvx/dotenvx encrypt,bunx @dotenvx/dotenvx rotate. The platform itself depends only on@dotenvx/primitives, imported lazily — a plain-dotenvworkspace never loads it.
aws-secrets
env:
provider: aws-secrets
secretId: openstation/notes
region: us-east-1 # optional; else AWS_REGION / AWS_DEFAULT_REGION, else a load error
One JSON secret per workspace, a flat object of variable name to string value:
{ "SLACK_BOT_TOKEN": "xoxb-…", "SLACK_APP_TOKEN": "xapp-…" }
One secret rather than one per variable, because that makes it one API call at boot, one atomic rotation, and one IAM statement:
{ "Effect": "Allow", "Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:…:secret:openstation/notes-*" }
- Read once, at boot. Nothing pins a version stage, so AWS serves the current one. A
rotated secret means a restart — exactly what a changed
EnvironmentFilealready means. There is no cache to invalidate and no mid-turn credential swap. - Credentials come from the default AWS chain — task role, instance profile, SSO profile, environment. Nothing about AWS auth enters the manifest.
- Region comes from
env.region, elseAWS_REGION, elseAWS_DEFAULT_REGION, else the read is a load error naming what to set. Those two fallbacks read the host's environment, not the workspace's: this provider is what fills the workspace's bag, so it cannot read from it. - Content that cannot be used is a boot error naming the secret, and never a value: one
that isn't JSON, isn't an object, or is binary with no
SecretString. A non-string value names the offending key too — it is the one case where a key is what's wrong. - The AWS SDK is imported lazily, so a
dotenvworkspace never loads it.
A failure at boot looks like this — the origin, and the reason, with nothing of the secret in it:
$ openstation notes serve
env provider aws-secrets: openstation/notes has no region — set env.region in the manifest, or AWS_REGION / AWS_DEFAULT_REGION
What the provider can no longer supply
The manifest names the provider, so the manifest has to be parsed before the provider can run. The order is manifest → env → connectors, and it was the other way round before the block existed. Two consequences follow, and both are permanent:
- A workspace's env cannot supply anything the manifest parse itself needs. This is safe
today because the loader resolves credential names and never reads a value — a
${VAR}in aconnections:block is a reference the loader strips toVAR, nothing more. It is now a rule rather than an accident. - A workspace's env cannot set
OPENSTATION_LOG_LEVEL.devandserveopen the log before they load the manifest, and the provider runs inside the boot that follows — so the level is resolved from the host environment while no workspace variable exists yet. The host environment and--log-levelstill work; a level in a workspace's.envis read, put in the bag, and ignored by a logger that was already open.
Who gets the bag
The resolved bag reaches the connectors (which credentials are present decides which
connectors start), the preflight (including its PATH, so a venv the workspace puts on
PATH is found), the Claude child, and the boot banner, which reports what the other
three were given. That is the whole list.
It does not reach subprocesses the platform itself spawns. git in particular is spawned
with no env at all, so it inherits the daemon's process.env — a workspace-declared
GITHUB_TOKEN would not be visible to a future git push, and making one visible means
threading the bag to that call site, not adding it to a provider.
Inside a turn it is a different question again: the child inherits the whole bag, so any tool the agent is granted can read all of it. See below.
Checking what a workspace resolves
openstation details reports the provider and its origin in the WORKSPACE block, above the
credential markers it qualifies:
env dotenv /Users/you/openstation/notes/.env (1 applied)
Three outcomes, not two. (N applied) is a successful read; (unavailable: …) is a read that
failed, and the reason leads the WARNINGS block as well, since every =unset marker below it
is then the host environment's answer rather than this workspace's. In --json that is
env.unavailable — a normal expected value on that field, including for a timeout, not an
error the caller has to handle as one.
The third, (not read), is an embedded caller's: it appears only when details({ env }) is
handed an environment, since reading a provider would then layer values under a bag the caller
had already decided. The CLI never passes one, so no openstation details invocation prints it.
Two things about the count are deliberate:
detailscounts what the provider supplied; the banner counts what was new. The banner printsapplied— the variables that had no value before — and namesreplacedseparately.detailshas no room to name anything, so its number isapplied + replaced. The same workspace can honestly print2 appliedat boot and(3 applied)here.detailsreads the provider without being asked to, and bounds anaws-secretsread at 5s. It is otherwise a local command, and anaws-secretsworkspace turns it into a network call no flag requested — but gating it behind--livewould print every variable unset for a workspace whose secrets simply were not read, which is misleading in the other direction. So it reads, and degrades the row with a reason if it can't. Only that provider is bounded; adotenvread is a local file and gets no timer.Be precise about what the bound covers: it bounds what you are told. The report renders 5s after the read starts, whatever the SDK is doing — the AWS SDK is imported before the timer, so the wall clock is that import plus this. The abort it fires reaches the
GetSecretValuerequest itself, so a stalled Secrets Manager call releases its socket and the CLI exits (measured: 5.6s, against never). It does not reach credential resolution — an SSO refresh, ansts:AssumeRole, acredential_processsubprocess — which runs earlier, through its own clients, and never sees the signal. A host stalled there gets a correct report at 5s and then a process that outlives it (measured: killed at 25s). A known gap, not a claim.
dev and serve deliberately get no bound, and the contrast is the point: a report can
degrade to "unavailable" and still be worth printing, while a daemon that cannot read its
secret has nothing to degrade to. It should fail loudly, on the SDK's own terms, rather than
fast on ours.
What a .env does not protect you from
The agent's settings deny Read(.env) — and Read(.env.keys), the dotenvx private key — and
that's worth having, but understand its limit: it guards the file, not the value. The
provider's variables go into the bag (for dotenvx, decrypted), and the executor
hands the Claude child that whole bag — so every credential this workspace holds is visible to
any tool the agent may run, including a granted subprocess like the notes example's
tools/stats.py. No file-path deny can catch that, because nothing reads a denied path.
Scoping the env closed half of this: the child now sees one workspace's variables rather than every workspace's, and a second workspace booted in the same process contributes nothing to the first. The other half is open. Practical consequence: put only what this workspace's agent and connectors actually need behind its provider. Narrowing the child environment to a declared allowlist is the real fix and isn't built yet.