Skip to content

Configuration

Everything an operator can set: environment variables, and the three YAML files in config/. Verified against internal/config/config.go, internal/secrets/infisical.go, internal/mcpclient/mcpclient.go, internal/ccrouter, internal/ccjobs, cmd/ws/main.go, infra/Dockerfile, the Makefile, the compose overrides, internal/gateway/router.go, internal/gateway/types.go, .env.example and infra/compose/compose.yaml, last verified at commit 97d4480.

Both ws serve and ws worker read the same environment. A .env file in the working directory is loaded if present (real environment wins over .env). With Infisical configured, secrets from the project are also loaded into the environment before parsing; see Secrets from Infisical.

Two variables are required: WS_SESSION_SECRET (at least 32 bytes) and WS_DATABASE_URL. Startup fails without them.

VariableDefaultPurpose
WS_ENVdevdev or prod. Outside dev, session cookies are marked Secure, so prod needs HTTPS
WS_LISTEN:8080App listener (API, web UI, preview proxy)
WS_ARTIFACT_LISTEN:8081Artifact origin listener
WS_PUBLIC_URLhttp://localhost:8080Public origin of the app. Also the default WebAuthn origin
WS_ARTIFACT_URLhttp://localhost:8081Public origin for artifacts. Must be a different origin from the app
WS_PREVIEW_DOMAINpreview.localhostParent domain for sandbox previews, used to derive {port}-{id}.<domain>
WS_SESSION_SECRETnone, requiredHMAC key for session cookies and, via derivation, the app-to-worker shared secret. 32 bytes minimum
WS_DATABASE_URLnone, requiredPostgres connection string
WS_BLOB_DIR./data/blobsBlob store directory (attachments, externalized tool output, artifact snapshots)
WS_OWNER_EMAILemptyThe first user to register with this email becomes owner; an invite is created and logged at first boot
WS_LOGinfodebug, warn, anything else is info. Read before .env and Infisical are loaded, so set it in the real process environment (for example Compose environment:), not in .env
WS_COMPACTION_BUDGET_TOKENS60000Soft ceiling on tokens sent per request before older turns are replaced by a summary. Keep it below the smallest context window you route to
VariableDefaultPurpose
RESEND_API_KEYemptyResend key. Without it, invite and magic-link emails are printed to the log
WS_EMAIL_FROMws@localhostSender address; the domain must be verified in Resend
VariableDefaultPurpose
WS_RP_IDhost of WS_PUBLIC_URLRelying party id. Passkeys are bound to it; changing it invalidates existing passkeys
WS_RP_NAMEwsDisplay name shown by the authenticator
WS_RP_ORIGINS[WS_PUBLIC_URL]Comma-separated allowed origins

Hosted providers are declared in config/endpoints.yaml and read their key from the variable named there. A provider whose key variable is unset is skipped.

VariableUsed by
ANTHROPIC_API_KEYanthropic provider
OPENAI_API_KEYopenai provider; also enables image generation through the seeded openai/gpt-image-1 endpoint
OPENROUTER_API_KEYopenrouter provider
CEREBRAS_API_KEYcerebras provider
LLAMA_SERVER_URLlocal-llama base URL, for example http://llama:8000/v1
VLLM_URLlocal-vllm base URL
MAC_STUDIO_URLmac-studio base URL
WS_ENDPOINTS_FILEPath to the endpoints seed file. Default config/endpoints.yaml
WS_POLICIES_DIRDirectory of routing policy YAML. Default config/policies
WS_MCP_FILEMCP server list. Default config/mcp.yaml
WS_MCP_SERVERtrue

The worker is the only process with the Docker socket. serve relays sandbox file and terminal requests to the worker’s internal API.

VariableDefaultPurpose
WS_SANDBOX_ENABLEDtrueTurn sandboxes off entirely. Read by the worker only
WS_SANDBOX_IMAGEghcr.io/jking323/ws-sandbox:latestImage for sandbox containers
WS_SANDBOX_NETWORKws_sandbox-netInternal Docker network; the Compose-created name. none disables
WS_SANDBOX_PROXY_URLhttp://worker:3128Egress proxy address as seen from inside a sandbox. none disables
WS_SANDBOX_RUNTIMEautoauto, runc, or runsc (gVisor, used by auto when installed)
WS_SANDBOX_MEMORY_MB4096Memory limit per sandbox
WS_SANDBOX_CPUS2CPU limit per sandbox
WS_SANDBOX_IDLE_STOP15mStop a container after this long idle. Go duration syntax; an invalid value becomes 0
WS_SANDBOX_REMOVE_AFTER168hRemove a container after this long idle; the volume stays. Go duration syntax; an invalid value becomes 0
WS_WORKER_URLhttp://worker:8082Where serve reaches the worker’s internal API. none or empty disables the sandbox relay and previews
WS_INTERNAL_LISTEN:8082The worker’s internal API listener
WS_PREVIEW_HOSTderivedPreview hostname pattern with {port} and {id}. Empty derives {port}-{id}.<WS_PREVIEW_DOMAIN>. Behind a single-level wildcard cert use something like ws-p-{port}-{id}.home.arpa
WS_EGRESS_LISTEN:3128Egress proxy listener
WS_EGRESS_ALLOWemptyExtra allowlisted hosts, comma-separated, added to the built-in list (DefaultAllow in internal/sandbox/egress_proxy.go). The WS_PUBLIC_URL host is always added

Both serve and worker read these (serve for the Admin install view), but only the worker’s egress proxy injects them; they never enter a sandbox.

VariablePurpose
GITHUB_APP_IDGitHub App id. Enables installation tokens, open_pr, and the Admin install view
GITHUB_APP_PRIVATE_KEYApp private key, inline. PEM with \n escapes, or base64
GITHUB_APP_PRIVATE_KEY_FILEPath to the key instead, for example /secrets/github-app.pem (Compose mounts ./secrets read-only at /secrets)
GITHUB_APP_SLUGApp slug, for the Admin install link
GITHUB_TOKENFallback personal token when no App is configured. open_pr is unavailable in this mode

Read by internal/secrets. With the first three set, both processes log in with the machine identity at boot, load every secret in the project environment into their environment before configuration is parsed, and refresh every five minutes.

VariableDefaultPurpose
INFISICAL_CLIENT_IDnoneMachine identity client id. Required to enable
INFISICAL_CLIENT_SECRETnoneMachine identity secret. Required to enable
INFISICAL_PROJECT_IDnoneProject to read. Required to enable
INFISICAL_ENVprodEnvironment slug
INFISICAL_PATH/Secret path
INFISICAL_SITE_URLhttps://app.infisical.comSet for a self-hosted Infisical
INFISICAL_OVERWRITEunset1 makes Infisical values replace anything already in the environment at boot. By default existing values win at boot, but the five-minute refresh always overwrites, so a .env value for a name Infisical also holds is replaced after about five minutes

If Infisical is unreachable at boot, ws logs it and continues on .env.

These are read by Compose and the Makefile rather than by the Go binary. See ../infra/compose/README.md.

VariableDefaultPurpose
POSTGRES_PASSWORDwsPassword for the Compose Postgres. Change it in any non-dev setup
WS_IMAGEghcr.io/jking323/ws:latestApp and worker image. Pin to a commit SHA to stop image updates
WS_BIND_ADDRloopbackHost binding for override.cloud.yaml or LAN testing
WS_HTTP_PORT, WS_ART_PORT8080, 8081Host ports for the same
WS_HOST, WS_ART_HOSTws.home.arpa, ws-art.home.arpaHostnames for override.traefik.yaml
WS_PREVIEW_PARENThome.arpaParent domain for the Traefik preview router
WS_DEPLOY_HW, WS_DEPLOY_INGRESSnoneWritten by make install-cd; default HW and INGRESS for make targets
WS_DEPLOY_PROFILESnoneCompose profiles to enable, for example infisical
DOCKER_GID999Host docker group id for the worker. make up derives it from the socket
CLOUDFLARE_TUNNEL_TOKENnoneToken for the cloudflare profile
TS_AUTHKEYnoneTagged auth key for the tailscale profile
HF_TOKENnoneHugging Face token for vLLM model downloads
VLLM_API_KEYemptyAPI key passed to the vLLM container by override.spark-cuda.yaml
TRAEFIK_NETWORKtraefik-netExternal network name used by override.traefik.yaml

Compose sets WS_DATABASE_URL, WS_BLOB_DIR, WS_LISTEN, WS_ARTIFACT_LISTEN, WS_ENDPOINTS_FILE and WS_POLICIES_DIR for the app and worker (compose.yaml), and the image bakes in WS_ENDPOINTS_FILE, WS_POLICIES_DIR, WS_BLOB_DIR, HOME=/home/nonroot and XDG_CACHE_HOME=/tmp/ws-cache (where the ssh multiplexing sockets go), so inside the containers these differ from the defaults above (for example blobs live in /data/blobs). INFISICAL_SITE_URL is also read by the mcp-infisical sidecar.

For the infisical Compose profile (WS_DEPLOY_PROFILES=infisical). These configure the mcp-infisical container, not the app.

VariableDefaultPurpose
INFISICAL_MCP_CLIENT_ID, INFISICAL_MCP_CLIENT_SECRETnoneA second, least-privilege machine identity for the agent’s mcp__infisical__* tools
INFISICAL_MCP_MASKtrueMask secret values in tool output
INFISICAL_MCP_TOOLSlist-projects,list-secrets,get-secret,create-secret,update-secretTools the sidecar exposes

Read by internal/config; both serve and worker read them. The router decides which lane a task should run on and, when dispatch is on, starts it. See CLAUDE_CODE_JOBS.md for the design.

VariableDefaultPurpose
WS_CC_WEB_ALLOW100.64.0.0/10,127.0.0.0/8,::1/128Which client networks may use the job tab routes (/api/jobs/cc): comma-separated CIDRs or single addresses; * turns the check off; a list that cannot be parsed refuses everyone. The address is the one the server sees after its real-IP middleware (it trusts X-Forwarded-For, X-Real-IP and True-Client-IP), so a proxy in front of ws must overwrite those headers. The routes also need the owner role
WS_CC_WEEKLY_CAP0A soft cap on subscription launches per calendar week (Monday 00:00 UTC), counted locally in cc_launch_counter; Claude’s own usage is never read, so it is a count of the launches ws knows of, not of tokens or hours. 0 counts without ever changing a route. A launch is counted once, when ws first learns of the job, in the week the job started, whoever reported it (wsj run, the router, or a refresh that finds a window nobody reported). wsj launches are counted but never refused: the cap only steers the router
WS_CC_WEEKLY_SOFT75The percent of the cap from which the router keeps low-value work off the subscription lane (see the notes below). A value outside 1 to 100 is read as 75
WS_CC_DISPATCHfalseWhile false, every spawn_job call only decides and logs a cc_route_decisions row, and chat conversations are not offered the tool. When true, chat conversations get the tool, real launches are allowed, and the tool’s approval policy is ask
WS_CC_TARGETSemptyPath to the launcher targets file as seen from this host. Empty uses the wsj default (WSJ_TARGETS, else $XDG_CONFIG_HOME/wsj/targets.toml, else ~/.config/wsj/targets.toml)
WS_CC_SSH_CONFIGemptyAn ssh_config file passed as ssh -F for every ssh target in the targets file (key, user, host name and known_hosts per target). Overrides the targets file’s ssh_config key; WSJ_SSH_CONFIG is the same setting for wsj. In the container it is /secrets/ssh_config, next to the mounted key
WS_CC_CLASSIFYtrueWhen a prompt matches no rule, spawn_job asks a small model (task class classify) to classify it; false keeps the rules’ default lane (api). In the shipped config/policies/default.yaml the classify class has its own rule: it prefers openrouter/openai/gpt-oss-120b@cerebras (gpt-oss-120b on Cerebras through OpenRouter, so it needs OPENROUTER_API_KEY), then local-llama/gpt-oss-20b and local-llama/qwen3-14b, and nothing else. A prompt the rules did not flag as sensitive can therefore be shown to Cerebras via OpenRouter; a flagged one never leaves the box. If OpenRouter is unconfigured and no local endpoint is up, the call fails and the rules’ decision stands. Chat turns carry chat or code, never classify, so this rule is reached only by spawn_job
WS_CC_ALIASESapi=best,openrouter=cheap,local=localMaps the three non-subscription lanes to gateway selectors (policy aliases from config/policies/). A value you set overrides the matching default; unlisted lanes keep theirs

Notes:

  • The subscription lane (claude-subscription) only works for the owner. spawn_job refuses it for any other user, whoever approves the call, and refuses it on a host where no owner check is wired.
  • With a cap set, spawn_job reads the week’s count after the rules and the classifier. Below the soft threshold nothing changes. From the threshold up, a subscription decision that is not clearly repo-bound (no working directory and fewer than two file paths: long agentic prompts, code fences with edit verbs, classifier verdicts) goes to the api lane instead, as rule budget:soft. At the cap every subscription decision goes to api as budget:full. A lane the caller asked for, with the lane argument or an @claude override, is never overridden, only noted, so the cap is a steering signal and not a hard limit. The decision log keeps the original rule and reason inside the new reason. If the count cannot be read the decision stands, with a note.
  • Sensitive prompts (secrets, personal data markers) are routed to local or api and never to OpenRouter or the subscription lane; a forced lane or @openrouter does not override that, and a model hint is ignored for them.
  • The ws image carries an ssh client but no tmux, claude or wsj config: from a container every target must be type = "ssh", and tmux and claude run on the target. Mount the key, an ssh_config, known_hosts and targets.toml under ./secrets/ (Compose mounts it at /secrets, read-only) and set WS_CC_TARGETS=/secrets/targets.toml and WS_CC_SSH_CONFIG=/secrets/ssh_config; see ../infra/launcher/README.md. Nothing in the code enforces “ssh only”; a local target in the container simply fails. Not verified against a real server: whether the server’s sshd accepts the extra key, and the launch itself. The other three lanes need nothing extra.

wsj is a separate CLI (cmd/wsj) and does not read .env or the server configuration. See CLAUDE_CODE_JOBS.md for the design.

SettingDefaultPurpose
WSJ_TARGETS$XDG_CONFIG_HOME/wsj/targets.toml, else ~/.config/wsj/targets.tomlPath to the targets file
XDG_CACHE_HOME~/.cacheLocal directory (wsj/) for the ssh multiplexing sockets. Prompt files are not stored here: each job’s prompt is written on the target host to ~/.cache/wsj/jobs/<id>/prompt.txt with umask 077 (mode 600), and removed by wsj kill and, when no window refers to the directory any more and it is over a minute old, by wsj clean

wsj can also report its jobs to ws for the read-only job tab. Add a [ws] table to the targets file with url (the ws origin) and key_file (a file holding one ws API key, ~ allowed), or set WSJ_WS_URL and WSJ_WS_KEY, which override them. With neither configured, wsj reports nothing. run reports after its startup check, and kill and clean report what they remove; a failed report is a warning and the command still exits 0. The key must belong to the owner.

The targets file names the machines jobs can run on (type = "local" or "ssh", an ssh config alias as host, a tmux session, default_dir, and optionally claude = "/path/to/claude", tmux_socket and repos = ["~/proj", "/srv/apps"], directories the router may match a job’s working directory against; default_dir counts without being listed; a ~ is compared literally and the longest matching directory wins). The top-level default = "<name>" key names the target used when --on is omitted; it is required when there is more than one target and none is called local. session defaults to subscription. Target names must match ^[a-z0-9][a-z0-9_-]{0,31}$. A missing targets file gives a single synthetic local target. Each target needs tmux and its own logged-in claude. --no-mux (disable ssh multiplexing) and --grace (startup check on wsj run) are command-line flags only, not settings.

Seeds providers and endpoints into the database at boot (upsert by id). The seed is re-applied on every boot: edits made in Admin to a seeded provider or endpoint are overwritten, except an endpoint’s enabled flag, which is kept. To change a seeded row permanently, change this file. Keys are referenced by variable name and never stored in the file.

providers:
- id: openrouter
kind: openai_compat # anthropic | openai_compat
name: OpenRouter
base_url: https://openrouter.ai/api/v1 # or base_url_env: VAR for per-box URLs
api_key_env: OPENROUTER_API_KEY
headers: { X-Title: ws } # optional extra request headers
endpoints:
- id: anthropic/claude-sonnet-5-5 # referenced by policies and API model names
provider: anthropic
model: claude-sonnet-5-5 # name sent upstream
display_name: Claude Sonnet 5.5
capabilities: { context_window: 1000000, max_output: 128000, tools: true,
vision: true, json_mode: true, reasoning: true,
prompt_cache: true, embeddings: false }
pricing: { input_per_m: 2.00, output_per_m: 10.00,
cache_read_per_m: 0.20, cache_write_per_m: 2.50 } # USD per million tokens
throughput_class: high # low | medium | high
latency_class: fast # fast | normal | slow
local: true # optional; marks free local endpoints (budget downgrade targets these)
extra_body: { provider: { order: [cerebras], allow_fallbacks: false } } # optional; merged into every request

Embedding endpoints carry embeddings: true. Agent memory uses task class embed and stores 768-dimension vectors, so the shipped local-llama/nomic-embed-text (preferred) and openai/text-embedding-3-small (extra_body: {dimensions: 768}, cheaper than a call to a chat model) fit; a model that returns another width gets its memories stored without a vector. The reflection that writes memories runs under task class reflect with model auto; the shipped policies have no rule for it.

Providers and models can also be added in Admin (the owner’s Providers and model forms): a provider stores only the names of the environment variables that hold its base URL and key, never the key. A provider or endpoint that also appears in this file is overwritten from the file on every boot, so edit those here.

Media endpoints (image and video) use the same file. capabilities.media marks an endpoint as a media endpoint: text requests never route to it, and requests of task class image or video route only to endpoints like it. Pricing is per output instead of per token:

- id: openai/gpt-image-1
provider: openai
model: gpt-image-1
capabilities: { media: { engine: openai_images, image: true, sizes: [1024x1024, 1536x1024, 1024x1536, auto], max_images: 4 } }
pricing: { input_per_m: 5.00, output_per_m: 40.00, per_image: 0.04 } # per_image: the estimate shown before a job runs

media keys: engine (the adapter in internal/media; only openai_images exists, comfyui, fal and google are planned), image, image_edit, video, image_to_video (what it can make), sizes (accepted WxH or engine keywords such as auto; empty means the engine’s default only), max_images (per job, 0 means 1) and max_seconds (video length). pricing.per_image and per_second price a job by output; when the engine reports token usage and the endpoint has token rates (gpt-image-1 does), the real price is computed from tokens and per_image is only the estimate. Only images are generated today: a request with kind video, edit or upscale is refused as not supported yet. The shipped default policy has an image rule preferring openai/gpt-image-1; the endpoint appears at boot once OPENAI_API_KEY is set, so restart app and worker.

Notes:

  • An endpoint may set enabled: true|false (default true). It is honored only when the endpoint is first inserted.
  • An empty throughput_class becomes medium and an empty latency_class becomes normal.
  • A provider with base_url_env is skipped when that variable is unset, and so are its endpoints. One file can serve every box.
  • Capabilities are declared because engines rarely report them accurately. The router filters on them.
  • extra_body is how an OpenRouter upstream is pinned and how engine-specific parameters are passed to local servers.
  • Pricing feeds the ledger and cost-aware routing. Local endpoints are free but still recorded.
  • The header comment in the file mentions WS_ENDPOINTS_SEED_MODE=overwrite. No code reads this variable (checked with a repo-wide search), so setting it has no effect. This is a stale comment, not a feature.

Routing policy. Each file is one policy; a lower priority number wins (default 100 when omitted or 0), and name defaults to the filename without .yaml. Policies are re-upserted from these files on every boot, so Admin edits to a file-backed policy are overwritten.

name: default
priority: 100
rules: # evaluated top-down; first match supplies the preference list
- match:
task_class: [code] # chat | code | summarize | title | embed | vision | classify | reflect | image | video
# users: [...] agents: [...] selector: [...] external: true|false
require: { tools: true } # capability filter (same fields as endpoint capabilities)
prefer: # ordered endpoint ids; the gateway fails over down this list
- anthropic/claude-opus-5-5
- local-vllm/qwen3-coder-next
deny: [] # endpoint ids never to use for this rule
max_cost_per_call_usd: 0 # drop candidates estimated above this
local_only: false # restrict to local endpoints
- match: {} # empty match catches everything else
aliases: # names usable as a model name by clients
best: [anthropic/claude-opus-5-5, anthropic/claude-sonnet-5-5]
cheap: [local-llama/gpt-oss-20b, anthropic/claude-haiku-4-5]

Empty match lists match everything. Endpoints whose provider isn’t configured on this box are skipped automatically. Failover only happens on retryable errors before the first token is streamed.

MCP servers whose tools the agent runtime exposes as mcp__<server>__<tool>.

servers:
- name: infisical
url: http://mcp-infisical:8000/mcp
transport: streamable_http # streamable_http (default) | sse
enabled: true
headers_env: # header name -> env var holding its value
Authorization: MCP_FOO_TOKEN
policy: ask # default for every tool: auto | ask | deny
policies: # per-tool overrides
list-secrets: auto
delete-secret: deny
allow: [] # if set, only these tools are registered
deny: [] # tools to leave out
idempotent: [list-secrets] # safe to re-run after a crash

Server name must match ^[a-z0-9][a-z0-9_-]{0,30}$; each server has either url or command, never both and never neither; policy defaults to ask. Tool names are truncated to 64 characters and each tool call has a 2 minute timeout. At boot ws retries each server for about 45 seconds so sidecars can start, and skips servers that stay unreachable with a warning. auto runs without asking, ask pauses the run for approval, deny blocks. Header values are read from the environment at connect time, so credentials stay out of the file.

Command (stdio) servers run inside a code project’s sandbox instead of connecting over the network:

- name: fs
command: [npx, -y, "@modelcontextprotocol/server-filesystem", /workspace]
env: ["FOO=bar"] # KEY=value entries, plain values only: secrets never enter a sandbox
policy: ask
policies: {read_file: auto}

The worker starts the command with docker exec in the project’s sandbox (working directory /workspace, user dev) the first time a call needs it, one session per sandbox container, and reconnects once if a call fails. At boot the worker probes the command once in a throwaway container with the same limits, no volume and a 256 MB tmpfs, to learn the tool list (retrying for about 45 seconds), then removes it. Command servers connect in the worker only (ws serve skips them) and only when the sandbox is enabled, and their tools appear in code projects. The command must exist in the sandbox image; the shipped image is unchanged and no command server is enabled by default (the example in config/mcp.yaml is commented out). headers_env and transport are for URL servers. Policies, allow, deny and idempotent work as for URL servers. Not verified: how an ask approval looks for a stdio tool in the web UI.