diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md
index 856037431a0..9e72a100603 100644
--- a/apps/web/AGENTS.md
+++ b/apps/web/AGENTS.md
@@ -20,6 +20,7 @@ Read these before making changes:
- **Don't ship cross-route state through outlet context.** React Router outlet context [re-renders every consumer when any field changes](https://reactrouter.com/start/framework/outlet), forces a bundled value through every layout, and silently resolves to `undefined` whenever an intermediate `` (a gate, a wrapper) sits between writer and reader. Cross-route state — auth, lifecycle, selection, feature flags, layout slots — belongs in a Zustand store so consumers can subscribe atomically and so intermediate routes don't break the channel. Use outlet context only for one-shot parent→direct-child wiring with no intermediate routes.
- **HeyAPI client interceptors**: The daemon, platform, and auth clients have different routing requirements. Daemon SDK requests forward unconditionally to the self-hosted gateway; platform requests use a segment allowlist. Don't share interceptors across clients with different routing needs. Interceptors [chain sequentially](https://heyapi.dev/openapi-ts/clients/fetch#interceptors) — a gate registered after a rewrite interceptor receives the *rewritten* request, so it must check the final URL, not assume platform origin.
- **Type colocation**: Types live with the module that owns them. `src/types/` is only for cross-domain types with no clear owning module. Don't create `-types.ts` files to break circular dependencies — use [`import type`](https://www.typescriptlang.org/docs/handbook/modules/reference.html#type-only-imports-and-exports) instead (erased at compile time, no runtime cycle). See [`docs/CONVENTIONS.md` — Top-level shared directories](./docs/CONVENTIONS.md#top-level-shared-directories).
+- **Org-readiness gating for daemon queries**: Platform-mode requests need the `Vellum-Organization-Id` header, which the interceptor reads from the org store. The store hydrates asynchronously after auth, so TanStack Query hooks that mount eagerly must gate on `useIsOrgReady()` via the [`enabled` option](https://tanstack.com/query/latest/docs/framework/react/guides/dependent-queries). See [`docs/STATE_MANAGEMENT.md` — Org-readiness gating](./docs/STATE_MANAGEMENT.md#org-readiness-gating-for-daemon-queries).
When a topic in `docs/CONVENTIONS.md` grows past ~100 lines and has a
coherent boundary, extract it into a `docs/TOPIC.md` sibling with a
diff --git a/apps/web/docs/STATE_MANAGEMENT.md b/apps/web/docs/STATE_MANAGEMENT.md
index 2fb2a1e652c..7e5a003a196 100644
--- a/apps/web/docs/STATE_MANAGEMENT.md
+++ b/apps/web/docs/STATE_MANAGEMENT.md
@@ -282,6 +282,29 @@ References:
- [TkDodo — Working with Zustand](https://tkdodo.eu/blog/working-with-zustand) — React Query maintainer's guidance on the boundary between server state (RQ) and client/infrastructure state (Zustand)
- [Zustand — Reading/writing state outside components](https://zustand.docs.pmnd.rs/guides/reading-and-writing-state-outside-components)
+### Org-readiness gating for daemon queries
+
+Platform-mode daemon requests require the `Vellum-Organization-Id`
+header. The org store hydrates asynchronously after auth, so queries
+that mount before hydration completes (e.g. conversation queries on
+the eager `ChatPage` path) must gate on `useIsOrgReady()`:
+
+```ts
+import { useIsOrgReady } from "@/hooks/use-is-org-ready";
+
+const isOrgReady = useIsOrgReady();
+const query = useQuery({
+ ...queryOptions,
+ enabled: enabled && Boolean(assistantId) && isOrgReady,
+});
+```
+
+Queries mounted inside `` typically don't race
+because the lifecycle resolves after org hydration, but the gate is
+cheap and safe to add defensively.
+
+Reference: [TanStack Query — Dependent Queries](https://tanstack.com/query/latest/docs/framework/react/guides/dependent-queries)
+
### Canonical migration example
When migrating an imperative `setTimeout`-driven fetch loop to
diff --git a/apps/web/src/domains/chat/api/global-search.ts b/apps/web/src/domains/chat/api/global-search.ts
index de97c45e8c5..7075e347e74 100644
--- a/apps/web/src/domains/chat/api/global-search.ts
+++ b/apps/web/src/domains/chat/api/global-search.ts
@@ -1,12 +1,50 @@
import { searchGlobalGet } from "@/generated/daemon/sdk.gen";
-import type { SearchGlobalGetResponse } from "@/generated/daemon/types.gen";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
-/** Search results grouped by category, as returned by the daemon. */
-export type GlobalSearchResponse = SearchGlobalGetResponse["results"];
+/**
+ * Search results grouped by category, as returned by the daemon.
+ *
+ * The generated `SearchGlobalGetResponse["results"]` type resolves to
+ * `{ [key: string]: unknown }` because HeyAPI collapses the inline nested
+ * object despite the spec having full property definitions. This interface
+ * mirrors the Zod schemas in `assistant/src/runtime/routes/global-search-routes.ts`
+ * (lines 34–76) so consumers get proper type safety.
+ */
+export interface GlobalSearchResponse {
+ conversations: Array<{
+ id: string;
+ title: string | null;
+ updatedAt: number;
+ excerpt: string;
+ matchCount: number;
+ }>;
+ memories: Array<{
+ id: string;
+ kind: string;
+ text: string;
+ subject: string | null;
+ confidence: number;
+ updatedAt: number;
+ source: "lexical" | "semantic";
+ }>;
+ schedules: Array<{
+ id: string;
+ name: string;
+ expression: string | null;
+ message: string;
+ enabled: boolean;
+ nextRunAt: number | null;
+ }>;
+ contacts: Array<{
+ id: string;
+ displayName: string;
+ notes: string | null;
+ lastInteraction: number | null;
+ }>;
+}
// ---------------------------------------------------------------------------
// API
@@ -48,7 +86,7 @@ export async function searchGlobal(
return EMPTY_RESULTS;
}
- return data.results;
+ return data.results as unknown as GlobalSearchResponse;
} catch (err) {
// AbortError is expected when debounced queries supersede each other.
if (err instanceof DOMException && err.name === "AbortError") {
diff --git a/apps/web/src/hooks/conversation-queries.ts b/apps/web/src/hooks/conversation-queries.ts
index 4ae36069ff4..5bcc5ee17c4 100644
--- a/apps/web/src/hooks/conversation-queries.ts
+++ b/apps/web/src/hooks/conversation-queries.ts
@@ -56,6 +56,7 @@ import {
assertHasResponse,
extractErrorMessage,
} from "@/utils/api-errors";
+import { useIsOrgReady } from "@/hooks/use-is-org-ready";
import {
archivedConversationsQueryKey,
backgroundConversationsQueryKey,
@@ -312,10 +313,11 @@ export function useConversationListQuery(
error: Error | null;
refetch: () => void;
} {
+ const isOrgReady = useIsOrgReady();
const query = useQuery({
queryKey: conversationsQueryKey(assistantId),
queryFn: () => listConversations(assistantId!),
- enabled: enabled && Boolean(assistantId),
+ enabled: enabled && Boolean(assistantId) && isOrgReady,
staleTime: QUERY_STALE_TIME_MS,
});
return {
@@ -349,10 +351,11 @@ export function useBackgroundConversationListQuery(
isLoading: boolean;
isPending: boolean;
} {
+ const isOrgReady = useIsOrgReady();
const query = useQuery({
queryKey: backgroundConversationsQueryKey(assistantId),
queryFn: () => listBackgroundConversations(assistantId!),
- enabled: enabled && Boolean(assistantId),
+ enabled: enabled && Boolean(assistantId) && isOrgReady,
staleTime: QUERY_STALE_TIME_MS,
});
return {
@@ -382,10 +385,11 @@ export function useScheduledConversationListQuery(
isLoading: boolean;
isPending: boolean;
} {
+ const isOrgReady = useIsOrgReady();
const query = useQuery({
queryKey: scheduledConversationsQueryKey(assistantId),
queryFn: () => listScheduledConversations(assistantId!),
- enabled: enabled && Boolean(assistantId),
+ enabled: enabled && Boolean(assistantId) && isOrgReady,
staleTime: QUERY_STALE_TIME_MS,
});
return {
@@ -415,10 +419,11 @@ export function useArchivedConversationListQuery(
error: Error | null;
refetch: () => void;
} {
+ const isOrgReady = useIsOrgReady();
const query = useQuery({
queryKey: archivedConversationsQueryKey(assistantId),
queryFn: () => listArchivedConversations(assistantId!),
- enabled: enabled && Boolean(assistantId),
+ enabled: enabled && Boolean(assistantId) && isOrgReady,
staleTime: QUERY_STALE_TIME_MS,
});
return {
@@ -442,12 +447,13 @@ export function useConversationGroupsQuery(
assistantId: string | null,
enabled: boolean = true,
): { conversationGroups: ConversationGroup[]; isLoading: boolean } {
+ const isOrgReady = useIsOrgReady();
const query = useQuery({
...groupsGetOptions({
path: { assistant_id: assistantId ?? "" },
} as Options),
select: (data) => data.groups,
- enabled: enabled && Boolean(assistantId),
+ enabled: enabled && Boolean(assistantId) && isOrgReady,
staleTime: QUERY_STALE_TIME_MS,
});
return {
diff --git a/apps/web/src/hooks/use-is-org-ready.ts b/apps/web/src/hooks/use-is-org-ready.ts
new file mode 100644
index 00000000000..ab6cba7778f
--- /dev/null
+++ b/apps/web/src/hooks/use-is-org-ready.ts
@@ -0,0 +1,13 @@
+import { useAuthStore } from "@/stores/auth-store";
+import { useOrganizationStore } from "@/stores/organization-store";
+
+/**
+ * Gate for queries that need the `Vellum-Organization-Id` header.
+ * Returns `true` when the org store has hydrated, or when no
+ * platform session exists (self-hosted / gateway-only auth).
+ */
+export function useIsOrgReady(): boolean {
+ const currentOrgId = useOrganizationStore.use.currentOrganizationId();
+ const hasPlatformSession = useAuthStore.use.hasPlatformSession();
+ return !hasPlatformSession || currentOrgId != null;
+}