boxkite
← All posts
Tutorial

Run the control-plane locally, on SQLite, in one command

The control-plane/multi-tenant API is the piece that turns boxkite's sandbox runtime into a real hosted service — accounts, API keys, fair-use limits, the whole HTTP surface your SDKs talk to. You don't need Postgres, Redis, or a Kubernetes cluster to bring it up and explore that surface: point it at a throwaway SQLite file and it runs zero-config. This walks through the exact steps, and is honest about the one thing SQLite-on-a-laptop can't give you.

Bring it up

From a checkout of the repo, everything installs with uv. The control-plane depends on the boxkite runtime as a sibling package, so you install that first (from the repo root, via the relative path), then the control-plane itself:

terminal
cd control-plane
uv venv                              # create .venv
uv pip install -e '../[dev]'         # install the boxkite runtime (sibling pkg)
uv pip install -e '.[dev]'           # install the control-plane itself

Then start the API. Two environment variables do all the configuration: ENVIRONMENT=developmenttells the app it's a dev run (its startup guards, which otherwise refuse to boot with insecure default secrets in production, downgrade to warnings so a zero-config local run just works), and DATABASE_URL points it at a SQLite file via the asyncaiosqlite driver:

terminal
ENVIRONMENT=development DATABASE_URL=sqlite+aiosqlite:///./cp.db \
  uv run uvicorn control_plane.main:app --port 8099

That's the whole setup. Three quick checks confirm it's serving:

GET /health is a pure liveness probe — it does no I/O, so it answers {"status":"ok"} the moment the process is up. GET /health/ready is the readiness probe: it round-trips a SELECT 1 and returns 503 if the DB is unreachable, so against your SQLite file it reports {"status":"ready","checks":{"database":"ok"}}. And GET /docs is the interactive OpenAPI explorer for every route below.

terminal
curl -s http://localhost:8099/health         # {"status":"ok"}
curl -s http://localhost:8099/health/ready   # {"status":"ready","checks":{"database":"ok"}}
open  http://localhost:8099/docs             # interactive OpenAPI docs

The flow: signup, login, API key, sandboxes

This local run earns its keep even without a cluster because the entire auth and account surface is real. Walk it end to end with curl. First, create an account — signup returns a short-lived dashboard session token (a JWT), which is the credential for account management, not for sandboxes:

terminal
curl -sX POST http://localhost:8099/v1/auth/signup \
  -H 'content-type: application/json' \
  -d '{"email":"you@example.com","password":"correct-horse-battery"}'
# -> 201
# {"access_token":"<JWT>","token_type":"bearer","expires_in":...,"refresh_token":null}

(The password minimum is 8 characters. refresh_token is null unless you've turned on refresh-token rotation — it's opt-in.) Logging back in later exchanges the same email/password for a fresh token in the identical shape:

terminal
curl -sX POST http://localhost:8099/v1/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"you@example.com","password":"correct-horse-battery"}'

Now mint an API key. This is the deliberate split worth noticing: the key-management route is authenticated with the dashboard JWT, never with an API key (you can't create a key using a key). The raw key comes back exactly once in this response and is never stored or retrievable again — only a hash is persisted:

terminal
JWT="<access_token from signup/login>"
curl -sX POST http://localhost:8099/v1/api-keys \
  -H "authorization: Bearer $JWT" \
  -H 'content-type: application/json' \
  -d '{"name":"local-dev"}'
# -> 201
# {"id":"...","name":"local-dev","prefix":"bxk_live_xxxxxxxx","role":"admin",
#  "key":"bxk_live_<secret-shown-once>", ...}

Finally, list sandboxes — this route accepts only the API key, not the JWT. On a fresh account with no cluster behind it, the answer is an honest empty list, which is exactly the point of the next section:

terminal
KEY="bxk_live_<secret from the previous step>"
curl -s http://localhost:8099/v1/sandboxes \
  -H "authorization: Bearer $KEY"
# -> []

The two credentials are not interchangeable, and the API tells you so rather than failing vaguely. Hand the dashboard JWT to a sandbox route (or an API key to a key-management route) and you get a specific error:

terminal
curl -s http://localhost:8099/v1/sandboxes -H "authorization: Bearer $JWT"
# -> 401 {"error":{"code":"wrong_credential_type",
#          "message":"This endpoint requires an API key, not a user session token"}}

What SQLite-on-a-laptop can and can't do

Be clear-eyed about the boundary. Against SQLite with no cluster you get the full HTTP, auth, and account-bookkeeping surface — signup, login, API keys, fair-use accounting, the OpenAPI docs, the shape of every request and response. What you don't get is real code execution: the control-plane creates each per-session sandbox as a Kubernetes pod at runtime, so a POST /v1/sandboxes that actually starts a pod needs a cluster behind it. A local kind cluster works — the repo ships deploy/local-kind/setup.shfor exactly that — but that's a separate step from this one.