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.
| Variable | Default | Purpose |
|---|---|---|
WS_ENV | dev | dev or prod. Outside dev, session cookies are marked Secure, so prod needs HTTPS |
WS_LISTEN | :8080 | App listener (API, web UI, preview proxy) |
WS_ARTIFACT_LISTEN | :8081 | Artifact origin listener |
WS_PUBLIC_URL | http://localhost:8080 | Public origin of the app. Also the default WebAuthn origin |
WS_ARTIFACT_URL | http://localhost:8081 | Public origin for artifacts. Must be a different origin from the app |
WS_PREVIEW_DOMAIN | preview.localhost | Parent domain for sandbox previews, used to derive {port}-{id}.<domain> |
WS_SESSION_SECRET | none, required | HMAC key for session cookies and, via derivation, the app-to-worker shared secret. 32 bytes minimum |
WS_DATABASE_URL | none, required | Postgres connection string |
WS_BLOB_DIR | ./data/blobs | Blob store directory (attachments, externalized tool output, artifact snapshots) |
WS_OWNER_EMAIL | empty | The first user to register with this email becomes owner; an invite is created and logged at first boot |
WS_LOG | info | debug, 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_TOKENS | 60000 | Soft ceiling on tokens sent per request before older turns are replaced by a summary. Keep it below the smallest context window you route to |
| Variable | Default | Purpose |
|---|---|---|
RESEND_API_KEY | empty | Resend key. Without it, invite and magic-link emails are printed to the log |
WS_EMAIL_FROM | ws@localhost | Sender address; the domain must be verified in Resend |
WebAuthn (passkeys)
Section titled “WebAuthn (passkeys)”| Variable | Default | Purpose |
|---|---|---|
WS_RP_ID | host of WS_PUBLIC_URL | Relying party id. Passkeys are bound to it; changing it invalidates existing passkeys |
WS_RP_NAME | ws | Display name shown by the authenticator |
WS_RP_ORIGINS | [WS_PUBLIC_URL] | Comma-separated allowed origins |
Model providers
Section titled “Model providers”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.
| Variable | Used by |
|---|---|
ANTHROPIC_API_KEY | anthropic provider |
OPENAI_API_KEY | openai provider; also enables image generation through the seeded openai/gpt-image-1 endpoint |
OPENROUTER_API_KEY | openrouter provider |
CEREBRAS_API_KEY | cerebras provider |
LLAMA_SERVER_URL | local-llama base URL, for example http://llama:8000/v1 |
VLLM_URL | local-vllm base URL |
MAC_STUDIO_URL | mac-studio base URL |
WS_ENDPOINTS_FILE | Path to the endpoints seed file. Default config/endpoints.yaml |
WS_POLICIES_DIR | Directory of routing policy YAML. Default config/policies |
WS_MCP_FILE | MCP server list. Default config/mcp.yaml |
WS_MCP_SERVER | true |
Coding sandboxes (worker)
Section titled “Coding sandboxes (worker)”The worker is the only process with the Docker socket. serve relays sandbox file and terminal requests to the worker’s internal API.
| Variable | Default | Purpose |
|---|---|---|
WS_SANDBOX_ENABLED | true | Turn sandboxes off entirely. Read by the worker only |
WS_SANDBOX_IMAGE | ghcr.io/jking323/ws-sandbox:latest | Image for sandbox containers |
WS_SANDBOX_NETWORK | ws_sandbox-net | Internal Docker network; the Compose-created name. none disables |
WS_SANDBOX_PROXY_URL | http://worker:3128 | Egress proxy address as seen from inside a sandbox. none disables |
WS_SANDBOX_RUNTIME | auto | auto, runc, or runsc (gVisor, used by auto when installed) |
WS_SANDBOX_MEMORY_MB | 4096 | Memory limit per sandbox |
WS_SANDBOX_CPUS | 2 | CPU limit per sandbox |
WS_SANDBOX_IDLE_STOP | 15m | Stop a container after this long idle. Go duration syntax; an invalid value becomes 0 |
WS_SANDBOX_REMOVE_AFTER | 168h | Remove a container after this long idle; the volume stays. Go duration syntax; an invalid value becomes 0 |
WS_WORKER_URL | http://worker:8082 | Where serve reaches the worker’s internal API. none or empty disables the sandbox relay and previews |
WS_INTERNAL_LISTEN | :8082 | The worker’s internal API listener |
WS_PREVIEW_HOST | derived | Preview 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 | :3128 | Egress proxy listener |
WS_EGRESS_ALLOW | empty | Extra 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 |
GitHub
Section titled “GitHub”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.
| Variable | Purpose |
|---|---|
GITHUB_APP_ID | GitHub App id. Enables installation tokens, open_pr, and the Admin install view |
GITHUB_APP_PRIVATE_KEY | App private key, inline. PEM with \n escapes, or base64 |
GITHUB_APP_PRIVATE_KEY_FILE | Path to the key instead, for example /secrets/github-app.pem (Compose mounts ./secrets read-only at /secrets) |
GITHUB_APP_SLUG | App slug, for the Admin install link |
GITHUB_TOKEN | Fallback personal token when no App is configured. open_pr is unavailable in this mode |
Secrets from Infisical
Section titled “Secrets from Infisical”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.
| Variable | Default | Purpose |
|---|---|---|
INFISICAL_CLIENT_ID | none | Machine identity client id. Required to enable |
INFISICAL_CLIENT_SECRET | none | Machine identity secret. Required to enable |
INFISICAL_PROJECT_ID | none | Project to read. Required to enable |
INFISICAL_ENV | prod | Environment slug |
INFISICAL_PATH | / | Secret path |
INFISICAL_SITE_URL | https://app.infisical.com | Set for a self-hosted Infisical |
INFISICAL_OVERWRITE | unset | 1 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.
Deployment (Compose)
Section titled “Deployment (Compose)”These are read by Compose and the Makefile rather than by the Go binary. See ../infra/compose/README.md.
| Variable | Default | Purpose |
|---|---|---|
POSTGRES_PASSWORD | ws | Password for the Compose Postgres. Change it in any non-dev setup |
WS_IMAGE | ghcr.io/jking323/ws:latest | App and worker image. Pin to a commit SHA to stop image updates |
WS_BIND_ADDR | loopback | Host binding for override.cloud.yaml or LAN testing |
WS_HTTP_PORT, WS_ART_PORT | 8080, 8081 | Host ports for the same |
WS_HOST, WS_ART_HOST | ws.home.arpa, ws-art.home.arpa | Hostnames for override.traefik.yaml |
WS_PREVIEW_PARENT | home.arpa | Parent domain for the Traefik preview router |
WS_DEPLOY_HW, WS_DEPLOY_INGRESS | none | Written by make install-cd; default HW and INGRESS for make targets |
WS_DEPLOY_PROFILES | none | Compose profiles to enable, for example infisical |
DOCKER_GID | 999 | Host docker group id for the worker. make up derives it from the socket |
CLOUDFLARE_TUNNEL_TOKEN | none | Token for the cloudflare profile |
TS_AUTHKEY | none | Tagged auth key for the tailscale profile |
HF_TOKEN | none | Hugging Face token for vLLM model downloads |
VLLM_API_KEY | empty | API key passed to the vLLM container by override.spark-cuda.yaml |
TRAEFIK_NETWORK | traefik-net | External 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.
Infisical MCP sidecar
Section titled “Infisical MCP sidecar”For the infisical Compose profile (WS_DEPLOY_PROFILES=infisical). These configure the mcp-infisical container, not the app.
| Variable | Default | Purpose |
|---|---|---|
INFISICAL_MCP_CLIENT_ID, INFISICAL_MCP_CLIENT_SECRET | none | A second, least-privilege machine identity for the agent’s mcp__infisical__* tools |
INFISICAL_MCP_MASK | true | Mask secret values in tool output |
INFISICAL_MCP_TOOLS | list-projects,list-secrets,get-secret,create-secret,update-secret | Tools the sidecar exposes |
Claude Code task router (spawn_job)
Section titled “Claude Code task router (spawn_job)”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.
| Variable | Default | Purpose |
|---|---|---|
WS_CC_WEB_ALLOW | 100.64.0.0/10,127.0.0.0/8,::1/128 | Which 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_CAP | 0 | A 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_SOFT | 75 | The 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_DISPATCH | false | While 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_TARGETS | empty | Path 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_CONFIG | empty | An 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_CLASSIFY | true | When 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_ALIASES | api=best,openrouter=cheap,local=local | Maps 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_jobrefuses 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_jobreads 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 theapilane instead, as rulebudget:soft. At the cap every subscription decision goes toapiasbudget:full. A lane the caller asked for, with thelaneargument or an@claudeoverride, 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
localorapiand never to OpenRouter or the subscription lane; a forced lane or@openrouterdoes not override that, and a model hint is ignored for them. - The
wsimage carries ansshclient but notmux,claudeorwsjconfig: from a container every target must betype = "ssh", and tmux andclauderun on the target. Mount the key, anssh_config,known_hostsandtargets.tomlunder./secrets/(Compose mounts it at/secrets, read-only) and setWS_CC_TARGETS=/secrets/targets.tomlandWS_CC_SSH_CONFIG=/secrets/ssh_config; see ../infra/launcher/README.md. Nothing in the code enforces “ssh only”; alocaltarget 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 (Claude Code session launcher)
Section titled “wsj (Claude Code session launcher)”wsj is a separate CLI (cmd/wsj) and does not read .env or the server configuration. See CLAUDE_CODE_JOBS.md for the design.
| Setting | Default | Purpose |
|---|---|---|
WSJ_TARGETS | $XDG_CONFIG_HOME/wsj/targets.toml, else ~/.config/wsj/targets.toml | Path to the targets file |
XDG_CACHE_HOME | ~/.cache | Local 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.
config/endpoints.yaml
Section titled “config/endpoints.yaml”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 requestEmbedding 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 runsmedia 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_classbecomesmediumand an emptylatency_classbecomesnormal. - A provider with
base_url_envis 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_bodyis 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.
config/policies/*.yaml
Section titled “config/policies/*.yaml”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: defaultpriority: 100rules: # 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.
config/mcp.yaml
Section titled “config/mcp.yaml”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 crashServer 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.