Configuration
This page documents all available environment variables for XTM One. The .env.sample file is the canonical source and includes inline comments for each variable.
SECRET_KEY
This is the most critical value. It encrypts all stored integration credentials. If you lose it or change it, all encrypted data becomes unrecoverable.
ADMIN_PASSWORD
The platform resets the admin password to the value in .env on every startup. This means .env is a standing password reset mechanism — anyone with read access to the file can take over the admin account by restarting the service. Treat .env as a privileged secret (owner-only permissions, never committed to version control). After first login, consider rotating the password via the UI and keeping the .env value in sync.
Security
| Variable | Required | Default | Description |
|---|---|---|---|
SECRET_KEY |
Yes | — | JWT signing + Fernet encryption key. Generate with python -c "import secrets; print(secrets.token_urlsafe(48))". Never change after deployment. |
ADMIN_EMAIL |
Yes | — | Default admin account email. Created or updated on every startup. |
ADMIN_PASSWORD |
Yes | — | Default admin account password. Reset to this value on every startup. |
ADMIN_TOKEN |
No | — | Programmatic API token for the admin (must start with fcp-, min 16 chars). |
URLs
| Variable | Required | Default | Description |
|---|---|---|---|
BASE_URL |
Yes | http://localhost:4000 |
Public URL of the platform. Used for OAuth/SAML callbacks and email links. |
FRONTEND_URL |
Yes | http://localhost:4000 |
Public URL of the frontend. Usually same as BASE_URL. |
Platform mode
| Variable | Required | Default | Description |
|---|---|---|---|
PLATFORM_MODE |
No | xtm_one |
xtm_one is the open standalone platform. Set ai_disabled to start with AI switched off — see Running without AI. |
PLATFORM_REGISTRATION_TOKEN |
No | — | Shared token for OpenCTI/OpenAEV registration. Must match across services. |
ENTERPRISE_LICENSE |
No | — | Enterprise license PEM. Can also be configured from Settings UI. |
PLATFORM_NAME |
No | Mode default | Display name override. DB value takes precedence. |
PLATFORM_LOGO |
No | Mode default | Logo override (data URI). DB value takes precedence. |
Database
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
No | Built from DB_PASSWORD |
Full asyncpg connection string. Override when using external PostgreSQL. |
DB_PASSWORD |
Yes (if bundled) | — | Password for the bundled PostgreSQL container. |
DB_POOL_SIZE |
No | 15 |
Connections per worker process. |
DB_MAX_OVERFLOW |
No | 10 |
Extra connections allowed beyond pool size. |
PG_MAX_CONNECTIONS |
No | 200 |
PostgreSQL max_connections (Docker Compose managed only). |
Redis
| Variable | Required | Default | Description |
|---|---|---|---|
REDIS_URL |
No | redis://redis:6379 |
Redis connection string. Override for external Redis. |
REDIS_PASSWORD |
No | — | Redis password (if required). |
REDIS_MODE |
No | standalone |
standalone or sentinel. |
REDIS_SENTINEL_HOSTS |
No | — | Comma-separated Sentinel addresses (host:port). |
REDIS_SENTINEL_SERVICE |
No | mymaster |
Sentinel service name. |
File storage (S3 / MinIO)
| Variable | Required | Default | Description |
|---|---|---|---|
S3_ACCESS_KEY |
Yes | — | S3/MinIO access key. |
S3_SECRET_KEY |
Yes | — | S3/MinIO secret key. |
S3_ENDPOINT |
No | minio:9000 |
S3-compatible endpoint. |
S3_BUCKET |
No | copilot-files |
Bucket name. |
S3_USE_SSL |
No | false |
Enable TLS for S3 connections. |
S3_REGION |
No | — | AWS region (required for AWS S3). |
AI providers
Pre-provision LLM providers via environment variables so the platform is ready without UI configuration.
| Variable | Required | Default | Description |
|---|---|---|---|
LLM_PROVIDER_{NAME}_API_KEY |
No | — | API key for the named provider. Re-applied on every startup (env wins). The provider shows an Env badge in Settings → AI Models and cannot be deleted from the UI; removing the variable and restarting removes it. |
LLM_PROVIDER_{NAME}_MODELS |
No | All available | Comma-separated list of models to expose. Either pinned or an editable baseline, depending on LLM_PROVIDER_MODELS_EDITABLE. When unset, the provider's stored model list is never touched by the startup seed. |
LLM_PROVIDER_MODELS_EDITABLE |
No | false |
false: the *_MODELS list is the catalog — it overwrites the provider's models on every startup, the Models editor is hidden for env-seeded providers and the API refuses model changes on them (403). true: the *_MODELS list is a baseline — seeded on first startup, then only its changes are applied on later restarts (models added to the variable are added, models removed from it are removed, every other edit made in Settings → AI Models is kept), and administrators can curate the models of env-seeded providers from the UI. The API key stays env-managed and the provider still cannot be deleted from the UI. |
MODELS_ENV_PROVIDING_ONLY |
No | false |
When true, all LLM provider management from the UI/API is disabled (add, edit, rename, delete return 403); providers are defined only through LLM_PROVIDER_* variables. Overrides LLM_PROVIDER_MODELS_EDITABLE. |
DEFAULT_MODEL |
No | First configured | Platform-wide default model. Re-applied on every startup when a configured provider still offers it (a warning is logged otherwise and the current default is kept). |
Supported provider names: OPENAI, ANTHROPIC, GEMINI, OPENROUTER, OLLAMA, MISTRAL, AZURE_OPENAI, BEDROCK, LITELLM, CUSTOM_OPENAI.
Managed deployments: operator baseline, admin curation
Set the credential and a baseline catalog from the environment, then let the tenant administrator curate which models are actually offered from Settings → AI Models:
LLM_PROVIDER_ANTHROPIC_API_KEY=sk-ant-...
LLM_PROVIDER_ANTHROPIC_MODELS=claude-opus-5,claude-sonnet-5,claude-haiku-4-5
LLM_PROVIDER_MODELS_EDITABLE=true
Adding a model to LLM_PROVIDER_ANTHROPIC_MODELS rolls it out to every install on the next restart; removing one retires it everywhere (agents, user preferences and platform defaults that pointed at it are reset). Models the administrator enabled or disabled on top of the baseline are preserved across restarts.
Performance
| Variable | Required | Default | Description |
|---|---|---|---|
WEB_WORKERS |
No | 2 |
Uvicorn worker processes. Production: 2 × CPU cores + 1. |
WORKER_CONCURRENCY |
No | 20 |
Concurrent async jobs per worker container. |
MAX_CONCURRENT_RUNS |
No | 10 |
Max parallel assignment executions. |
MAX_CONCURRENT_SUB_AGENT_TASKS |
No | 5 |
Max parallel sub-agent tasks. |
MAX_CONCURRENT_AUTONOMOUS_RUNS |
No | 12 |
Dedicated slots for autonomous attack-path runs, separate from the assignment/sub-agent pool so dozens can run in parallel without starving normal execution. 0 shares the pool. |
AUTONOMOUS_BURST_MAX_CYCLES |
No | 8 |
Max decision cycles an autonomous run chains back-to-back before briefly yielding, so a multi-step engagement doesn't stall between cycles. |
AUTONOMOUS_BURST_BUDGET_SECONDS |
No | 180 |
Wall-clock budget for that back-to-back burst (whichever limit is reached first triggers the yield). |
AUTONOMOUS_WAITING_INPUT_RECHECK_SECONDS |
No | 3600 |
How often a run waiting on operator input re-checks for an answer or stop. 0 waits purely for the operator's reply. |
AUTONOMOUS_CANCEL_POLL_SECONDS |
No | 2 |
How often the worker re-checks whether an in-flight run was stopped, so an operator Stop aborts the running agent within seconds instead of after the current decision cycle finishes. 0 falls back to the slower lease-heartbeat detection. |
PostgreSQL connection budget
(DB_POOL_SIZE + DB_MAX_OVERFLOW) × (WEB_WORKERS + SAQ workers) + 20 < PG_MAX_CONNECTIONS
Example with 4 web workers and 1 SAQ worker: (15 + 10) × (4 + 1) + 20 = 145 → fits the default PG_MAX_CONNECTIONS=200.
Agent run budget
Cost controls for a single agent run (one assignment execution). Each agent run gets a budget object bounding it along four dimensions at once; before every model call the run reserves that call's worst-case spend, debits the actual spend afterwards, and refuses the next call once the budget no longer covers it. A slice of the cost/token budget is always held back so the run can still compose a final answer from the work it completed.
Every limit at 0 (the default) means no budget is applied and runs behave exactly as before.
These values do double duty. They are the default every run inherits, and they are the ceiling a non-admin cannot exceed: an agent's Budget tab and a flow's Budget node may tighten any dimension freely, but raising one above the value set here requires an administrator. Set them even on a deployment that mostly relies on per-agent budgets — with a dimension left at 0 there is no ceiling for it, so any user may set any value for that dimension on their own agents. See Run budgets for the per-agent and per-flow surfaces and how the layers resolve.
| Variable | Required | Default | Description |
|---|---|---|---|
AGENT_LOOP_MAX_COST_USD |
No | 0 |
Hard ceiling, in USD, on the model spend of one run. Requires pricing for the model in the platform's price table (seeded automatically for known models); with no price known, cost is not enforced (a warning is logged) and the other limits still apply. 0 = unlimited. |
AGENT_LOOP_MAX_TOTAL_TOKENS |
No | 0 |
Hard ceiling on the tokens one run may consume. 0 = unlimited. |
AGENT_LOOP_MAX_TOOL_CALLS |
No | 0 |
Hard ceiling on the number of tool calls one run may make. Bounds parallel tool use, which the internal iteration limit cannot. 0 = unlimited. |
AGENT_LOOP_MAX_DURATION_SECONDS |
No | 0 |
Hard ceiling on the wall-clock duration of one run. Combined with the assignment's own timeout — the tighter of the two applies. 0 = unlimited. |
AGENT_LOOP_BUDGET_FINAL_ANSWER_RESERVE |
No | 0.1 |
Fraction of the cost and token budgets reserved for the run's closing answer, so an exhausted budget still yields a written result instead of a raw digest of tool output. Clamped to 0–0.5. |
AGENT_LOOP_BUDGET_FALLBACK_ENABLED |
No | false |
When the cost budget can no longer pay for the next model call, continue the run on the platform's fallback model (Settings → AI Models → Fallback Model / DEFAULT_FALLBACK_MODEL) instead of stopping. One downgrade per run. Only cost qualifies — a cheaper model buys no extra tokens, tool calls, or time. An individual agent or flow may turn this on for itself even when it is off here: it cannot raise the cost ceiling, so it is not a privileged change. |
A budget that cannot cover one model call
A limit smaller than the cost or tokens of a single call to the configured model makes every run stop immediately and return a summary of tool output. The backend logs an explicit warning at the start of such a run — raise the budget, or lower the model's maximum output tokens.
Finishing on a cheaper model instead of stopping
A cost cap of, say, $0.50 on an expensive model usually means "don't spend more than $0.50 at this quality", not "abandon the task" — and the same budget buys several times more work on a cheaper model. With AGENT_LOOP_BUDGET_FALLBACK_ENABLED=true, a run that reaches its cost budget switches to the fallback model and carries on, once. The rest of the run — including the closing answer — is then charged at the fallback's rates, so the cap still holds.
It is off by default: finishing a run on a weaker model is a quality decision, so it has to be one you made. Requirements and limits:
- A fallback model must be configured, and the platform must know its price (pricing is seeded automatically for known models). Without a price there is no way to know the next call is actually cheaper, so the run stops as before.
- The switch is visible: the run's timeline shows a model-downgrade step, the agent is told its model changed, and every call in the run records which model produced it.
- The switch is skipped — and the run stops instead — when the fallback would make things worse: its context window cannot hold the agent's instructions, its provider allows fewer tools than the run is using, or the run requires structured JSON output and the fallback is on a different provider. Each case is logged with the reason.
Agentic quota
One agentic execution — a chat turn, an assignment run, a background sub-agent task, a flow's agent step, a browser-extension task — spends one agentic action from the user's action quota. The action is deducted before the agent runs, which is what lets the platform refuse a request instead of discovering mid-run that it was never allowed. The consequence is that a run which ends in failure has already spent an action, on work the user never received.
The agent failure pool gives that unit back, up to a bounded number of failures per agent:
| Variable | Required | Default | Description |
|---|---|---|---|
AGENT_FAILURE_QUOTA_REFUND_ENABLED |
No | true |
Master switch for the failure pool. On by default — spending an action on a run the platform failed to deliver is the wrong default, and the pool is what keeps that from becoming free unlimited retries. Set it to false to deduct an action for every failure, like a success. |
AGENT_FAILURE_QUOTA_POOL |
No | 5 |
Failures credited back per agent, per user, per pool window. The N+1th failure in the same window spends its action normally. 0 closes the pool without touching the switch above. |
How it behaves:
- The first
AGENT_FAILURE_QUOTA_POOLfailed runs of a given agent, for a given user, in the current window have their action credited back. Every failure after that spends an action like a successful run — so a provider outage or a broken tool is forgiven, while an agent that fails every time keeps consuming the action quota. - The window is one year, on the anniversary of your XTM license. With no license installed it falls back to the anniversary of this platform instance, and if neither is readable, to the calendar year. Settings → Quotas shows which anchor is in use and the exact renewal date.
- It is scoped per agent and per user: on a company-managed agent shared by a whole company, one user's failures never spend another user's pool.
- Failed runs still count as work performed: the execution telemetry and the run's own record are untouched, and the run is still visible as failed. Only the action is credited back.
When no model was ever called
A run that could not call a model at all — the provider was unreachable, it was down or overloaded, this platform is not configured / authorised to call it, or the platform abandoned the run itself (the worker was lost, its lease expired, its deadline blew mid-run) — is a different case: nothing was produced, and nothing on the user's side caused it. Those runs have their action credited back for free, outside the pool: they never consume a pool slot, and they are never refused, however many of them happen. One provider outage must not be able to exhaust an agent's whole allowance and leave real failures charged for the rest of the year.
The free credit-back is not gated by AGENT_FAILURE_QUOTA_REFUND_ENABLED:
that variable sizes the pool, and an unreachable provider is not a pool
question. Turning the pool off still credits back a run that never reached a
model.
What counts as "never reached a model" (is_provider_unavailable_error in
app/llm/router.py, the single source of truth shared by chat, assignment runs
and extension tasks):
| Failure | Credited back | Paid by |
|---|---|---|
| No LLM provider configured at all | Yes | Free |
| Connection error, DNS failure, timeout | Yes | Free |
| Provider 5xx / 529 overload, gateway 502-504 | Yes | Free |
| Every provider in the failover chain failed for one of the above | Yes | Free |
| Run abandoned by the orchestration engine (worker lost, lease expired, deadline blown mid-run) | Yes | Free |
| Background sub-agent task abandoned the same way | Yes | Free |
| Rate limit / 429 | Yes | The agent's pool |
| Invalid API key, provider not authorised (401/403) | Yes | The agent's pool |
Provider account out of credit (insufficient_quota, billing) |
Yes | The agent's pool |
| Context overflow, request too large (413) | Yes | The agent's pool |
| Tool error, malformed model output, any other crash | Yes | The agent's pool |
Everything the provider replies sits on the pool side deliberately — a rate limit, a rejected credential, an account out of credit. They are persistent and indefinitely reproducible: forgiving them without a bound would stop the action quota metering entirely for as long as they last, which is the free-unlimited-retries outcome the pool exists to prevent. They are still forgiven — out of the bounded pool, which is what bounded is for.
Settings → Quotas reports the two reasons apart — "credited back · agent failure" (from the pools) and "credited back · provider unavailable" (free) — with a per-agent breakdown, so a large number can be attributed before anyone acts on it.
A run the engine abandons is failed from a different process than the one that charged it, so the credit-back cannot rely on anything held in memory: the charge is recorded on the run itself when it happens, and the abandonment path reads it back. A run that was never claimed — nobody ever executed it, which is what a scheduled run does when no worker is running — was never charged, so there is nothing to credit and nothing appears.
Background sub-agent tasks are covered too, by a different mechanism: their action is charged inside the agent turn, where nothing knows about the task, so the charge writes a short-lived metering receipt keyed by the task. The abandonment path redeems that receipt — atomically, so two sweeps cannot credit twice — which means it credits back only what was really charged rather than assuming a charge happened.
Not yet covered (#3128)
Flow runs still are not. A flow can meter several actions (one per agent node), so a single receipt per run does not describe it; the mechanism extends — one receipt per step — but the abandonment path has to enumerate the steps it charged. A worker lost mid-flow therefore still leaves those actions charged.
Bounded on the generous side
A run whose first model calls succeeded and which then died on a provider outage is also credited back for free — charging a user for an outage they could not see is the worse error. Removing that approximation is part of
3128, which also gives failures a classifiable cause instead of the
free-text error string they carry today.
- The General Assistant is not covered — there is no agent to pool against.
- Resetting a user's quota usage from the admin UI also restores their pools.
Recharging a pool before its renewal date
An administrator can restore every pool on the platform without waiting for the anniversary, by redeeming a support code issued by Filigran in Settings → Quotas → Agent Failure Pool.
- A code is a single signed token (
XTMP-…) that the admin pastes in. It is verified offline, against the same Filigran trust anchor the platform already uses for license certificates — an air-gapped deployment can redeem one, and no secret is added to the platform. - Each code carries an expiry and an identifier, and is single-use: the
identifier is recorded in
support_code_redemptionson redemption, so a second attempt (or a code shared between two admins) is refused. Settings → Quotas lists the codes already redeemed, when, and by whom. - A code is normally bound to one
PLATFORM_IDand refused elsewhere; Filigran can also issue aglobalcode, redeemable on any platform. - Because verification is offline, a code cannot be revoked once issued — it can only expire. Ask support for a short validity when that matters.
Refusals are shown inline with the reason: not signed by Filigran, altered, expired on a date, issued for another platform, or already used on a date.
Why a pool instead of refunding every failure
Nothing available at the point of failure reliably separates "the provider was down" from "this prompt can never succeed". Crediting back every failure would make an endlessly retried, hopeless run free; crediting none spends a user's action quota on outages they did not cause. A small pool per agent covers the accidental case and charges the systematic one to the quota, with no classification to get wrong.
Sizing is environment-only
Both variables are environment-only, by design: what consumes the action quota belongs to whoever operates the deployment, not to whoever administers the tenant. Changing either one requires a restart. The admin screen is read-only apart from redeeming a support code.
Logging
| Variable | Required | Default | Description |
|---|---|---|---|
LOG_LEVEL |
No | info |
debug, info, warning, error, critical. |
LOG_FORMAT |
No | json |
json (structured) or console (human-readable). When unset, defaults to json in production or console when DEBUG=true. |
Email (SMTP)
| Variable | Required | Default | Description |
|---|---|---|---|
SMTP_HOSTNAME |
No | — | SMTP server hostname. |
SMTP_PORT |
No | 587 |
SMTP port. |
SMTP_USERNAME |
No | — | SMTP authentication username. |
SMTP_PASSWORD |
No | — | SMTP authentication password. |
SMTP_FROM |
No | — | Sender address for outgoing emails. |
SMTP_STARTTLS |
No | true |
Use STARTTLS. |
Observability
| Variable | Required | Default | Description |
|---|---|---|---|
LANGFUSE_HOST |
No | https://cloud.langfuse.com |
Langfuse server URL. |
LANGFUSE_PUBLIC_KEY |
No | — | Langfuse public key. |
LANGFUSE_SECRET_KEY |
No | — | Langfuse secret key. |
PROMETHEUS_ENABLED |
No | false |
Enable Prometheus metrics endpoint. |
PROMETHEUS_BEARER_TOKEN |
No | — | Bearer token for metrics endpoint (min 16 chars). |
Security feature flags
| Variable | Required | Default | Description |
|---|---|---|---|
DISABLE_CODE_INTERPRETER |
No | true (xtm_one mode) |
Disable code interpreter at infra level. |
DISABLE_CUSTOM_TOOLS |
No | true (xtm_one mode) |
Disable custom tools at infra level. |
CUSTOM_TOOL_SANDBOX |
No | auto |
Custom tool execution mode: auto, docker, subprocess, opensandbox. opensandbox delegates to an OpenSandbox-managed container (also used by the code interpreter) and requires the OpenSandbox connection variables below. |
DISABLE_SANDBOX_QUOTA_USAGE |
No | false |
Opt out of per-user sandbox usage counting (quota_usage rows with usage_type="sandbox", alongside the existing LLM-proxy/agentic usage). Counted by default; set true for self-hosted/on-prem deployments that manage their own sandbox infrastructure independently of hosted usage-based billing. Restart required to take effect. |
XTM Hub sign-in gate
Restricts sign-in to people XTM Hub has entitled to this XTM One instance, and lets the Hub decide who is an administrator here. OIDC only — XTM Hub does not provide SAML.
| Variable | Required | Default | Description |
|---|---|---|---|
XTM_HUB_ADMIN_GROUP |
No | — | The role, in this instance's XTM Hub claim, that means administrator. Matched exactly, including case. Setting it enables the gate; empty leaves OIDC behaving exactly as it does without this feature. |
The gate reads a claim named after this instance — {PLATFORM_ID}_groups — so
PLATFORM_ID must be set when you enable it. It is the switch that is
XTM_HUB_ADMIN_GROUP rather than PLATFORM_ID, because forcing a platform
identifier is an ordinary thing to do for clone/restore identity continuity and
must not enable a sign-in gate as a side effect. Enabling the gate without
PLATFORM_ID refuses to start, rather than silently refusing every sign-in.
Enabling it changes three things at once:
- Every sign-in is checked, not only the first. Someone who already has an account and has since lost their Hub entitlement is turned away too.
- A refused sign-in creates no account and lands on the login page with a neutral message. The reason is in the server log, not on the page — the person is unauthenticated, and the criteria describe how your IdP is configured.
- Administrator follows the Hub in both directions: granted when the Hub
asserts it, and removed when it stops. The account named by
ADMIN_EMAILis exempt from removal, so a Hub-side group change cannot lock you out of your own instance.
If a misconfigured gate refuses everyone, sign in with a local account — that route stays available and is the way back in.
MCP OAuth
MCP OAuth runs in two independent directions, and each has its own switch.
Inbound — an external MCP client (Claude Desktop, Cursor, …) connecting to XTM One. The platform acts as the authorization server and lets clients self-register, which the MCP specification requires because a client has no credentials before any user session exists.
Outbound — XTM One connecting to a remote MCP server. With no client ID configured, the platform discovers the remote authorization server and registers itself, so a server can be added from its URL alone. This is the MCP specification's intended onboarding path: modern remote MCP servers do not issue static credentials to paste into a form.
| Variable | Required | Default | Description |
|---|---|---|---|
MCP_DCR_ENABLED |
No | true |
Inbound. Allow external MCP clients to self-register against this platform (POST /mcp/oauth/register, RFC 7591). The endpoint is unauthenticated by specification, so it is protected by a per-IP rate limit and a bounded client table rather than a login. Set false to turn registration off entirely (the endpoint then returns 403). |
MCP_DCR_MAX_CLIENTS |
No | 500 |
Inbound. Upper bound on dynamically registered clients. At the cap the oldest registrations are evicted; RFC 7591 clients re-register transparently. Values below 1 are clamped to 1. |
MCP_OUTBOUND_DCR_ENABLED |
No | true |
Outbound. Let the platform register itself with a remote MCP server that advertises a registration endpoint, so remote servers can be connected without pre-provisioned credentials. Set false to require every outbound OAuth client to be provisioned by hand — connecting a remote server then needs an explicitly configured client ID. |
OpenSandbox
Only used when CUSTOM_TOOL_SANDBOX=opensandbox. Governs the OpenSandbox-managed sandbox used by the code interpreter and by custom tools.
| Variable | Required | Default | Description |
|---|---|---|---|
OPENSANDBOX_DOMAIN |
Yes (if opensandbox mode) |
localhost:8090 |
Host (and optional port) of the OpenSandbox service. The default matches local dev (see dev.sh) and isn't enforced at startup — a production deployment that forgets to set this won't fail loudly, it will just try to reach localhost:8090 and fail to connect. |
OPENSANDBOX_PROTOCOL |
No | http |
Protocol used to reach OpenSandbox (http or https). |
OPENSANDBOX_API_KEY |
No | — | API key for authenticating to OpenSandbox. |
OPENSANDBOX_IMAGES |
No | python:sandbox-python:0.1,nodejs:sandbox-nodejs:0.1 |
Per-key container image, as comma-separated key:image pairs (split on the first : per entry, so a registry:port-qualified image survives intact). Overrides merge on top of the defaults, so overriding one key leaves the other on its default. |
OPENSANDBOX_K8S_POOL_NAMES |
No | — (cold start, k8s-only) | Per-key Kubernetes Pool binding, as comma-separated key:pool-name pairs, e.g. python:my-python-pool,nodejs:my-js-pool. A Pool's warm pods run one fixed image, so one name can't back both sandbox kinds — a key missing from the mapping (or the whole variable unset) cold-starts for that key. |
OPENSANDBOX_NETWORK_ALLOW |
No | — (all egress denied) | Comma-separated list of domains/IPs to allow as egress from every OpenSandbox container. Empty means all egress is denied. |
SANDBOX_CPU |
No | 1 |
CPU requested for each OpenSandbox container (code interpreter + custom tools). Passed through as-is to the OpenSandbox SDK's resource argument on the cold-start path. When OPENSANDBOX_K8S_POOL_NAMES binds that sandbox kind to a k8s Pool, this is not read at all — the Pool manifest's own resources.requests governs warm-pod sizing instead, so there's no sync requirement between the two - see dev-docs/opensandbox-pool-management.md. |
SANDBOX_RAM |
No | 128Mi |
Memory requested for each OpenSandbox container. Same cold-start-only pass-through as SANDBOX_CPU. |
OPENSANDBOX_USE_SERVER_PROXY |
No | false |
Route sandbox traffic through the OpenSandbox server instead of connecting directly to each sandbox's endpoint. Useful for any deployment topology where this process can reach the OpenSandbox server but not the individual sandbox endpoints it hands back — e.g. a native dev.sh backend against a containerized server, where the server advertises endpoints via a hostname like host.docker.internal that only resolves from inside other containers (dev.sh sets this automatically in that case), or a production/k8s deployment where sandbox pods aren't directly routable from this process but the server's service is. |
Running the OpenSandbox server itself
The variables above configure how the platform connects to an OpenSandbox server. These configure how that server itself runs:
| Variable | Required | Default | Description |
|---|---|---|---|
OPENSANDBOX_SERVER_DOCKER |
No | false |
Local dev only (dev.sh / dev.ps1 / dev-podman.sh). By default the launcher runs opensandbox-server natively; set true to run it as the opensandbox Docker Compose service (docker-compose.dev.yml, plain bridge networking) instead. The native default remains preferred on WSL2, where Docker Desktop's port-publish relay has been observed to fail, or hang unpredictably, for this container. dev.sh also sets OPENSANDBOX_USE_SERVER_PROXY=true automatically when this is enabled, since the backend runs natively but the server does not — see that variable above. |
KUBECONFIG_PATH |
Yes (if the opensandbox-k8s Compose profile is enabled) |
— | Production only (docker-compose.yml). Path to a kubeconfig, authorized against the target cluster, mounted into the opensandbox-k8s service so it can manage sandbox pods there instead of local Docker containers. That cluster must already have the BatchSandbox CRD, opensandbox-controller, and the kata-fc RuntimeClass installed. |
docker-compose.yml (production) exposes two mutually exclusive, opt-in profiles for the OpenSandbox server, matching where sandbox containers/pods should run: docker compose --profile opensandbox-docker up -d (Docker containers on the same host) or docker compose --profile opensandbox-k8s up -d (pods on an external Kubernetes cluster).
- Target namespace for the
opensandbox-k8sprofile: set via thenamespacekey in[kubernetes]inbackend/sandbox/sandbox.k8s.firecracker.toml(mounted into that service) — there's no env var for this specific setting, it's TOML-only. (opensandbox-serveritself does read two env vars:OPENSANDBOX_SERVER_API_KEYfor its API key, andSANDBOX_CONFIG_PATHas a fallback for--configwhen no CLI flag is given —dev.ps1relies on the latter — but neither substitutes for TOML settings likenamespace.) Defaults tonull(server default) if omitted. See the OpenSandbox server configuration reference for the full[kubernetes]section.
Sandbox call log fields
Every OpenSandbox-routed call (code interpreter, custom JS tool, custom
Python tool) emits one structured sandbox_call log event to stdout
(JSON in production — see Logging above), independent of the
quota_usage counters. These fields are stable
across releases so a log pipeline (e.g. shipping stdout into Grafana/Loki)
can filter and aggregate on them; a field not listed here (e.g. level,
timestamp, logger) is generic structlog metadata, not part of this
schema.
| Field | Type | Present when | Description |
|---|---|---|---|
usage_type |
string | Always | One of code_interpreter, custom_tool_js, custom_tool_python. |
user_id |
string (UUID) | Only when the call had a known user context | The caller's user ID. |
outcome |
string | Always | success or failure. |
failure_reason |
string | Only when outcome=failure |
One of sandbox_ready_timeout (the sandbox never became ready in time), sandbox_create_failed (other sandbox-creation error), sandbox_create_error (unexpected error creating the sandbox), nonzero_exit (the executed code/tool exited with a non-zero status), execution_error (an unexpected error while running the code/tool). |
cpu_used_percentage |
float | Successful calls and nonzero_exit failures, when metrics could be retrieved |
CPU used, as a percentage. |
cpu_count |
float | Same as cpu_used_percentage |
Number of CPU cores allocated to the sandbox. |
memory_used_in_mib |
float | Same as cpu_used_percentage |
Memory used, in MiB. |
memory_total_in_mib |
float | Same as cpu_used_percentage |
Memory allocated to the sandbox, in MiB. |
tte_ms |
integer | Successful calls and nonzero_exit failures, when the sandbox backend reported a completed execution |
Time-to-execute: the sandbox's own reported execution duration, in milliseconds (not overall call time, which also includes sandbox creation/setup). Can be absent even on a nonzero_exit failure if the process errored before an execution-complete event was ever emitted (e.g. an immediate crash). |
egress_default_action |
string | Always | Always "deny" today. |
egress_allow_list |
array of strings | Always (may be empty) | The egress allow-list (OPENSANDBOX_NETWORK_ALLOW) applied when the sandbox was created. |
CPU/RAM/TTE are omitted (not emitted as null) whenever they aren't
available — e.g. no sandbox was ever created (sandbox_ready_timeout,
sandbox_create_failed, sandbox_create_error), or the metrics lookup
itself failed or timed out. A slow or unreachable metrics lookup is
bounded to a few seconds and never delays or fails the underlying sandbox
call.
Webhook delivery
Governs POST /api/hooks/assignments/{assignment_id} — the endpoint an external system (SIEM/SOAR, ticketing, CI) posts to in order to drive an agent.
An assignment runs one execution at a time, because parallel runs of the same assignment race the same tools and database writes. A delivery arriving while a run is active is therefore queued, not discarded: it becomes a run in the queued status and starts as soon as the assignment goes idle. An accepted delivery produces exactly one run, eventually.
| Variable | Required | Default | Description |
|---|---|---|---|
ASSIGNMENT_QUEUE_ENABLED |
No | true |
Queue deliveries that arrive while the assignment is busy. Set false only to restore the legacy behaviour, where such a delivery was discarded and still answered 200. |
ASSIGNMENT_QUEUE_MAX_DEPTH |
No | 20 |
Maximum deliveries held per assignment. Bounds the backlog a slow or stuck assignment can accumulate. |
ASSIGNMENT_QUEUE_OVERFLOW_POLICY |
No | reject |
What happens to a delivery arriving at a full queue. reject: HTTP 429 + Retry-After, so a sender with retry logic loses nothing. drop_oldest: the oldest waiting delivery is finalized as a skipped run (visible, payload preserved) and the new one takes its slot. drop_newest: the incoming delivery is discarded with a 200 and a warning — no run row is created. |
ASSIGNMENT_QUEUE_RETRY_AFTER_SECONDS |
No | 30 |
Retry-After value returned with the reject policy's 429. |
ASSIGNMENT_QUEUE_HOLD_ON_AWAITING_HUMAN |
No | true |
Whether a run paused for human input holds its place in the queue. true keeps deliveries waiting behind the unanswered approval — bounded and visible, unlike the old silent drop. false lets the queue drain past a paused run; only safe when a HITL pause leaves no half-written shared state. |
ASSIGNMENT_QUEUE_DRAIN_BATCH |
No | 20 |
Maximum assignments drained per reconciler cycle. |
WEBHOOK_REQUESTS_PER_MINUTE |
No | 60 |
Per-assignment delivery throttle, applied before the request body is read. Over-limit deliveries get 429 + Retry-After. Raise it for a high-cadence alert feed — the queue still bounds how many accepted deliveries become runs. |
WEBHOOK_DEDUP_TTL_SECONDS |
No | 300 |
Idempotency window. A delivery whose key was already seen within the window replays the first delivery's response instead of firing a second run. |
Response statuses
status |
Meaning |
|---|---|
ok |
A run started. triggered[0].run_id is its id. |
queued |
Accepted, waiting behind the active run. run_id is the queued run, queue_depth the current backlog. |
duplicate (with deduplicated: true) |
The same idempotency key already arrived within the window. The original response is replayed; nothing new was triggered. |
dropped |
The queue was full under drop_newest. This delivery will never run. |
ignored |
The target does not resolve (unknown, disabled, or not a webhook trigger), or an event filter excluded it. |
| HTTP 429 | Not accepted — retry after the Retry-After delay. Either the per-assignment throttle or a full queue under reject. |
Idempotency
Duplicate deliveries — proxy retries, sender at-least-once redelivery, a double-submitted test POST — are deduplicated so a retry returns the original run_id without re-running the assignment. The key is derived in this order:
- An explicit header:
Idempotency-Key,X-Idempotency-Key,X-GitHub-Delivery,Webhook-Id/Svix-Id, orX-Hook-Signature-Id. - A provider-specific id in the payload (OpenCTI
notification_id, Slackevent_id, HubSpoteventId, or a JSON-APItype+idpair). - A SHA-256 of the URL scope + raw request body.
Send an Idempotency-Key
Step 3 cannot tell a retry apart from a genuine recurrence. Two distinct events carrying byte-identical bodies inside the window — a repeated detection with no timestamp, a state notification with no counter — collapse into a single run, and the second delivery is answered with deduplicated: true. Sending an Idempotency-Key that is unique per logical event removes the ambiguity entirely. Failing that, shorten WEBHOOK_DEDUP_TTL_SECONDS below the sender's event cadence.
Runners
A Runner is a downloadable native daemon a user installs on a machine they control, enrolls once from Profile > Runners, and leaves running. It makes only outbound connections and pulls commands from the platform (the platform never opens a connection to the machine), giving agents genuine full-desktop screenshots, real-browser control, shell + file operations, and synthetic input through the computer_use built-in tool. Enrollment is a single-use, short-lived token exchanged once for a durable credential stored in the machine's OS keychain.
The runner installers, bare-binary archives, and self-update artifacts are built by the release pipeline and embedded in the platform image — every instance serves its own version-matched downloads directly from Profile > Runners and answers the runners' self-update checks from the same embedded set. There is no external download URL, CDN, or GitHub Releases dependency to configure, and an air-gapped deployment is fully self-sufficient. A source/dev build (no embedded distribution) simply reports downloads as unavailable while everything else — enrollment, transport, computer_use — keeps working with runners you built yourself (apps/runner/build.ps1 / build.sh).
| Variable | Required | Default | Description |
|---|---|---|---|
RUNNERS_ENABLED |
No | true |
Master switch for the whole Runner subsystem: enrollment, the runner transport (WebSocket + long-poll), and the computer_use agent tools. Set false to remove it entirely (every runner endpoint then returns 404 and the Profile tab is hidden). |
RUNNER_DIST_DIR |
No | runner_dist |
Directory of the embedded runner distribution inside the image, populated by CI at image build time. Only needs changing if you repackage the image and relocate the artifacts. Empty/absent (source builds) reports downloads as unavailable. |
RUNNER_MIN_SUPPORTED_VERSION |
No | — | Minimum runner binary version accepted at connect (protocol gate). Empty means no floor; a runner below it is told to self-update. Format MAJOR.YYMMDD.SEQ. |
RUNNER_MAX_PER_USER |
No | 200 |
Cap on active runners a single user (or the company-managed fleet) may have enrolled at once, enforced at enrollment so an unattended roll-out cannot create unbounded records. 0 = unlimited. |
RUNNER_MAX_INFLIGHT_COMMANDS |
No | 10 |
Cap on concurrent in-flight commands per runner; a burst past this is refused with a transient "busy" so parallel tool calls cannot swamp one machine. 0 = unlimited. |
Tuning knobs
The transport cadence and timeouts (RUNNER_HEARTBEAT_INTERVAL_SECONDS, RUNNER_OFFLINE_AFTER_SECONDS, RUNNER_LONG_POLL_TIMEOUT_SECONDS, RUNNER_RESULT_WAIT_TIMEOUT_SECONDS, RUNNER_COMMAND_DEFAULT_TIMEOUT_SECONDS, RUNNER_MAX_RESULT_BYTES, RUNNER_ENROLLMENT_TTL_SECONDS) have sensible defaults and rarely need changing. See .env.sample for the full list.
Feature flags
| Variable | Required | Default | Description |
|---|---|---|---|
ENABLED_FEATURES |
No | — (none enabled) | Comma-separated list of feature flag ids to turn on experimental/in-development features, e.g. MY_FLAG,OTHER_FLAG. Use * to enable all of them. Leaving this unset enables none of them. Changing it requires a restart — there is no toggle in the Settings UI. An unrecognized flag id (e.g. left over from another branch) is logged as a warning and ignored — it never enables anything and never prevents the platform from starting. |
The resolved list of enabled flags is also sent to the frontend (as part of the public, boot-time GET /auth/config response), so a flag can control both backend behavior and whether a UI element is shown — with the backend independently enforcing the same flag on any route it gates.
Skill loading
An agent's prompt carries a catalog of its attached skills (name + description) and the agent pulls a skill's full content in on demand with the load_skill tool, so prompt size scales with the number of attached skills rather than with the size of their content. A skill that must stay in the prompt at all times can be pinned per agent on the agent's Configuration tab.
These caps bound the worst case and rarely need changing:
| Variable | Required | Default | Description |
|---|---|---|---|
SKILL_LOAD_MAX_CHARS |
No | 60000 |
Maximum characters returned by one load_skill call. Longer content is truncated with an explicit marker naming how much was cut. |
SKILL_LOAD_BUDGET |
No | 8 |
Maximum number of distinct skills one run may load. Re-loading an already-loaded skill never counts again. 0 = unlimited. |
SKILL_RESOURCE_MAX_BYTES |
No | 40000 |
Maximum bytes returned by one read_skill_resource call (files bundled with a packaged skill). |