Skip to main content
ClaudeWave
Skill3.9k repo starsupdated 4d ago

openbot-data-access

Governs how the OpenBot browser app reads and writes server data — every request goes through `client` in app/src/lib/client.ts, every read is a queryOptions factory in app/src/lib/<entity>/queries.ts, every write is a mutationOptions factory in app/src/lib/<entity>/mutations.ts, and components consume them through useQuery/useMutation. Use when adding or changing a screen that loads server data, calling a /api/... endpoint from the browser, adding a query key, writing a create/update/delete flow, deciding where a fetch belongs, or reviewing a diff that contains the word fetch under app/src. Don't use for server-side route handlers under server/ (that is not browser code), for form validation schemas (those live in lib/<entity>/form.ts), for page layout and Item rows, or for the AG-UI stream itself, which the runtime carries rather than the client.

Install in Claude Code
Copy
git clone --depth 1 https://github.com/CopilotKit/OpenBot /tmp/openbot-data-access && cp -r /tmp/openbot-data-access/.claude/skills/openbot-data-access ~/.claude/skills/openbot-data-access
Then start a new Claude Code session; the skill loads automatically.

SKILL.md

# OpenBot Data Access

## When To Use

This skill applies to any change under `app/src` that moves data between the browser and the API
server. It fires on new screens, new endpoints, new query keys, and on any diff that introduces
`fetch` anywhere but `app/src/lib/client.ts`.

It does not cover server handlers under `server/`, zod form schemas (`lib/<entity>/form.ts`), or page
layout. It does cover `lib/copilot/`: the conversation itself streams over AG-UI, but the tool calls a
Bot makes during a turn are ordinary authenticated requests and go through the client like everything
else.

## The Shape

Every entity the browser knows about owns a directory under `app/src/lib/`:

```
app/src/lib/
  client.ts      # the only fetch in the app
  <entity>/
    queries.ts   # read types, key factory, queryOptions factories
    mutations.ts # input type, mutationOptions factories
    form.ts      # zod schema (a different skill's territory)
```

`client.ts` owns the transport: credentials, the JSON content type, body serialisation, and turning a
failed status into an `Error` carrying the server's own message. It owns nothing about meaning — the
envelope key and the sentence a person reads stay at the call site, because those are facts about one
endpoint rather than about requests in general.

```ts
client<T>(path, key, options?): Promise<T>    // parsed, and `key` unwrapped
client(path, options?): Promise<Response>     // for a caller that only needed it to work
tryClient(path, options?): Promise<Response>  // never throws; the status is the answer
```

`options` is `{ method?, body?, fallback?, signal? }`. `body` is serialised by the client, which is
also what sets the content type — so a caller passes an object, never a string. Passing
`JSON.stringify(x)` sends a JSON string of a JSON string, which no endpoint accepts.

### Three kinds of request

Not everything crossing the wire is cached state, and the shape follows from which kind it is.

1. **A cached read** is a `queryOptions` factory in `queries.ts`. It has a key, and something can
   invalidate it.
2. **A write somebody asked for** is a `mutationOptions` factory in `mutations.ts`. It invalidates on
   success.
3. **Everything else is a plain exported function**, living beside the factories for its entity.
   A verdict about this moment (`decideComponent`, `testAgentConnection`), a tool call during a
   Bot's turn (`callPluginTool`, the computer control surface), a frame of a screen, a step inside
   another write (`storeMcpToken`). These fail closed and return a value rather than throwing,
   because a refusal is usually the answer. Giving one a cache key would create a key nothing reads
   and an invalidation nothing triggers.

The third kind still lives under `lib/`. It is not licence to call the server from a component.

There are thirteen of these today — `agents`, `audit`, `auth`, `channels`, `components`, `computers`,
`connectors`, `credentials`, `package`, `plugins`, `sandboxed`, `skills`, `copilot`. They all look the
same on purpose. `lib/agents/queries.ts` and `lib/agents/mutations.ts` are the reference pair; read them
before writing a new one.

**The one rule that matters:** a React component never calls `fetch`. If a component file contains
`fetch`, the change is wrong regardless of whether it works.

## Procedures

### Procedure 1: Add a read

1. Create or open `app/src/lib/<entity>/queries.ts`.
2. Declare the browser-shaped type for the payload — `<Entity>Profile`, `<Entity>Status`,
   `<Entity>Summary`, or `<Entity>Record`, matching whichever sibling name fits. This type describes
   what the browser receives, not what the database stores.
3. Include the server's authorization verdicts as fields on that type (`canManage`, `systemOwned`,
   `mine`, `hasAuth`) and document them. The browser renders these flags; it never recomputes
   ownership or permission rules from other fields.
4. Never put a secret's value in a read type. A credential is `hasAuth: boolean` or a
   `revokedAt` timestamp. Secrets are write-only in this codebase.
5. Add or extend the key factory, named `<entity>Keys`:

   ```ts
   export const agentKeys = {
     all: ["agents"] as const,
     list: (hidden = false) => ["agents", "list", { hidden }] as const,
     detail: (agentId: string) => ["agents", "detail", agentId] as const,
   };
   ```

   `all` is always the bare entity name and is the invalidation root. List keys carry their
   parameters as a trailing object so two filters are two cache entries. Every array is `as const`.
   A single-key entity still gets a factory: `export const packageKeys = { active: ["tenant-package", "active"] as const };`.

6. Export a factory function returning `queryOptions({ queryKey, queryFn })`, named
   `<subject>QueryOptions`. Existing spellings: `agentListQueryOptions`, `agentQueryOptions`,
   `agentComponentsQueryOptions`, `activePackageQueryOptions`.
7. Inside `queryFn`, call `client` with the path, the envelope key, and a `fallback` sentence. It
   sends the credentials, checks the status, raises the server's message when there is one, and
   unwraps the key so the caller receives the payload rather than the wrapper:

   ```ts
   queryFn: (): Promise<AgentProfile[]> =>
     client("/api/agents", "agents", { fallback: "Could not load coworkers" }),
   ```

   Where the whole body is the payload, omit the key and read it: `(await client(path, { fallback
   })).json()`. Where a failed status is an *answer* rather than an error — a refused component call,
   a 401 that means "not signed in" — use `tryClient` and read the status.

8. Annotate the `queryFn` return type explicitly (`(): Promise<AgentProfile[]>`). `client` is generic
   in its payload, so the annotation is what fixes what the key unwraps to.

### Procedure 2: Add a write

1. Create or open `app/src/lib/<entity>/mutations.ts`.
2. Declare the input type as `<Entity>Input` — the shape the API accepts, which is not the form's
   shape. Mapping between them is