Skip to main content
This is a self-hosting guide. The hosted product at https://www.orcapods.ai needs none of this — sign in and go.
Orca’s source repository is currently private. This guide applies to teams with source access (design partners and licensed self-hosters). If that’s you and you don’t have access yet, email support@okik.io.

Repository Layout


Prerequisites


Initial Setup


Development Modes

Mode 1: Sidecars Only (Fastest Iteration)

Use this when you’re working on the conductor or runner only and don’t need to change the sidecar.
make up starts the Node.js sidecars using Docker Compose so you don’t need to manage them manually.

Mode 2: All Processes (Full Hot Reload)

Use this when you’re changing the sidecar code.

Mode 3: Full Docker Stack

Use this for production-parity testing.

Landing Site

Use this when you’re changing the standalone marketing site. It is a separate Vite app that reuses the dashboard design tokens. The landing site ships dark by default, exposes a nav theme toggle, and stores an explicit light/dark preference in localStorage as orca-theme; keep landing/index.html and landing/src/lib/theme.ts in sync when changing theme defaults.

Environment Variables

Copy this into a .env file at the repo root for Docker Compose, or export them in your shell for process mode.

Runner Authentication

The runner trusts the X-Tenant-ID header outright, because its only legitimate caller is the conductor. RUNNER_AUTH_SECRET is what proves a caller is the conductor: the conductor stamps it on every runner request, and the runner rejects anything else with 401 before the tenant header is even read. /healthz and /readyz stay open, or a gated liveness probe would restart-loop the runner. Everything else is gated, /metrics included (it keeps its own bearer gate on top). Leave it unset locally. make demo runs the conductor and runners on loopback, and a sandbox worker reaching the runner through a tunnel holds no shared secret, so the gate would block exactly the path you are testing.

Dynamic Sandbox Workers

Profiles with workerMode: "sandbox" need a session-scoped signing key and either a runner-managed substrate or an out-of-band worker. The runner fails closed when WORKER_TOKEN_HMAC_KEY is unset or decodes to fewer than 32 bytes: managed workers cannot launch, /worker/* calls are rejected, and the sandbox session’s MCP endpoint requires a valid worker token.
Stopping a sandbox frees compute but not disk. A stopped Daytona sandbox holds its full allocation against the organisation’s total disk limit (30 GiB on tier 1), so three abandoned 8 GiB workers are enough to make every subsequent launch fail with 400 ... Total disk limit exceeded while nothing at all is running. Only archiving returns that disk.Two traps in Daytona’s semantics make the safe-looking setting the wrong one: the default archive interval is 7 days, and 0 means the maximum interval rather than “disabled”. WORKER_AUTO_ARCHIVE exists because no runner-side cleanup can reach an orphan, by definition: the runner that owned it is gone.Setting WORKER_IDLE_TIMEOUT=0 disables the provider auto-stop, and auto-archive only ever applies to a sandbox that is already stopped, so the two together leave an orphan’s disk allocated indefinitely. The runner warns at boot (worker_substrate.orphan_disk_unreclaimable) when it sees that combination.

E2B differs from Daytona in three ways that matter

Both run through the same provider-neutral workersubstrate.NewCloud, so the substrate code is identical. What differs is what the provider does underneath it. The timeout kills by default. E2B’s timeout is a wall-clock time-to-live, and reaching it destroys the sandbox rather than stopping it. Daytona’s auto-stop preserves the disk, which is the assumption WORKER_IDLE_TIMEOUT is written against. The runner therefore always sets autoPause for worker sandboxes, which converts the timer into a pause and makes the two providers mean the same thing. Nothing in a deployment should turn that off; without it a worker is destroyed mid-session and takes the conversation with it. The upside of the same mechanism: E2B’s auto-pause captures a full memory snapshot, so a resumed worker comes back with its processes and in-memory state intact. Daytona’s pause degrades to stop plus start, which is why the worker persists its state to sandbox disk and rehydrates on resume. That fallback is still correct on E2B, just no longer load-bearing. There is no auto-archive. WORKER_AUTO_ARCHIVE has no E2B equivalent, so a paused worker holds its disk until something deletes it. On Daytona, archiving is the one reclaim that reaches a worker whose runner died; on E2B there is none, and orphans accumulate against the team quota until they are cleaned up out of band. There is no domain allow-list. WORKER_DOMAIN_ALLOWLIST builds an exact DNS allow-list, which Daytona accepts and E2B cannot express: its network controls are CIDR-shaped (allow_internet_access, network.denyOut). An E2B worker sandbox therefore has unrestricted egress, and the containment the Daytona path gets from a derived allow-list is simply absent. The runner names both absences at boot under worker_substrate.e2b_gaps, because neither ever surfaces as an error downstream. Concurrency is not the constraint it is on Daytona. The Daytona tier caps concurrent worker sandboxes at roughly two (memory) and accumulated stopped sandboxes at 30 GiB (disk), and both bite during ordinary testing. Four concurrent E2B sandboxes allocate without complaint, and 17 paused ones coexist holding ~390 GB, so neither cap applies in the same way. That makes the missing auto-archive a slow leak rather than an outage: nothing fails, the paused sandboxes simply accumulate until someone deletes them.
WORKER_IDLE_TIMEOUT=0 means “disable the provider backstop” on Daytona. On E2B it cannot mean that, and the failure is silent: an omitted timeout does not disable anything, it falls back to E2B’s own default of 15 seconds. Every worker would auto-pause at roughly the time it takes to boot, and the only symptom would be a cold-start timeout naming the substrate. The runner refuses to start with WORKER_SUBSTRATE=e2b and a zero WORKER_IDLE_TIMEOUT rather than serve that.

Letting an agent choose its substrate

A runner configured for more than one substrate lets each profile pick, via workerSubstrate on the agent (the Runs in control under Compute in the agent form):
Resolution order for one profile is: workerSubstrate if set, else sandbox.provider (the shell’s) if this runner serves it, else the runner’s default. Inheriting the shell is the common case and needs no configuration: an agent given an E2B shell almost always wants its worker on E2B, and having to say so twice is how the two drift apart. The two sources fail differently, on purpose. An explicit workerSubstrate the runner does not serve is an error, because someone chose it and quietly running elsewhere would hide the mistake. An inherited one is only a hint: a docker-only runner serving an agent whose shell is on E2B falls back to docker rather than failing a profile nobody edited. Per-substrate images are not optional once a runner serves several. “The worker image” is not one artifact: the same worker is an E2B template id, a Daytona snapshot name, and a local Docker reference, built by three different pipelines and never interchangeable. A runner serving two substrates from a single WORKER_IMAGE hands each of them the other’s identifier, and the failure surfaces as a provider-side “not found” naming neither the substrate nor the setting.

Routing across a mixed fleet

Runners advertise their substrates on /runner/info (substrates, plus substratesKnown), and the conductor routes a new session to a runner that serves the profile’s workerSubstrate. A fleet where one runner has E2B credentials and another has Daytona therefore works without the caller knowing which is which. Four rules make that safe:
  • The mode routes even when the substrate does not. workerMode: "sandbox" means a worker has to be launched, which is a different fact from where, and most sandbox profiles name no substrate at all. Reading “no substrate named” as “no requirement” put a per-session-worker agent on a runner that launches nothing: the session was accepted and then narrated starting agent worker up to its three-minute timeout, on a fleet whose own placement report already said nothing will start. A profile that needs a worker is refused at create when no eligible runner launches one, rather than falling back to the shared sidecars, because the author opted into a per-session worker and running elsewhere silently is the failure this reporting exists to prevent.
  • Only an explicit choice routes to a particular substrate. An inherited one (the shell’s provider) is a hint the broker may ignore in favour of the runner default, so routing on it would refuse runners that would have served the session perfectly well.
  • A named substrate nothing serves is a hard failure, not a fallback: no runner serves worker substrate "daytona" (fleet serves: docker, e2b). The runtime path widens to the whole fleet when nothing matches, because a runner advertising no capabilities serves them all. Substrates have no such contract, so widening would swap a clear error for a cold-start timeout on a runner that never could have run it.
  • Unknown is not none. A runner too old to report substrates stays eligible, so a fleet mid-upgrade does not read as an outage. A runner that reports an empty list is excluded, because it would accept the session and fail its first run. substratesKnown is what tells those apart.
The choice is not validated when the agent is saved. Which substrates exist is a property of the fleet, not of the profile. Routing catches the common case at session create; the runner still re-checks at launch and fails with an error listing what it serves (this runner does not serve substrate "daytona" (configured: docker, e2b)), which is what catches a session pinned to a runner whose config changed under it. The runner logs its own set once at boot under worker_substrate.configured.Placement is read when a worker is launched, not on every run. Editing an agent’s substrate mid-session leaves the live worker where it is, because re-placing it would destroy the conversation it holds; the change takes effect on that session’s next worker.

How a worker reaches its runner

A worker never listens on a port; it dials out. So something has to be reachable from inside the sandbox, and the runner itself must not be that thing, because RUNNER_AUTH_SECRET gates every /runner/* route and a worker running inside a tenant’s sandbox cannot hold a shared secret. The conductor forwards instead. It mounts exactly six routes, unauthenticated by Clerk but each one verifying a worker token whose session claim must match the session in its own path, then forwards to the runner that owns that session with the runner-auth secret attached:
The last one is easy to miss and breaks differently: it is the worker’s MCP reverse channel, minted into each run command from WORKER_RUNNER_URL. Point that at the runner and every run starts fine, then fails the moment the agent calls a tool. It is minted from WORKER_RUNNER_URL and not from MCP_BASE_URL, and the two must not be collapsed. MCP_BASE_URL is the address a STATIC sidecar dials, and a static sidecar is on the private network and sends no auth on this callback by contract. A sandbox worker is on the public internet and reaches the runner only through the conductor’s proxy, which demands a worker token on every request. Setting MCP_BASE_URL to the conductor to serve sandbox workers therefore re-points every static sidecar at that proxy too, and the DEFAULT path — almost every agent — starts answering invalid or missing worker token on its first tool call while the sandbox path it was changed for works fine. That shipped to prod once. The worker process token is valid for 24 hours because it must survive across runs. The capability token embedded in sessionMcpUrl is minted separately for each run: it defaults to 2 hours when the run has no deadline, otherwise it uses the remaining run deadline plus 5 minutes of grace, with a 5-minute minimum and a 24-hour signing ceiling. Both are bearer credentials scoped to one tenant and session. The conductor meters this machine-to-machine surface separately from browser traffic. RPM tiers require rate limiting to be enabled; the in-flight cap is always enforced because long polls hold connections and goroutines for their full duration. An exhausted tier or in-flight cap returns 429 Too Many Requests. AGENT_ORC_RATELIMIT_ENABLED=false disables the RPM tiers but not AGENT_ORC_RATELIMIT_WORKER_INFLIGHT. Setting one of these RPM values to 0 means something stronger than it does for the RUNS/SESSIONS/TOPOLOGY tiers. Those routes still fall back to the general per-tenant and per-IP ceilings; these do not, because they are excluded from the per-IP tier by design so each is charged once to its own bucket. A 0 here therefore leaves that route with no request bound at all, and only the resource bounds (the fan-out limit and the in-flight cap) remain. Locally, scripts/worker-tunnel-guard.mjs (below) plays the same role for a tunnel pointed at a single runner. For a local process worker:
For Daytona, build the worker image as a snapshot before starting the runner. The snapshot entrypoint must be /app/sandbox-entrypoint.sh; it starts the outbound worker, writes diagnostic output to /tmp/orca-worker.log, and keeps the sandbox available so the runner can read that log after an early worker exit. Size CPU, memory, and disk when pushing the snapshot, because Daytona rejects resource overrides when creating a sandbox from a snapshot.
For E2B, build the worker image as a template first. The template plays the same role as the Daytona snapshot, from a different pipeline: E2B reads a Dockerfile rather than pushing a local image. Use the repository target, which builds agent-worker/e2b.Dockerfile:
That Dockerfile is separate from agent-worker/Dockerfile for two reasons. It is Debian-based rather than Alpine, because E2B injects a glibc-linked envd daemon into the guest and a musl base leaves it unable to start, with no error worth reading. And it sets no CMD, ENTRYPOINT, or start command at all.
Do not give the template a start command (-c / --cmd). E2B runs a template’s start command once at build time and captures it in the snapshot, and E2B does not expose environment supplied at sandbox creation to it. A worker started that way would hold a build-time environment forever: no ORCA_SESSION_ID, no run token, no runner URL. It is the one substrate where creating a sandbox starts nothing, so the runner execs /app/sandbox-entrypoint.sh itself after create, in the background, with the session environment (workersubstrate’s entrypointStarter, implemented by the E2B provider).
The entrypoint still has to be /app/sandbox-entrypoint.sh and still tees to /tmp/orca-worker.log, because that log is how you diagnose a worker that exits before it calls home. The path is workersubstrate.WorkerEntrypointPath; change one and you must change the other, or the start becomes a silent 127 inside a backgrounded process. e2b-bridge/ must be deployed and reachable before the runner starts. It is what gives the Go provider a flat HTTP surface over E2B’s per-sandbox gRPC-Web envd API. The runner refuses to boot without E2B_BRIDGE_URL, and the post-create start goes through it too, so an unreachable bridge fails the launch rather than producing an empty sandbox.
When a Daytona worker is testing against a local runner, do not expose the runner directly through a tunnel. Run the repository’s allowlist proxy and point the tunnel at the proxy instead; it forwards only the authenticated worker callback routes and the session MCP route:
Set WORKER_RUNNER_URL and MCP_BASE_URL to the public tunnel URL. Daytona’s domain list is exact rather than additive, so the runner derives public callback hosts, each injected provider credential’s API host (honoring supported base-URL overrides), and public configured OTLP hosts. Loopback names such as localhost, IP addresses, and other non-public hosts are omitted because a cloud sandbox cannot reach them and Daytona rejects the entire allocation if any domain is invalid. Use WORKER_DOMAIN_ALLOWLIST only for additional operator-approved public egress destinations. At runner boot, worker_substrate.domain_allowlist logs the final list. To receive Daytona sandbox state notifications while testing locally, expose only the conductor’s event ingress through a separate allowlist proxy, then point the public tunnel at that proxy:
Configure the provider webhook as https://<event-tunnel-host>/api/sandbox-events/daytona. Do not tunnel the conductor directly: --allow replaces the proxy’s default worker callback routes and keeps the rest of /api/* unreachable from that hostname. The first run for a sandbox-worker session starts the worker. Later runs reuse it, and deleting the session closes it. Daytona sandboxes carry an orca.session label with the session ID so operators and diagnostic scripts can locate the matching sandbox without inspecting redacted environment variables. A worker that never reaches its first long poll causes the run to fail after the bounded cold-start wait instead of hanging indefinitely.

Compare Static and Sandbox Workers

With the conductor and both worker paths already configured, run the parity harness from the repository root:
The harness creates timestamped static and sandbox profiles, runs the same prompt through each sequentially, and exits non-zero unless both paths succeed and agree on terminal event type, collapsed event-type shape, session_init presence, and token reporting. It deliberately does not compare model response text. The output also reports the sandbox cold-start time relative to the static sidecar.

Check That a Conversation Survives Pause and Resume

A cloud sandbox loses everything held in the worker’s memory when it is paused, because the provider stops the container and restarts it from disk. The per-session worker lifetime depends on the worker having written its state to disk first, so that path needs its own check:
The script plants a number in turn one, stops the sandbox through the provider API rather than through the runner (which is how an idle sweeper or a provider-initiated stop would arrive), then asks for the number back on the same session. It exits non-zero unless the second turn recalls it. This check is meaningful only against a cloud substrate. docker pause retains the container’s memory, so under the Docker substrate the conversation survives whether or not anything was ever persisted.

Makefile Targets

make demo runs demo-free-ports first, including ports 8091, 8092, and 8081, then starts ./bin/billing on :8091, ./bin/mcp-bridge on :8092, and ./bin/vfs serve --addr :8081 --s3-bucket "$S3_BUCKET" --s3-endpoint "$AWS_ENDPOINT_URL_S3" with OTEL_SERVICE_NAME=vfs before launching the dashboard. It generates BILLING_INTERNAL_SIGNING_KEY_CURRENT, MCP_BRIDGE_INTERNAL_SIGNING_KEY_CURRENT, and COMPOSIO_SUBJECT_PEPPER in .env when they are absent; keep the generated subject pepper stable because changing it re-keys derived Composio subjects. Connected Apps is available from http://localhost:5173/settings; the bridge still starts when COMPOSIO_API_KEY is unset, but the app gallery remains empty. When WORKER_TUNNEL_URL is set, make demo runs runner-b sandbox workers on Daytona instead of the substrate from .env, using WORKER_DAYTONA_SNAPSHOT or the default orca-agent-worker-dyn. It uses the tunnel URL for both the worker callback and session MCP endpoint; pi and vercel profiles route to this runner. DAYTONA_API_KEY must be configured. These sandboxes idle-pause rather than exit, so list them with daytona sandbox list after testing and delete any you no longer need. Set WORKER_E2B_TEMPLATE as well and runner-b serves both substrates, so an agent can pick Runs in: e2b or inherit it from an E2B shell; daytona stays the default. Build the template with make worker-e2b-template first. Without it the runner advertises daytona alone, and an agent asking for e2b silently falls back to the fleet default — which the agent’s workerPlacement field reports, and which is worth reading before treating it as a routing bug. The demo starts the billing service but defaults BILLING_EMIT_ENABLED to false, so the conductor does not call it: runs bypass the credit gate and the Usage page reports that billing is not connected. To exercise the real gate, set BILLING_EMIT_ENABLED=true in .env; because make demo sources that file, its value overrides the same variable supplied on the make command line. Outside dev, an unwired billing client is not allowed: when AGENT_ORC_ENV is any non-dev value, the conductor refuses to start unless billing is enabled with BILLING_BASE_URL or BILLING_INTERNAL_URL and a valid base64 signing key of at least 32 decoded bytes. The demo binds VirtualFS to the local SeaweedFS bucket from .env when those variables are set. make runner and make demo also export VFS_S3_BUCKET="$S3_BUCKET" and VFS_S3_ENDPOINT="$AWS_ENDPOINT_URL_S3" so runner-side filesystem tools use the same bucket. The Files page is available at http://localhost:5173/files once the demo is ready. Billing health is available at http://localhost:8091/healthz, MCP bridge health at http://localhost:8092/healthz, and VirtualFS metrics at http://localhost:8081/metrics. The billing service reads POLAR_*, BILLING_INTERNAL_SIGNING_KEY_CURRENT, REDIS_URL, and the same Postgres cluster as the conductor. POLAR_CREDIT_PACK_PRODUCT_IDS maps one-time credit-pack amounts in cents to Polar product IDs. A checkout amount must match one of these configured packs. make vfs-shell builds ./bin/vfs, starts vfs serve --profile mixed --disk-root .vfs-shell-data on :8088, and serves the tracked virtualfs/vfs-shell.html through the Bun proxy at virtualfs/serve.ts on :5170. /agents is RAM (ephemeral); /pools and /data are disk-backed under .vfs-shell-data/ (gitignored, persistent across restarts — rm -rf .vfs-shell-data to reset). When VFS_SHELL_S3_BUCKET or VFS_S3_BUCKET is set, the mixed profile also mounts that S3-compatible bucket at /seaweed; VFS_SHELL_S3_ENDPOINT falls back to VFS_S3_ENDPOINT. The proxy keeps the shell and /vfs/*, /healthz, and /metrics on the same origin for browser testing. make vfs-shell-strict uses the same proxy with production-style mount configuration and no S3 environment so unavailable mounts remain visible with their boot-time reason.

Load Testing Harness

agent-runtime/cmd/loadgen drives high-concurrency campaigns against a conductor, and agent-runtime/cmd/mock-sidecar is a zero-cost sidecar that streams realistic progress, tool_call, usage, and terminal events. Together they exercise the conductor -> runner -> sidecar -> billing path without spending LLM tokens. Both are fenced by LOADTEST_ENABLED=true; when the switch is off, campaign start or mock /run calls fail closed. The load generator exposes a small control API: Campaign bodies include behavior, optional profile, ramp, hold_s, abort_error_rate, run_timeout_s, check_billing, and optional mix. Without mix, loadgen uses the single profile plus behavior path. With mix, each entry supplies a profile, optional behavior, and optional weight; loadgen round-robins through the weighted entries so one campaign can drive multiple general-runtime profiles concurrently. Level reports include by_profile run and OK counts for mixed campaigns. When check_billing is true and LOADGEN_DSN is configured, the report checks that cumulative usage_records rows for the isolated tenant and selected profile set are at least the campaign’s finished runs, then reports token sums, tool-call sums, unpriced token rows, and the same billing totals broken down per profile. Key loadgen environment: Set LOADTEST_SEED_TENANT on a dedicated load-test conductor to pre-provision that tenant and seed the canonical orca profile at boot. Add LOADTEST_SEED_PROFILES when a mixed campaign needs additional general-runtime profiles, for example lt-haiku=anthropic:claude-haiku-4-5,lt-gpt=openai:gpt-5.5. This is useful when loadgen signs /api/runs through the chatsig internal listener, which does not run the Clerk provision-and-seed hook. Leave both unset for normal conductors. The mock sidecar reads behavior from the run prompt. Use one of the built-in archetypes (fast, heavy, flaky, whale, or chatterbox) or pass a JSON object with fields such as events, work_ms, jitter_pct, model, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, tool_calls, and fail. MOCK_DEFAULT_MODEL supplies the priced model id for emitted usage when a behavior omits one.

Running Tests


Smoke Testing the Stack

After starting everything:

Tool Smoke Tests

A dedicated smoke test binary tests all platform tools end-to-end:
This exercises web_search, web_extract, time_now, math_add, runner_info, and other platform tools against a live runner. VirtualFS also has a self-contained smoke gate for the v1.5 north-star UX samples. It runs against an in-process VirtualFS and does not require external services:
To verify the runner-to-VirtualFS tool wiring, run the agent-runtime smoke binary. With no VFS_* environment it uses the in-memory dispatcher fallback:
GitHub Actions runs the full-stack e2e-pipeline workflow on pushes and pull requests to main. It brings up the Docker-backed services, starts host-side e2b-bridge, VirtualFS, two runners, and the conductor, then runs pipeline-smoke, e2e-smoke, and the dashboard typecheck/build gate. Logs from the run are uploaded from .ci-logs/ as a workflow artifact. For a long-running VirtualFS HTTP server for SDK examples and local agent demos, run:
It listens on :8080 by default, accepts VFS_TOKEN=dev-token, emits structured startup and request logs, exposes /healthz and /metrics, and seeds /s3/log.jsonl plus /s3/report.parquet. Use --addr HOST:PORT or VFS_SERVE_ADDR when that port is already in use. By default, vfs serve runs the prod profile: it registers one system mount named root at /, backed by R2 with the bucket and endpoint resolved from VFS_S3_BUCKET / CF_R2_BUCKET / S3_BUCKET. The well-known paths (/agents, /pools, /s3, /data, /kb) are directories inside that mount. If no bucket is configured, the root mount comes up unavailable; the server still boots so you can inspect GET /vfs/mounts and see the failure reason. There is no silent in-memory fallback. The server also exposes a session surface alongside /vfs/mounts: POST /vfs/sessions, GET /vfs/sessions, GET /vfs/sessions/{id}, DELETE /vfs/sessions/{id}. A session names a list of allowed path prefixes; once created, callers pass session_id in /vfs/exec (or any typed-op) body to scope that call to those prefixes. Ops outside the allowlist return HTTP 403 SESSION_FORBIDDEN (or exit 1 with a stderr line for /vfs/exec). Sessions are in-memory and reset on process restart. Both SDKs (create_session / createSession) wrap this surface. Configure live backends with --postgres-dsn, --redis-url, --s3-bucket, and --s3-endpoint; VFS_S3_BUCKET and VFS_S3_ENDPOINT are also honored as explicit S3/R2 settings. Set VFS_BACKEND=production to let Postgres, Redis, and S3 flag defaults come from environment variables such as VFS_POSTGRES_DSN, VFS_REDIS_URL, CF_R2_BUCKET, CF_R2_ENDPOINT, S3_BUCKET, and AWS_ENDPOINT_URL_S3. Cloudflare R2 credentials are selected when the configured endpoint matches .r2.cloudflarestorage.com; other endpoints use AWS credentials.

Mount profile

vfs serve accepts --profile {prod|dev|disk|mixed}:
  • prod (default) — registers one root mount at /, backed by R2 with the bucket and endpoint resolved from VFS_S3_BUCKET / CF_R2_BUCKET / S3_BUCKET. When no bucket is configured, the mount comes up unavailable; the server still boots so you can inspect GET /vfs/mounts and see the failure.
  • dev — registers one all-RAM root mount at /. Use this for SDK example smoke tests and standalone local poking. Does not pretend to be S3.
  • disk — registers one disk-backed root mount at / under --disk-root or VFS_DISK_ROOT. The disk root directory must already exist.
  • mixed — registers RAM-backed /agents plus disk-backed /pools and /data under --disk-root or VFS_DISK_ROOT. Use this when testing cross-mount behavior across heterogeneous backends.
When running it alongside the local conductor and dashboard, bind VirtualFS to :8081 so the dashboard’s /api/vfs file-route dev proxy can reach it:

Dashboard Development

The dashboard talks to the conductor at /api and to VirtualFS at /api/vfs for the Files page. In default local Vite dev mode, VirtualFS file routes under /api/vfs are proxied directly to the local VirtualFS server, while /api/vfs/leases remains on the conductor because it reports per-session VFS leases alongside the session API. Backend deployments can instead put VirtualFS behind the conductor by setting VFS_BASE_URL; the conductor then forwards /api/vfs/* to upstream /vfs/* and injects VFS_AUTH_TOKEN server-side when it is set. For the Vercel-hosted dashboard, dashboard/vercel.json also rewrites /vfs/* directly to the deployed Railway VirtualFS service. dashboard/middleware.ts runs on that path and replaces the browser’s placeholder Authorization header with Bearer ${VFS_TOKEN} before the rewrite reaches Railway. Set VFS_TOKEN in the Vercel project environment to the same value as VFS_AUTH_TOKEN on the Railway VirtualFS service; if it is missing, /vfs/* returns 500 with VFS_TOKEN_UNSET. Default local Vite development does not run this middleware, and the local VirtualFS server accepts the default dev-token bearer token. Use make frontend-prod when you want to run the dashboard locally while pointing it at the deployed Railway backend. It sets PROXY_TARGET=prod, proxies all /api traffic to PROD_CONDUCTOR_URL, proxies legacy /vfs traffic to PROD_VFS_URL, and injects Authorization: Bearer ${VFS_TOKEN} on that legacy /vfs path when VFS_TOKEN is set. The dashboard still reads Clerk configuration from dashboard/.env.local, so those keys must match the Clerk instance accepted by the deployed conductor or /api calls will return 401. Set VFS_TOKEN only when you need direct legacy /vfs file uploads. Override the Railway URLs from the shell when testing another environment:
Vite dev server config (dashboard/vite.config.ts) uses a local proxy by default:
So start the conductor on :8080, start VirtualFS on :8081 if you need the Files page, and run the Vite dev server on :5173.

Common Development Issues

Ensure your reverse proxy (nginx, Caddy, Traefik) has buffering disabled:
In local dev this isn’t an issue since you’re hitting the conductor directly.
Make sure RUNNER_BASE_URL is set to a URL the conductor can actually reach. If both run on localhost, http://localhost:7070 works. In Docker, use the container name.
Check that the tool name in the profile’s tools array matches exactly what the runner registry exports. Use GET /runner/toolkit/specs on the runner to see all registered tools.
If an MCP header contains ${VAR}, verify the env var is set on the runner (not the conductor or sidecar). The runner resolves placeholders before forwarding.