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.
Runner Authentication
The runner trusts theX-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 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, /worker/* calls are rejected, and the sandbox session’s MCP endpoint requires a valid worker token.
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 Runs in control under Compute in the agent form):
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 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.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, becauseRUNNER_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:
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:
/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.
agent-worker/e2b.Dockerfile:
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.
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.
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:
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: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. 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: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, 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 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 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:
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 GET /runner/toolkit/specs on the runner to see all registered 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.