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 inlocalStorage 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.
Clerk Webhooks Locally
Signup credit is granted when Clerk reports a new organization to the conductor, so to see it on the local stack the Clerk dev instance has to reachPOST /api/clerk/webhook
on your machine.
- Open a tunnel to the conductor:
cloudflared tunnel --url http://localhost:8080(orngrok http 8080). Note the public https URL it prints. - In the Clerk dashboard for the dev instance, add a webhook endpoint at
https://<tunnel-host>/api/clerk/webhooksubscribed touser.*,organization.*andorganizationMembership.*. Copy its signing secret (whsec_...). - In
.env:CLERK_WEBHOOK_SECRET=whsec_...(the route answers 404 until it is set) andBILLING_EMIT_ENABLED=true(without it the conductor has no billing client and skips the grant). To receive the receipt email, also setRESEND_API_KEYandRESEND_FROM; without them the grant is made and the send is logged as skipped. make demopasses the billing serviceBILLING_TRIAL_GRANT_MICRO_USD=20000000,BILLING_TRIAL_LAZY=falseandBILLING_DASHBOARD_URL=http://localhost:5173by default; override any of them in.env.
billing.signup.granted and billing.signup.receipt_sent, and the
Usage page shows the credit before any run. Re-deliver the membership event from the
Clerk dashboard to confirm nothing is granted or sent twice.
Runner Authentication
Keep runner services on a private network and configure authentication before deploying outside local development. Applications authenticate through the Conductor API.Dynamic Sandbox Workers
Profiles withworkerMode: "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 and sandbox communication is rejected.
make demo builds agent-worker-marlin:dev and configures the Marlin-capable
runner to launch it through the local Docker substrate. A Marlin profile leaves
workerSubstrate automatic, so the same profile uses Docker in the demo and an
eligible runner’s configured cloud substrate in deployment.
E2B differs from Daytona in three ways that matter
Both run through the same provider-neutralworkersubstrate.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.
Letting an agent choose its substrate
A runner configured for more than one substrate lets each profile pick, viaworkerSubstrate on the agent (the Sandbox runtime control in the Execution environment section of the agent form, separate from the Compute workspace section):
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 supported substrates, and the conductor routes a new session to a runner that serves the profile’sworkerSubstrate. 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 narratedstarting agent workerup 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.
substratesKnownis 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.Compare Static and Sandbox Workers
With the conductor and both worker paths already configured, run the parity harness from the repository root: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: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. Marlin also routes to runner-b when WORKER_IMAGE_MARLIN_DAYTONA is set. Build that snapshot with make worker-marlin-daytona-snapshot. 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. Marlin needs its separate E2B template from make worker-marlin-e2b-template and WORKER_IMAGE_MARLIN_E2B. Runner-b advertises Marlin only when every cloud substrate it serves has a Marlin artifact, preventing an E2B placement from receiving a Daytona snapshot name. Without the generic E2B template 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.
Running Tests
Smoke Testing the Stack
After starting everything:Tool Smoke Tests
A dedicated smoke test binary tests all platform tools end-to-end: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:
VFS_* environment it uses the in-memory dispatcher fallback:
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:
:8080 by default, authenticates via VFS_AUTH_TOKEN (clients then connect with VFS_TOKEN set to that same value), 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 onerootmount at/, backed by R2 with the bucket and endpoint resolved fromVFS_S3_BUCKET/CF_R2_BUCKET/S3_BUCKET. When no bucket is configured, the mount comes upunavailable; the server still boots so you can inspectGET /vfs/mountsand see the failure.dev: registers one all-RAMrootmount at/. Use this for SDK example smoke tests and standalone local poking. Does not pretend to be S3.disk: registers one disk-backedrootmount at/under--disk-rootorVFS_DISK_ROOT. The disk root directory must already exist.mixed: registers RAM-backed/agentsplus disk-backed/poolsand/dataunder--disk-rootorVFS_DISK_ROOT. Use this when testing cross-mount behavior across heterogeneous backends.
: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 caller-supplied 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:
dashboard/vite.config.ts) uses a local proxy by default:
:8080, start VirtualFS on :8081 if you need the Files page, and run the Vite dev server on :5173.
Common Development Issues
SSE stream drops immediately
SSE stream drops immediately
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.
Runner not found by conductor
Runner not found by conductor
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.Tool not available in session
Tool not available in session
Check that the tool name in the profile’s
tools array matches exactly what the runner registry exports. Use the platform tools reference to check available tools.MCP placeholder not resolved
MCP placeholder not resolved
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.