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.
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.
- A cached read is a
queryOptions factory in queries.ts. It has a key, and something can
invalidate it.
- A write somebody asked for is a
mutationOptions factory in mutations.ts. It invalidates on
success.
- 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
Create or open app/src/lib/<entity>/queries.ts.
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.
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.
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.
Add or extend the key factory, named <entity>Keys:
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 };.
Export a factory function returning queryOptions({ queryKey, queryFn }), named
<subject>QueryOptions. Existing spellings: agentListQueryOptions, agentQueryOptions,
agentComponentsQueryOptions, activePackageQueryOptions.
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:
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.
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
Create or open app/src/lib/<entity>/mutations.ts.
Declare the input type as <Entity>Input — the shape the API accepts, which is not the form's
shape. Mapping between them is form.ts's job (agentInputFrom).
Call client. Do not write a per-entity request helper — agentRequest,
componentRequest and two others each owned a private copy of the same credentials, headers and
error extraction, and one of the four had quietly dropped the extraction. client owns it now:
mutationFn: (input: AgentInput): Promise<AgentProfile> =>
client("/api/agents", "agent", { method: "POST", body: input, fallback: FALLBACK }),
Where the write returns nothing a caller needs, omit the key and await client(path, { ... }).
Keep one const FALLBACK per file rather than repeating the sentence: the reader of the failure
cares which entity failed, and within one file that never changes.
Export one factory per write, named <verb><Entity>MutationOptions(queryClient), returning
mutationOptions({ mutationFn, onSuccess }). Existing spellings: createAgentMutationOptions,
updateAgentMutationOptions, duplicateAgentMutationOptions, setAgentHiddenMutationOptions,
deleteAgentMutationOptions.
Give mutationFn exactly one parameter. For a single value that is the value
(agentId: string); for more than one it is a named variables object
({ agentId: string; input: AgentInput }).
Invalidate on success — never patch the cache by hand:
onSuccess: () => queryClient.invalidateQueries({ queryKey: agentKeys.all })
With several writes in one file, wrap that in a private invalidate<Entity>(queryClient) helper.
Server-derived fields are the reason: a hand-patched cache entry is a guess at what the server
decided, and it is wrong the first time the server adds a rule.
queryClient.removeQueries instead of invalidateQueries when the data should stop existing
rather than be refetched. Sign-out is the only current case
(lib/auth/mutations.ts).
A fire-and-forget write takes no queryClient and has no onSuccess
(recordChannelActivityMutationOptions). Deliberate, and it carries a comment saying why the
failure is acceptable. Do not reach for this to avoid writing error handling.
Procedure 3: Consume from a component
Import the factories, never the endpoint — and import the queryClient, never call
useQueryClient():
import { queryClient } from "@/query-client";
const credentials = useQuery(credentialListQueryOptions());
const createCredential = useMutation(createCredentialMutationOptions(queryClient));
useQueryClient() is not the convention here. There is exactly one client: constructed at
module scope in app/src/query-client.ts and handed to both QueryClientProvider and the router
context in main.tsx. The hook therefore resolves to the same object the import already holds,
at the cost of a hook call and a const line. The import also works where a hook cannot — a
plain event handler, a module with no component around it, a test — which is why mutation
factories take a QueryClient parameter rather than reaching for the hook themselves.
Import with the @/ alias — @/lib/credentials/queries, not ../../../lib/credentials/queries.
Both spellings exist in the tree today; the alias is the correct one.
Branch on all four states in order, every time — pending, error, empty, rows — and never
dereference data without having handled the first three:
{credentials.isPending ? null : credentials.error ? (
<p className="text-destructive text-sm" role="alert">Could not load credentials.</p>
) : credentials.data?.length === 0 ? (
<PageEmpty>No credentials are configured.</PageEmpty>
) : (
<PageRows>{/* rows */}</PageRows>
)}
The pending branch renders nothing. OpenBot has no loading placeholder — no "Loading…"
text, no spinner, no skeleton, no shimmer. The section's heading is already on screen; what
arrives underneath it is the answer, and a placeholder that appears and vanishes inside a
local round-trip is a flicker rather than information. A few screens still carry
<PageEmpty>Loading …</PageEmpty> and one carries a Skeleton block, both from before this
decision. They are not the pattern to copy.
isPending is still branched on, and branched on first. Deleting the branch instead of
returning null from it would show the empty-state sentence — "No credentials are
configured." — for the whole duration of the fetch, which states something false.
This says nothing about mutations. A button the person just pressed still says "Saving…" or
"Deleting…", because that is feedback for an action they took rather than a placeholder for
data they are waiting on. Derive it from the mutation, not from useState:
disabled={... || createCredential.isPending}.
Read credentials.tsx in app/src/routes/_authed/admin/ for the whole pattern end to end —
with the caveat that its pending branch still renders text.
Procedure 4: Preload in a route
When data gates navigation, load it in beforeLoad with ensureQueryData and the same options
factory the component uses:
const user = await context.queryClient.ensureQueryData(currentUserQueryOptions());
if (!user) throw redirect({ to: "/sign" });
The factory is shared between the guard and the component on purpose — one key, one fetch, and
the component's useQuery is already warm.
Authorization decided here, not inside a component (routes/_authed.tsx,
routes/_authed/admin/route.tsx).
Inside beforeLoad and loader, use context.queryClient — the router's typed handle, and the
same singleton main.tsx put there. No import needed at those two call sites; everywhere else,
import it.
Decision Tree
- Adding a screen that displays server data → Procedure 1, then Procedure 3.
- Adding a create, update, delete, or toggle → Procedure 2, then Procedure 3.
- The data decides whether the person is allowed on the page at all → Procedure 4.
- Changing an existing payload's shape → Procedure 1, step 2, and check every consumer of the type.
- Filtering or paginating an existing list → Procedure 1, step 5: a new parameter goes in the
trailing object of the list key, not into a second key factory.
- The data is form input rather than server state → not this skill;
lib/<entity>/form.ts.
- The data arrives over the CopilotKit runtime → not this skill;
lib/copilot/.
Red Flags
| Signal |
What it means |
Do instead |
fetch( anywhere but lib/client.ts |
Either the read has no key and nothing can invalidate it, or the transport has been rewritten by hand |
Move it into lib/<entity>/ behind a factory, and call client |
A module-private <entity>Request helper |
Superseded. Four of these existed and one had lost its body.error extraction |
Call client |
client on an endpoint whose refusal is an answer |
Turns the boundary working into an exception the caller has to catch |
tryClient, and read the status |
body: JSON.stringify(x) at a call site |
Double-encoded; the client serialises |
Pass the object |
A one-shot tool call written as a mutationOptions factory |
Gets a queryClient and an invalidation it has no use for |
A plain function beside the factories |
A fallback sentence repeated on every write in a file |
The reader cares which entity failed, and that does not change within a file |
One const FALLBACK per file |
<PageEmpty>Loading …</PageEmpty>, a spinner, or a Skeleton while a query is pending |
OpenBot uses no loading placeholder; the flicker costs more than the reassurance buys |
Return null from the pending branch |
The pending branch deleted rather than returning null |
The empty-state sentence shows for the length of the fetch, asserting something false |
Keep the branch, first in the chain, returning null |
const queryClient = useQueryClient() |
A hook call and a local binding for an object that is one import away, and unavailable outside a component |
import { queryClient } from "@/query-client" |
new QueryClient() anywhere outside app/src/query-client.ts |
A second cache; queries written by one client are invisible to the other |
Import the singleton. A test needing isolation constructs its own and passes it explicitly |
useQuery({ queryKey: ["agents"], ... }) at a call site |
An inline key drifts from the factory and silently stops matching invalidations |
Call the factory: useQuery(agentListQueryOptions()) |
queryClient.setQueryData(...) after a mutation |
Guesses at server-derived fields; wrong the moment the server adds a rule |
invalidateQueries({ queryKey: <entity>Keys.all }) |
A component computing user.role === "admin" && thing.ownerId === user.id |
Duplicates an authorization rule that the server already decided |
Render the server's flag (canManage, mine) |
A read type carrying a token, key, or plaintext |
Secrets are write-only in OpenBot |
Expose hasAuth: boolean or a revokedAt timestamp |
A hand-written body.error extraction |
client already does it, and did it more consistently than the four copies did |
Pass fallback and let it raise |
queryFn returning { agents: [...] } |
Leaks the transport envelope into every component |
Unwrap in the queryFn; components see the array |
A second <entity>Keys object, or keys defined in mutations.ts |
Two sources of truth for one cache namespace |
One factory per entity, in queries.ts; mutations.ts imports it |
Error Handling
- A 401 in a
queryFn: client sends the credentials, so a 401 means the session expired — the
_authed guard handles the redirect on the next navigation. Do not add per-query redirect logic.
The one place a 401 is expected is currentUserQueryOptions, which uses tryClient because not
being signed in is an answer there rather than a failure.
- The server returns no
error field: client falls back to the fallback option. Name the
entity in it ("Could not load coworkers"). Do not print a status code to a person.
- A refusal arrives as a thrown
Error instead of a value: the call site used client where it
needed tryClient. The gateway declining is the product working, not a fault.
- The server rejects a body it should accept, or reads it as a string: something stringified
before handing it over.
client serialises; a caller passes the object.
- An invalidation does not refresh the list: the key at the call site does not match the key the
mutation invalidated. Both must come from the same
<entity>Keys factory. Check for an inline key
array before anything else.
- Two filters of the same list overwrite each other's cache: the parameter is missing from the
list key. Add it to the trailing object (
list: (hidden = false) => [..., { hidden }]).
- A mutation succeeds but the screen shows stale server-derived fields: something patched the
cache instead of invalidating it. Remove the patch.
- TypeScript cannot infer the
queryFn return: the explicit Promise<T> annotation is missing
from the queryFn signature. Add it rather than casting at the call site.
- "No X are configured." flashes before the list appears: the pending branch is missing, or it
sits after the empty check. It goes first and returns
null.
- A query is slow enough that the blank feels broken: the answer is not a placeholder, it is a
slow endpoint. Fix the endpoint, or preload the data in the route (Procedure 4) so the screen is
not entered until it is there.
- A mutation factory is needed where no hook can run — a bare event handler, a module with no
component around it, a
bun test file: import queryClient from app/src/query-client.ts and
pass it in. This is the reason the singleton is the convention rather than the hook.
- An invalidation appears to do nothing and the keys do match: two clients exist. Search for
new QueryClient( outside app/src/query-client.ts.
- Unsure which entity directory a new endpoint belongs to: name it after the noun the URL is
about (
/api/agents/:id/plugins is plugins, keyed by agent). If no existing directory fits,
create one with the same three-file shape rather than adding the read to a neighbour.
1---2name: openbot-data-access3description: 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.4---56# OpenBot Data Access78## When To Use910This skill applies to any change under `app/src` that moves data between the browser and the API11server. It fires on new screens, new endpoints, new query keys, and on any diff that introduces12`fetch` anywhere but `app/src/lib/client.ts`.1314It does not cover server handlers under `server/`, zod form schemas (`lib/<entity>/form.ts`), or page15layout. It does cover `lib/copilot/`: the conversation itself streams over AG-UI, but the tool calls a16Bot makes during a turn are ordinary authenticated requests and go through the client like everything17else.1819## The Shape2021Every entity the browser knows about owns a directory under `app/src/lib/`:2223```24app/src/lib/25 client.ts # the only fetch in the app26 <entity>/27 queries.ts # read types, key factory, queryOptions factories28 mutations.ts # input type, mutationOptions factories29 form.ts # zod schema (a different skill's territory)30```3132`client.ts` owns the transport: credentials, the JSON content type, body serialisation, and turning a33failed status into an `Error` carrying the server's own message. It owns nothing about meaning — the34envelope key and the sentence a person reads stay at the call site, because those are facts about one35endpoint rather than about requests in general.3637```ts38client<T>(path, key, options?): Promise<T> // parsed, and `key` unwrapped39client(path, options?): Promise<Response> // for a caller that only needed it to work40tryClient(path, options?): Promise<Response> // never throws; the status is the answer41```4243`options` is `{ method?, body?, fallback?, signal? }`. `body` is serialised by the client, which is44also what sets the content type — so a caller passes an object, never a string. Passing45`JSON.stringify(x)` sends a JSON string of a JSON string, which no endpoint accepts.4647### Three kinds of request4849Not everything crossing the wire is cached state, and the shape follows from which kind it is.50511. **A cached read** is a `queryOptions` factory in `queries.ts`. It has a key, and something can52 invalidate it.532. **A write somebody asked for** is a `mutationOptions` factory in `mutations.ts`. It invalidates on54 success.553. **Everything else is a plain exported function**, living beside the factories for its entity.56 A verdict about this moment (`decideComponent`, `testAgentConnection`), a tool call during a57 Bot's turn (`callPluginTool`, the computer control surface), a frame of a screen, a step inside58 another write (`storeMcpToken`). These fail closed and return a value rather than throwing,59 because a refusal is usually the answer. Giving one a cache key would create a key nothing reads60 and an invalidation nothing triggers.6162The third kind still lives under `lib/`. It is not licence to call the server from a component.6364There are thirteen of these today — `agents`, `audit`, `auth`, `channels`, `components`, `computers`,65`connectors`, `credentials`, `package`, `plugins`, `sandboxed`, `skills`, `copilot`. They all look the66same on purpose. `lib/agents/queries.ts` and `lib/agents/mutations.ts` are the reference pair; read them67before writing a new one.6869**The one rule that matters:** a React component never calls `fetch`. If a component file contains70`fetch`, the change is wrong regardless of whether it works.7172## Procedures7374### Procedure 1: Add a read75761. Create or open `app/src/lib/<entity>/queries.ts`.772. Declare the browser-shaped type for the payload — `<Entity>Profile`, `<Entity>Status`,78 `<Entity>Summary`, or `<Entity>Record`, matching whichever sibling name fits. This type describes79 what the browser receives, not what the database stores.803. Include the server's authorization verdicts as fields on that type (`canManage`, `systemOwned`,81 `mine`, `hasAuth`) and document them. The browser renders these flags; it never recomputes82 ownership or permission rules from other fields.834. Never put a secret's value in a read type. A credential is `hasAuth: boolean` or a84 `revokedAt` timestamp. Secrets are write-only in this codebase.855. Add or extend the key factory, named `<entity>Keys`:8687 ```ts88 export const agentKeys = {89 all: ["agents"] as const,90 list: (hidden = false) => ["agents", "list", { hidden }] as const,91 detail: (agentId: string) => ["agents", "detail", agentId] as const,92 };93 ```9495 `all` is always the bare entity name and is the invalidation root. List keys carry their96 parameters as a trailing object so two filters are two cache entries. Every array is `as const`.97 A single-key entity still gets a factory: `export const packageKeys = { active: ["tenant-package", "active"] as const };`.98996. Export a factory function returning `queryOptions({ queryKey, queryFn })`, named100 `<subject>QueryOptions`. Existing spellings: `agentListQueryOptions`, `agentQueryOptions`,101 `agentComponentsQueryOptions`, `activePackageQueryOptions`.1027. Inside `queryFn`, call `client` with the path, the envelope key, and a `fallback` sentence. It103 sends the credentials, checks the status, raises the server's message when there is one, and104 unwraps the key so the caller receives the payload rather than the wrapper:105106 ```ts107 queryFn: (): Promise<AgentProfile[]> =>108 client("/api/agents", "agents", { fallback: "Could not load coworkers" }),109 ```110111 Where the whole body is the payload, omit the key and read it: `(await client(path, { fallback112 })).json()`. Where a failed status is an *answer* rather than an error — a refused component call,113 a 401 that means "not signed in" — use `tryClient` and read the status.1141158. Annotate the `queryFn` return type explicitly (`(): Promise<AgentProfile[]>`). `client` is generic116 in its payload, so the annotation is what fixes what the key unwraps to.117118### Procedure 2: Add a write1191201. Create or open `app/src/lib/<entity>/mutations.ts`.1212. Declare the input type as `<Entity>Input` — the shape the API accepts, which is not the form's122 shape. Mapping between them is `form.ts`'s job (`agentInputFrom`).1233. Call `client`. Do **not** write a per-entity request helper — `agentRequest`,124 `componentRequest` and two others each owned a private copy of the same credentials, headers and125 error extraction, and one of the four had quietly dropped the extraction. `client` owns it now:126127 ```ts128 mutationFn: (input: AgentInput): Promise<AgentProfile> =>129 client("/api/agents", "agent", { method: "POST", body: input, fallback: FALLBACK }),130 ```131132 Where the write returns nothing a caller needs, omit the key and `await client(path, { ... })`.133 Keep one `const FALLBACK` per file rather than repeating the sentence: the reader of the failure134 cares which entity failed, and within one file that never changes.1351364. Export one factory per write, named `<verb><Entity>MutationOptions(queryClient)`, returning137 `mutationOptions({ mutationFn, onSuccess })`. Existing spellings: `createAgentMutationOptions`,138 `updateAgentMutationOptions`, `duplicateAgentMutationOptions`, `setAgentHiddenMutationOptions`,139 `deleteAgentMutationOptions`.1405. Give `mutationFn` exactly one parameter. For a single value that is the value141 (`agentId: string`); for more than one it is a named `variables` object142 (`{ agentId: string; input: AgentInput }`).1436. Invalidate on success — never patch the cache by hand:144145 ```ts146 onSuccess: () => queryClient.invalidateQueries({ queryKey: agentKeys.all })147 ```148149 With several writes in one file, wrap that in a private `invalidate<Entity>(queryClient)` helper.150 Server-derived fields are the reason: a hand-patched cache entry is a guess at what the server151 decided, and it is wrong the first time the server adds a rule.1527. `queryClient.removeQueries` instead of `invalidateQueries` when the data should stop existing153 rather than be refetched. Sign-out is the only current case154 (`lib/auth/mutations.ts`).1558. A fire-and-forget write takes no `queryClient` and has no `onSuccess`156 (`recordChannelActivityMutationOptions`). Deliberate, and it carries a comment saying why the157 failure is acceptable. Do not reach for this to avoid writing error handling.158159### Procedure 3: Consume from a component1601611. Import the factories, never the endpoint — and import the `queryClient`, never call162 `useQueryClient()`:163164 ```ts165 import { queryClient } from "@/query-client";166167 const credentials = useQuery(credentialListQueryOptions());168 const createCredential = useMutation(createCredentialMutationOptions(queryClient));169 ```1701712. **`useQueryClient()` is not the convention here.** There is exactly one client: constructed at172 module scope in `app/src/query-client.ts` and handed to both `QueryClientProvider` and the router173 context in `main.tsx`. The hook therefore resolves to the same object the import already holds,174 at the cost of a hook call and a `const` line. The import also works where a hook cannot — a175 plain event handler, a module with no component around it, a test — which is why mutation176 factories take a `QueryClient` parameter rather than reaching for the hook themselves.1773. Import with the `@/` alias — `@/lib/credentials/queries`, not `../../../lib/credentials/queries`.178 Both spellings exist in the tree today; the alias is the correct one.1794. Branch on all four states in order, every time — pending, error, empty, rows — and never180 dereference `data` without having handled the first three:181182 ```tsx183 {credentials.isPending ? null : credentials.error ? (184 <p className="text-destructive text-sm" role="alert">Could not load credentials.</p>185 ) : credentials.data?.length === 0 ? (186 <PageEmpty>No credentials are configured.</PageEmpty>187 ) : (188 <PageRows>{/* rows */}</PageRows>189 )}190 ```1911925. **The pending branch renders nothing.** OpenBot has no loading placeholder — no "Loading…"193 text, no spinner, no skeleton, no shimmer. The section's heading is already on screen; what194 arrives underneath it is the answer, and a placeholder that appears and vanishes inside a195 local round-trip is a flicker rather than information. A few screens still carry196 `<PageEmpty>Loading …</PageEmpty>` and one carries a `Skeleton` block, both from before this197 decision. They are not the pattern to copy.1986. `isPending` is still branched on, and branched on **first**. Deleting the branch instead of199 returning `null` from it would show the empty-state sentence — "No credentials are200 configured." — for the whole duration of the fetch, which states something false.2017. This says nothing about mutations. A button the person just pressed still says `"Saving…"` or202 `"Deleting…"`, because that is feedback for an action they took rather than a placeholder for203 data they are waiting on. Derive it from the mutation, not from `useState`:204 `disabled={... || createCredential.isPending}`.2058. Read `credentials.tsx` in `app/src/routes/_authed/admin/` for the whole pattern end to end —206 with the caveat that its pending branch still renders text.207208### Procedure 4: Preload in a route2092101. When data gates navigation, load it in `beforeLoad` with `ensureQueryData` and the same options211 factory the component uses:212213 ```ts214 const user = await context.queryClient.ensureQueryData(currentUserQueryOptions());215 if (!user) throw redirect({ to: "/sign" });216 ```2172182. The factory is shared between the guard and the component on purpose — one key, one fetch, and219 the component's `useQuery` is already warm.2203. Authorization decided here, not inside a component (`routes/_authed.tsx`,221 `routes/_authed/admin/route.tsx`).2224. Inside `beforeLoad` and `loader`, use `context.queryClient` — the router's typed handle, and the223 same singleton `main.tsx` put there. No import needed at those two call sites; everywhere else,224 import it.225226## Decision Tree227228- Adding a screen that displays server data → Procedure 1, then Procedure 3.229- Adding a create, update, delete, or toggle → Procedure 2, then Procedure 3.230- The data decides whether the person is allowed on the page at all → Procedure 4.231- Changing an existing payload's shape → Procedure 1, step 2, and check every consumer of the type.232- Filtering or paginating an existing list → Procedure 1, step 5: a new parameter goes in the233 trailing object of the list key, not into a second key factory.234- The data is form input rather than server state → not this skill; `lib/<entity>/form.ts`.235- The data arrives over the CopilotKit runtime → not this skill; `lib/copilot/`.236237## Red Flags238239| Signal | What it means | Do instead |240|--------|---------------|------------|241| `fetch(` anywhere but `lib/client.ts` | Either the read has no key and nothing can invalidate it, or the transport has been rewritten by hand | Move it into `lib/<entity>/` behind a factory, and call `client` |242| A module-private `<entity>Request` helper | Superseded. Four of these existed and one had lost its `body.error` extraction | Call `client` |243| `client` on an endpoint whose refusal is an answer | Turns the boundary working into an exception the caller has to catch | `tryClient`, and read the status |244| `body: JSON.stringify(x)` at a call site | Double-encoded; the client serialises | Pass the object |245| A one-shot tool call written as a `mutationOptions` factory | Gets a `queryClient` and an invalidation it has no use for | A plain function beside the factories |246| A `fallback` sentence repeated on every write in a file | The reader cares which entity failed, and that does not change within a file | One `const FALLBACK` per file |247| `<PageEmpty>Loading …</PageEmpty>`, a spinner, or a `Skeleton` while a query is pending | OpenBot uses no loading placeholder; the flicker costs more than the reassurance buys | Return `null` from the pending branch |248| The pending branch deleted rather than returning `null` | The empty-state sentence shows for the length of the fetch, asserting something false | Keep the branch, first in the chain, returning `null` |249| `const queryClient = useQueryClient()` | A hook call and a local binding for an object that is one import away, and unavailable outside a component | `import { queryClient } from "@/query-client"` |250| `new QueryClient()` anywhere outside `app/src/query-client.ts` | A second cache; queries written by one client are invisible to the other | Import the singleton. A test needing isolation constructs its own and passes it explicitly |251| `useQuery({ queryKey: ["agents"], ... })` at a call site | An inline key drifts from the factory and silently stops matching invalidations | Call the factory: `useQuery(agentListQueryOptions())` |252| `queryClient.setQueryData(...)` after a mutation | Guesses at server-derived fields; wrong the moment the server adds a rule | `invalidateQueries({ queryKey: <entity>Keys.all })` |253| A component computing `user.role === "admin" && thing.ownerId === user.id` | Duplicates an authorization rule that the server already decided | Render the server's flag (`canManage`, `mine`) |254| A read type carrying a token, key, or `plaintext` | Secrets are write-only in OpenBot | Expose `hasAuth: boolean` or a `revokedAt` timestamp |255| A hand-written `body.error` extraction | `client` already does it, and did it more consistently than the four copies did | Pass `fallback` and let it raise |256| `queryFn` returning `{ agents: [...] }` | Leaks the transport envelope into every component | Unwrap in the `queryFn`; components see the array |257| A second `<entity>Keys` object, or keys defined in `mutations.ts` | Two sources of truth for one cache namespace | One factory per entity, in `queries.ts`; `mutations.ts` imports it |258259## Error Handling260261- **A 401 in a `queryFn`**: `client` sends the credentials, so a 401 means the session expired — the262 `_authed` guard handles the redirect on the next navigation. Do not add per-query redirect logic.263 The one place a 401 is expected is `currentUserQueryOptions`, which uses `tryClient` because not264 being signed in is an answer there rather than a failure.265- **The server returns no `error` field**: `client` falls back to the `fallback` option. Name the266 entity in it ("Could not load coworkers"). Do not print a status code to a person.267- **A refusal arrives as a thrown `Error` instead of a value**: the call site used `client` where it268 needed `tryClient`. The gateway declining is the product working, not a fault.269- **The server rejects a body it should accept, or reads it as a string**: something stringified270 before handing it over. `client` serialises; a caller passes the object.271- **An invalidation does not refresh the list**: the key at the call site does not match the key the272 mutation invalidated. Both must come from the same `<entity>Keys` factory. Check for an inline key273 array before anything else.274- **Two filters of the same list overwrite each other's cache**: the parameter is missing from the275 list key. Add it to the trailing object (`list: (hidden = false) => [..., { hidden }]`).276- **A mutation succeeds but the screen shows stale server-derived fields**: something patched the277 cache instead of invalidating it. Remove the patch.278- **TypeScript cannot infer the `queryFn` return**: the explicit `Promise<T>` annotation is missing279 from the `queryFn` signature. Add it rather than casting at the call site.280- **"No X are configured." flashes before the list appears**: the pending branch is missing, or it281 sits after the empty check. It goes first and returns `null`.282- **A query is slow enough that the blank feels broken**: the answer is not a placeholder, it is a283 slow endpoint. Fix the endpoint, or preload the data in the route (Procedure 4) so the screen is284 not entered until it is there.285- **A mutation factory is needed where no hook can run** — a bare event handler, a module with no286 component around it, a `bun test` file: import `queryClient` from `app/src/query-client.ts` and287 pass it in. This is the reason the singleton is the convention rather than the hook.288- **An invalidation appears to do nothing and the keys do match**: two clients exist. Search for289 `new QueryClient(` outside `app/src/query-client.ts`.290- **Unsure which entity directory a new endpoint belongs to**: name it after the noun the URL is291 about (`/api/agents/:id/plugins` is `plugins`, keyed by agent). If no existing directory fits,292 create one with the same three-file shape rather than adding the read to a neighbour.