# Developer conventions and contributing

> The rules a change must satisfy: pnpm only, kernel review standards for `workshop-backend` and `workshop-shared`, doc-comment every exported member, no hand-written RPC mirror interfaces with `as unknown as`, promise pipelining and stub disposal, structured logging field vocabularies and the never-log-secrets rule, opt-in frontend error reporting boundaries, and the narrow external-PR policy.

- Repository: cloudflare/cloudflare-os
- GitHub: https://github.com/cloudflare/cloudflare-os
- Human docs: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd
- Complete Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/llms-full.txt

## Source Files

- `AGENTS.md`
- `CONTRIBUTING.md`
- `packages/backend-utils/src/logger-core.ts`
- `packages/backend-utils/src/observability-context.ts`
- `packages/error-reporting/src/index.ts`
- `.oxlintrc.json`

---

---
title: "Developer conventions and contributing"
description: "The rules a change must satisfy: pnpm only, kernel review standards for `workshop-backend` and `workshop-shared`, doc-comment every exported member, no hand-written RPC mirror interfaces with `as unknown as`, promise pipelining and stub disposal, structured logging field vocabularies and the never-log-secrets rule, opt-in frontend error reporting boundaries, and the narrow external-PR policy."
---

Cloudflare OS is a pnpm workspace whose review standards are not uniform across packages: `packages/workshop-backend` is treated as the kernel and is read line-by-line, along with any API change in `packages/workshop-shared`. The conventions below are the ones the repository actually encodes — in `AGENTS.md`, `CONTRIBUTING.md`, `.oxlintrc.json`, and in the type signatures of `packages/backend-utils` and `packages/error-reporting`, where several rules are enforced by the type checker rather than by review.

## Package tiers and review bar

```text
┌──────────────────────────────────────────────────────────────┐
│ kernel — every line reviewed, diffs kept small and elegant   │
│   packages/workshop-backend      (Workers server)            │
│   packages/workshop-shared       (RPC API surface)           │
├──────────────────────────────────────────────────────────────┤
│ normal bar                                                   │
│   packages/workshop-frontend     (React SPA, Vite)           │
│   packages/gatekeeper-*          (per-vendor Workers)        │
│   packages/mcp-shared            (library, not a Worker)     │
│   packages/configurator-ui       (type-only helpers)         │
│   packages/typed-storage, packages/router, packages/         │
│   backend-utils, packages/error-reporting                    │
└──────────────────────────────────────────────────────────────┘
```

<Warning>
A large kernel change must be split by concern into separate PRs. At minimum, group commits so that `workshop-backend` and `workshop-shared` can be reviewed apart from UI changes. Fewer kernel lines means easier review.
</Warning>

### Kernel rules

| Rule | Applies to | Detail |
| --- | --- | --- |
| Doc-comment every exported member | `workshop-shared` public API | Types, consts, and functions — not just interfaces |
| No mirrored RPC interfaces | `workshop-shared`, `workshop-backend` | Never add a hand-written interface that mirrors an RPC interface plus an `as unknown as` cast; derive from the real type instead, or rethink the design |
| Reuse over parallel mechanisms | kernel | Prefer extending an existing mechanism to introducing a second one alongside it |
| Small, elegant diffs | kernel | Split large changes by concern into separate PRs |

### Capability-based security invariant

A resource becomes *ambient* (auto-injected into a chat's env) only through user or admin configuration. A gatekeeper must never assert its own ambience. For auto-provisioning connectors, the account capability — not an asserted identity — is the authority after provisioning: `GatekeeperVendor.createAccount()` takes no user identity, and the per-vendor **disabled** / **optional** / **enabled** mode (default **optional**) is resolved by the deployment admin in `provisioning-policy.ts`.

For the MCP connectors, the equivalent invariant is that `packages/mcp-shared/src/tools.ts` is the trust boundary and nothing outside it reads a tool's annotations. A tool declaring `readOnlyHint: true` runs as an observation; everything else is queued for approval. Auto-applying a write additionally requires a `vetted` endpoint, producible only by the portal via `MCP_PORTAL_TRUST_ANNOTATIONS`.

<Note>
OAuth work in `mcp-shared` uses the official `@modelcontextprotocol/client`. Always pass `sdkFetch(...)` to SDK OAuth operations so every request and redirect retains endpoint and SSRF checks.
</Note>

## Package manager

pnpm only. The repository is a pnpm workspace and its scripts are pnpm scripts (`pnpm import:format-blueprint`, the recursive `build`/`types:check`/`test` scripts). Do not introduce npm or yarn lockfiles or invoke another package manager in scripts.

## RPC conventions

The client/server protocol is Cap'n Web, defined in `packages/workshop-shared/src/api.ts`. Its semantics resemble Cloudflare's Worker-to-Worker RPC while running in a browser over WebSocket; the vendored README at `packages/workshop-shared/node_modules/capnweb/README.md` is the reference.

<AccordionGroup>
<Accordion title="Derive types; never mirror an RPC interface">
A hand-written interface shaped like an RPC interface, paired with `as unknown as` to bridge the two, is rejected in review. Derive from the real type, or change the design so no bridge is needed.
</Accordion>
<Accordion title="Promise pipelining and stub disposal">
Cap'n Web stubs are capabilities with lifetimes. Pipeline dependent calls rather than awaiting each round trip, and dispose stubs you mint. In `packages/integration-tests`, stub minting is centralized so lifetimes stay auditable.
</Accordion>
<Accordion title="Never import capnweb values outside rpc-client.ts (integration tests)">
`.oxlintrc.json` restricts value imports of `capnweb` inside `packages/integration-tests/**/*.ts`, with an override re-allowing them only in `packages/integration-tests/src/rpc-client.ts`. `import type` remains allowed everywhere.

```jsonc
// .oxlintrc.json — integration-tests override
"no-restricted-imports": ["error", { "paths": [{
  "name": "capnweb",
  "message": "Mint stubs via stubFor() from rpc-client: a consumer repo can hold two capnweb copies, and a stub from the wrong one fails to serialise. `import type` is fine.",
  "allowTypeImports": true
}]}]
```

The failure this prevents: a repo that vendors this one as a submodule installs its own workspace and this one separately, ending up with two copies of `capnweb`. A stub minted by one copy is unserialisable by the other's session — an error that surfaces only once the installs are split, i.e. in CI.
</Accordion>
</AccordionGroup>

## Structured logging

Backend logging goes through `packages/backend-utils`. `createLoggerWithContext()` returns a `Logger<ExtraFields>` whose `debug`/`info`/`warn`/`error` methods each take a message plus a details object, and whose `with()` returns a new logger with additional fields rather than mutating the receiver.

```ts
// packages/backend-utils/src/logger-core.ts
export interface Logger<ExtraFields extends object = Record<never, never>> {
  with(fields: Readonly<Partial<AllowedFields<ExtraFields>>>): Logger<ExtraFields>;
  debug(message: string, details: Readonly<LogDetails<ExtraFields>>): void;
  info(message: string, details: Readonly<LogDetails<ExtraFields>>): void;
  warn(message: string, details: Readonly<LogDetails<ExtraFields>>): void;
  error(message: string, details: Readonly<LogDetails<ExtraFields>>): void;
}
```

<ParamField body="component" type="string" required>
Base field on every logger, supplied to `createLoggerWithContext(defaults)`. It is fixed: `#write` re-applies `component` last, so neither ambient context nor call details can override it.
</ParamField>

<ParamField body="event" type="string" required>
Required on every log call. Details are `LogDetails<ExtraFields>`, which mandates `event` and allows optional `error`.
</ParamField>

<ParamField body="error" type="unknown">
Optional per call. Normalized to a string via `normalizeError`; when the value is an `Error` with a stack, `errorStack` is populated automatically. When `error` is `undefined` the field is deleted rather than emitted as `undefined`.
</ParamField>

### Reserved fields and the never-log-secrets rule

`ReservedLogField` is a closed union, and the `ProhibitedFields` mapped type sets each reserved key to `?: never` in `ExtraFields`, logger defaults, and call details. Attempting to attach one is a type error, not a runtime warning.

| Reserved field | Allowed as a caller-supplied field? |
| --- | --- |
| `secret` | No |
| `token` | No |
| `prompt` | No |
| `body` | No |
| `message` | No — written by the logger from the message argument |
| `header`, `headers` | No |
| `errorStack` | No — derived from `error` |
| `component` | Only as a logger default (`LoggerDefaults` exempts it) |
| `event` | Only in call details (`LogDetails` exempts it) |
| `error` | Only in call details (`LogDetails` exempts it) |

<Warning>
The reserved list is the mechanical half of the rule; the intent is broader. Never route secrets, tokens, prompts, request bodies, or headers into logs under a differently named field either — the type system cannot see through a rename.
</Warning>

### Field value types

Field values must satisfy `LogValue`: `string | number | boolean | Date | null | undefined`, plus nested records and arrays of `LogValue`. `SafeFields` maps any non-`LogValue` field to `never`, so an object that Workers Logs cannot represent fails to type-check.

### Ambient context

`createObservabilityContext<Fields>()` in `packages/backend-utils/src/observability-context.ts` builds an isolated, typed context per package or domain over `AsyncLocalStorage`, returning `{ createLogger, get, with: withContext }`.

Merge precedence in `#write`, lowest to highest: ambient context → logger defaults → call details → `component`.

<Warning>
Context does not cross RPC, hibernation, or restart. Re-establish it at those boundaries; a gatekeeper convention is a logger carrying `component` and `vendorId`.
</Warning>

```mermaid
classDiagram
  class Logger~ExtraFields~ {
    <<interface>>
    +with(fields) Logger
    +debug(message, details)
    +info(message, details)
    +warn(message, details)
    +error(message, details)
  }
  class LoggerImpl {
    -#defaults LoggerDefaults
    -#readContext LogContextReader
    -#write(level, message, details)
  }
  class ObservabilityContext {
    +createLogger(defaults) Logger
    +get() ContextFields
    +with(fields, callback) Result
  }
  class ReservedLogField {
    <<type>>
    body component error errorStack
    event header headers message
    prompt secret token
  }
  Logger~ExtraFields~ <|.. LoggerImpl
  ObservabilityContext ..> Logger~ExtraFields~ : createLogger
  LoggerImpl ..> ReservedLogField : excluded via ProhibitedFields
```

## Frontend error reporting

Error reporting is opt-in and bounded by design. `packages/error-reporting` defines two distinct schemas whose trust levels differ, and the distinction is load-bearing.

| Type | Producer | Trust |
| --- | --- | --- |
| `ErrorEventV1` | Worker-side capture sites | Internal; `attributes` capped at `MAX_ATTRIBUTE_KEYS` (32) scalars |
| `ErrorReporterProps` | Reporter service binding config | Trusted producer metadata (`service`, `release`, `environment`) |
| `FrontendErrorReportV1` | Browser | Untrusted, bounded; "no field in this report conveys authority" |
| `FrontendFrameErrorReportV1` | Trusted opaque-origin frame | A `Pick` of the frontend report: `failureSite`, `severity`, `handled`, `captureMechanism`, `exception` |

### Boundary rules

- Only trusted opaque-origin UI frames may post failures, using the `FRONTEND_ERROR_MESSAGE_TYPE` discriminator `"gadgets.frontend-error.v1"`.
- Every enumerated field arriving from a frame is allowlisted, never passed through: `severity` ∈ `warning | error | fatal`; `captureMechanism` ∈ `window.error | unhandledrejection | react | explicit`; `surface` ∈ `workshop | gatekeeper-app | configurator`; `browser.family` ∈ `Chromium | Firefox | Safari | Other`; `browser.platform` ∈ `Windows | macOS | Linux | Android | iOS | Other`.
- Unrecognized values fall back to a default (`severity: "error"`, `captureMechanism: "explicit"`) rather than being propagated.
- Strings are clipped against `MAX_STRING_CHARS`, `MAX_MESSAGE_CHARS`, and `MAX_STACK_CHARS`; any clip sets `truncated: true` on the report.
- Untrusted objects are read with `Object.getOwnPropertyDescriptor(...)?.value` (`ownValue`), not direct property access, so prototype-supplied values cannot slip in.
- `FrontendBrowserFacts` is documented as coarse triage telemetry and is "never authoritative for identity or access."

<Note>
Reporting is opt-in via the `VITE_FRONTEND_ERROR_REPORTING` frontend variable and the optional reporter binding on the backend. Without both, capture is a no-op — see the environment-variables and troubleshooting pages.
</Note>

## Lint and type-check posture

`.oxlintrc.json` sets `correctness` and `suspicious` to `error` and enables the `typescript`, `unicorn`, `oxc`, and `import` plugins, with per-area overrides.

| Scope | Added plugins / env |
| --- | --- |
| `packages/workshop-frontend/**/*.{ts,tsx}` | `react`, `jsx-a11y`; `browser` env |
| `packages/gatekeeper-*/**/*.tsx` | `react`; classic JSX runtime with the `h` pragma, not automatic `react-jsx` |
| `workshop-backend`, `router`, `gatekeeper-*/src`, `workshop-shared`, `typed-storage` | `serviceworker` env |
| `**/*.test.ts(x)`, `**/vitest.config.ts` | `vitest` plugin and env |
| `scripts/**/*.mjs`, root `*.js`/`*.mjs` | `node` env |

Rules deliberately turned off, and why:

| Rule | State | Reason recorded in config |
| --- | --- | --- |
| `import/default` | off | Gatekeepers import `.txt` files as bundled text assets; the resolver misreports "no default export" |
| `import/no-unassigned-import` | off | Side-effect imports are deliberate: `./styles.css`, `cloudflare:workers` |
| `unicorn/no-empty-file` | off | Comment-only placeholder modules are kept intentionally (e.g. `App.tsx`, `gatekeeper-cloudflare/src/types.d.ts`) |
| `no-underscore-dangle` | off | Conflicts with the `_`-prefix convention for intentionally unused bindings |
| `no-unused-vars` | error, tuned | `args: "none"`, `caughtErrors: "none"`, `varsIgnorePattern: "^_"`, `ignoreRestSiblings: true` — unused imports and locals still flagged |
| `no-shadow`, `typescript/no-this-alias`, `typescript/no-extraneous-class`, `unicorn/consistent-function-scoping` | warn | Real improvements but churny for an initial rollout; visible for incremental cleanup instead of blocking CI |

Ignored paths: `**/dist/**`, `**/generated/**`, `**/*.gen.ts`, `**/node_modules/**`, `**/.wrangler/**`, `**/worker-configuration.d.ts`.

<Warning>
Type-aware linting is intentionally not enabled. The type-aware engine uses tsgo (TypeScript 7), which requires an explicit `rootDir` when emitting declarations and has dropped `baseUrl`. This monorepo emits declarations while importing sibling-package *source* files via `paths`, so a `rootDir` that satisfies tsgo would break the real `tsc` build (TS6059). Full type safety is enforced by `tsc` through the `types:check` script instead — do not "fix" this by enabling type-aware rules.
</Warning>

## Generated files are not editable

Generated modules live under ignored paths and are produced by scripts, not committed by hand.

:::files
```text
packages/workshop-backend/
  format-blueprints/          committed data: <name>.gadget + <name>.json
  src/generated/
    format-blueprints.ts      gitignored; built by scripts/build-format-blueprints.mjs
packages/gatekeeper-context/
  app/                        single-file React SPA (Vite + Tailwind + Kumo)
  src/generated/app.txt       built by build-app.mjs
packages/gatekeeper-*/
  src/generated/              configurator UI built by
                              scripts/build-gatekeeper-configurator.mjs
```
:::

`scripts/build-format-blueprints.mjs` globs `format-blueprints/` (override the directory with `FORMAT_BLUEPRINTS_DIR` so a fork can ship its own set without touching this submodule) into the gitignored `src/generated/format-blueprints.ts`. Because of that, `build`, `types:check`, and `test` all run the generator first.

<Steps>
<Step title="Replace an existing format blueprint">
```bash
pnpm import:format-blueprint <export.gadget> <blueprintId>
```
</Step>
<Step title="Add a new format blueprint">
```bash
pnpm import:format-blueprint <export.gadget> --new <name>
```
</Step>
<Step title="Never rename a deployed blueprintId">
Install and promotion are keyed on `blueprintId`; a rename orphans the old entry. See `format-blueprints/README.md`.
</Step>
</Steps>

## External contribution policy

`CONTRIBUTING.md` states the project is not seeking outside contribution at this time. The stated reasoning: AI has made writing code easy, and the hard part today is reviewing it, keeping quality high, and keeping the product coherent — so external code contributions donate the easy part of the job while creating more of the hard work.

| Contribution | Accepted |
| --- | --- |
| Small, trivially-verified PR that fixes a problem | Yes |
| Low-value PR (e.g. typo fix) | No — closed with a reference to the guideline |
| PR larger than a dozen or so lines | No — closed with a reference to the guideline |
| Big idea | Open a discussion at `https://github.com/cloudflare/cloudflare-os/discussions` |

<Note>
The policy may change as the project matures.
</Note>

## Pre-submit checklist

<Check>
Ordered by what tends to fail first in review.
</Check>

1. Used pnpm; no other package manager artifacts or invocations added.
2. Kernel diff (`workshop-backend`, `workshop-shared`) is small; large changes split by concern into separate PRs, and at minimum committed so kernel review is separable from UI review.
3. Every new exported member of the `workshop-shared` public API — type, const, and function — carries a doc comment.
4. No hand-written interface mirroring an RPC interface, and no `as unknown as` bridge; types derived from the real ones.
5. Cap'n Web usage pipelines dependent calls and disposes minted stubs; integration-test code mints only through `stubFor()` in `rpc-client.ts`.
6. No gatekeeper asserts its own ambience; ambience comes from user or admin configuration.
7. Logging passes `component` at logger creation and `event` at each call site, uses `LogValue`-compatible fields, and touches no reserved field — including under a renamed key.
8. Any new frontend error path allowlists enumerated values, bounds strings, marks `truncated`, and treats browser-supplied data as untrusted and non-authoritative.
9. Generated modules were regenerated by their scripts, not edited; no deployed `blueprintId` renamed.
10. `pnpm lint` (oxlint plus recursive `tsc --noEmit`) and `pnpm test` pass, with the generators having run first.

## Related pages

<CardGroup cols={2}>
<Card title="Build, lint, and test" href="/build-lint-test">The exact commands CI enforces, their ordering, and generator prerequisites.</Card>
<Card title="RPC API reference" href="/rpc-api-reference">The Cap'n Web interfaces plus stub-disposal and promise-pipelining constraints.</Card>
<Card title="Build a gatekeeper" href="/build-a-gatekeeper">Connector package layout, configurator UI build, and the `component`/`vendorId` logger.</Card>
<Card title="Environment variables" href="/environment-variables">`VITE_FRONTEND_ERROR_REPORTING`, `MCP_PORTAL_TRUST_ANNOTATIONS`, and the rest of the backend surface.</Card>
<Card title="Integration testing" href="/integration-testing">How `createTestHarness()` boots real workers and speaks Cap'n Web over `/api`.</Card>
<Card title="Manage bundled format blueprints" href="/bundled-format-blueprints">`FORMAT_BLUEPRINTS_DIR`, the `.gadget`/`.json` split, and why `blueprintId` is immutable.</Card>
</CardGroup>
