Environment Variables
All environment variables used by Crewlet and its integrations.
Two kinds of variable appear below. A few names are read directly by the engine or CLI (marked as such). Everything else is a ${VAR} reference convention: any string value in the YAML config can reference any environment variable (see Usage in YAML), and the names listed here are simply the conventions the bundled examples/nimbus.company.yaml uses — rename them freely as long as the config references match. (Its smaller sibling, examples/nimbus-claude-cli.company.yaml, is chat-only on a coding-CLI subscription and so references a small subset: MATTERMOST_* and CREWLET_*.)
| Variable | Description | Where to get it |
|---|---|---|
CREWLET_NODE_ID | This process’s identity, when node.id is unset in the Tier A file. Labels every log line, health payload, and config-apply event. Must be stable across restarts; defaults to node-0 | Your orchestrator (Kubernetes pod name / StatefulSet ordinal, or the host name) |
TURSO_GO_CACHE_DIR | Read directly by the turso driver (and by the engine, which prepares it): where its embedded ~20 MB native library is extracted and loaded from. Default os.UserCacheDir() — ~/.cache on Linux. Point it at a writable, persistent path in an ephemeral container, or every restart pays the extraction again. See Deployment § The store | — |
CREWLET_API_TOKEN_FOUNDER | Bearer token for the founder API identity (api.auth.tokens) | Generate one: openssl rand -hex 32 |
LLM_API_KEY | API key for your LLM provider (providers.llm.default.api_keys) | Your LLM provider dashboard |
LLM_MODEL | Model id served by your OpenAI-compatible endpoint (providers.llm.default.model in the example) | Your LLM provider docs |
LLM_BASE_URL | Your OpenAI-compatible endpoint’s /v1/ base URL (providers.llm.default.base_url in the example) | Your LLM provider docs |
OPENAI_API_KEY | Read directly as a fallback by the openai / openai-compatible LLM providers when api_keys is omitted or empty, and by the OpenAI embeddings provider when api_key is unset. Resolved through the secret store first. Only when the field names nothing: an entry whose ${VAR} resolves to nothing runs on no key (a clean 401 naming the provider) rather than on this one, and Settings › Models & keys shows that key as Not set | OpenAI dashboard |
OPENAI_ORG_ID, OPENAI_PROJECT_ID, OPENAI_ADMIN_KEY, OPENAI_BASE_URL, OPENAI_CUSTOM_HEADERS, OPENAI_WEBHOOK_SECRET | Never read by the engine’s own OpenAI clients (a coding box is handed OPENAI_BASE_URL for its own CLI — see Code sandbox). The OpenAI SDK loads each at construction and would send them to whatever endpoint an openai-compatible entry or an embedder names; both providers undo every one, so the only OpenAI settings the engine uses are the ones its configuration states | — |
ANTHROPIC_API_KEY | Read directly as a fallback by the anthropic LLM provider when api_keys is omitted or empty — never when the keys it names resolve to nothing. Resolved through the secret store first | Anthropic Console |
CREWLET_LLM_CLI_HOME | Read directly by every cli-agent LLM provider: the root under which each provider keeps its credential directory and per-seat CLI homes (<root>/<provider key>). Default ~/.crewlet/llm-cli. Point it at a persistent volume when the engine runs in an ephemeral container, or the subscription login is lost on every restart. Overridden per provider by cli.state_dir. | — |
CLAUDE_CODE_OAUTH_TOKEN | Read as the subscription credential by a cli-agent provider on the claude-code profile when cli.auth.token is unset. Resolved through the secret store first, so crewlet llm login <key> -capture-token stores it there and nothing needs exporting. | claude setup-token, or crewlet llm login … -capture-token |
CREWLET_LLM_CLI_<KEY>_CREDENTIALS | Conventional name for a cli-agent provider’s exported credential bundle (<KEY> is the providers.llm key upper-cased, non-alphanumerics folded to _). Restored into the provider’s credential directory at boot when that directory is empty, so a fresh container comes up already authenticated. Overridden by cli.auth.credential_bundle. | crewlet llm export <key> -secret-store |
CREWLET_SANDBOX_LOCAL_HOME | Read directly by the local sandbox backend: the parent directory its per-box homes are created under. Default ~/.crewlet/sandboxes. Overridden per provider by providers.sandbox.local.state_dir. | — |
CREWLET_TOOL_SKILLS_SPACE | The -space default for crewlet confluence import and crewlet confluence resync: which Confluence space Tool Skill pages are published into and read back from. Default knowledge.skills_container, itself defaulting to TS. The engine never reads this variable — the space it watches comes from the company document and only from there, because a fleet whose nodes each read a variable out of whoever’s shell started them would disagree about which space holds the skills. To turn tool skills off, set skills_container: "" in the company config. | — |
CREWLET_API_TOKEN | Read directly by the CLI commands that write through a running node (crewlet config against a live node, crewlet secrets with -api, crewlet budgets): the bearer token to send. Read before the Tier A file’s first api.auth.tokens entry, and never taken as a flag value, because a token on a command line lands in shell history and ps | The value of one of the node’s api.auth.tokens |
CREWLET_OPERATOR | Read directly by the CLI as the operator it records when it writes or reads the stores itself rather than through a node: a revision crewlet config import writes locally or crewlet config rekey writes, a secret crewlet secrets set or crewlet llm login stores locally, a secrets rekey, and the secret_revealed log line. Falls back to USER, then LOGNAME, then unknown | Your own name or a CI job id |
Logging
Section titled “Logging”Read directly by the engine and the CLI. Each describes the invocation
rather than the company: the same crewlet.yaml is deployed to a container
with no terminal and run on a laptop with one, and the level a CI step wants
out of crewlet migrate has nothing to do with the node it is migrating.
Two of them are variables because there is nowhere else for them to live:
CREWLET_LOG_COLOR and NO_COLOR describe the screen someone is looking at,
which no deployment document can know, so colour has no Tier A field at all.
The other three answer for every command except crewlet run, which takes
no logging flags — crewlet run has -log-level, -log-format, -log-file
and -debug of its own, reads the logging: block from its Tier A file, and
ignores all three variables. So CREWLET_LOG_LEVEL, CREWLET_LOG_FORMAT and
CREWLET_LOG_FILE do have Tier A counterparts; what they have no counterpart
for is the one-shot commands, which read no logging: block.
| Variable | Description | Where to get it |
|---|---|---|
CREWLET_LOG_LEVEL | The level every command except crewlet run logs at — debug, info, warn (the default) or error. Those commands are quiet by design (a store open logs a line per migration, which is noise on a one-shot command whose stdout is piped or diffed), and this is the escape hatch when a half-applied migration or a failing deploy gate is what you are looking at. A value this build does not recognise resolves to warn, so a typo can never be why an operator cannot run a migration. crewlet run ignores it and takes its level from logging.level in Tier A and its own -log-level / -debug flags | — |
CREWLET_LOG_FORMAT | The shape those same commands log in — console (the default), text or json. The sibling of CREWLET_LOG_LEVEL, and it exists for the same reason: these commands take no logging flags, so a CI step shipping a crewlet migrate run to a collector has no other way to ask for json. An unrecognised name resolves to console | — |
CREWLET_LOG_FILE | A file every command except crewlet run appends its log to, beside stderr — the third sibling of the two above, and it exists for the same reason: these commands take no logging flags, so a CI step that wants a crewlet migrate in the same durable record as the node it is migrating for has no other way to ask. It carries what the command logs; whatever it prints for its caller still goes to stdout, so a piped or diffed output is unchanged. Rotation is the same as a node’s, at the defaults (100 MB, 5 backups) — the caps belong to a deployment’s own logging.file block, and a one-shot command reads none, nor does it have a logging.stderr to switch off: stderr always stays on here. A path that cannot be opened fails the command: unlike a level, a path has no sane default to fall back to, and a record an operator asked for and silently did not get is worse than a refusal. crewlet run ignores it and takes its file from logging.file in Tier A and its own -log-file flag | — |
CREWLET_LOG_COLOR | Whether console output carries ANSI colour — auto (the default: colour only when the stream is a live terminal), always or never. always is for a CI log viewer that renders ANSI without being a terminal, which auto-detection cannot discover on its own. Applies to crewlet run and every other command | — |
NO_COLOR | Set to any non-empty value to suppress colour, following the no-color.org convention. It overrides auto — colour this program would have added on its own initiative — but not an explicit CREWLET_LOG_COLOR=always, which is an instruction about this program rather than initiative | — |
TERM=dumb also disables colour: an editor’s shell pane sets it precisely to
say it cannot render escape sequences. None of the three colour levers reach a
log file: a file is never a terminal, so a console-format file is never
coloured and always carries the full date.
| Variable | Description | Where to get it |
|---|---|---|
SLACK_BOT_TOKEN_<ROLE> | Bot User OAuth Token (xoxb-...) | Written by crewlet slack provision, or Slack app > OAuth & Permissions |
SLACK_SIGNING_SECRET_<ROLE> | Signing Secret | Written by crewlet slack provision, or Slack app > Basic Information |
SLACK_CONFIG_REFRESH_TOKEN | Bootstrap only. The app-configuration refresh token (xoxe-1-...) that seeds an empty app ledger; crewlet slack provision exchanges it for a 12-hour access token and stores both halves of the rotated pair in the ledger. Once the ledger holds a pair, this variable is ignored — see the precedence note below. | api.slack.com/apps > Your App Configuration Tokens (once) |
Replace <ROLE> with the role name in uppercase (e.g., SLACK_BOT_TOKEN_ENGINEER). The per-role names are conventions — any ${VAR} name referenced from role.integrations.slack works, and crewlet slack provision writes whatever names the YAML uses.
The ledger beats the shell, which is the reverse of the usual “an explicit input wins” rule, and the reverse is the point. Slack’s config-token rotation is single-use in both directions: every successful rotate invalidates the refresh token it was given, so the value sitting in a SLACK_CONFIG_REFRESH_TOKEN export is dead the moment this command first used it. Preferring it would trade the ledger’s live pair — the only way back into the operator’s apps — for a token Slack has already retired, on every run after the first, for ever. So -config-token and $SLACK_CONFIG_REFRESH_TOKEN seed a ledger that holds nothing, and are ignored once it does.
| Variable | Description | Where to get it |
|---|---|---|
JIRA_URL | Your Jira instance URL (integrations.jira.url, and the JIRA_URL the shared atlassian MCP server reads) | e.g., https://company.atlassian.net |
JIRA_ADMIN_TOKEN | Admin or service-account API token (integrations.jira.token) | Atlassian account > API tokens |
JIRA_ADMIN_EMAIL | That account’s email, for Cloud Basic Auth (integrations.jira.email) | Your Atlassian account email |
JIRA_WEBHOOK_SECRET | HMAC secret for Data Center webhooks (integrations.jira.webhook_secret); Cloud relays through the Forge app instead | Set when creating the Jira webhook |
JIRA_SITE_URL | The human-clickable site base, as the jira_base_url skill variable | e.g., https://company.atlassian.net |
Per-agent Atlassian credentials
Section titled “Per-agent Atlassian credentials”Each seat’s own Atlassian account covers Jira and Confluence alike, on the shared atlassian MCP server’s mcp_env entry.
| Variable | Description |
|---|---|
ATLASSIAN_EMAIL_<SEAT> | The seat’s Atlassian account email (mcp_env.atlassian.JIRA_USERNAME and CONFLUENCE_USERNAME, e.g. ATLASSIAN_EMAIL_CTO) |
ATLASSIAN_TOKEN_<SEAT> | That account’s API token (mcp_env.atlassian.JIRA_API_TOKEN and CONFLUENCE_API_TOKEN, e.g. ATLASSIAN_TOKEN_CTO). The knowledge search runs as the seat when it holds one |
ATLASSIAN_FOUNDER_ACCOUNT_ID | A human seat’s Atlassian account id (contact.atlassian_account_id) |
Confluence
Section titled “Confluence”| Variable | Description | Where to get it |
|---|---|---|
CONFLUENCE_URL | Your Confluence instance URL (integrations.confluence.url) | e.g., https://company.atlassian.net/wiki |
CONFLUENCE_ADMIN_TOKEN | Admin or service-account API token (integrations.confluence.token), what the tool-skill sync and skill promotion run on | Atlassian account > API tokens |
CONFLUENCE_ADMIN_EMAIL | That account’s email, for Cloud Basic Auth (integrations.confluence.email) | Your Atlassian account email |
CONFLUENCE_SITE_URL | The human-clickable wiki base, as the confluence_base_url skill variable | e.g., https://company.atlassian.net/wiki |
CONFLUENCE_WEBHOOK_SECRET | HMAC secret for Data Center webhooks (integrations.confluence.webhook_secret) | Set when creating the webhook |
CONFLUENCE_WEBHOOK_TOKEN | Shared token every Cloud hook carries in its URL (integrations.confluence.webhook_token), compared constant-time by /webhooks/confluence/{event}. Confluence Cloud signs nothing, so this is the whole authentication: treat it as a signing key. | Minted by crewlet confluence provision; -recreate-webhooks rotates it |
Per-agent Confluence credentials go through role.mcp_env on the atlassian
MCP server (CONFLUENCE_USERNAME / CONFLUENCE_API_TOKEN), the
ATLASSIAN_EMAIL_<SEAT> and ATLASSIAN_TOKEN_<SEAT> pair above.
Web Search (optional)
Section titled “Web Search (optional)”| Variable | Description | Where to get it |
|---|---|---|
TAVILY_API_KEY | Key for the shared Tavily web-search MCP server the example org declares | https://tavily.com |
Mattermost
Section titled “Mattermost”Conventions used by the Mattermost integration and its provisioning/bootstrap tooling. Apart from MATTERMOST_ADMIN_TOKEN (read directly by the CLI) and MATTERMOST_PUBLIC_URL (read by docker-compose.yml and the bootstrap script), these are ${VAR} references in the company YAML — crewlet mattermost provision mints the token values into .env for you.
| Variable | Description | Where to get it |
|---|---|---|
MATTERMOST_URL | Mattermost instance base URL (integrations.mattermost.url, and the MATTERMOST_URL each role’s MCP server reads) | Written to .env by scripts/mattermost-dev-bootstrap.sh locally; your deployment’s URL otherwise |
MATTERMOST_PUBLIC_URL | The address browsers use. Read by the bundled docker-compose.yml (it becomes MM_SERVICESETTINGS_SITEURL) and by the bootstrap script. Getting it wrong costs every human live updates while the engine keeps working — see The Site URL. Defaults to http://localhost:8065; the bootstrap defaults it to the address you reached the host on over SSH. | You — it is the address in your browser’s address bar |
MATTERMOST_ADMIN_TOKEN | Read directly by crewlet mattermost provision as the operator credential (a system-admin personal access token; --admin-token overrides) | Mattermost profile > Security > Personal Access Tokens (system-admin account) |
MATTERMOST_TOKEN_<SEAT> | Per-agent bot personal access token (each role’s integrations.mattermost.bot_token and mcp_env.mattermost.MATTERMOST_TOKEN, e.g. MATTERMOST_TOKEN_PM) | Minted by crewlet mattermost provision |
GitLab
Section titled “GitLab”Conventions used by the GitLab integration. Apart from the operator credential — read from the environment only, and never from the secret store — these are ${VAR} references in the company YAML, resolved through the secret store first and the environment behind it. crewlet gitlab provision mints the PAT values into whichever sink you name.
| Variable | Description | Where to get it |
|---|---|---|
GITLAB_ADMIN_TOKEN | Read directly by crewlet gitlab provision as the operator credential fallback (group Owner / admin PAT with api scope; -admin-token overrides) | GitLab > Access tokens |
GITLAB_ROUTING_TOKEN | Read-only routing token (integrations.gitlab.token), optional — without it, thread activity reaches only the people a payload names | A PAT with the read_api scope. Nothing mints it: the GitLab card asks for it, or set the variable yourself |
GITLAB_SIGNING_SECRET | The hook’s signing token (integrations.gitlab.signing_secret) — the HMAC key every delivery is verified against, and the route’s only credential. Must be whsec_ over standard base64 of a 32-byte key. | crewlet gitlab provision mints one into this variable; GitLab’s own Generate signing token button produces the same shape |
GITLAB_TOKEN_<SEAT> | Per-agent service-account PAT (each role’s mcp_env.gitlab.GITLAB_TOKEN, also referenced from role.sandbox.env, e.g. GITLAB_TOKEN_SWE) | Minted by crewlet gitlab provision |
GitHub
Section titled “GitHub”Conventions used by the GitHub integration. All of them are ${VAR} references in the company YAML, resolved through the secret store first and the environment behind it.
Unlike GitLab, nothing here is minted for you: GitHub issues no credential on a provisioner’s behalf, so crewlet github provision registers webhooks and reports which account each seat’s own token authenticates as. The tokens themselves are ones you create.
| Variable | Description | Where to get it |
|---|---|---|
GITHUB_WEBHOOK_SECRET | HMAC secret every inbound delivery is verified against (integrations.github.webhook_secret), and the route’s only credential. A route with nothing to check against answers 503 rather than accepting a delivery. | crewlet github provision mints one into this variable when it is empty, or GitHub > repository/org > Webhooks |
GITHUB_ENGINE_TOKEN | The credential crewlet github provision registers webhooks with (integrations.github.token), where it is required — there is no degraded form of registering a hook. The engine does not read it: participant fan-out goes through each agent’s own App, scoped to what that agent may see rather than to whatever the person who minted a token could reach, which is why the connect form asks for no personal access token at all. | A PAT with repository and organization webhook access |
GITHUB_TOKEN_<SEAT> | Optional per-agent token (each role’s mcp_env.github, e.g. GITHUB_TOKEN_SENIOR), for a seat whose tools read one. It is not what gives a seat its GitHub identity any more: each agent carries its own GitHub App, which is what mints the token its tools use and what inbound routing resolves it by. A seat with no token here and an installed App of its own is complete. | A PAT on that agent’s own GitHub account |
GITHUB_APP_KEY_<SEAT> | The private key of that agent’s own GitHub App, written by the engine when it converts the App manifest — GitHub returns it exactly once, so this is the only copy there is. Never typed in, and not deleted by a disconnect: GitHub offers no API for deleting an App registration, so the key stays valid for something that still exists and a disconnect names it rather than destroying it. | Written by the engine; nothing to obtain |
GITHUB_APP_WEBHOOK_SECRET_<SEAT> | The secret that agent’s own App signs its deliveries with, also written by the engine at conversion time and also named rather than deleted by a disconnect. | Written by the engine; nothing to obtain |
Datadog
Section titled “Datadog”Conventions used by the Datadog integration.
| Variable | Description | Where to get it |
|---|---|---|
DATADOG_WEBHOOK_TOKEN | The shared token compared against the X-Crewlet-Token header on every delivery (integrations.datadog.webhook_token). Treat it as a signing key: Datadog’s webhook can attach headers only with fixed values, so there is nothing varying with the payload to sign, and this constant-time comparison is the entire authentication. A replayed delivery is indistinguishable from a fresh one and anyone holding the token can forge an alert. Rotate it the way you would a signing secret; the next reconcile pass writes the new value into the webhook it keeps at Datadog. | Generate one: openssl rand -base64 32, or let the setup form mint it |
DATADOG_API_KEY | Says which organization the engine acts in (integrations.datadog.provisioning.api_key). Required when the block is enabled: it is half of what registers the webhook that makes alerts arrive. | Datadog > Organization Settings > API Keys |
DATADOG_APP_KEY | Says which user acts (integrations.datadog.provisioning.app_key). Datadog refuses a write carrying only an API key, and its message names neither. | Datadog > Organization Settings > Application Keys |
integrations.datadog.route_to, webhook_name and handle_tag are not secrets and belong in the company document as plain values, not as ${VAR} references — nothing resolves them, so a reference written there is used as the literal text it is. integrations.public_base_url is not a secret either, but it is resolved: a whole ${VAR} there is read through this node’s chain wherever an address is built from it, which is how one document serves a staging deployment and a production one. A reference nothing can read yields no address at all — no webhook registered, no app manifest — rather than one containing ${VAR}. route_to is required when the block is enabled: it names the seat an alert wakes when no monitor tag names an owner, and without it those alerts are verified, counted and delivered to nobody. See Routing.
The store
Section titled “The store”There is no database environment variable, because there is no database
server. The store is a local file, named by store.path in the Tier A
bootstrap YAML:
store: path: "/var/lib/crewlet/company.db"That file is owned exclusively by one engine process. It is not a shared
database and there is no DSN to point anywhere; two engines opening one file
corrupt it. Everything that genuinely has to be shared between nodes — seat
leases, config activations, the completion ledger, dedupe and the rate
valves — lives in the coordination slot instead.
There is no driver to pick: Turso is the store. TURSO_GO_CACHE_DIR (see Core
above) is where its native database engine is extracted.
The event store is a table in that same file, created by the engine’s own
migrations — there is no separate observability database to configure.
The external broker
Section titled “The external broker”Every Tier A field below takes a ${VAR}, so these are conventions rather than variables the engine looks up by name — the name is whatever your crewlet.yaml writes. They are listed because a deployment that invents its own names ends up with the same value spelled three ways.
The whole block lives under stream:, which is the only place an external NATS estate is configured. (There is no providers.queue — Tier A refuses unknown keys, so a config written against that path fails to load.)
| Variable | Tier A field | Where to get it |
|---|---|---|
CREWLET_STREAM_URL | stream.url — the NATS server to dial. Required for type: nats, and refused for embedded, which starts a server in this process and has no address for anyone to dial. The value goes to the NATS client verbatim, so a comma-separated list of a cluster’s members is one setting and the client fails over between them | Your cluster’s client address (nats://nats-1.internal:4222) |
CREWLET_NATS_TOKEN | stream.token — this engine’s bearer token, for a server running with token authentication (see Deployment § An external NATS server) | Whoever runs the cluster: it is the value in the server’s authorization { token: … } |
Four more stream: fields carry no ${VAR} convention because they are paths on disk rather than credentials. stream.credentials is a NATS credentials file — an NKey/JWT .creds, and the usual way to authenticate to an external estate instead of stream.token. stream.tls.ca / .cert / .key are the transport underneath that authentication: the private CA to trust for the server’s certificate, and the client certificate to present. A broker configured the way a hardened NATS deployment is — tls { verify: true } — refuses a connection presenting no client certificate, whatever credentials would have followed.
There is no coordination variable, because there is no second broker to authenticate against. The coordination KV — the completion ledger, the delivery dedupe, the token counter, the activation pointer, the company’s sealed secrets, and in a fleet the seat leases too — rides the stream’s own NATS connection on every topology, so coordination: carries only type (local or embedded-kv) and lease_ttl_seconds, and has nothing to point anywhere. A second dial would work and would be worse: two connections to one broker fail independently, so a node could hold live leases over a connection that still works while the one carrying its inbox has dropped — alive to its peers, deaf to its work. See Coordination.
Secret Encryption (Optional)
Section titled “Secret Encryption (Optional)”| Variable | Description | Where to get it |
|---|---|---|
CREWLET_SECRET_KEY_<ID> | Base64-encoded 32-byte key referenced by a Tier A secrets.keys[].material. When a keyring is configured, the entire Tier B config is stored encrypted at rest in the DB as one opaque blob instead of as verbatim ${VAR} references. | crewlet secrets keygen |
The keyring lives in Tier A (crewlet.yaml) and is the sole root of trust — the DB holds only the encrypted document, never the key, and the key is required for every config read. Without a keyring, Crewlet keeps the default ${VAR}-reference behaviour and every env var on this page is resolved from the environment at construction time. See Configuration § Secrets.
A keyring lets you retire the per-secret env vars on this page (LLM_API_KEY, ATLASSIAN_TOKEN_<SEAT>, SLACK_BOT_TOKEN_<ROLE>, *_WEBHOOK_SECRET, and the rest) two different ways:
- Secret store (recommended) — keep the
${VAR}references in the config and store the values in the encrypted store (crewlet secrets set, or-secret-storeon a provisioning CLI). The engine consults it ahead of the process environment, so a name it answers no longer needs to be exported at all. Rotation is a write of one record, and it reaches every node. - Literal values in the encrypted config — set them via
PUT /configor import acompany.yamlwith literals. Simpler, but every rotation writes a new immutable revision that archives the superseded secret, and one credential referenced from two places (a Slack bot token is bothrole.integrations.slack.bot_tokenandrole.mcp_env.slack.SLACK_MCP_XOXB_TOKEN) becomes two literals that must change together.
Either way, ${VAR} references that remain unanswered by the store still resolve from the environment.
Those are the only two sources. The engine does not read a .env file.
crewlet … provision -env-file PATH writes one for an operator to source,
and the values reach the engine only once they are in the process
environment — so an -env-file run ends with “source it and restart”, every
time. A dotenv loader in the engine would be a third source of truth for
secrets, discovered by filename and able to override the Tier A keyring that
opens the store, which is the inversion the two-tier design exists to refuse.
-secret-store is the path that needs no file and no restart: the values land
in the encrypted table, and crewlet config activate
makes a running fleet re-read them.
Nothing in Tier A can move into the store, no matter how it is configured — CREWLET_SECRET_KEY_<ID> above all. Tier A is what locates and decrypts the store, so it is always env- or file-sourced; it resolves with the store deliberately switched off.
OpenTelemetry (Optional)
Section titled “OpenTelemetry (Optional)”All read directly by the engine.
| Variable | Description | Example |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | The collector’s base URL. The engine appends the signal path, so do not include /v1/traces here. | http://localhost:4318 |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | The traces endpoint in full, used verbatim. Overrides the base above when you need a non-standard path. | https://collector.example.com/otlp/v1/traces |
OTEL_EXPORTER_OTLP_HEADERS | k=v,k2=v2 headers for the OTLP backend (e.g. auth). Also used engine-side as the upstream auth for forwarded sandbox telemetry — never handed to the sandbox itself. | authorization=Bearer%20... |
OTEL_EXPORTER_OTLP_PROTOCOL | How the engine talks to the collector: http/protobuf (default) or grpc. Anything else is refused at startup, naming the two that work. | http/protobuf |
OTEL_SERVICE_NAME | The service the spans are reported under. Defaults to crewlet; set it when two companies share one collector. | crewlet-acme |
OTEL_TRACES_SAMPLER_ARG | Head-sampling ratio for traces this node roots, 0–1. Unset samples everything. An unparseable or out-of-range value warns and falls back to always-on rather than refusing to boot. | 0.1 |
The base and the signal endpoint are different settings. OTEL_EXPORTER_OTLP_ENDPOINT
is a base that the exporter appends /v1/traces to; OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
is the complete URL. Putting /v1/traces on the base is the common mistake — it also
reaches the sandbox forwarder, which appends /v1/{signal} of its own, so the
collector sees /v1/traces/v1/traces.
Sampling is parent-based, always. A remote sampling decision is honoured whatever the ratio says, because these traces cross processes routinely and an unsampled parent with sampled children is a broken tree at the collector. The ratio governs only the traces this node starts itself.
When OTEL_EXPORTER_OTLP_ENDPOINT (or the traces endpoint) is set, the engine
exports spans to it — Jaeger, Grafana Tempo, or any OTLP backend. Without it,
spans are still created and their ids still flow into every event, the event
store and the dashboard’s trace view; nothing is shipped anywhere. The engine’s
exporter and the sandbox OTLP forwarder read the
same endpoint and headers on purpose, so a coding agent’s spans land in the
same backend as the turn that started it and nest underneath it.
Code Runtime (Sandbox, Optional)
Section titled “Code Runtime (Sandbox, Optional)”Used only when providers.sandbox is configured so sandbox-enabled roles can author code in an isolated sandbox — see Code Sandbox. There is nothing to install: the binary carries every backend it has, and talks to E2B’s REST API directly. The variable names below are the conventions the Nimbus example references; any ${ENV} name works.
| Variable | Description | Where to get it |
|---|---|---|
E2B_API_KEY | E2B API key, referenced by providers.sandbox.api_key. Required for self-hosted clusters too — every call authenticates with it, sent as X-API-KEY; the cluster domain only changes which API is talked to. | e2b.dev dashboard (cloud) or your self-hosted cluster’s key management |
E2B_DOMAIN | Self-hosted / local cluster domain, referenced by providers.sandbox.domain. Omit for the vendor cloud. The engine reads it through the config field, never from the environment directly, so a stale export cannot silently reroute a box. | Your self-hosted E2B deployment |
CREWLET_SANDBOX_OTEL_RECEIVER_URL | Read by every node: the externally-reachable base URL of whichever node serves your webhooks (an ingress one) (e.g. http://host.docker.internal:80). With Tier A api.public.port set, the receiver is served on that public listener only, so this names it. When set, the engine wires its /otlp/{token}/v1/{signal} receiver route and sandbox runs export telemetry through it (forwarded to OTEL_EXPORTER_OTLP_* when configured). Unset = no sandbox telemetry. | Your engine’s public address |
CREWLET_MCP_BRIDGE_URL | Read by every node that runs seats: the base URL of this node’s own HTTP listener, as a sandbox can dial it. A session lives in the process that opened it, so on a fleet each node sets its own value; a load balancer in front of several nodes, or a peer that runs only ingress, answers 401 to every call. When set, the engine mounts its /mcp/{token} tool bridge, which is what lets a subscription CLI in agent mode call its seat’s own tools from inside a box (see Code sandbox § The tool bridge). Unset = no bridge, and agent mode is refused for the seat rather than started without tools; set on a node with no listener (api.port: 0), agent mode is refused naming api.port. With Tier A api.public.port set the bridge is served on that public listener only, on every node, so this names this node’s public port; without it, a node without the ingress role serves the bridge beside its probes, on api.port, and nothing else. | This node’s own address, as a sandbox reaches it |
Inside each sandbox run the engine injects CREWLET_AGENT_HANDLE and CREWLET_AGENT_EMAIL — the running agent’s identity facts, readable by role.sandbox.setup recipes (e.g. to configure git config user.name/user.email). They are outputs of the engine, not inputs you set.
The coding agent’s LLM credential derives from the role’s resolved providers.llm entry — no sandbox-specific LLM secret is needed. External-service tokens are explicit config: each seat declares them in role.sandbox.env (e.g. GITLAB_TOKEN: "${GITLAB_TOKEN_SWE}" — by convention the same PAT the seat already uses for its mcp_env.gitlab server, so merge requests land under the agent’s identity). The engine itself injects only the generic facts above — it never names a tool-specific variable; a declared ${ENV} reference that resolves to empty logs sandbox_env_unresolved at launch. With an OpenAI-compatible LLM provider, keep default_coding_agent: opencode (provider-agnostic); claude-code additionally requires an Anthropic-compatible credential.
Usage in YAML
Section titled “Usage in YAML”A ${ENV_VAR} reference works in both config files, and the two resolve it at different moments, from different places:
providers: llm: default: api_keys: # LLM providers take a list (one or many) - "${LLM_API_KEY}" embeddings: api_key: "${OPENAI_API_KEY}" # embeddings still take a single scalar- The company (Tier B) keeps a reference verbatim — in the store, in an export, on
GET /config— and resolves it where the provider, transport or integration that uses it is built: from the secret store first (when one is configured and holds the name), then the process environment. crewlet.yaml(Tier A) resolves every reference at startup, from the process environment alone — it holds the keyring that opens the secret store, so it cannot read a value out of it. Into a text field a reference works whole or embedded; into a number or a switch it must be the whole value, and what it resolves to is read as the same characters written there would be. Environment Variable References has the rules for both tiers.
An unanswered reference resolves to the empty string — except in a Tier A number or switch, where a reference that resolves to nothing is refused by name rather than read as unset.
Tier A trims what a reference carries. A value out of a file, a --from-file secret, a .env line or a captured command’s output routinely keeps a trailing newline, so every string in crewlet.yaml is trimmed of the whitespace around it before a single rule reads one — which is also what the engine then runs — and so is what a reference into a number or a switch resolves to, before it is read. That matters because the two used to differ: stream.cluster.advertise: " " validated as unset and reached the broker as a space, where nats-server refuses it while starting, and a node.labels key with a stray space passed its own check and then matched no role.placement selector. Two keys in one map that are the same key once trimmed are refused rather than collapsed, since which survived would be decided by map order. Tier B is left alone: it carries the company’s prompts and personas, where the whitespace around a fragment is the author’s.
Only the braced identifier form is substituted — ${NAME} where NAME matches [A-Za-z_][A-Za-z0-9_]*. Bare $NAME and shell parameter expansions (${1:-x}, ${line#host=}) are left untouched, so config-authored script content — a sandbox setup step’s helper script, say — survives intact.
Part of Crewlet. Generated from crewlet/crewlet main at f665f5a. This is not the current version — see the latest docs.