> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Tenant console wiring

> Endpoint-by-endpoint map of the tenant console — login and the (currently unrendered) workspace fetch, the insights snapshot, remediation CRUD and export, sources, members, and accept-invite — plus which view renders which collection and how loading/locked/error states are chosen.

The tenant console (`src/app/(console)/`) is the client-facing product: Home, Loops, Issues, Remediation, Sources, and Settings, all wrapped by `ConsoleLayout` (`src/app/(console)/layout.tsx`) behind the `cl_tenant_session` cookie. Every data call in this surface goes through the same session-gated proxy documented in [Proxy and sessions](/api-reference/integration/proxy-and-sessions); this page maps each call to the component that makes it and what the UI does with the result.

## Sign-in surfaces

| Page                                       | Call                                                                    | Backend operation                                                                                                                                                                                                           | UI effect                                                                                                                                                                                                                                                                                                                      |
| ------------------------------------------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `src/app/login/page.tsx` (client tab)      | `tenantLogin(workspace, email, password)` (`src/lib/api/tenantAuth.ts`) | `POST /login`                                                                                                                                                                                                               | On success: `router.push("/")` + `router.refresh()`. Maps `429` → rate-limit message, `401`/`400` → "Invalid workspace, email, or password"                                                                                                                                                                                    |
| `src/app/login/page.tsx` (workspace fetch) | `fetch("/api/workspaces")`                                              | `GET /workspaces` (via the standalone, unauthenticated Next.js route — not the `/api/backend` proxy; see [Proxy and sessions](/api-reference/integration/proxy-and-sessions#apiworkspaces-the-one-route-that-isnt-proxied)) | Stores the result in a `workspaces` state variable that is **not currently rendered anywhere** — the Organization field is a plain text `<input>` defaulting to `"yc-demo"`, not a dropdown; the login form works even if this fetch fails                                                                                     |
| `src/app/accept-invite/page.tsx`           | `acceptInvitation(workspace, token, password, fullName)`                | `POST /accept-invite`                                                                                                                                                                                                       | On success: `router.push("/")` + `router.refresh()` — the backend both creates the account and mints a `cl_tenant_session` in the same call, so accepting an invite logs the user straight into the console. On `401`: a special-cased message ("account was created, but this workspace isn't live yet") — see the note below |

<Note>
  A `401` from `POST /accept-invite` does **not** mean the invitation itself failed. The backend accepts the invitation (creates the account, marks it accepted) and only then attempts to auto-login the new user — and that auto-login step requires the tenant to already be live. The frontend's error handling reflects this exactly: "Your account was created, but this workspace isn't live yet" rather than a generic failure message.
</Note>

`ConsoleLayout` itself (a server component) is the auth gate for every route under `(console)`: it reads `cl_tenant_session`, fetches `${BACKEND_URL}/me` directly with that cookie attached, and redirects to `/login` if either the cookie is missing or the fetch fails. On success it builds the console's `Session` object (`src/lib/session.ts`) from the returned `TenantIdentity`:

```typescript theme={null}
const session: Session = {
  name, initials,
  role: mapPermissionsToWorkspaceRole(identity.permissions),
  clientId: identity.workspace,
  clientName: identity.workspace,
};
```

`mapPermissionsToWorkspaceRole()` (`src/lib/api/tenantAuth.ts`) turns the backend's real permission strings into one of four display roles, checked in this exact priority order: `users.manage` → **Risk Admin**, `sources.manage` → **Loop Owner**, `reviews.write` → **Analyst**, else → **Auditor**. Two gates in `src/lib/session.ts` derive from that role by rank (`Auditor` \< `Analyst` \< `Loop Owner` \< `Risk Admin`):

* `canReview(session)` — rank ≥ `Analyst`. Gates every remediation mutation (assign owner, change status, mark a step done, adopt a suggested plan, submit a loop decision).
* `canOperate(session)` — rank ≥ `Loop Owner`. Gates source creation/upload in `SourcesClient`.

<Warning>
  The backend is the actual authority for every one of these actions — `require_permission(...)` dependencies on each mutating route (see `services/api/remediation_api.py`, `services/api/sources_api.py`) reject an unauthorized call independently of anything the console decides to render. `canReview`/`canOperate` only control whether a button is enabled or disabled; they are not the security boundary.
</Warning>

## The insights snapshot

Every report-backed view (Home, Loops, Issues, Remediation) is built on one shared hook: `useConsoleData()` (`src/lib/data/useConsoleData.ts`), which wraps a single query:

| Call                                                      | Type  | Key                                     | Backend operation        |
| --------------------------------------------------------- | ----- | --------------------------------------- | ------------------------ |
| `fetchClientInsights()` (`src/lib/api/clientInsights.ts`) | query | `["tenant-insights", session.clientId]` | `GET /insights/snapshot` |

The response carries **all nine** insight collections in one coherent read — five deterministic, model-derived collections (`MODEL_COLLECTIONS`: `summary`, `themes`, `issues`, `severity`, `lens`) and four of the five LLM-derived collections the frontend's `LLM_COLLECTIONS` constant lists (`fishbone`, `theme_summaries`, `narratives`, `recommendations`) — plus `dataset_version`, `model_version`, `model_ready`, `all_ready`, and `share_onboarding_outputs`. The backend chooses a single `(dataset_version, model_version)` pair before returning any collection, specifically so one render can never mix results from two different pipeline runs.

`LLM_COLLECTIONS` itself has a fifth entry, `cause_remediation`, but `services/api/insights_api.py` treats it as `_EXPORT_ONLY_COLLECTIONS`: the snapshot endpoint serves `_CLIENT_COLLECTIONS = _ALL_COLLECTIONS - _EXPORT_ONLY_COLLECTIONS`, so `cause_remediation` never appears in `GET /insights/snapshot` — it's only populated in the XLSX export (see [Export](#export)). The snapshot's nine collections are the five model collections plus the other four LLM collections.

```typescript theme={null}
export const MODEL_COLLECTIONS = ["summary", "themes", "issues", "severity", "lens"] as const;
export const LLM_COLLECTIONS = [
  "fishbone", "theme_summaries", "narratives", "recommendations", "cause_remediation",
] as const;
```

`useConsoleData()` runs the (expensive, 9-collection) adaptation inside TanStack Query's `select` option rather than in each consuming component's render body, specifically so the \~10 call sites across the console share one memoized result instead of each re-running the adapter on every render. It also polls every 4 seconds — but only while at least one collection is still `"generating"`:

```typescript theme={null}
refetchInterval: (query) =>
  query.state.data &&
  Object.values(query.state.data.collections).some((state) => state.status === "generating")
    ? 4000
    : false,
```

### The four states a view can be in

`useConsoleData()` exposes a four-way discriminated result (`ConsoleDataResult` in `src/lib/data/useConsoleData.ts`), and every report-backed view branches on it in the same order:

```mermaid theme={null}
flowchart TD
    Q["useQuery(['tenant-insights', clientId])"] --> Pending{TanStack status}
    Pending -->|pending| Loading[["status: loading"]]
    Pending -->|error| Err[["status: error<br/>+ refetch()"]]
    Pending -->|success| Ready{modelReady?}
    Ready -->|no, and no collection visible<br/>+ share_onboarding_outputs off| Locked[["status: locked"]]
    Ready -->|no, otherwise| Loading
    Ready -->|yes| Done[["status: ready<br/>+ data, pending[], narrative?"]]
```

| Status    | Rendered by                      | Component                                                                                   |
| --------- | -------------------------------- | ------------------------------------------------------------------------------------------- |
| `loading` | Home, Loops, Issues, Remediation | `<ViewSkeleton>` (`src/components/shell/Skeletons.tsx`) — a branded skeleton, not a spinner |
| `locked`  | Home, Loops, Issues, Remediation | `<LockView>` (`src/components/shell/LockView.tsx`) — see below                              |
| `error`   | Home, Loops, Issues, Remediation | `<ErrorState onRetry={consoleData.refetch}>` (`src/components/shell/ErrorState.tsx`)        |
| `ready`   | Home, Loops, Issues, Remediation | The view's real content, built from `consoleData.data`                                      |

`locked` is a real-tenant-only state: a tenant onboarded with "share onboarding outputs" turned **off** (see [Admin portal wiring](/api-reference/integration/admin-portal-wiring)'s Activation step), with nothing ingested yet, gets `<LockView>` — a soft "dashboards populate automatically" message pointing at `/sources` — instead of a self-service empty state, because this tenant was deliberately onboarded so the client never triggers the pipeline themselves. `error` is kept distinct from a plain empty report specifically so a genuine fetch/parse failure (after TanStack's retries are exhausted) never gets silently presented as "no report yet."

### `ready`, in more detail

A `ready` result can still have work in flight: the five `MODEL_COLLECTIONS` being ready is enough to unblock the view, but the four `LLM_COLLECTIONS` (fishbone, theme summaries, narratives, recommendations) can still be `"generating"`. `useConsoleData()` surfaces that as a `pending: CollectionName[]` array alongside `data`, and `InsightsBanner` (`src/components/shell/InsightsBanner.tsx`, mounted once in `ConsoleLayout` under the pagebar) renders a live status line for it:

```typescript theme={null}
if (pending.length > 0) {
  return (
    <div className="insights-banner peri">
      <LiveDot />
      <span>Generating narratives ({pending.length} of {TOTAL_COLLECTIONS} collections pending)…</span>
    </div>
  );
}
```

`consoleData.narrative` (the LLM-written portfolio narrative, once generated) is consumed only by `HomeView`, which prefers it over the deterministic, template-built hero line whenever it's present.

## Which view reads which collection

| View        | Component                                                    | Primary collections consumed                                                                                                                                                      |
| ----------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Home        | `src/components/views/HomeView.tsx`                          | All five model collections (via the adapted `ConsoleData` shape: `t1`/loops, `issues`, `bw`/bone weights, `alloc`, `srcs`) plus the LLM `narratives` collection for the hero line |
| Loops       | `src/components/views/LoopsView.tsx`                         | `t1` (loop/monitoring records), `bw`, `causes`, `paths` — same adapted `ConsoleData`, filtered/grouped per lens                                                                   |
| Issues      | `src/components/views/IssuesView.tsx`                        | `issues`, `alloc` — per-issue severity, driver bone, and loop membership                                                                                                          |
| Remediation | `src/components/views/RemediationView.tsx`                   | `causes` (suggested fix plans) merged with live tenant CAPs (`useTenantCaps()`, below)                                                                                            |
| Settings    | `src/components/views/SettingsView.tsx`                      | None of the insight snapshot — reads `listTenantMembers()` directly (Members tab) and static session fields (General tab)                                                         |
| Sources     | `src/components/views/SourcesView.tsx` → `SourcesClient.tsx` | None of the insight snapshot — reads `listTenantSources()` directly                                                                                                               |

Every collection name (`CollectionName`) is a union of `MODEL_COLLECTIONS` and `LLM_COLLECTIONS`; `ConsoleData` itself is the *adapted* shape (`src/lib/data/types.ts`, built by `adaptClientInsights()`) that all four report-backed views actually consume, not the raw wire response.

## Remediation: CAPs and reviews

The remediation domain is entirely tenant-scoped — `src/lib/api/hooks-tenant-remediation.ts` is explicit that this is "the sole production remediation boundary." Every hook wraps `useQuery`/`useMutation` directly.

| Hook                                                                      | Type     | Key                                                                                   | Backend operation                                  | Called from                                                                                                                                                     |
| ------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useTenantCaps(targetType?, targetId?, enabled?)`                         | query    | `["tenant-caps", targetType, targetId]`                                               | `GET /remediation/caps[?target_type=&target_id=]`  | `RemediationView` (no filter — full list), `PlanModal` (no filter, deduplicated against the same key), `Pagebar` (full list, to compute the action-queue count) |
| `useCreateTenantCap(input)`                                               | mutation | invalidates `["tenant-caps"]`                                                         | `POST /remediation/caps`                           | `RemediationView`'s "Adopt as plan" button, `PlanModal`'s adopt action                                                                                          |
| `useUpdateTenantCap({capId, expected_version, ...patch})`                 | mutation | invalidates `["tenant-caps"]`; on a `409` conflict, invalidates instead of toasting   | `PATCH /remediation/caps/{cap_id}`                 | `PlanModal` — "Assign owner", "Update status", "Update path"                                                                                                    |
| `useCompleteTenantCapStep({capId, step, expected_version, note?, done?})` | mutation | invalidates `["tenant-caps"]`; same 409-conflict handling                             | `POST /remediation/caps/{cap_id}/steps/{step}`     | `PlanModal` — "Mark done" / "Reopen" on each of Contain/Correct/Prevent                                                                                         |
| `useTenantReviews(targetType?, targetId?, enabled?)`                      | query    | `["tenant-reviews", targetType, targetId]`, only enabled once both params are present | `GET /remediation/reviews?target_type=&target_id=` | `LoopDrawer` — `useTenantReviews("loop", loopId)`, the loop-level governance panel                                                                              |
| `useCreateTenantReview({target_type, target_id, decision, notes?})`       | mutation | invalidates `["tenant-reviews", target_type, target_id]`                              | `POST /remediation/reviews`                        | `LoopDrawer`'s Approve / Reject / Escalate buttons                                                                                                              |

<Warning>
  **`POST /remediation/caps/{cap_id}/steps/{step}` and `POST /remediation/reviews` are defined in `services/api/remediation_api.py` but are not currently included in `services/api/product_app.py`'s `REMEDIATION_OPERATIONS` allowlist.** `product_app.create_product_app()` silently drops any router route whose method+path isn't in its allowlist (it only raises if a promised operation is *missing*, not if the router has *extra* routes) — verified by constructing the app and enumerating its routes: neither operation is registered on the deployed product API. Concretely, this means `PlanModal`'s "Mark done"/"Reopen" step buttons and `LoopDrawer`'s Approve/Reject/Escalate buttons call operations that currently 404 against the deployed product surface, even though the frontend, the router, and its own test suite (`tests/test_remediation_api.py`) all implement and exercise them. If you're debugging a step-completion or review-submission failure in a deployed environment, check the allowlist in `product_app.py` first — the frontend code is not the likely cause. `services/api/product_app.py`'s `REMEDIATION_OPERATIONS` frozenset is the single place both operations need to be added to close this gap.
</Warning>

`RemediationView` merges live tenant CAPs with the report's suggested fix plans (`data.causes`) itself — a loop with no adopted CAP shows every suggested cause-group with a "Suggested" chip and an "Adopt as plan" action; adopting one replaces only that suggestion (matched by problem statement), leaving every other suggested fix for the same loop listed alongside it. `Pagebar`'s "Action queue" count is every cap whose `status` is `proposed`, `blocked`, or `completed` — the three states that represent a plan currently awaiting a human decision.

## Export

| Call                                                    | Backend operation              | UI effect                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| ------------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `downloadExport()` (`src/lib/api/remediationExport.ts`) | `GET /remediation/export.xlsx` | `Pagebar`'s "Export" button. Fetches the XLSX as a blob, reads the filename from `Content-Disposition`, and triggers a client-side download via a synthetic `<a download>` click — no server round trip to the frontend's own origin beyond the proxy. Disabled whenever `useConsoleData()` isn't `ready`. On failure, pushes a toast (`pushErrorToast`) with either the backend's `detail` or a generic "Export failed" message rather than throwing |

The export is the authenticated tenant's own coherent snapshot — same "one dataset/model version pair" guarantee as `/insights/snapshot` — rendered server-side as an XLSX workbook (issues, loops, causes, and fix plans as of the last completed pipeline run).

## Sources

`SourcesView` (`src/components/views/SourcesView.tsx`) is a thin permission wrapper around `SourcesClient` (`src/components/views/SourcesClient.tsx`), passing `canOperate(session)` down as `canManageSources`.

| Call                                                     | Type                                                          | Key                  | Backend operation                                                                                                                                                                                                               | UI effect                                                                                                                                                                                                                                                                                               |
| -------------------------------------------------------- | ------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `listTenantSources()`                                    | query                                                         | `["tenant-sources"]` | `GET /sources`                                                                                                                                                                                                                  | Renders the KPI strip (connected/healthy/synced counts) and one card per source with health status and last-sync time                                                                                                                                                                                   |
| `createTenantSource({kind, name, config, credentials?})` | plain async call inside `handleUpload()`, not a `useMutation` | —                    | `POST /sources`                                                                                                                                                                                                                 | Only called when no `file_upload`-kind source exists yet — the upload flow lazily creates one on first use rather than requiring the tenant to pre-register a source                                                                                                                                    |
| `uploadTenantSource(sourceId, file)`                     | plain async call                                              | —                    | `POST /sources/{source_id}/upload` (multipart, same FormData-without-Content-Type pattern as the admin portal's ingest upload — see [Admin portal wiring](/api-reference/integration/admin-portal-wiring#upload-flow-formdata)) | On success: pushes an info toast with the new-record count and dataset version, then invalidates **both** `["tenant-sources"]` and `["tenant-insights"]` — the second invalidation is what makes a fresh upload eventually flip the console out of `locked`/stale data once the pipeline reprocesses it |

Both calls run inside one `handleUpload()` function with local `uploading`/`uploadError` state rather than `useMutation`, since the sequence (conditionally create a source, then upload to it) doesn't map cleanly onto a single mutation key.

## Members

`SettingsView`'s Members tab (`src/components/views/SettingsView.tsx`) is read-only in the MVP:

| Call                  | Type  | Key                                    | Backend operation | UI effect                                                                                                                                                                                                                                                                                                                                                                   |
| --------------------- | ----- | -------------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `listTenantMembers()` | query | `["tenant-members", session.clientId]` | `GET /members`    | Renders each member's name/email, joined role profiles, and active/inactive chip. A trailing hint states plainly: "Invitations and role changes remain staff-managed in the MVP" — there is no member-invite or role-edit call in this console; that entire lifecycle lives in the staff portal (see [Admin portal wiring](/api-reference/integration/admin-portal-wiring)) |

## Related

* [Proxy and sessions](/api-reference/integration/proxy-and-sessions)
* [Admin portal wiring](/api-reference/integration/admin-portal-wiring)
* [Insights console](/features/insights-console)
* [Remediation](/features/remediation)
