This guide walks you through setting up the Kilo Code monorepo for local development on macOS.
You need the following system-level tools installed before proceeding. If you already have any of these, skip the relevant step.
xcode-select --installInstall from https://brew.sh or from the GitHub releases.
If Homebrew isn't on your PATH yet:
echo 'export PATH=/opt/homebrew/bin:$PATH' >> ~/.zshrc
source ~/.zshrcbrew install git git-lfs
git lfs install --skip-repoThe --skip-repo flag avoids conflicts with the project's Husky hooks. Git LFS is used for large binary files (videos).
The project requires Node.js 24.14.1 locally (see .nvmrc) and accepts any Node.js 24.x runtime in package.json engines.
brew install nvm
mkdir -p ~/.nvmAdd the following to your ~/.zshrc:
# nvm (Node Version Manager)
export NVM_DIR="$HOME/.nvm"
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh"
[ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm"Then reload your shell:
source ~/.zshrcThe project uses pnpm as its package manager. Use Corepack so the active pnpm version matches the version pinned in package.json (packageManager).
corepack enable
corepack installInstall Docker Desktop either from the website or via Homebrew:
brew install --cask dockerImportant: Open Docker Desktop at least once after installation — it configures the CLI tools needed for docker compose.
Used to pull environment variables from the Vercel project:
pnpm add -g vercelInstall it to enable local Stripe webhook forwarding. pnpm dev:start skips the Stripe forwarder when the CLI is not installed.
brew install stripe/stripe-cli/stripegit clone git@github.com:Kilo-Org/cloud.git
cd cloud
nvm install
nvm usepnpm install
git lfs pullThe project pulls environment variables from Vercel. Run these commands interactively (each will prompt for browser-based authentication):
vercel login
vercel link --project kilocode-app
vercel env pullThis creates .env.local with all required environment variables.
The KiloClaw pages (/claw/*) render the Pylon support chat widget, which requires two env vars to activate:
NEXT_PUBLIC_PYLON_APP_ID— the Pylon app ID from the Pylon dashboardPYLON_IDENTITY_SECRET— the identity verification secret used to HMAC-sign user emails
Both are already present in Vercel and pulled by vercel env pull. If either is missing the widget is silently skipped, so local dev continues to work without Pylon configured.
If you do not have Vercel access (typical for non-Kilo-employees), run the interactive setup CLI to bootstrap .env.local:
pnpm dev:setup-envThis prompts for the 8 required env vars only, generates secrets automatically, and writes .env.local. It warns if .env.local already exists before making changes.
After it completes, run:
pnpm dev:envThe setup covers: NEXTAUTH_SECRET, NEXTAUTH_URL, POSTGRES_URL, CALLBACK_TOKEN_SECRET, BYOK_ENCRYPTION_KEY, INTERNAL_API_SECRET, STRIPE_SECRET_KEY, and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY.
These changes will allow you to do local testing with a fake account.
Use the repository workflow instead of editing Vercel projects independently. It updates kilocode-app and kilocode-global-app together for Development, Staging, and Production:
pnpm web:env set EXAMPLE_API_TOKENPrerequisites:
- Sign in with
vercel loginand have access to both projects in thekilocodescope. - Install the 1Password CLI and have write access to the
Kilo Web ENV Productionvault. If needed, the CLI prompts you to sign in with Touch ID. - Have
pnpmavailable; the command runs the pinned Vercel CLI withpnpm dlx.
The command asks whether the variable is sensitive, defaulting to yes. Sensitive Production and Staging values use Vercel's sensitive type, while Development remains encrypted but exportable through vercel env pull. The Production value is also stored as a concealed, exact-name item in Kilo Web ENV Production; its notes identify the local user and computer that last updated it.
Answer no for public or otherwise non-secret configuration. NEXT_PUBLIC_* variables must be non-sensitive because Next.js exposes them to browsers. Non-sensitive values are not copied to 1Password.
The command prompts for single-line values without echoing them, then asks for a default value for each tracked root and apps/web dotenv file. Enter a value directly, or press Return to skip that file. If every file is skipped, the command warns that the application must work without the variable so external contributors can still run it. A tracked default cannot match a remote value; use a non-secret local default instead. Invalid yes/no answers and empty remote values are prompted again instead of terminating the command. For multiline values, use --development-file, --staging-file, and --production-file. Use --dry-run to preview the redacted plan.
Remote updates are sequential rather than transactional. If a provider fails partway through, fix the problem and rerun the same command; it safely upserts every target. The workflow does not deploy, so trigger the appropriate deployment separately.
The project uses PostgreSQL 18 with pgvector, running via Docker. The compose file is at dev/docker-compose.yml:
docker compose -f dev/docker-compose.yml up -dThis starts a PostgreSQL container on port 5432 with:
- User:
postgres - Password:
postgres - Database:
postgres
pnpm drizzle migrateYou need to re-run this every time you pull new migrations from the repository.
If you want to fully reset the local dev database first, use:
pnpm dev:db:reset
pnpm drizzle migrateTo smoke-test that migrations still bootstrap correctly from a fresh empty database, run:
pnpm drizzle:verify-bootstrapKILO_PORT_OFFSET=auto pnpm dev:startThis launches a tmux dashboard with the Next.js app and local infrastructure.
The automatic offset keeps secondary worktrees from colliding with the root
checkout. When the Stripe CLI is installed, the command also starts the Stripe
webhook forwarder. Run pnpm dev:status to get the web app's port.
To stop all services:
pnpm dev:stopRun the root test script to confirm everything is working:
pnpm testThis runs the web tests and web environment tests. They should pass against the local PostgreSQL database.
| Command | Description |
|---|---|
KILO_PORT_OFFSET=auto pnpm dev:start |
Start all local services in a tmux dashboard with worktree-safe ports |
pnpm dev:stop |
Stop the tmux session and all services |
pnpm dev:env |
Sync .dev.vars files from .env.local (see Worker .dev.vars setup) |
pnpm test |
Run web tests and web environment tests |
pnpm typecheck |
Run the TypeScript type checker |
pnpm lint |
Lint all source files |
pnpm format |
Format all supported files with oxfmt |
pnpm format:changed |
Format only files changed since main |
pnpm validate |
Run the root typecheck, lint, and test scripts |
pnpm drizzle migrate |
Apply pending database migrations |
pnpm drizzle generate |
Generate a new migration after schema changes |
pnpm drizzle:verify-bootstrap |
Create a temporary empty database and verify pnpm drizzle migrate bootstraps it cleanly |
pnpm dev:db:reset |
Drop all app-owned schemas in the local dev database, recreate public, and leave the DB truly empty before re-migrating |
pnpm --filter web stripe |
Start Stripe webhook forwarding to localhost |
pnpm test:e2e |
Run Playwright end-to-end tests |
- Direct commits to
mainare blocked by a git hook. Always work on a feature branch.
To test Stripe integration locally:
- Install and log in to Stripe CLI:
stripe login - Start local development:
pnpm dev:start - The dev launcher starts the webhook forwarder and writes
STRIPE_WEBHOOK_SECRETtoapps/web/.env.development.local.
If the Stripe CLI is not installed, pnpm dev:start skips webhook forwarding. To run only the webhook forwarder manually, use pnpm --filter web stripe.
- Edit the schema in
packages/db/src/schema.ts - Generate a migration:
pnpm drizzle generate - Apply it:
pnpm drizzle migrate
If you prefer Nix, the project includes a flake.nix with a dev shell that provides all required tools. With direnv installed, the .envrc file will automatically activate the Nix environment when you enter the project directory.
In local development, you can sign in without real OAuth by navigating to:
http://localhost:<port>/users/sign_in?fakeUser=<email>&callbackPath=<path>
Use the port reported by pnpm dev:status. This creates a local-only user with the @@fake@@ hosted domain. Set callbackPath to go directly to a page after login:
http://localhost:<port>/users/sign_in?fakeUser=someone@example.com&callbackPath=/profile
Some features (e.g., admin panels) are only visible to users with is_admin = true.
- Real OAuth: new
@kilocode.aisignups are never automatically made admins. An existing qualifying@kilocode.aiadmin must grant access explicitly from/admin/admins. - Fake login: emails must end in
@admin.example.comto get admin access automatically at signup. This bootstrap only applies in environments where fake login is enabled; note that a fake-loginsomeone@kilocode.aiuser does not qualify for production-domain admin grants because its hosted domain is@@fake@@, notkilocode.ai.
To sign in as a fake admin:
http://localhost:<port>/users/sign_in?fakeUser=yourname@admin.example.com&callbackPath=/admin
A non-@admin.example.com email (e.g., someone@kilocode.ai) used via fake login will not be an admin, because the fake-login provider sets hosted_domain to @@fake@@, not kilocode.ai.
New organizations start with a 30-day enterprise trial. After expiry, the UI progressively locks down: first a soft lock (read-only with dismiss option), then a hard lock (no access without subscribing). This can be inconvenient in local development.
The easiest approach is to use the pre-configured dev organization. While signed in, run the following in the browser console (the endpoint is POST-only):
fetch('/api/dev/create-kilocode-org', { method: 'POST' })
.then(r => r.json())
.then(console.log);This creates a "Kilocode Local" org (id: 00000000-0000-0000-0000-000000000000) with:
plan: 'enterprise'require_seats: false— bypasses all trial/subscription checksfree_trial_end_at: '9999-12-31'— effectively never expires
If you've already created an organization and want to prevent its trial from expiring, you have two options:
Option A: Set require_seats to false in the database
This is the most reliable bypass — it short-circuits all trial enforcement (server-side middleware, client-side UI, and login redirects):
UPDATE organizations SET require_seats = false WHERE id = '<your-org-id>';Option B: Use the admin panel
- Sign in as a fake admin (
yourname@admin.example.com) - Open the admin panel from the account dropdown in the top-right corner
- Find your organization and either:
- Set
free_trial_end_atto a far-future date - Toggle on
suppress_trial_messaging(hides all trial UI)
- Set
Trial status is checked at three layers:
| Layer | Mechanism | Bypassed by require_seats = false |
|---|---|---|
| tRPC mutations | requireActiveSubscriptionOrTrial() middleware throws FORBIDDEN on hard expiry |
Yes |
| Login redirect | isOrganizationHardLocked() redirects to /profile |
Yes |
| Client UI | OrganizationTrialWrapper shows banners and lock dialogs |
Yes |
A script creates 6 organizations with different trial states for UI testing:
pnpm --filter web script:run db create-trial-test-orgs yourname@admin.example.comThe application consists of the Next.js app plus several Cloudflare Worker services (see pnpm-workspace.yaml). In local development, most day-to-day work only requires the Next.js app and PostgreSQL — workers are started individually as needed.
AI inference works locally without any extra services. The Next.js app includes an OpenRouter proxy route (/api/openrouter/[...path]) that calls real AI providers using API keys from .env.local. There are no mocks or local stubs — all inference hits real APIs (OpenRouter, OpenAI, Anthropic, Mistral, etc.).
Each worker in the workspace can be started individually with wrangler dev (or pnpm dev) from its directory. Workers communicate with Next.js over HTTP using env vars like CLOUD_AGENT_NEXT_API_URL, CODE_REVIEW_WORKER_URL, etc. Dev ports are defined in each worker's wrangler.jsonc.
The easiest way to run workers is with pnpm dev:start (see Common Development Commands), which starts groups of related services in a tmux dashboard.
KiloClaw uses docker-local by default for local development — no Fly.io access required. To set it up:
- Expose the Docker socket over loopback:
socat TCP-LISTEN:23750,bind=127.0.0.1,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock - Build the local image:
cd services/kiloclaw && ./scripts/build-local-image.sh - Run
pnpm dev:envto create the.dev.varsfile (already configured for docker-local) - Start KiloClaw:
pnpm dev:start kiloclaw
See services/kiloclaw/README.md for more details, including how to switch to the Fly provider.
Most workers require a .dev.vars file with secrets like NEXTAUTH_SECRET and INTERNAL_API_SECRET. A script automates this:
pnpm dev:envThe script (dev/local/env-sync/) scans every .dev.vars.example in the repo and apps/web/.env.development.local.example, resolves each variable's value, and writes (or patches) the corresponding generated local env file. Before applying, it shows a diff of what will change and asks for confirmation.
Values are resolved using annotations in example env file comment lines:
| Annotation | What it does | Example |
|---|---|---|
| (none) | Copies the value from .env.local if the key matches, otherwise keeps the template literal |
INTERNAL_API_SECRET=your-secret-here |
# @override |
Always uses the template literal, even when .env.local contains the same key |
# @override above a development-only bucket name |
# @url <service> |
Builds http://localhost:<port> from the service's dev port in wrangler.jsonc |
# @url nextjs → http://localhost:3000 |
# @from <KEY> |
Copies the value of a different key from .env.local |
# @from CODE_REVIEW_WORKER_AUTH_TOKEN |
# @pkcs8 |
Copies from .env.local and converts PKCS#1 PEM keys to PKCS#8 format |
# @pkcs8 above a private key var |
For example, in a .dev.vars.example:
# @url nextjs
API_URL=http://localhost:3000
# @from CODE_REVIEW_WORKER_AUTH_TOKEN
BACKEND_AUTH_TOKEN=your-backend-auth-tokenThe @url annotation accepts multiple comma-separated services (e.g., # @url svc-a,svc-b) and appends path suffixes (e.g., # @url nextjs/api/events).
Run pnpm dev:env again after pulling changes that add new env vars to any .dev.vars.example.
Generate a dedicated RSA keypair when one runtime encrypts environment-backed secrets and another runtime decrypts them:
pnpm exec tsx dev/generate-rsa-env-keypair.ts -- \
--out-dir <secure-output-dir> \
--public-env <PUBLIC_KEY_ENV> \
--private-env <PRIVATE_KEY_ENV>The command requires a new output directory outside the repository, then writes restricted PKCS#8 private-key, SPKI public-key, and base64 env-assignment files without overwriting existing output. Store private.pem and private.env in an approved secrets manager and never commit them. Generate a separate keypair for each encryption domain; do not reuse deployment, agent-profile, or GitHub user-token keypairs.
KiloClaw emits events to Cloudflare Analytics Engine (datasets kiloclaw_events, kiloclaw_controller_telemetry). A local-only Grafana is available for querying those datasets against the real production CF account — there is no local ClickHouse, and wrangler dev cannot simulate AE writes, but Grafana can always read what prod has already written.
Grafana is part of the observability group in the dev runner, so pnpm dev:start observability (or pnpm dev:start all) boots it alongside the other observability workers. It shows up in the tmux sidebar under OBSERVABILITY on port 4000.
One-time setup:
- Create a Cloudflare user API token with a single permission: All accounts → Account Analytics: Read. No zone, DNS, or write permissions required — this is strictly a read-only token.
- Add it to
.env.local(the same file used bypnpm dev:env):CF_AE_TOKEN=<token> pnpm dev:start observability— Grafana is available at http://localhost:4000 (defaultadmin/admin).
The dev runner passes --env-file .env.local to docker compose when starting infra, so the token reaches the Grafana container via env substitution without being loaded into the runner's process.env. Shell exports still override file values.
If CF_AE_TOKEN is missing, Grafana will still boot — only dashboard queries fail. The runner prints an advisory warning at startup. See dev/grafana/README.md for full provisioning details and dashboard coverage.
- Service bindings resolve locally for Workers launched together by
pnpm dev:startwhen the bound target is running. Bindings to optional services remain unavailable unless their owning group is started (for example, session-ingest -> o11y requires theobservabilitygroup). - Webhook → KiloClaw Chat triggers require the KiloClaw worker running on port 8795. The webhook worker calls it via
KILOCLAW_API_URL(HTTP, not a service binding) to deliver messages to Stream Chat. Stream Chat credentials (STREAM_CHAT_API_KEY,STREAM_CHAT_API_SECRET) must be inkiloclaw/.dev.vars. - Cloudflare Containers (used by Cloud Agent Next and App Builder) always run on Cloudflare's remote infrastructure, even in dev mode. Purely local execution is not possible.
- Analytics Engine writes are no-ops in
wrangler dev— there is no local AE simulator. Reads against the real prod datasets still work via the local Grafana above. Pipelines and dispatch namespaces don't work locally.
The core Next.js app handles profiles, organizations, usage tracking, billing, and the OpenRouter inference proxy without any workers. Features that require a specific worker (e.g., Cloud Agent sessions, code reviews, app builder) will fail gracefully or show connection errors if that worker isn't running.
If you use git worktree to run multiple checkouts simultaneously, set the KILO_PORT_OFFSET environment variable to avoid port collisions between worktrees:
# Automatic offset derived from the worktree directory name (0 for the primary worktree):
export KILO_PORT_OFFSET=auto
# Or a fixed numeric offset (added to every service port):
export KILO_PORT_OFFSET=100With auto, the primary worktree gets offset 0 (default ports), and secondary worktrees get a deterministic offset based on the directory name. The offset is added to the Next.js port (3000), all worker dev ports, and the URLs generated by pnpm dev:env. Use the same offset when syncing env values and starting or restarting services in a worktree.
pnpm dev:start also passes a worktree-local Wrangler service-discovery registry at .wrangler/dev-registry into its tmux session. For worktrees with distinct kilo-dev-* session names, this allows concurrent offset Worker stacks such as agents to use the same local Worker names without resolving bindings to Workers running from sibling worktrees. The absolute registry path is recorded in dev/logs/manifest.json for diagnostics.
Infrastructure containers (postgres on 5432, redis on 6379, redis-http on 8079, grafana on 4000) always bind to their fixed host ports regardless of the offset - they are shared services, not per-worktree instances. Concurrent worktrees reuse those containers, and pnpm dev:stop leaves them running while another kilo-dev-* session remains active.
The Next.js dev script exports local Redis defaults before next dev starts: UPSTASH_REDIS_REST_URL=http://localhost:8079 and UPSTASH_REDIS_REST_TOKEN=example_token for the shared @upstash/redis REST helper, plus REDIS_URL=redis://localhost:6379 for Chat SDK state because @chat-adapter/state-redis uses the Redis TCP protocol.
If you see errors about unsupported Node.js versions, ensure you're using the pinned Node 24 release:
nvm use
node --version # Should output v24.14.1Make sure the PostgreSQL container is running:
docker compose -f dev/docker-compose.yml up -d
docker ps | grep postgresThe connection string used by the app is postgres://postgres:postgres@localhost:5432/postgres.
The dev server won't start without environment variables. Run vercel env pull to create .env.local. If you don't have Vercel access yet, ask a team member for help.
This means your active Node.js version doesn't match the supported 24.x range in package.json. Switch to the pinned local version with nvm use.
If image/video files appear as small text files with oid sha256:..., run:
git lfs pull