Skip to content

Configuration

Create a .env file or use npm run setup to generate one interactively. Runtime settings migrate to the database on first boot; later changes belong in Settings or the API. Bootstrap fields remain environment-backed. See Configuration precedence.

ServicePortEnv Var
Backend API3005API_PORT (or PORT)
Web UI3007WEB_PORT (Vite development server and production server)

.env.example ships PORT=3005, so that is the port you get from a normal install; with neither API_PORT nor PORT set, the code falls back to 3000 (src/config/bootstrap-loader.ts). The Vite development server defaults to 3007 and accepts WEB_PORT — under Docker Compose the host-side ports are OCTIPUS_API_PORT (default 3015) and OCTIPUS_WEB_PORT (default 3017), mapped onto 3005/3007 in the container.

# ─── Storage (choose embedded or external) ────────────────────
STORAGE_MODE=external # embedded uses PGlite; no PostgreSQL service needed
# DATA_DIR=~/.octipus/data # embedded database directory
DATABASE_URL=postgres://user:password@localhost:5432/octipus
# Security keys (minimum 32 characters each, use `npm run setup` to generate)
MASTER_KEY=your-master-key-at-least-32-characters
JWT_SECRET=your-jwt-secret-at-least-32-characters
SESSION_SECRET=your-session-secret-at-least-32-chars
# ─── Server ───────────────────────────────────────────────────
API_PORT=3005
API_HOST=0.0.0.0
LOG_LEVEL=info
LOG_STDERR=0 # 1 = write logs to stderr instead of stdout
GATEWAY_STDIO=0 # 1 = also serve the gateway protocol over this process's stdin/stdout as JSON lines (same as `--stdio`); logs move to stderr
GATEWAY_MAX_CONNECTIONS_PER_USER=20 # signed-in /gateway connections per user; see "Gateway Config" below
GATEWAY_MAX_FRAME_BYTES=262144 # largest frame a gateway client may send, bytes
GATEWAY_REPLAY_MAX_SESSIONS=500 # sessions whose recent events are kept for replay
CORS_ORIGINS=http://localhost:3007 # your web origin; code default is http://localhost:3001
TRUSTED_PROXIES= # reverse proxies whose X-Forwarded-For is believed; see "Reverse proxy" below
REGISTRATION_MODE=open # who may register: open | invite_only (with a space invite link) | closed; the first account always may (docs/SPACES.md → Guests)
SELF_REGISTERED_INSTALL_MODELS=false # may self-registered accounts use the install's models? (docs/SPACES.md → Who may use the install's models)
# ─── Models ───────────────────────────────────────────────────
LITELLM_URL=http://localhost:4000 # LiteLLM proxy (optional)
OLLAMA_URL= # Ollama URL (optional — set in Settings > Configuration or here)
# Default model is configured in the database via the Models page.
# Provider API keys (OpenAI, Anthropic, Gemini) are stored in the encrypted vault.
# ─── First-boot model bootstrap (consumed once, then ignored) ─
# Written by `npm run setup` / `octi init`. On first boot,
# `bootstrapDefaultModel` reads these, seeds a single default
# model_config row, stores the API key in the vault, and stops
# touching .env. Editing these after first boot has no effect —
# use the Models page instead. See CONFIGURATION-PRECEDENCE.md.
BOOTSTRAP_PROVIDER= # ollama | litellm | openai | anthropic | gemini | deepseek | mistral | zai | moonshot | openrouter | cli
BOOTSTRAP_MODEL= # e.g. llama3.2:3b, gpt-4o-mini, claude-haiku-4-5-20251001
BOOTSTRAP_API_KEY= # required for cloud providers
BOOTSTRAP_BASE_URL= # for litellm: the proxy URL
# ─── Channels (optional) ─────────────────────────────────────
TELEGRAM_BOT_TOKEN= # From @BotFather
SLACK_BOT_TOKEN= # xoxb-...
SLACK_APP_TOKEN= # xapp-...
SLACK_USER_TOKEN= # xoxp-... optional; only channel_search needs it
TEAMS_APP_ID=
TEAMS_APP_PASSWORD=
WHATSAPP_ACCESS_TOKEN= # Meta Cloud API token
WHATSAPP_PHONE_NUMBER_ID= # From Meta dashboard
WHATSAPP_VERIFY_TOKEN= # Webhook verify token
WHATSAPP_APP_SECRET= # Meta App Secret
# ─── Agent Limits ────────────────────────────────────────────
AGENT_MAX_TOKEN_BUDGET=100000 # Per-agent token limit (0 = unlimited)
AGENT_DEFAULT_TIMEOUT=900000 # Per-agent timeout in ms (default: 15min)
AGENT_MAX_ITERATIONS=50 # Max iterations per agent loop
# ─── Sessions ────────────────────────────────────────────────
SESSION_RETENTION_DAYS=14 # Delete sessions idle this many days (0 = never); "keep"-marked sessions are exempt
# ─── Group channels ──────────────────────────────────────────
GROUP_CHANNELS_UNPROMPTED_ENABLED=false # Let channels in listen/proactive mode post unprompted (docs/CHANNELS.md)
# ─── Shared spaces (docs/SPACES.md) ──────────────────────────
SPACES_CREATION=any_user # Who may create a space: any_user | admins
SPACES_MAX_MEMBERS=50 # Most members per space
SPACES_INVITE_MAX_TTL_HOURS=720 # Longest invite-link lifetime (hours); longer requests are clamped
SPACES_PURGE_AFTER_ARCHIVE_DAYS=7 # Days a space stays archived before its owner can delete it
SPACES_NOTE_MAX_BYTES=114688 # Largest space note (bytes); at most half of GATEWAY_MAX_FRAME_BYTES or startup fails
SPACES_DOC_MAX_UPDATES_PER_SECOND=30 # Live-note edits one tab may send per second
SPACES_DOC_PERSIST_DEBOUNCE_MS=2000 # Idle time before a live note is saved (also saved when the last editor leaves)
SPACES_DOC_REINDEX_MINUTES=10 # Most a live note's links/search index may lag its text
SPACES_DOC_BASE_TTL_MINUTES=30 # How long a read of a live note stays a valid merge base for a write
SPACES_FILE_LEASE_TTL_SECONDS=180 # "Someone is editing" lease on a space file, without renewal
SPACES_MEMORY_MAX_ITEMS=50 # Space-memory entries given to one turn of a space session
ROOMS_MAX_QUEUED_PER_MEMBER=3 # Requests one member may have waiting in a room
ROOMS_APPROVAL_TIMEOUT_MINUTES=30 # A room turn waiting this long on an approval gives up
ROOMS_TRANSCRIPT_WINDOW_CHARS=6000 # Room transcript after the summary before the room is compacted
# ─── Spaces across installs (federation) ─────────────────────
FEDERATION_MODE=off # off | visit | host | both (host needs PUBLIC_URL)
FEDERATION_LAN_CIDRS= # Ranges a peer link may use over plain ws:// (e.g. 192.168.1.0/24)
FEDERATION_HEARTBEAT_SECONDS=15 # Link ping interval; three missed pings close the link
FEDERATION_MAX_VISITORS_PER_INSTANCE=50 # Live memberships one other install may hold here
FEDERATION_MAX_REMOTE_TURNS_PER_INSTANCE=5 # Host agent turns one other install's members may have queued or running
FEDERATION_AGENT_POSTS_PER_HOUR=20 # Agent-labelled posts from other installs per room per hour
FEDERATION_REMOTE_POSTS_PER_HOUR=120 # All posts from one other install per room per hour
WORKSPACE_PATH=./workspace
SEARXNG_URL=http://localhost:8888 # SearXNG meta-search (optional)
# ─── Integrations (optional) ─────────────────────────────────
N8N_URL=http://localhost:5678
N8N_API_KEY=
MCP_SERVERS_CONFIG=./mcp-servers.json # Path to MCP client server config (see MCP-INTEGRATION.md)
# ─── Several server processes on one PostgreSQL (optional) ───
# Task wakeups reach every process over LISTEN/NOTIFY, and a role heartbeat
# runs one turn per hook across all processes (a lease on the hook row that
# lapses after the agent timeout + 10 min if its process died). These only
# decide which leases a process may clear at startup:
# OCTIPUS_INSTANCE_ID=node-a # Unique per process AND stable across its restarts (e.g. a StatefulSet
# # pod name, never a random or shared value). When set, a restarted process
# # clears the leases its previous boots left. Unset: nothing is cleared at
# # startup and a crashed process's leases lapse by TTL. Two processes must
# # never share one id: each would clear the other's live leases.
# OCTIPUS_SINGLE_PROCESS=0 # 1 = exactly one server process uses this database: clear every lease at
# # startup. Never set it when several processes share the database.
# # (Embedded PGlite always behaves this way.)
# ─── Migrations ──────────────────────────────────────────────
SKIP_MIGRATIONS=false # Use true only if migrations are applied separately before deployment
# ─── Voice (optional) ────────────────────────────────────────
WHISPER_MODEL_PATH=
PIPER_MODEL_PATH=
# ─── Observability / Opt-outs ────────────────────────────────
TRAJECTORY_LOGGING=true # Record one JSONL line per handleMessage run (default on)
SKILL_AUTO_EXTENSION=true # Pattern-detect recurring topic/tool sequences into skill_proposals (default on)
SPINNER_STYLE=classic # TUI spinner style: classic | kawaii

Specialist model resolution uses topic bindings through ModelRegistry.getModelForTopic(role), with expert preferences and executor/backup bindings where configured. The general root can use the configured default model. See Model routing for planner/executor selection.

Resolution order inside the swarm spawner (SwarmSpawner.resolveChildModelAndExpert):

  1. Expert modelPreference — if the matched expert has an explicit preference.
  2. ModelRegistry.getModelForTopic(childRole) — otherwise the model bound to the child’s topic.
  3. Throw — if neither resolves, with a message pointing at the Models page. No silent fallback.
  • Agent and Subagent children resolve their own topic bindings, not the parent’s model. The root alone can fall back to the configured default.
  • The embedding path (litellm-client.ts:embed()) and vision path (visual/analyzer.ts) resolve the embedding / vision topic bindings the same way, or throw.

Bind models to topics via the web UI (Settings → Models → Edit → Topics) or the API (PATCH /api/models/:name).

KeyDefaultPurpose
swarm.perUserSpawnsPerMinute30Per-user rate limit on spawn_child invocations. Enforced by rate-limiter.ts.
swarm.orphanReaperIntervalMs600000Interval for the orphan reaper sweep that flips long-running swarm_nodes to cancelled after process restart.

Swarm level budgets are configurable under swarm.levelDefaults.root, .agent, and .subagent (tokens, wallMs, fanOut, maxPendingDetached). getLevelDefault in src/core/swarm/types.ts reads them, falling back to LEVEL_DEFAULT. These are execution bounds, not exact billing limits. swarm.contractRetries defaults to 1 (range 0–5) for retryable failed scorer gates.

In Settings → Configuration → Swarm, root.tokens is the root turn’s initial shared pool (swarm.levelDefaults.root.tokens, default 200,000). It is separate from Agent → maxTokenBudget (agent.maxTokenBudget), which bounds the worker and is also the default session budget. Raising the general agent budget does not raise the root swarm pool. New root turns snapshot their configured tokens, wall-clock and fan-out limits; their stored token cap is what the swarm tree displays. Existing tree rows retain their recorded caps. The spawn budget calculation can adopt a higher live parent pool for subsequent delegation without rewriting historical tree rows.

swarm.worktreeIsolation (default false, env SWARM_WORKTREE_ISOLATION) gives each coding-role child running on a CLI adapter (Claude Code, Codex, or any other CLI model) its own git worktree under ~/.octipus/worktrees/<id> on a branch octipus/<id>. It applies only to dev-mode sessions whose project directory is itself a git repository root (never the per-user sandbox), and only when that project has no uncommitted tracked changes at spawn time: a worktree starts at HEAD and would not see them, and its merge would be skipped anyway, so such a child runs on the shared tree. Retries of the same child reuse its worktree. On completion the framework commits any uncommitted work on that branch and reports {branch, headSha, diffStat, filesChanged, merge} on the child’s receipt. It merges the branch into the project’s current branch (git merge --no-ff) only when the child finished ok from an attempt that ran in the worktree (not a native backup-model attempt, skipped_other_attempt), and the project is on a branch, has no uncommitted tracked changes or merge in progress (skipped_dirty), and still contains the commit the worktree started from (skipped_moved). A conflict is aborted and the branch kept. Unmerged branches are never deleted, and a worktree whose HEAD is on no branch (a detached commit) is removed only after that commit is kept by fast-forwarding octipus/<id> or by a octipus/<id>-detached ref. Every server-side git call runs with repository hooks and fsmonitor disabled. Agents spawned by a worktree child (and their own children) inherit its worktree instead of creating another: a CLI descendant runs there, a native descendant’s file and shell tools resolve against it (its projectPath), and their evidence gates measure it; their work is captured by the worktree child’s own commit and merge. A fresh worktree has no dependencies installed, so when the repo root has node_modules, the worktree’s node_modules is a single symlink to it, and /node_modules is added once, with a comment, to the repository’s .git/info/exclude (git keeps that file in the shared git directory, so the line also applies to the main tree, where it only affects an untracked root node_modules). That node_modules is shared, not isolated: an install or delete inside a worktree changes the project’s real dependencies. swarm.worktreeLinkNodeModules (default true, env SWARM_WORKTREE_LINK_NODE_MODULES) turns the symlink off, leaving worktrees without node_modules. While the flag is on, the orphan reaper removes an abandoned worktree only when it is clean and its HEAD and branch are merged into the project, and reports the rest; a worktree whose owning process is still alive (<id>.pid) is left alone. If a worktree cannot be created, the child runs on the shared tree as before.

Both this tokens cap and AGENT_MAX_TOKEN_BUDGET (agent.maxTokenBudget, per-agent) are spend proxies, not context-fill meters. AgentWorker’s own budget gate (src/core/agent-worker.ts:973) checks this.billableTokensUsed directly — fresh input plus paid cache writes plus output, cache reads excluded (billableTokens in src/models/billable-tokens.ts) — a field the worker always owns, so there’s no fallback there. checkSpawnBudget’s syncParentTokenUsage (src/core/swarm/spawn-budget.ts:68) reads the same billable figure off the worker via getBillableTokens?.(), but falls back to getTotalTokens?.() — the grand total — for a worker reference with no billable method. getTotalTokens() itself, and sessions.token_count, stay on the grand total including cache reads; a well-cached run can show a large token count there while staying cheap and nowhere near its budget.

KeyDefaultPurpose
agent.pipelineTokenBudget2_000_000Token pool for one pipeline RUN, summed over every node visit and checked at each node boundary. 0 disables it. Per-node caps (a template step’s maxTokens) bound a single visit; this bounds the run, which is the only bound a foreach loop respects — plan items can be appended while it runs, so the number of visits is not known when the run starts. Env: PIPELINE_TOKEN_BUDGET.
multiuser.unattendedDenyActions[]Legacy additional deny list for unattended calls; entries name a container (shell) or action (shell__run). ASK is blocked for unattended calls even when this list is empty. Attended children inherit their session approval surface. Use reviewed, scoped, expiring grants for permitted automation. Env: UNATTENDED_DENY_ACTIONS (comma-separated).
KeyDefaultPurpose
compaction.minSavingsRatio0.10Minimum compression ratio that clears the session stall flag. A compaction pass that saves less than this keeps the stall flag set.
compaction.growthMultiplier2.0Trigger compaction when current context grows by this multiple relative to the last compaction baseline.
compaction.hardCeiling1_000_000Hard ceiling in tokens — compaction always runs above this threshold regardless of other gates.
KeyDefaultPurpose
gateway.maxConnectionsPerUser20Signed-in /gateway connections one user may hold (each browser tab holds one, each TUI one). The one over the cap is refused; the web shows “Too many open tabs”. Env: GATEWAY_MAX_CONNECTIONS_PER_USER.
gateway.maxFrameBytes262144Largest frame a gateway client may send, in bytes — the socket’s maxPayload, read at server start. A bigger frame closes the connection (1009). Raise it for large TUI image attachments. Env: GATEWAY_MAX_FRAME_BYTES.
gateway.replayMaxSessions500Sessions whose recent events stay in memory so a reconnecting tab can replay what it missed; the least recently active is dropped first. Env: GATEWAY_REPLAY_MAX_SESSIONS.

Shared spaces are always on; these keys bound them. What each one governs is described in SPACES.md → Settings.

KeyDefaultPurpose
security.registrationopenWho may create an account: open, invite_only (only with a valid space invite, redeemed with the account) or closed. The install’s first account may always register; SAML, SCIM and admin-created accounts are not affected. Env: REGISTRATION_MODE.
security.selfRegisteredInstallModelsfalseWhether accounts created on the sign-in page (open registration or a space invite link) may run on the install’s models and keys. Off: they use only their own models (Settings → My models) and what a space sponsors, until an admin allows it under Users. Admins, SAML, SCIM and admin-created accounts may always. Env: SELF_REGISTERED_INSTALL_MODELS.
spaces.creationany_userWho may create a space: any_user or admins. Env: SPACES_CREATION.
spaces.maxMembers50Most members per space. Env: SPACES_MAX_MEMBERS.
spaces.inviteMaxTtlHours720Longest invite lifetime, hours; longer requests are clamped. Env: SPACES_INVITE_MAX_TTL_HOURS.
spaces.purgeAfterArchiveDays7Days a space stays archived before its owner can delete it. Env: SPACES_PURGE_AFTER_ARCHIVE_DAYS.
spaces.noteMaxBytes114688Largest space note (112 KiB). Startup fails above half of gateway.maxFrameBytes. Env: SPACES_NOTE_MAX_BYTES.
spaces.docMaxUpdatesPerSecond30Live-note edits one tab may send per second. Env: SPACES_DOC_MAX_UPDATES_PER_SECOND.
spaces.docPersistDebounceMs2000Quiet time before a live note is saved (it is also saved when the last editor leaves). Env: SPACES_DOC_PERSIST_DEBOUNCE_MS.
spaces.docReindexMinutes10Most a live note’s links and search index may lag its text; billed as install work to the last editor. Env: SPACES_DOC_REINDEX_MINUTES.
spaces.docBaseTtlMinutes30How long a read of a live note stays a valid merge base for a write. Env: SPACES_DOC_BASE_TTL_MINUTES.
spaces.fileLeaseTtlSeconds180Lifetime of a lease on a space file without renewal. Env: SPACES_FILE_LEASE_TTL_SECONDS.
spaces.memoryMaxItems50Space-memory entries given to one turn, newest first. Env: SPACES_MEMORY_MAX_ITEMS.
rooms.maxQueuedPerMember3Requests one member may have waiting in a room. Env: ROOMS_MAX_QUEUED_PER_MEMBER.
rooms.approvalTimeoutMinutes30A room turn waiting this long on its requester’s approval gives up. Env: ROOMS_APPROVAL_TIMEOUT_MINUTES.
rooms.transcriptWindowChars6000Room transcript kept verbatim after the summary before the room is compacted. Env: ROOMS_TRANSCRIPT_WINDOW_CHARS.
KeyDefaultPurpose
backup.enabledtrueRun the daily backup of the database and the workspace files. Env: BACKUP_ENABLED.
backup.path~/.octipus/backupsWhere backups are written. Env: BACKUP_PATH.
backup.hour2Hour of the day (server time) the backup runs. Env: BACKUP_HOUR.
backup.keep7Good backups kept. Env: BACKUP_KEEP.
backup.includeFilestrueInclude the workspace files. Env: BACKUP_INCLUDE_FILES.

See BACKUP.md.

KeyDefaultPurpose
modules.enabled[]The modules that are on, set in Settings → Modules or by the onboarding. Empty: the core only. Installs from before modules start with every module on. Env: MODULES_ENABLED (comma-separated), read only while nothing is stored: an upgraded install, or one where a module was switched, keeps the stored list, and the upgrade that declares a new module stores a list too. Use Settings → Modules rather than the env var. Changed through /api/modules only, not the generic settings routes.
protection.remoteAtLevel3falseLet conversations holding level 3 content (personal data) use remote models, pseudonymized (PROTECTION-LEVELS.md). Off: level 3 stays on local models, like level 4. Env: PROTECTION_REMOTE_AT_LEVEL3.

See MODULES.md.

Spaces across installs (design: plans/federation-spec.md). Off by default. Each install has an Ed25519 identity, created on first use and kept in the vault as the reserved system secret federation.identity, which the vault API does not list, change or delete: peers pin it. When this install hosts, other installs dial /federation on it; the endpoint is always registered and refuses every peer (close code 4403) unless federation.mode is host or both, so a mode change applies without a restart.

KeyDefaultPurpose
federation.modeoffoff, visit (members here join spaces on other installs), host (members of other installs join spaces here) or both. Hosting needs PUBLIC_URL; without it startup logs an error and invites carry no federation part. A change applies at once: turning hosting off closes every inbound link, turning visiting off every outbound one. Env: FEDERATION_MODE.
federation.lanCidrs[]Addresses or CIDR ranges a peer link may use over plain ws://, dialled or accepted. Everything else needs wss:// to a public address (or, inbound, a trusted proxy that terminated TLS). Loopback, link-local and private addresses are never dialled unless listed here. Env: FEDERATION_LAN_CIDRS (comma-separated).
federation.heartbeatSeconds15Ping interval on a peer link; three missed pings close it. Env: FEDERATION_HEARTBEAT_SECONDS.
federation.maxVisitorsPerInstance50Live space memberships the members of one other install may hold here. Env: FEDERATION_MAX_VISITORS_PER_INSTANCE.
federation.maxRemoteTurnsPerInstance5Host agent turns, queued or running, that one other install’s members may have started here. Env: FEDERATION_MAX_REMOTE_TURNS_PER_INSTANCE.
federation.agentPostsPerHour20Posts labelled as a visitor’s agent that one room accepts per hour. Env: FEDERATION_AGENT_POSTS_PER_HOUR.
federation.remotePostsPerHour120All posts, agent-labelled or not, that one room accepts per hour from the visitors of one install (the label is the visitor’s word; this bound is not). Env: FEDERATION_REMOTE_POSTS_PER_HOUR.

Octipus takes a client’s address from the TCP connection. The X-Forwarded-For and X-Real-IP headers are ignored — anyone can send them — unless the connection comes from a proxy listed in security.trustedProxies (env TRUSTED_PROXIES, comma-separated; addresses or CIDR ranges, IPv4 or IPv6). The address is used for the per-address REST rate limits, the login and passkey lockouts, the gateway’s cap on unauthenticated connections, and the audit log. It never grants anything: there is no “local” trust, so a request from loopback is treated like any other.

Behind nginx, Caddy or a load balancer, list the proxy, or every client shares the proxy’s address for those limits:

# nginx / Caddy on the same host
TRUSTED_PROXIES=127.0.0.1,::1
# a load balancer subnet
TRUSTED_PROXIES=10.0.0.0/8

The proxy must set (not append to a client-supplied) X-Forwarded-For, or append the peer address as nginx’s $proxy_add_x_forwarded_for does: the header is read right to left, skipping trusted proxies, and the first other hop is the client. An entry that is not an address or CIDR range is rejected when the configuration loads.

The bundled web server (web/serve.mjs: port 3007 in the image, published as 3017 by docker-compose.yml) proxies /api, /a and /__artifacts__ to the backend over loopback and appends its peer address to X-Forwarded-For. In that setup, set TRUSTED_PROXIES=127.0.0.1,::1 so the backend sees each browser’s address instead of one shared one; a client that forges the header only adds hops left of the real one, which are ignored. If nginx or a load balancer sits in front of port 3017, list it too, and have it set or append X-Forwarded-For as above (a front proxy that sends only X-Real-IP is not enough here, because the bundled server always sends X-Forwarded-For).

Embedded mode requires no external database service. For external mode, provide PostgreSQL with pgvector. The following is an example for operators who maintain a separate ~/docker-services Compose project; that directory is not shipped by Octipus:

Terminal window
# Start required services
cd ~/docker-services
docker compose up -d db
# Start optional services
docker compose up -d ollama litellm searxng
ServicePortImageRequired
PostgreSQL5432pgvector/pgvector:pg16External storage mode only
Ollama11434ollama/ollama:rocmNo
LiteLLM4000ghcr.io/berriai/litellm:main-latestNo
SearXNG8888searxng/searxng:latestNo

Live artifacts (docs/ARTIFACTS.md) ship hosted HTML pages. Two modes:

  1. Add a DNS record (A or CNAME) for artifacts.<your-host> pointing to the same origin as your main app. Cloudflare users: orange-cloud is fine; Universal SSL covers it. If you already have *.<host> wildcard DNS, no new record is needed.
  2. Set ARTIFACTS_HOST=artifacts.<your-host> in your env.
  3. Reverse-proxy any Host: artifacts.<your-host> traffic to the same octipus backend; the in-process router matches the host header.

This puts hosted user-influenced HTML on a different origin so it cannot read app cookies, ride localStorage, or hit /api/* directly with session credentials.

Leave ARTIFACTS_HOST unset. Artifacts are served at /__artifacts__/a/:slug on the main host. The iframe still uses sandbox (no allow-same-origin) but a CSP-escape bug becomes a same-origin XSS against the app.

Acceptable for single-user / trusted-tenant deploys; not recommended when untrusted users can author artifacts in your workspace.

VarPurpose
ARTIFACTS_HOSTSubdomain for hosted pages. Empty → path-prefix fallback.
ARTIFACTS_PROTOhttps (default) or http for local dev.
ARTIFACTS_GATEWAY_WSSGateway WS origin baked into the embed CSP connect-src.
ARTIFACT_SDK_SHA256sha256 of octipus-artifact-client.js — pinned in CSP.
ARTIFACT_TOKEN_SECRETHMAC key for artifact-scoped JWT. Falls back to JWT_SECRET HKDF.
ARTIFACT_BUNDLES_DIRFilesystem root for custom JS bundles. Default data/artifacts/.

Edit this page in the repository.