# RPC API reference

> The Cap'n Web interfaces shared between client and backend: `PublicApi`, `LoginAttempt`, `AuthenticatedApi`, `AdminApi`, and `Overseer`, plus supporting types (`GadgetMetadata`, `UiBundle`, `CodeUpdate`, `ActionLogEntry`, `AgentSpawnerConfig`, `AiModelConfig`, `ServerConfig`). Documents `OPEN_GADGET_ERROR_CODES`, `validateBindingName`, observer-config callbacks, and stub-disposal and promise-pipelining constraints.

- 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

- `packages/workshop-shared/src/api.ts`
- `packages/workshop-shared/src/gatekeeper.ts`
- `packages/workshop-shared/src/feature-flags.ts`
- `packages/workshop-shared/src/external-message-gateway.ts`
- `packages/workshop-backend/src/server.ts`
- `AGENTS.md`

---

---
title: "RPC API reference"
description: "The Cap'n Web interfaces shared between client and backend: `PublicApi`, `LoginAttempt`, `AuthenticatedApi`, `AdminApi`, and `Overseer`, plus supporting types (`GadgetMetadata`, `UiBundle`, `CodeUpdate`, `ActionLogEntry`, `AgentSpawnerConfig`, `AiModelConfig`, `ServerConfig`). Documents `OPEN_GADGET_ERROR_CODES`, `validateBindingName`, observer-config callbacks, and stub-disposal and promise-pipelining constraints."
---

The entire client/backend API lives in `packages/workshop-shared/src/api.ts` as TypeScript interfaces spoken over Cap'n Web (`capnweb`). There is no REST surface and no generated schema: the interfaces *are* the protocol. The backend serves one endpoint, `/api`, which the client upgrades to a WebSocket at startup and keeps open for the whole session, reconnecting with backoff on break. `packages/workshop-backend/src/server.ts` answers that path with `newWorkersRpcResponse(req, new PublicApiImpl(...))`; the client opens it with `newWebSocketRpcSession<PublicApi>(wsUrl)`.

Every entry point is reached by capability, not by URL. `PublicApi.authenticate()` returns an `AuthenticatedApi`; `AuthenticatedApi.openGadget()` returns an `Overseer`; `Overseer.getGadget()` returns a `GadgetClient`. Authorization checks happen once, when the capability is minted, so downstream methods do not re-check.

## Session bootstrap

```ts
// packages/workshop-frontend/src/main.tsx
const wsUrl = (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + apiHost + '/api'
return newWebSocketRpcSession<PublicApi>(wsUrl)
```

The backend host defaults to `VITE_BACKEND_HOST`, falls back to `localhost:8787` when the page is served from `localhost` (the Vite dev server on port 3000), and otherwise uses the page's own host.

Two request-level gates apply before the RPC session starts:

| Condition | Behavior |
| --- | --- |
| `CF_ACCESS_AUD` set and `Origin` header ≠ request origin | `403 Cross-origin API access not allowed.` |
| `CF_ACCESS_AUD` set, CF Access JWT invalid | `403 Invalid CF access JWT.` |
| `CF_ACCESS_AUD` set, JWT carries no `email` | `403 Access JWT didn't specify email address.` |
| First `/api` request in an isolate | Fires `AdminSettings.ensureFormatBlueprintsInstalled()` via `ctx.waitUntil` (idempotent; retried on partial install) |

Session abort is implemented by closing the WebSocket, not by `ctx.abort()`; the backend passes an `abortSession` callback into `PublicApiImpl` and re-applies it if the abort races the `await`.

```mermaid
flowchart LR
  subgraph client["Browser (workshop-frontend)"]
    main["main.tsx<br/>newWebSocketRpcSession"]
    ui["GadgetUI.tsx<br/>newMessagePortRpcSession"]
    iframe["sandboxed gadget iframe"]
  end
  subgraph backend["workshop-backend"]
    pub["PublicApiImpl"]
    auth["AuthenticatedApiImpl"]
    admin["AdminApi"]
  end
  subgraph dos["Durable Objects"]
    ov["Overseer"]
    gc["GadgetClient"]
    gk["GatekeeperClient&lt;Session&gt;"]
    as["AdminSettings"]
  end
  main -- "WebSocket /api" --> pub
  pub -- "authenticate() / authenticateFromCfAccess() / login()" --> auth
  auth -- "getAdminApi()" --> admin
  admin --> as
  auth -- "openGadget() / newGadget()" --> ov
  ov -- "getGadget() / createGadget()" --> gc
  ov -- "newGatekeeper() / getGatekeeperById()" --> gk
  gc -- "getUiBundle() / connectToGadget()" --> ui
  ui -- "postMessage + MessagePort" --> iframe
```

## PublicApi

Exposed to the internet with no authentication. Extends `RpcTarget`.

<ResponseField name="getServerConfig()" type="Promise&lt;ServerConfig&gt;">
Deployment-level boot configuration. Contains no secrets.
</ResponseField>

<ResponseField name="startGatekeeperLogin(vendorId)" type="Promise&lt;{ url: string; attempt: RpcStub&lt;LoginAttempt&gt; }&gt;">
Begins sign-in via an auth-capable, allowlisted gatekeeper (see `ServerConfig.authVendors`); throws otherwise. The client opens `url` in a new tab. Disposing `attempt` abandons the sign-in and cancels the server-side wait.
</ResponseField>

<ResponseField name="authenticate(token)" type="Promise&lt;AuthenticatedApi&gt;">
Authenticates from a stored session token.
</ResponseField>

<ResponseField name="authenticateFromCfAccess()" type="Promise&lt;AuthenticatedApi&gt;">
Uses the existing Cloudflare Access session's credentials instead of a token.
</ResponseField>

<ResponseField name="login(username, passwordHash)" type="Promise&lt;string | null&gt;">
Returns a session token, or `null` for no-such-user / wrong password. May be disabled under SSO.
</ResponseField>

<ResponseField name="createAccount(username, displayName, passwordHash)" type="Promise&lt;string | null&gt;">
Returns a session token, or `null` if the username already exists. Other failures throw.
</ResponseField>

<ResponseField name="getBlueprint(id)" type="Promise&lt;BlueprintPublicInfo | null&gt;">
Unauthenticated: knowing the ID is sufficient, since a blueprint is just data.
</ResponseField>

<ResponseField name="downloadBlueprint(id)" type="Promise&lt;ReadableStream&lt;Uint8Array&gt;&gt;">
`.gadget` archive containing `BlueprintMetadata` plus the current code snapshot — not the full KV record.
</ResponseField>

### Password hashing contract

`login()` and `createAccount()` take a client-derived `passwordHash`, never a password. The client computes:

```ts
argon2id({
  password,
  salt: SERVICE_SALT + encode(username, 'utf8'),
  parallelism: 1,
  iterations: 3,
  memorySize: 64 MiB,
  hashLength: 32,
})
```

`SERVICE_SALT` is an exported 16-byte constant in `api.ts`. The server hashes these bytes again before storage, so the server never sees the password and the expensive KDF runs on the client. Because the server stores and compares the submitted bytes verbatim without re-deriving them, `packages/integration-tests` substitutes a SHA-256 stand-in to avoid 64 MiB of argon2 per test login.

### LoginAttempt

```ts
export interface LoginAttempt extends RpcTarget {
  wait(): Promise<string>;
}
```

Holding this stub *is* the capability to receive the resulting session token. `wait()` resolves once the gatekeeper popup completes, or rejects if the attempt fails or is abandoned; it is safe to call immediately after `startGatekeeperLogin()`. Sign-in completes entirely inside the gatekeeper Worker (the OAuth redirect lands on `/gatekeeper/<name>/oauth`), so the backend hosts no `/auth/*` callbacks — the result is bridged back through this stub.

## AuthenticatedApi

The post-authentication root capability. Selected groups:

| Group | Methods |
| --- | --- |
| Identity | `whoami`, `setOwnDisplayName`, `changePassword`, `hasPasswordLogin`, `setAvatar`, `getAvatar` |
| Models | `listModels`, `addModel`, `deleteModel`, `setQuickModel`, `getQuickModel`, `getAiConfig`, `getPreferredModel`, `setPreferredModel` |
| Onboarding / flags | `isOnboardingCompleted`, `completeOnboarding`, `getUiFeatureFlags` |
| Workspaces | `openGadget`, `newGadget`, `listGadgets`, `listOutputs`, `listOutputFormats`, `dismissSharedGadget` |
| Connections | `listGatekeeperVendors`, `connectAccount`, `ensureAccountResources`, `listAddableGatekeepers`, `provisionAmbientAccount`, `subscribeConnectedAccounts`, `disconnectAccount`, `reconnectAccount`, `startResourceConfigurator` |
| Blueprints | `listOwnBlueprints`, `getOwnBlueprint`, `listLibraryBlueprints`, `setBlueprintPinned`, `isBlueprintPinned`, `listFeaturedBlueprints`, `addBlueprintToLibrary`, `removeBlueprintFromLibrary`, `isBlueprintInLibrary`, `newGadgetFromBlueprint`, `deleteOrphanedBlueprint`, `importBlueprint` |
| Cloudflare limits | `getCloudflareUsage`, `listCloudflareAccounts`, `selectCloudflareAccount` |
| Gatekeeper apps | `listGatekeeperApps`, `getGatekeeperApp` |
| Admin | `amIAdmin`, `getAdminApi` |

`getAdminApi()` returns `RpcStub<AdminApi> | null` — `null` for non-admins. The check runs once at mint time.

`setAvatar()` expects a compressed JPEG/PNG, ideally under 50 KB; `getAvatar(userId)` accepts any user ID so other users' avatars can be rendered in chat.

### openGadget

```ts
openGadget(id: string, shareKey?: string,
           configureObservers?: RpcStub<ObserverConfigCallback>): Promise<RpcStub<Overseer>>;
```

- When `shareKey` is supplied, redemption happens before the open, adding the caller as a collaborator in the same round trip. An invalid or expired key throws.
- Missing gadgets **throw** rather than returning `null`, specifically so calls can be pipelined onto the returned stub.
- Expected missing/authorization failures carry a machine-readable `code`.

<Warning>
`openGadget()` throwing is load-bearing for pipelining. Do not "fix" it to return `null` — a nullable return would force an `await` before every downstream call and cost an extra round trip on the hot path.
</Warning>

#### OPEN_GADGET_ERROR_CODES

```ts
export const OPEN_GADGET_ERROR_CODES = {
  workspaceNotFound: "WORKSPACE_NOT_FOUND",
  workspaceAccessDenied: "WORKSPACE_ACCESS_DENIED",
} as const;
```

| Code | Message |
| --- | --- |
| `WORKSPACE_NOT_FOUND` | `Workspace not found.` |
| `WORKSPACE_ACCESS_DENIED` | `You don't have access to this workspace.` |

Three helpers travel with it: `createOpenGadgetError(code)` returns `Error & { code }`, `getOpenGadgetErrorCode(error)` reads the code back off an unknown value (returning `undefined` for anything else), and `OpenGadgetErrorCode` is the union type. Match on the code; never match on the message.

```ts
try {
  using overseer = await api.openGadget(id, shareKey)
} catch (err) {
  switch (getOpenGadgetErrorCode(err)) {
    case OPEN_GADGET_ERROR_CODES.workspaceNotFound:   /* 404 view */ break
    case OPEN_GADGET_ERROR_CODES.workspaceAccessDenied: /* request access */ break
    default: throw err   // unexpected: surface it
  }
}
```

## Observer configuration callbacks

A non-owner opening a shared gadget may have to supply their *own* connected accounts before they are allowed to observe it. The overseer drives that through a client-supplied callback.

```ts
export interface ObserverConfigCallback extends RpcTarget {
  configure(needs: ObserverBindingNeed[]): Promise<ObserverAccountChoice[]>;
}
```

<ParamField body="ObserverBindingNeed.gatekeeperId" type="WorkpieceId" required>
The overseer-assigned gatekeeper id, echoed back in the corresponding `ObserverAccountChoice`.
</ParamField>

<ParamField body="ObserverBindingNeed.vendorId" type="string" required>
The vendor the user must have a connected account for (e.g. `"google"`). The frontend filters the user's accounts by this to find candidates.
</ParamField>

<ParamField body="ObserverBindingNeed.resourceTitle" type="string" required>
Human-readable resource title for the configuration modal.
</ParamField>

<ParamField body="ObserverBindingNeed.resourceUrl" type="string">
Canonical resource URL, when known, for display.
</ParamField>

<ParamField body="ObserverBindingNeed.failure" type="ObserverBindingFailure">
Present only when this binding *was* configured but its account failed verification on this attempt (expired credentials, revoked grant, upstream outage, or a genuine denial). Absent for a never-configured binding.
</ParamField>

`ObserverBindingFailure` carries `accountId` (a `ConnectedAccountRecord` id in the *opening user's own* User DO — pre-select it and aim the re-authenticate affordance at it) and `reason`, free display text that **must not** be parsed or matched on. `ObserverAccountChoice` is `{ gatekeeperId, accountId }`.

Contract details that matter for client implementations:

- `configure()` is invoked **only** for a non-owner who has unconfigured bindings. Owners and already-configured observers never see it, so the common-case open stays a single pipelined round trip.
- `open()` does not resolve until `configure()` returns. Rejecting the callback denies the open.
- `configure()` may be called a **second time** within one open, for just the subset of bindings that failed verification, so a user can re-authenticate an expired account without leaving the flow.
- The overseer bounds re-prompts. A client that keeps resubmitting a failing account eventually gets a denial rather than an endless loop.
- `ObserverBindingNeed` deliberately carries no "credentials valid" flag: the client already has that live from `subscribeConnectedAccounts()`, and a wire copy would go stale while the modal is open across an OAuth round trip.

`Overseer.listObserverRequirements(role)` returns the same `ObserverBindingNeed[]` shape purely so a sharer can *preview* what sharing will cost the recipient. It grants nothing and mints no capability.

## validateBindingName

```ts
export function validateBindingName(name: string): void
```

One shared validator applied at every chokepoint that writes a binding name: gadget binding edges, the workspace default binding list, chat binding maps, spawner env configs, and the agent tools. Throws a descriptive `Error`; returns nothing on success.

| Rule | Rejects | Reason |
| --- | --- | --- |
| `/^[A-Za-z_][A-Za-z0-9_]*$/` | `2fa`, `my-name`, `$env`, non-ASCII | Names render as `env.NAME`; `$` is conventionally reserved for code generators |
| ECMAScript reserved words | `class`, `await`, `let`, `static`, `yield`, `interface`, `null`, `true`, … | Valid identifiers that cannot follow `.` in all contexts |
| `name === "prototype"` or `name in Object.prototype` | `__proto__`, `constructor`, `hasOwnProperty`, `toString`, `prototype` | Binding maps are plain objects; these collide with inherited members or mutate the prototype chain |

`ALL_CAPS_WITH_UNDERSCORES` is style guidance only — recommended in tool descriptions and used for generated names — and is **not** enforced here.

## Overseer

One workspace = one Overseer Durable Object. Workspace-level concerns live here: the gadget registry, code sync (a single Yjs doc for the whole workspace), chats, actions/hooks, sharing, and blueprint listing. Per-gadget operations live on `GadgetClient`.

<AccordionGroup>
<Accordion title="Metadata, presence, lifecycle">
`getMetadata()`, `subscribeToMetadata(callback)`, `subscribeToPresence(subscriber)`, `setTitle(title)`, `setPinned(pinned)`, `deleteSelf()`. After `deleteSelf()`, further calls fail. `subscribeToMetadata` takes an `RpcStub<(metadata: GadgetMetadata) => void>` and fires once immediately with current state, then on every change.
</Accordion>

<Accordion title="Workpieces">
`subscribeToWorkpieces(subscriber)` delivers one `entry()` per existing workpiece, then `ready()`, then incremental `entry()`/`removed()`. In v1 only gadget-type workpieces are delivered.

`createGadget(title, chatId?, bindingName?)` — `title` is required (gadgets have no default title); the new gadget starts with no files and no bindings. With `chatId`, creation is provisional to that chat and stays pending until the chat's changes are merged (reverting deletes the gadget). Without `bindingName`, the server derives one from the title using the quick model when configured, else a generic fallback. Gadget binding names are workspace-unique — throws if taken, *including* by a gadget still pending in another chat.

`getGadget(id)` throws when the id is unknown, again to preserve pipelining.
</Accordion>

<Accordion title="Code sync">
`subscribeToCode(subscriber, fromVersion?)` and `updateCode(update, chatId?)`. Code is a single Yjs doc for the whole workspace; each file-owning workpiece has its own root `Y.Map` (file name → `Y.Text`) named per `WorkpieceSummary.filesRoot`. Updates are whole-doc and may span workpieces. Omit `fromVersion` (or pass zero) to download from scratch. `updateCode()` without `chatId` writes committed mainline; with `chatId` it records a live draft edit on that chat's branch.
</Accordion>

<Accordion title="Gatekeepers">
`getGatekeeperById(id)` (throws on unknown id), `newGatekeeper(accountId, resourceUrl)` (returns `null` when the resource can't be connected), `newAiModelGatekeeper(modelId)`, `newAgentSpawnerGatekeeper(config)`. New gatekeepers are workspace-level workpieces and are **not** bound into any gadget's `env` by default — use `GadgetClient.bind()` or `bindWithSuggestedName()`.
</Accordion>

<Accordion title="Actions, hooks, auto-approval">
`listActions()`, `approveAction(id)`, `rejectAction(id)`, `subscribeToActions(subscriber, startAfter?)`, `listHooks()`, `enableHook(id)`, `disableHook(id)`, `deleteHook(id)`, `setAutoApprovedActionKind(gatekeeperId, actionKind)`, `removeAutoApprovedActionKind(gatekeeperId, tag)`, `listAutoApprovedActionKinds()`, `listPreApprovableActions()`, `acceptConnectionRequest(requestId, {gatekeeperId})`, `denyConnectionRequest(requestId)`.

Auto-approval rules are workspace-wide **per gatekeeper**: approving an action kind approves it regardless of which gadget invokes it, and applies immediately to matching already-pending actions. `denyConnectionRequest()` deliberately does *not* resume the agent — the turn stays ended.
</Accordion>

<Accordion title="Chats">
`listChats`, `listModels`, `getChatHistory`, `getChatMessage`, `subscribeToChat`, `listSlashCommands`, `newChat`, `sendChatMessage`, `uploadChatAttachment`, `getChatAttachmentContent`, `deleteChatAttachment`, `setChatTitle`, `mergeChanges`, `revertChanges`, `finalizeChatDraft`, `discardChatDraftChanges`, `deleteChat`, `stopAgent`, `retryAgent`, `subscribeToConsoleLogs`.

Console logs are not stored; the only way to see them is to be subscribed while they happen.
</Accordion>

<Accordion title="Blueprints and sharing">
`listBlueprints`, `updateBlueprint(blueprintId, options)`, `deleteBlueprint`, `retryBlueprintPublish`, `listObserverRequirements`, `listCollaborators`, `addCollaborator`, `removeCollaborator`, `previewRemoveCollaborator`, `createShareLink`, `newShareLinkKey`, `listShareLinks`, `updateShareLink`, `revokeShareLink`, `previewRevokeShareLink`.

`updateBlueprint()` requires at least one option and applies metadata plus code atomically in one propagation pass; `updateCode: true` snapshots committed code and bumps the version, `updateBindings: true` refreshes connection annotations without touching the snapshot. `retryBlueprintPublish()` exists for records whose `dirty` flag is set after a failed propagation.

`createShareLink()` generates a random 128-bit key, stores only its HMAC-SHA-256 hash, and returns the raw key once — it is never stored server-side. A link may back several keys (`newShareLinkKey()` mints more); revoking the link revokes all of them.
</Accordion>
</AccordionGroup>

### Per-workpiece sub-capabilities

`WorkpieceClient` is the shared base: `getId()`, `getTitle()`, `setTitle()`, `remove()`. Removing a gadget deletes its registry entry (including its binding map) and hooks and clears its files; gatekeepers it bound survive. Removing a *gatekeeper* destroys the connection itself — distinct from `GadgetClient.unbind()`.

`GadgetClient extends WorkpieceClient` adds `getUiBundle(chatId?)`, `connectToGadget(chatId?)`, `exportPdf(chatId?)`, plus binding management (`listBindings`, `getBinding`, `bind`, `bindWithSuggestedName`, `unbind`, `renameBinding`, `getBlueprintAnnotation`, `setBlueprintAnnotation`) and `createBlueprint(title?, description?, screenshot?)`. A blueprint is always owned by the workspace owner regardless of who calls `createBlueprint()`.

`GatekeeperClient<Session extends RpcCompatible<Session>> extends WorkpieceClient` adds `describe(): Promise<ResourceDescription>`, `openSession(): Promise<RpcStub<Session>>`, and `getCreationSpec(): Promise<GatekeeperCreationSpec>`. Binding-edge concerns (names, blueprint annotations) live on `GadgetClient` because one gatekeeper may be bound by several gadgets under different names.

## AdminApi

Obtained only via `AuthenticatedApi.getAdminApi()`. Covers branding, agent instructions, formats, and which connectors/resources are offered. Authentication config (sign-in providers, password login) is deliberately **excluded** and stays env-var driven. Every setter throws on invalid input.

| Method | Constraint |
| --- | --- |
| `getSettings()` | Returns the whole `AdminSettingsView` in one call |
| `setSignupsEnabled(enabled)` | Existing users can still log in while signups are closed |
| `setSiteName(name)` | `""` resets to `DEFAULT_SITE_NAME`; rejects over `MAX_SITE_NAME_LENGTH` (40) |
| `setSiteLogo(data)` | `null` restores the default mark; server enforces PNG header, size, dimensions |
| `setInstanceInstructions(text)` | `""` clears; rejects over `MAX_INSTANCE_INSTRUCTIONS_LENGTH` (8000) |
| `setResourceEnabled(vendorId, urlPattern, enabled)` | Soft enforcement — hides from connect UI, picker, and agent; does not revoke held capabilities |
| `setGatekeeperMode(vendorId, mode)` | Ambient gatekeepers accept all three modes; ordinary ones reject `'optional'` |
| `setAnnouncement(text)` | `""` clears; rejects over `MAX_ANNOUNCEMENT_LENGTH` (2000) |
| `setBanner(text, color)` | Empty text hides; rejects invalid `BannerColor` |
| `setAccentColor(color)` | `""` resets; rejects non-hex (validated by `isHexColor` before CSS interpolation) |
| `isBlueprintFeatured(id)` | `null` when the blueprint can't be featured |
| `setBlueprintFeatured(id, featured)` | — |
| `promoteFormat(id)` | Appends last; re-promoting preserves curation so a failed mirror write can be repaired by retry |
| `removeFormat(id)` | Refused for a bundled format — use `updateFormat({enabled: false})` |
| `updateFormat(id, patch)` | `agentHint: ""` clears; an `overrides` field set to `null` reverts to the blueprint's declaration |
| `setFormatOrder(blueprintIds)` | Must be a permutation of currently promoted ids |

Related exported constants and guards: `MAX_ANNOUNCEMENT_LENGTH`, `MAX_INSTANCE_INSTRUCTIONS_LENGTH`, `MAX_SITE_NAME_LENGTH`, `MAX_SITE_LOGO_BYTES` (256 KiB), `MAX_SITE_LOGO_DIMENSION` (512), `BANNER_COLORS`, `DEFAULT_BANNER_COLOR` (`'info'`), `DEFAULT_SITE_NAME` (`"Cloudflare OS"`), `AMBIENT_GATEKEEPER_MODES`, `isBannerColor`, `isHexColor`, `isAmbientGatekeeperMode`, `isOutputIcon`, `resolveSiteName`.

`AmbientGatekeeperMode` resolves as: `'disabled'` (not offered; existing account dormant), `'optional'` (users opt in from the Connectors page — the default), `'enabled'` (auto-provisioned for everyone; not removable).

## Supporting types

### ServerConfig

Returned by `getServerConfig()`; no secrets.

<ResponseField name="authVendors" type="AuthVendorInfo[]">
Auth-capable, allowlisted vendors offered as sign-in buttons. Empty means password-only. Each entry is `{ vendorId, displayName, logo?, color? }` built from the gatekeeper's `VendorDescription`.
</ResponseField>

<ResponseField name="passwordAuthEnabled" type="boolean">
Defaults true. `DISABLE_PASSWORD_AUTH` makes a deployment OAuth-only, but this is **forced true when no auth vendor is configured**, to avoid locking everyone out.
</ResponseField>

<ResponseField name="cloudflareLimitsEnabled" type="boolean">
When false (the default, e.g. self-hosted), usage is unlimited and the credits UI is hidden.
</ResponseField>

<ResponseField name="signupsEnabled" type="boolean">
Admin-configurable, default true. The signup page hides the create-account form when false.
</ResponseField>

<ResponseField name="siteName" type="string">
Empty falls back to `DEFAULT_SITE_NAME`. Resolve with `resolveSiteName()` so server and client agree.
</ResponseField>

<ResponseField name="siteLogo" type="AvatarImage | undefined">
Undefined uses the default Cloudflare OS mark.
</ResponseField>

<ResponseField name="announcement" type="string">
Top-bar notice. Empty when unset.
</ResponseField>

<ResponseField name="banner / bannerColor" type="string / BannerColor">
Full-width banner. Empty `banner` hides it.
</ResponseField>

<ResponseField name="accentColor" type="string">
Hex brand color, or `""` for the default theme. The client overrides brand CSS variables (and derived shades) at runtime.
</ResponseField>

### GadgetMetadata

Workspace metadata — one Overseer DO and everything in it.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | `string` | Random url-safe base64, used with `openGadget()` |
| `title` | `string` | Workspace title; per-gadget titles live on `WorkpieceSummary` |
| `totalCost` | `number?` | Total AI inference cost in dollars, if known |
| `pinned` | `boolean?` | Pinned to the top of the user's list |
| `owner` | `AiChatAuthorInfo?` | **Presence means the viewer is a collaborator, not the owner** |
| `role` | `CollaboratorRole?` | Absent implies `"build"` for backwards compatibility |
| `sharingProhibited` | `boolean?` | True once the gadget has observed share-prohibited data; no further sharing is possible |
| `defaultGadgetId` | `WorkpieceId?` | Fallback when an API object omits its `gadgetId` |

`GadgetMetadataWithTimestamps` adds `created` and `lastActive`. Those are available from `listGadgets()` (the user's own collection) but **not** from `Overseer.getMetadata()`, which does not track them.

### UiBundle

```ts
export type UiBundle = {
  jsCode: string;
};
```

Raw JS to execute in the gadget iframe. A commented-out content-addressed `url` field records the intended direction (HTTP-served, highly cacheable across gadgets sharing a blueprint), but today the code crosses the wire inline. The bundle runs in a sandbox whose only outside channel is `postMessage()` to the parent frame; the frontend establishes a `newMessagePortRpcSession` over a transferred `MessagePort` after a `"handshake"` message.

### CodeUpdate and CodeSubscriber

```ts
export type CodeUpdate = {
  version: number;      // version AFTER this update is applied
  timestamp: Date;
  update: Uint8Array;   // Yjs encoded update, always V2 format
};
```

<Warning>
`CodeSubscriber.update()` ordering is a hard constraint. When a subscriber is several versions behind, the server may send multiple incremental updates or one large one, and may make several calls in rapid succession **without waiting for previous calls to return**. Cap'n Web guarantees in-order delivery, so the subscriber must apply each update — or enqueue it — *synchronously*. An `async` handler that awaits before applying will reorder the doc.
</Warning>

`ready()` fires the first time the subscriber is up to date with the server's latest known version.

### ActionLogEntry

Common fields: `id` (sequential from workspace creation), `gatekeeperId?` (omitted for non-gatekeeper sources such as the `webFetch` tool), `resourceTitle`, `resourceUrl?`, `createdAt`, `appliedAt?`, `state`. `ActionState` is `"pending" | "approved" | "rejected"`.

The entry is a discriminated union on `type`:

| `type` | Extra fields | Notes |
| --- | --- | --- |
| `"action"` | `description: ActionDescription`, `resolvedBy?`, `autoApproved?` | `resolvedBy` is set when the action leaves `pending`; for an auto-approval it is the user who enabled the rule, since auto-approvals run under their authority. `autoApproved` only ever appears with `state: "approved"` — there is no automatic rejection. |
| `"observation"` | `description: ObservationDescription` | Read-only; needs no approval |
| `"bindHook"` | `description: HookDescription`, `hookId?`, `enabled` | **`state` is not meaningful for hooks.** They are enabled/disabled and freely toggled, not approved/rejected. `hookId` is `undefined` if the hook was later deleted. |

`ActionsSubscriber` is `{ entry(record: ActionLogEntry): void; ready(): void }`.

### AgentSpawnerConfig

```ts
export type AgentSpawnerConfig = {
  displayName: string;
  modelId: string | null;
  env: Record<string, WorkpieceId>;
};
```

Creates a binding that lets a gadget programmatically start new agent chat threads, which appear in the gadget's agent chat UI as new conversations. Spawned agents typically use `executeCode` against the gadget's bindings rather than editing gadget code.

- `modelId: null` creates a chat without running an agent; the chat is flagged as needing attention, the same as an agent chat where the agent never marked the task complete.
- `env` maps binding name → target workpiece. On spawn it is **snapshotted** into the spawned chat's seed binding layer, dropping entries whose targets no longer exist. The spawned agent sees only these bindings, never the workspace default binding list.
- `env` entries are deliberately **not** limited to bindings held by the owning gadget: a spawner may define its own names and targets.

Names in `env` go through `validateBindingName()`. The isolation is the point: a gadget that answers email can spawn one agent per message with a stub scoped to replying to *that* thread only, so prompt injection or leakage cannot cross threads.

### AiModelConfig and AiGatewayInfo

```ts
export type AiModelConfig = {
  provider: AiModelProvider;   // "openai" | "anthropic" | "google" | "cloudflare" | "ollama"
  model: string;
  apiToken: string;
  accountId?: string;          // required for provider "cloudflare" (account-scoped REST endpoint)
  apiUrl?: string;             // override for AI-gateway-style proxies or compatible providers
};
```

`apiUrl` is what keeps model access portable: any OpenAI-compatible endpoint, self-hosted `ollama`, or a gateway proxy can be pointed at without a code change. `getAiConfig()` returns `AiGatewayInfo`, either `{ enabled: true, enabledProviders }` or `{ enabled: false }`, and the frontend adjusts the model-management UI accordingly.

`SUGGESTED_MODELS: Record<AiModelProvider, Record<string, {name, contextWindow, outputLimit?}>>` populates the picker. `contextWindow` is the maximum tokens one request may total; `outputLimit`, when present, is both the requested response cap and the space reserved for it, leaving the remainder as the prompt budget that context compaction sizes against. `WORKERS_AI_OUTPUT_LIMIT` is `32768`, applied to every Cloudflare model because Workers AI adds the response cap to the prompt and rejects requests whose total exceeds the window.

### Feature flags

`packages/workshop-shared/src/feature-flags.ts` exports `UI_FEATURE_FLAGS` (currently a single `placeholder-flag`), the derived `UiFeatureFlagName` / `UiFeatureFlags` types, `DEV_UI_FEATURE_FLAGS` (local development values), and `DEFAULT_UI_FEATURE_FLAGS` (used when Flagship is unavailable or unconfigured). The backend resolves these for the frontend through `AuthenticatedApi.getUiFeatureFlags()`.

### ExternalMessageGateway

`packages/workshop-shared/src/external-message-gateway.ts` defines a **service-binding** RPC interface (importing `RpcStub`/`RpcTarget` from `cloudflare:workers`, not `capnweb`) — a separate surface from the browser API.

```ts
export interface ExternalMessageGateway {
  submitExternalMessage(input: SubmitExternalMessageInput): Promise<SubmitExternalMessageResult>;
}
```

<Warning>
`SubmitExternalMessageInput.callerEmail` is a trust delegation: "The backend trusts the gateway: supplying this email grants access as that account." Only bind this to a gateway worker you control.
</Warning>

`gadgetKey`, `chatKey`, and `messageKey` are idempotency keys selecting/creating the workspace and chat and deduplicating the originating message. The result is `{ accepted: true, chatPath }` or `{ accepted: false, message }`, where `message` is a user-facing explanation of an actionable rejection. Responses come back through `ChatGatewayRpcTarget.onGadgetResponse(response)`, whose implementations **must be idempotent** because delivery is at-least-once when acknowledgements fail.

## RPC usage constraints

These are enforced by review, not by the type checker, and each has a concrete failure mode.

### Promise pipelining

Cap'n Web pipelines promises. A method returning a stub does not need to be awaited — the promise is usable in place of the stub — and a promise for a future *value* can be passed as an argument, where it is replaced by its resolution on the server before delivery.

<CodeGroup>
```ts title="Pipelined — one round trip"
// No await: chain straight through the promise.
const overseer = api.openGadget(id)
const gadget = overseer.getGadget(gadgetId)
const bundle = await gadget.getUiBundle()
```

```ts title="Serialized — three round trips"
const overseer = await api.openGadget(id)
const gadget = await overseer.getGadget(gadgetId)
const bundle = await gadget.getUiBundle()
```
</CodeGroup>

This is also why `no-floating-promises` is not enabled in this repo: type-aware oxlint rules are off (the tsgo engine requires an explicit `rootDir` under declaration emit and drops `baseUrl`, incompatible with cross-package source imports), and pipelining intentionally leaves promises unawaited. Type safety still comes from `tsc` via `pnpm types:check` and `pnpm build`.

### Stub disposal

Every stub must be disposed or the server leaks the capability. Call `stub[Symbol.dispose]()`, or use a `using` declaration where possible. In React, a stub obtained in a `useEffect` must be disposed in that effect's cleanup function.

```ts
useEffect(() => {
  const sub = overseer.subscribeToActions(stubFor(subscriber))
  return () => { sub[Symbol.dispose]() }   // otherwise the subscription lives forever
}, [overseer])
```

Every subscription method (`subscribeToMetadata`, `subscribeToPresence`, `subscribeToWorkpieces`, `subscribeToCode`, `subscribeToActions`, `subscribeToChat`, `subscribeToConsoleLogs`, `subscribeConnectedAccounts`) returns `Promise<RpcStub<{}>>` whose sole purpose is disposal — disposing it cancels the subscription. Disposing a `LoginAttempt` abandons the sign-in.

### Stubs in React state

<Warning>
An `RpcStub` must never be a `useState` value directly. At runtime every stub appears callable, because the system cannot know whether the stub points at a server-side function. `useState`'s setter treats any callable — including a stub — as an updater function and *invokes it* to compute the state. Wrap the stub in an object and store that: `setGk({ stub })`.
</Warning>

### No hand-written mirror interfaces

Never introduce 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. `workshop-shared` and `workshop-backend` are reviewed line by line as the kernel, and every exported member of the `workshop-shared` public API — types, constants, and functions, not just interfaces — must carry a doc comment.

The one sanctioned cast lives in the integration-test helper `stubFor()`, and is documented there: a stub is only serializable by the `capnweb` instance owning the session, and a consumer that vendors this repo as a `public/` submodule ends up with two installs and two stores. The symptom is `Cannot serialize value: [object RpcStub]`, and it appears only once the installs are separate — so a single-install dev machine will not reproduce it but CI will. Always mint callback stubs through the helper rather than importing `RpcStub` directly.

### Capability-based security

A resource becomes "ambient" (auto-injected) only through user or admin configuration. A gatekeeper must never assert its own ambience.

## Related pages

<CardGroup cols={2}>
<Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
`GatekeeperVendor`, `VendorDescription`, `ResourceDescription`, and the session types these RPC interfaces hand out.
</Card>
<Card title="Observations, actions, and approval queues" href="/observations-and-actions">
`ObservationDescription`, `ActionDescription`, `ActionKind`, and the state machine behind `ActionLogEntry`.
</Card>
<Card title="Sharing, roles, and observer re-verification" href="/sharing-and-observers">
`CollaboratorRole`, the `use` allowlist, and how observer re-verification produces `ObserverBindingNeed`.
</Card>
<Card title="Admin configuration reference" href="/admin-configuration">
The `AdminConfig` schema behind `AdminApi`, its defaults, and the KV mirror.
</Card>
<Card title="Integration testing" href="/integration-testing">
Driving these interfaces over the real `/api` WebSocket with `createTestHarness()`.
</Card>
<Card title="Developer conventions and contributing" href="/conventions-and-contributing">
Kernel review standards, doc-comment requirements, and the full RPC rule set.
</Card>
</CardGroup>
