This file provides guidance when working with code in this repository, including for Claude Code (claude.ai/code) and other AI coding assistants.
IMPORTANT: The documentation in apps/docs/ describes the intended system design and behavior, not necessarily the current implementation state. Documentation represents the target architecture and how the system should work, serving as both specification and aspiration. When implementation and documentation differ, the documentation defines the goal, not a bug to be "fixed" in the docs.
Studio is an open-source control plane for Model Context Protocol (MCP) traffic. It provides a unified layer for authentication, routing, and observability between MCP clients (Cursor, Claude, VS Code) and MCP servers. The system is built as a monorepo using Bun workspaces with TypeScript, Hono (API), and React 19 (UI).
# Start full dev environment (migrations + client + server)
bun run dev
# Start Studio client only (Vite dev server on port 4000)
bun run --cwd=apps/web dev
# Start Studio server only (Hono with hot reload)
bun run --cwd=apps/api dev:server
# Run documentation site locally
bun run docs:dev
# Native desktop app (Tauri) dev loop — HMR via Vite on port 4420
bun run --cwd=apps/native devOne-time macOS setup for apps/native devs: run
bun run --cwd=apps/native dev:signing:setup once. It creates or reuses the
local self-signed decocms-dev code-signing identity; no Apple Developer
account is required. It also builds, signs, and installs one fixed
decocms-keychain-helper under the user's Application Support directory.
Debug app rebuilds talk to that unchanged helper over JSON stdin/stdout, so
Keychain sees one stable executable; tokens never use argv, logs, or a
filesystem fallback. The native dev runner still signs the app itself and
fails closed if signing drifts, but the fixed helper—not the app's self-signed
designated requirement—is what makes debug Keychain access stable. Debug
sessions stay in the Keychain-only com.decocms.studio.dev namespace; release
sessions stay in com.decocms.studio.
# Run all tests (Bun test runner)
bun test
# Run tests for specific file/pattern
bun test path/to/file.test.ts
# TypeScript type checking (all workspaces)
bun run check
# Lint with oxlint and custom plugins
bun run lint
# Format code with Biome (ALWAYS run before committing)
bun run fmt
# Check formatting without modifying
bun run fmt:check# Run full resilience suite (builds containers, runs tests, tears down)
./tests/resilience/run.sh
# Or step by step:
docker compose -f tests/resilience/docker-compose.yml up -d --build --wait
bun test tests/resilience/scenarios/ --serial --timeout 900000
docker compose -f tests/resilience/docker-compose.yml down -vResilience tests use Docker Compose with Toxiproxy to simulate infrastructure failures (DB outages, NATS disconnections, high-latency MCP servers). See tests/resilience/ for scenario files and configuration.
IMPORTANT: Always run bun run fmt after making code changes to ensure consistent formatting. A lefthook pre-commit hook is configured to run this automatically. Install with npx lefthook install.
# Run Kysely migrations (from apps/api/)
bun run --cwd=apps/api migrate
# Run Better Auth schema migrations
bun run --cwd=apps/api better-auth:migrateThe dev server uses embedded postgres on a dynamic port. To query it while bun run dev is running:
- Find the port:
ps aux | grep "postgres -D" | grep -v grep
# Look for -p <PORT> at the end of the command- Run queries via a bun inline script (uses the
pgpackage from apps/api):
cat << 'EOF' | bun run --cwd apps/api -
import pg from "pg";
const client = new pg.Client("postgresql://postgres:postgres@localhost:<PORT>/postgres");
await client.connect();
const { rows } = await client.query("SELECT * FROM <table> LIMIT 5");
console.log(JSON.stringify(rows, null, 2));
await client.end();
EOFReplace <PORT> with the port found in step 1. The --cwd apps/api is required so bun resolves the pg dependency from the API workspace.
# Build runtime package
bun run build:runtime
# Build Studio client (production)
bun run --cwd=apps/web build
# Build Studio server (bundle for deployment)
bun run --cwd=apps/api build:server
# Run production build
bun run --cwd=apps/api startStudioContext (apps/api/src/core/studio-context.ts)
The central runtime interface injected into all tools. Provides:
auth: Authentication state (user, session, organization)access: Access control layer (RBAC checks)storage: Database operations (Kysely-based)vault: Credential vault for secure token storagetracer: OpenTelemetry distributed tracingmeter: OpenTelemetry metrics collection
Tools NEVER access HTTP objects, database drivers, or environment variables directly—all dependencies flow through StudioContext.
defineTool() (apps/api/src/core/define-tool.ts)
Declarative API for creating type-safe, auditable MCP tools. Automatically provides:
- Input/output validation (Zod schemas)
- Authorization checking (
ctx.access.check()) - Audit logging
- OpenTelemetry tracing and metrics
- Structured error handling
Example tool structure:
export const EXAMPLE_TOOL = defineTool({
name: "EXAMPLE_TOOL",
description: "...",
inputSchema: z.object({ ... }),
outputSchema: z.object({ ... }),
handler: async (input, ctx) => {
await ctx.access.check(); // Authorization
const result = await ctx.storage.someTable.create(...);
return result;
},
});The workspace is managed via Bun workspaces. Studio is split into an independent
Hono backend and Vite/React frontend. Documentation lives in apps/docs/
(Astro-based).
apps/api/ - Hono backend
src/api/- Hono HTTP routes + MCP proxy routessrc/auth/- Better Auth (OAuth 2.1 + SSO + API keys)src/core/- StudioContext, AccessControl, defineToolsrc/tools/- Built-in MCP management tools (organized by domain)src/storage/- Kysely database adapters and operationssrc/event-bus/- Pub/sub event delivery system (CloudEvents v1.0)src/encryption/- Token vault & credential managementsrc/observability/- OpenTelemetry tracing & metricsmigrations/- Kysely database migrations
apps/web/ - React 19 admin UI (Vite + TanStack Router)
packages/ - Shared logic
packages/shared/- Private isomorphic contracts, browser-safe SDK utilities, and async primitives shared through explicit@decocms/shared/*subpathspackages/bindings/- Core MCP bindings and connection abstractions (defines standardized interfaces)packages/runtime/- Runtime utilities for MCP proxy, OAuth, and toolspackages/ui/- Shared React components (shadcn-based design system)packages/create-deco/- Project scaffolding tool (npm init)
Database migrations live in apps/api/migrations/, code quality plugins in
plugins/, and infrastructure/deploy configs in deploy/.
Virtual MCPs (apps/api/src/tools/virtual/)
Runtime strategies modeled as Virtual MCPs—different ways of exposing tools through one endpoint:
- Full-context: expose all tools (simple, deterministic)
- Smart selection: narrow toolset before execution
- Code execution: load tools on demand in sandbox
Virtual MCPs are configurable and extensible.
Bindings System (packages/bindings/)
Standardized interfaces that MCPs can implement (similar to TypeScript interfaces but with runtime validation):
- Define contracts with Zod schemas
- Tools check if connections implement specific bindings
- Well-known bindings: collections (CRUD), models (AI providers), event bus, event subscriber
- Uses
createBindingChecker()for runtime verification
Event Bus (apps/api/src/event-bus/)
Pub/sub system between connections following CloudEvents v1.0 spec:
- At-least-once delivery with exponential backoff (1s to 1hr, max 20 attempts)
- Scheduled delivery (
deliverAt) and recurring events (cron) - Per-event results (subscribers can return individual results per event)
- NotifyStrategy: NATS notify (immediate wake-up) + PollingStrategy (safety net for scheduled/cron delivery)
packages/bindings/src/well-known/event-bus.ts- EVENT_BUS_BINDING (PUBLISH, SUBSCRIBE, UNSUBSCRIBE, CANCEL, ACK)packages/bindings/src/well-known/event-subscriber.ts- EVENT_SUBSCRIBER_BINDING (ON_EVENTS)apps/api/src/event-bus/- EventBus implementation and workerapps/api/src/event-bus/polling.ts- Timer-based PollingStrategy (safety net for scheduled/cron delivery)apps/api/src/event-bus/nats-notify.ts- NatsNotifyStrategy (immediate wake-up via NATS)apps/api/src/storage/event-bus.ts- Database operationsapps/api/src/tools/eventbus/- MCP tools (publish, subscribe, unsubscribe, list, cancel, ack)apps/api/migrations/008-event-bus.ts- Database schema
EVENT_PUBLISH- Publish events (supportsdeliverAtfor scheduled,cronfor recurring)EVENT_SUBSCRIBE- Subscribe to event typesEVENT_UNSUBSCRIBE- Remove subscriptionsEVENT_SUBSCRIPTION_LIST- List subscriptionsEVENT_CANCEL- Cancel a recurring cron event (only publisher can cancel)EVENT_ACK- Acknowledge event delivery (used withretryAfterflow)
EVENT_BUS_BINDING- For connections using the event bus (PUBLISH, SUBSCRIBE, UNSUBSCRIBE, CANCEL, ACK)EVENT_SUBSCRIBER_BINDING- For connections receiving events (implementsON_EVENTS)
Subscribers can return batch or per-event results:
// Batch mode
{ success: true }
// Per-event mode
{
results: {
"event-1": { success: true },
"event-2": { success: false, error: "Validation failed" },
"event-3": { retryAfter: 60000 } // Retry in 1 minute, use EVENT_ACK to confirm
}
}The worker doesn't poll internally - it relies on a NotifyStrategy to trigger processing:
NatsNotifyStrategy- Primary: immediate wake-up via NATS pub/subPollingStrategy(intervalMs)- Safety net: picks up scheduled/cron deliveries- Both are composed together via
compose()so the worker responds to either signal
{
pollIntervalMs: 5000, // Poll interval for PollingStrategy (default 5s)
batchSize: 100, // Max events per batch
maxAttempts: 20, // Delivery attempts before failure
retryDelayMs: 1000, // Base delay (1s)
maxDelayMs: 3600000, // Max delay cap (1hr)
}Uses Kysely ORM with embedded PostgreSQL (via embedded-postgres package) for local development and standard PostgreSQL for production.
- Database URL:
DATABASE_URLenvironment variable (defaults topostgresql://postgres:postgres@localhost:5432/postgres) - Local data directory:
~/deco/services/postgres/data - Schema types:
apps/api/src/storage/types.ts - Operations organized by domain:
apps/api/src/storage/ - Multi-tenancy: Workspace/project isolation for config, credentials, policies, audit logs
- Migrations use Kysely's migration system combined with Better Auth migrations
Database schema key concepts:
- Organizations managed by Better Auth organization plugin
- Connections are organization-scoped (workspace or project level)
- Permissions follow Better Auth format:
{ [resource]: [actions...] }
Better Auth for authentication:
- OAuth 2.1, SSO, API keys
- Config:
AUTH_*environment variables (apps/api/src/auth/auth-env.ts)
AccessControl (apps/api/src/core/access-control.ts) for authorization:
- Organization/project-level RBAC
- Fine-grained permissions per workspace/project
- Connection-specific permissions (e.g.,
{ "conn_<UUID>": ["SEND_MESSAGE"] })
OpenTelemetry (apps/api/src/observability/)
- Full tracing for tools, workflows, and UI interactions
- Metrics collection (Prometheus exporter)
- Logging with OTLP exporter
- Every tool call automatically traced
@decocms/shared/std is the ONE canonical home for these — a small,
zero-dependency, isomorphic (Node / Bun / browser) module ported from Deno std.
It was consolidated from ~9 ad-hoc backoff copies and ~9 ad-hoc sleep copies.
Do NOT write another Math.min(base * 2 ** attempt, cap) formula, jitter
expression, for/while retry loop, new Promise(r => setTimeout(r, ms)), or
Bun.sleep (Bun-only — defeats portability).
sleep(ms, { signal? })/delay(...)(same function) — waitms, optionally cancellable viaAbortSignal(rejects with the signal's reason on abort;.catch(() => {})if you want resolve-on-abort).retry(fn, opts)— call a possibly-async function until it succeeds. SupportsmaxAttempts,minTimeout/maxTimeout,multiplier,jitter(0–1), anisRetriable(err)predicate (e.g. retry only 5xx), and anAbortSignal. ThrowsRetryError(with.cause) on exhaustion.exponentialBackoffWithJitter(cap, base, attempt, multiplier, jitter)— the pure delay calculator, for stateful loops that can't be a single function (WebSocket/SSE reconnect, durable event delivery).jitter:0= none,0.5= equal[exp/2, exp],1= full[0, exp].
All consumers (apps/api, apps/web, and packages) import via
@decocms/shared/std. If you think you need a new retry/sleep mechanism, you
don't — extend the options or ask. The circuit breaker
(mcp-clients/circuit-breaker.ts) is a different pattern (fault isolation) and
is intentionally separate.
Org boolean toggles live in the organization_settings.flags jsonb bag — never
a new column. Adding one = one line in OrgFlagsSchema
(packages/shared/src/organization/schema.ts, the single source of truth) +
its consumer, then bun run --cwd=apps/api generate:tool-contracts. Read via
useOrgFlag("<flag>") (web) or settings?.flags?.<flag> (api); set via
ORGANIZATION_SETTINGS_UPDATE { flags: { <flag>: true } }. Updates
shallow-merge (explicit false persists, omitted keys survive); unset reads as
off. Flags are product gating, not access control. Anything non-boolean or ever
needing an index/constraint gets its own column instead.
- Biome enforces two-space indentation and double quotes
- ALWAYS run
bun run fmtafter making code changes (pre-commit hook via lefthook) - Components and classes: PascalCase
- Hooks and utilities: camelCase
- Files in shared packages: kebab-case (enforced by
plugins/enforce-kebab-case-file-names.ts) - A comment that takes a paragraph to justify a workaround is a signal the code is wrong, not the comment—fix the code, don't explain it away
The domain concept is a thread — that's the name on the backend and in all code: DB columns/tables, storage, tools, API routes, wire payloads, query keys, types, hooks, variables, functions. Do NOT rename any of these to "chat".
User-facing copy calls it a chat — anything a person reads in the UI: JSX text, button/menu labels, placeholders, tooltips, aria-labels, headings, empty states, toasts/error messages. Write these as "chat".
So a thread-named identifier can render "New chat" in a label; keep the code identifier as thread and only the displayed string as "chat". When in doubt: if it crosses the wire or lives in code, it's "thread"; if a user reads it, it's "chat".
The web UI (apps/web/src) is internationalized by a zero-dependency module at
apps/web/src/i18n/ — plain TS dictionaries, no library.
- Never hardcode user-facing strings in
apps/web/src— JSX text, toasts, placeholders,aria-labels, tooltips, empty states all go throught(). - Usage:
const t = useT()(@/i18n/use-t.ts) inside a component/hook, thent("settings.title")ort("some.key", { name })—{name}placeholders are interpolated. - Dictionaries: one file per feature domain in
i18n/en/(e.g.en/settings.ts), flat keys namespaced by domain ("settings.preferences.theme"). English is the source of truth:en/index.tsspreads every domainas constand derivesTranslationKeyfrom it. - Translations:
i18n/pt-br/<domain>.tsmirrors its en counterpart and mustsatisfies Record<keyof typeof <enDomain>, string>— a missing or extra key is a compile error, sobun run checkproves translation completeness. New domain: create the en file, spread it inen/index.ts, mirror inpt-br/and spread inpt-br/index.ts. - Preference:
languagelives inusePreferences()(localStorage), defaulting fromnavigator.language.useTis reactive to it (TanStack Query) — no provider, no reload. - The "thread vs chat" rule applies to dictionary values; keys are code and may say
thread.*. - Language option labels ("English", "Português (Brasil)") stay in their own language — never translated.
- Deliberately out of scope: server-originated strings (API
error.messageshown in toasts), transactional emails, and seeded/user data stay English — do not thread the locale through to the server. - Strings interleaved with JSX elements (links/bold mid-sentence) that can't be expressed as a
single template: mark with
// TODO(i18n): rich textand leave hardcoded for a manual pass. packages/uistays i18n-free: its few built-in English defaults are overridable via props; pass translated strings from the app.
- Uses React 19 with React Compiler (babel-plugin-react-compiler)
- DO NOT use
useEffect(banned byplugins/ban-use-effect.ts)—prefer alternatives - DO NOT use
useMemo/useCallback/memo(banned byplugins/ban-memoization.ts)—React 19 compiler handles optimization - Tailwind v4 design system with tokens enforced by
plugins/ensure-tailwind-design-system-tokens.ts
Located in plugins/:
enforce-kebab-case-file-names.ts- kebab-case for shared package filesenforce-query-key-constants.ts- query keys must use constantsban-use-effect.ts- ban useEffectban-memoization.ts- ban useMemo/useCallback/memoensure-tailwind-design-system-tokens.ts- enforce Tailwind consistencyban-cross-tree-imports.js- prevent packages from reaching into app sourceban-web-server-imports.js- enforce theapps/web↛apps/api/srcboundaryban-e2e-app-imports.js- deny-by-default import allowlist for thepackages/e2esuite (see E2E isolation below)
- Favor explicit types over
any - Use Zod for runtime validation and schema definitions
- TypeScript 7 (native compiler) with strict mode enabled. The programmatic
compiler API is absent from TS 7.0 (returns in 7.1) — consumers of it are
pinned to 5.9:
apps/api/scripts/generate-tool-contracts.ts(via thetypescript5alias) andpackages/typegen(its tsup dts build). Drop both pins when 7.1 lands.
See TESTING.md for the testing philosophy and rules.
Short version: two tiers, no third.
- Unit (
bun test) — pure logic only. No mocks, no DB, no network. Co-located*.test.tsnext to source. - E2E (Playwright) — everything else. Real Postgres + NATS + Better Auth. Lives in
packages/e2e/tests/(the isolated@decocms/e2eworkspace).
If a test needs vi.mock, mock.module, a stubbed StudioContext, or a fake fetch — it's not a unit test. Move it to e2e.
The e2e suite is a black-box contract over HTTP + DB: spin the server, hit it over the wire,
assert on responses. It must stay decoupled from the implementation so a component can be rewritten
— even in another language — and the same suite still holds. The in-sandbox daemon's suite
(packages/sandbox/daemon-e2e/daemon.*.e2e.test.ts) already works this way: it spawns the built binary
(swap it via the DAEMON_E2E_CMD env) and asserts only over HTTP. The Studio suite lives in the
dedicated packages/e2e (@decocms/e2e) workspace behind the same wall — its Playwright config
spawns apps/api and apps/web as separate processes via webServer.cwd
(process boundaries, not imports).
Rules:
- No imports from
apps/*/src/**and no@/app alias inpackages/e2e. Enforced byplugins/ban-e2e-app-imports.js(oxlint,error, deny-by-default) + apaths: {}override inpackages/e2e/tsconfig.json. Only a small explicit allowlist of workspace packages is permitted (any unlisted@decocms/*is denied too, so app code creeping intopackages/can't silently widen the test surface). - Do not silence this lint. If a test needs a value, either inline the expected shape (a
black-box test owning its contract is correct, not duplication — a divergence from the app is a
wire-contract regression signal) or add the dep to both
packages/e2e/package.jsonand the plugin allowlist, with justification. - Tenant-scope every DB assertion (per-test org/user/thread/run) — that's what makes
fullyParallelsafe. Never assert on values shared across runs; the one global namespace is email domain (use a unique domain per run). Playwright's worker count is effectively the Postgres connection budget.
When creating new MCP tools:
- Use
defineTool()fromapps/api/src/core/define-tool.ts - Place tools in appropriate domain folder under
apps/api/src/tools/ - Always inject
StudioContextas second parameter - Call
await ctx.access.check()for authorization - Use
ctx.storagefor database operations (never access Kysely directly) - Define Zod schemas for input/output validation
- Tools are automatically traced, logged, and metrified
When defining or checking bindings:
- Import from
@decocms/bindingsor well-known subpaths (e.g.,/collections,/models) - Use
createBindingChecker()to verify if tools implement a binding - Collection bindings require base entity fields:
id,title,created_at,updated_at,created_by,updated_by - Use
{ readOnly: true }for collections that shouldn't be modified - Bindings define contracts—tools implement the actual logic
Follow Conventional Commit format: type(scope): message
- Wrap type in brackets for chores:
[chore]: update deps - Reference issues:
(#1234) - Examples:
feat(roles): add granular model permissionsfix(event-bus): handle retry after flow correctly[release]: bump to 2.72.0
Lefthook runs bun run fmt automatically. Install with:
npx lefthook installPRs should include:
- Succinct summary of changes
- Testing notes and affected areas
- Screenshots for UI changes
- Confirm
bun run fmtandbun run lintpass - Run
bun testbefore requesting review - Flag follow-up work with TODOs linked to issues
Auditing ~300 merged PRs shows a consistent shape: a first draft ships the happy path, then a hardening pass (frequently a second person — the author who vibecoded it opens, an engineer takes over the branch, adds commits, and merges) adds the same categories of change every time. Those categories are below. Do them in the first PR — they are the difference between "works in the demo" and "survives production." Each item cites a real PR.
-
Handle the variants, not just the happy path. Cover empty / null / whitespace / duplicate / oversized inputs and every schema shape, not the one in front of you. (sections-editor #4008 added
@hide, Lazy-wrapped, and blank-title cases the first pass skipped; storage #4426 had to measure payload size beforeJSON.stringify, not after; sandbox #4445 added pagination + filename-collision disambiguation + stale-file pruning to a catalog writer.) -
Scope by tenant and permission. Reads/writes are scoped to the current user/org; other people's data is read-only unless owned. A validation or dedup gate must inspect the complete payload, not one representative slice. Never reuse a cache/list/React key across two shapes, and never conflate ids. (#4230 fix: teammates' threads must be read-only in the "All" view; #4416: the publish gate had to union the committed and working-tree diffs; #4373: list keys collided across skill/prompt of the same name.)
-
Get concurrency right — no silent data loss. For any "start B while A is in flight" path, trace what happens to A's output and B's input under concurrency-1 / a latch / a retry. Make side-effecting steps non-retriable unless idempotent, claim fences/slots at dispatch, not at request time, and coalesce fire-and-forget writes that share a path. (#4365: a fence claimed at POST time silently dropped an in-flight run's reply; #4409: a retriable agent-loop step re-ran 3× and spliced generations; #4445: per-run catalog re-sync raced itself.)
-
Test each behavior you touch — in the right tier (see Testing). Add a test per fix. When you fix a bug, find the test that encodes the old behavior and invert it — don't just append a new one.
grepall tiers (unit andpackages/e2e) for any string or wire/storage contract you changed. Storage changes need a real-Postgres test (in-memory fakes accept columns theupdate()whitelist silently drops). E2E must not depend on a model tier/provider absent in the test org. (#4008 shipped a test with each fix; #4446/#4430 had to invert tests that asserted the bug; #4350's e2e still asserted pre-redesign copy; #4355 needed real-PG; #4365's e2e depended on a provider tier.) -
Leave no dead code. After you change who calls a symbol, narrow its export to module-private and delete the newly-orphaned helpers/components/branches in the same PR. Run
knipbefore declaring done (see Gotcha #6). (#4230 deleted 297 lines of components the refactor orphaned; #4449, #4350, #4373 each shipped a follow-up un-exporting a now-internal symbol knip flagged.) -
Complete the lifecycle and reset state. New persisted state ships create + update + delete together; make the writer idempotent (clear-then-write for index/slug-named files); clean it up when the parent is deleted. A "start/reset" transition must clear the previous cycle's terminal columns. (#4449 implemented create only — update ignored the field, delete orphaned the subtree; #4355 left a stale
failure_reasonbecauseRUN_STARTEDdidn't null it.) -
Make risky and infra changes reversible and bounded. Any change on a boot/install/dispatch hot path gets its own default-off flag — never piggyback on a neighbor's flag, and never let "deployed" mean "enabled." New caches/artifacts need eviction (TTL + cap), bad-entry invalidation (publish only after a health signal), and
.git/info/excludeso they don't leak onto user branches. Prefer the idle reaper to a fixed wall-clock timeout; emit heartbeats during silent phases. (#4357: golden cache shipped dormant behindGOLDEN_CACHE_ENABLED, with GC and health-gated publish; #4445 git-excluded its artifact; #4355 dropped a fixed timeout for progress-based reaping; #4409 added heartbeats.) Overstating a safety property in a comment/doc is itself a bug (#4357, #4363). -
Validate external input; don't over-engineer. Validate URLs and user input. Prefer schema-driven behavior to clever runtime inference — an engineer reverted exactly that "infer from runtime data" cleverness in #4008. Use design-system tokens, not raw palette (#4350:
text-emerald-600→text-success), and runbun run fmtbefore the first push (#4461 was a pure-format follow-up). -
Keep type-safety at compile time. Don't
as-cast to read a field off a union — narrow with anin/ discriminant check so a rename is a compile error, not a runtime regression only e2e catches (#4365). RespectnoUncheckedIndexedAccess.
The first-pass checklist above captures the review-driven gotchas mined from PR history. The list below is the always-load-bearing set.
-
Never access environment variables directly in tools—use StudioContext
-
Never access HTTP context in tools—use StudioContext for all state
-
Database migrations: Remember to run both Kysely migrations (
bun run migrate) and Better Auth migrations (bun run better-auth:migrate) -
Event bus: The worker doesn't poll internally—it relies on NotifyStrategy to trigger processing
-
Formatting: The pre-commit hook will reject commits if code isn't formatted with Biome
-
Never modify knip configuration (
knip.json,knip.config.ts, etc.) to silence warnings. Knip warnings indicate dead code, unused exports, or unused dependencies—these are code smells that should be fixed by removing the unused code/export/dependency, not by adding exclusions to the knip config. -
CI errors are always on your branch. The
mainbranch CI always passes. When CI fails, the problem is in the code you changed—do not assume it's a pre-existing issue or a flaky test. Investigate and fix your code. -
Never persist, commit, or paste a real credential — redact at first sight. Sandbox clone URLs carry a live GitHub App token (
https://x-access-token:ghs_...@github.com/...), config payloads carry tenant secrets, and a node's cache is readable by every sandbox on that node. So:- Strip at the write, not at the caller. One guard at the single place a value is
persisted covers every caller present and future, and it must fail closed — an
unparseable URL persists nothing rather than falling through to the raw string. See
stripCredentialsinpackages/sandbox/daemon-go/internal/setup/install.go. - Assert on the artifact's bytes, not the struct. The struct can be right while the write path is wrong, and the file is what a co-tenant reads.
- Fixtures are synthetic, always. Never paste a real token into a test, not even truncated — a committed test is not a place to keep a credential. Never a real customer repo name either; shared artifacts stay neutral.
- A force-push does NOT undo a leak. GitHub keeps unreferenced objects and serves them by SHA. Only GitHub Support purges, and they refuse when the risk is mitigable by rotating the credential. Rotate first; treat anything pushed as public forever.
- When you read a secret while debugging — a node's cache, a pod's env — redact it in the very first message that mentions it. This entry exists because that step was skipped and a customer's token reached a commit and a PR body.
- Strip at the write, not at the caller. One guard at the single place a value is
persisted covers every caller present and future, and it must fail closed — an
unparseable URL persists nothing rather than falling through to the raw string. See
-
The sandbox daemon is Go (
packages/sandbox/daemon-go/**) — one static binary per sandbox pod, the only daemon there is (the TypeScript one is deleted). Write Go there, not TypeScript. Its health probe is unforgiving: Studio polls it and marks the sandbox dead on a single miss, tearing the pod down mid-session, so never hold a lock across slow I/O on that path. The daemon's contract is asserted black-box inpackages/sandbox/daemon-e2e/(swap the binary under test withDAEMON_E2E_CMD). Blocking work is still banned in Studio's own Bun processes — seeCONTRIBUTING.md.
All org-scoped API routes use the canonical shape /api/:org/... where :org is the
organization slug. The resolveOrgFromPath middleware (apps/api/src/api/middleware/resolve-org-from-path.ts)
looks up the org by slug, verifies the authenticated principal is a member, and sets
ctx.organization. Returns 404 for unknown slugs, 403 for non-members.
The legacy unscoped routes (e.g., /api/connections/:id/oauth-token, /mcp/:connectionId,
/oauth-proxy/:connectionId/*) are still mounted with a logDeprecatedRoute middleware
that emits console.log("deprecated route", { route, method, org, user, ua }). They will
be removed in a follow-up PR after the deprecation window. New code MUST use the
org-scoped paths; new frontend code MUST NOT send x-org-id or x-org-slug headers
for migrated routes (the org slug is in the URL path).
The aggregator that mounts every org-scoped sub-router lives at
apps/api/src/api/routes/org-scoped.ts. Add new org-scoped routes there.
Org slugs are immutable — ORGANIZATION_UPDATE rejects slug changes — so URLs remain
stable.
Instance-level routes (no org context, e.g. the deployment-admin surface) use an
underscore-prefixed namespace like /api/_admin/..., mounted before the /api/:org
catch-all so the static segment wins over the slug param.
MIT License — see LICENSE.md for details.