Open-Inspect is a background coding agent system that spawns sandboxed dev environments to work on GitHub repositories. Single-tenant design. Stack: Cloudflare Workers (TypeScript), Modal (Python), Next.js (React), Terraform.
Three tiers connected by WebSockets:
- Web Client (Next.js on Vercel or Cloudflare Workers via OpenNext) — UI with GitHub OAuth, session dashboard, real-time streaming
- Control Plane (Cloudflare Workers + Durable Objects) — session lifecycle, WebSocket hub, GitHub/auth integration. Each session is a Durable Object with SQLite storage. Uses D1 for the session index, repo metadata, environments, and encrypted secrets.
- Data Plane (Modal, Python) — sandboxed environments running coding agents. Manages sandbox creation, snapshots, and repository/environment image builds.
Bot integrations — all Cloudflare Workers using Hono:
slack-bot— Slack messages → coding sessionsgithub-bot— PR review assignments and @mention commandslinear-bot— Linear agent webhooks → coding sessions
Data flow: User prompt → web client → control plane DO (WebSocket) → Modal sandbox → streaming events back through the same WebSocket chain.
@open-inspect/shared ← control-plane, web, slack-bot, github-bot, linear-bot
Build @open-inspect/shared first whenever you change shared types. Other packages import from
it at build time.
| Package | Lang / Framework | Purpose |
|---|---|---|
shared |
TypeScript | Shared types, auth utilities, model definitions |
control-plane |
TypeScript / CF Workers + DO | Session management, WebSocket streaming, GitHub integration |
web |
TypeScript / Next.js 16 + React 19 | User-facing dashboard, OAuth, real-time UI |
docs |
TypeScript / Next.js 16 + Fumadocs | Public user documentation at docs.backgroundagents.dev |
slack-bot |
TypeScript / CF Workers + Hono | Slack event handler, session creation |
github-bot |
TypeScript / CF Workers + Hono | PR review and @mention webhook handler |
linear-bot |
TypeScript / CF Workers + Hono | Linear agent webhook handler |
modal-infra |
Python 3.12 / Modal + FastAPI | Sandbox lifecycle, WebSocket bridge to control plane |
# Install & build
npm install
npm run build # all packages
npm run build -w @open-inspect/shared # shared only (build first!)
# Lint & format
npm run lint:fix # ESLint + Prettier fix
npm run format # Prettier only
npm run typecheck # tsc across all TS packages
# Tests — TypeScript (Vitest)
npm test -w @open-inspect/control-plane # unit tests (node env)
npm run test:integration -w @open-inspect/control-plane # integration (workerd/Miniflare + real D1)
npm test -w @open-inspect/web
npm test -w @open-inspect/docs
npm run build -w @open-inspect/docs
npm test -w @open-inspect/github-bot
npm test -w @open-inspect/slack-bot
npm test -w @open-inspect/linear-bot
# Tests — Python (pytest)
cd packages/modal-infra && pytest tests/ -v
# Python linting
cd packages/modal-infra && ruff check --fix && ruff formatAll TypeScript packages use Vitest; Python uses pytest + pytest-asyncio.
- control-plane unit: co-located as
src/**/*.test.ts— run in Node environment - control-plane integration: separate
test/integration/*.test.ts— run in workerd via@cloudflare/vitest-pool-workerswith real D1 bindings - web, slack-bot, linear-bot: co-located
src/**/*.test.ts - github-bot: separate
test/*.test.ts - modal-infra:
tests/test_*.py
These run inside a real workerd runtime with Miniflare, using the cloudflareTest() plugin from
@cloudflare/vitest-pool-workers. Important:
- Integration tests share one D1 instance — use
cleanD1Tables()or equivalent cleanup inbeforeEach/afterEachto avoid cross-test pollution - D1 migrations from
terraform/d1/migrations/are applied automatically viatest/integration/apply-migrations.ts - Helpers in
test/integration/helpers.ts:initSession(),queryDO(),seedEvents()
- Use seconds for Python, milliseconds for TypeScript. These match each ecosystem's conventions
(Modal
timeout=takes seconds; control-plane uses_MSsuffixes throughout). - Encode the unit in the name. Python:
timeout_seconds. TypeScript:timeoutMs,INACTIVITY_TIMEOUT_MS. Never use a baretimeout. - Define each default value exactly once. Extract to a named constant and import everywhere.
- Don't restate literal values in comments. Write
Defaults to DEFAULT_SANDBOX_TIMEOUT_SECONDS, notDefault: 7200.
- When threading an existing field through new code paths, evaluate whether the existing design (naming, types, units) is correct — don't blindly propagate it. Fix bad names or units in the same change rather than spreading the problem.
Use conventional commits: feat:, fix:, docs:, refactor:, chore:, test:. Keep the subject
under 72 characters. Use the PR body for details, not the commit message.
- Build order: always build
@open-inspect/sharedbefore packages that depend on it. - PKCS#8 keys: Cloudflare Workers require PKCS#8 format for GitHub App private keys — convert
with
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt. - Durable Object bindings: new DO bindings require a two-phase Terraform deploy — first with
enable_durable_object_bindings = false, thentrue. - No
wrangler.toml: control-plane config is generated by Terraform, not checked in. - Modal deployment: from
packages/modal-infra, runuv run python deploy.py --build-sandbox-imagebeforeuv run modal deploy deploy.py(oruv run modal deploy -m src). Never deploysrc/app.pydirectly; it doesn't import function modules. - Modal image rebuild: update
CACHE_BUSTERinsrc/images/base.pyto force a rebuild. - Web platform choice: set
web_platform = "cloudflare"in Terraform variables to deploy the web app to Cloudflare Workers via OpenNext instead of Vercel. When using Cloudflare, Vercel credentials are not required (dummy defaults are used).NEXT_PUBLIC_WS_URLmust be available at build time since Next.js inlinesNEXT_PUBLIC_*vars into the client bundle. - Repo owners can be nested namespaces: a
repo_owneris not always a single segment. GitHub owners are (octocat), but GitLab subgroups nest (group/subgroup), so an owner may contain/. Onlyrepo_nameis a single path segment (it's the checkout directory under/workspace); the owner remains part of the repository identity in clone URLs, API routes, manifests, and storage keys. Don't validate or split owners as single segments. Use the shared repository identity helpers in TypeScript; where a fullowner/namestring is unavoidable, split on the last/and encode the owner as one API route segment.repo_config.parse_repositoriesaccepts/-joined owners (seeis_safe_repo_owner).
Pushing to main auto-deploys changed services:
- Terraform → control plane + D1 migrations + web app if
web_platform = "cloudflare"(triggers:terraform/,packages/*/) - Vercel → web app when
web_platform = "vercel"(triggers:packages/web/,packages/shared/) - Vercel Docs → public documentation is not auto-deployed; run the
Deploy Docsworkflow manually onmain(requires the dedicated docs project secrets described inpackages/docs/README.md) - Modal → data plane (triggers:
packages/modal-infra/, deployed via Terraform apply)
CI runs lint, typecheck, and tests for all TypeScript and Python packages on every push and PR.
- docs/GETTING_STARTED.md — deploy your own instance
- docs/HOW_IT_WORKS.md — detailed architecture and session lifecycle
- CONTRIBUTING.md — contribution guidelines
- docs/OPENCODE_LOCAL.md — running OpenCode locally on this repo
- packages/control-plane/README.md — API reference, WebSocket protocol, D1 schema, security model
- packages/modal-infra/README.md — sandbox internals, Modal deployment, endpoint URLs