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.
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-accessSKILL.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