openstation

Reference

Environment variables

Authentication, connector credentials, and environment providers.

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.

Email

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 stale SLACK_BOT_TOKEN left in a ~/.zshrc shadowed 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 — for dotenv, 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. dev fails 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/.env creates 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_ME placeholder 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=$HOME sets 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 .env could never be auto-committed — but a workspace gets pushed to a remote for backup, and that guards against your own git 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 encrypt before 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 .env is 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* and DOTENV_PRIVATE_KEY* entries are file plumbing, not workspace variables.
  • No interpolation, no command substitution. Values are decrypted one by one; $VAR in a decrypted value stays literal and $(cmd) never runs — the same stance as dotenv, 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-dotenv workspace 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 EnvironmentFile already 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, else AWS_REGION, else AWS_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 dotenv workspace 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 a connections: block is a reference the loader strips to VAR, nothing more. It is now a rule rather than an accident.
  • A workspace's env cannot set OPENSTATION_LOG_LEVEL. dev and serve open 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-level still work; a level in a workspace's .env is 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:

  • details counts what the provider supplied; the banner counts what was new. The banner prints applied — the variables that had no value before — and names replaced separately. details has no room to name anything, so its number is applied + replaced. The same workspace can honestly print 2 applied at boot and (3 applied) here.

  • details reads the provider without being asked to, and bounds an aws-secrets read at 5s. It is otherwise a local command, and an aws-secrets workspace turns it into a network call no flag requested — but gating it behind --live would 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; a dotenv read 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 GetSecretValue request 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, an sts:AssumeRole, a credential_process subprocess — 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.

View Markdown source on GitHub ↗