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

# Overview

> Build SaaS/team workspaces with invitations, per-member authority, and self-nesting

[Workspaces](/data-models/workspace) give your app the B2B/SaaS collaboration layer: an account/team that people are invited into, with granular per-member authority and optional self-nesting (org → client → project). Unlike [Spaces](/sdk/spaces/overview) (a community shape), Workspaces is **invite-only** with an owner apex and a capability/permission model.

<Note>
  **Requires the `workspaces` bundle.** See [Bundles](/bundles) to add it.
</Note>

The React/React Native surface is a set of plain hooks re-exported from `@sublay/core` (there is **no** `WorkspaceProvider` and **no** Redux slice — the list uses the [Wrapper pattern](/hooks/workspaces/use-fetch-many-workspaces-wrapper), matching house style).

## Key concepts

* **[capabilities vs permissions](/data-models/workspace-member#the-two-arrays-capabilities-vs-permissions)** — Sublay enforces the closed `capabilities` vocabulary; it is blind to your opaque `permissions`.
* **[Ownership](/data-models/workspace#ownership)** — one owner per workspace (the creator); ancestor owners are gods over descendants.
* **[Reach & the wall/door rule](/data-models/workspace#reach-and-the-wall-door-rule)** — strict per-node by default; opt-in downward reach via the child-owned `inheritsFromParent` flag.
* **[Invitations](/data-models/workspace-invitation#acceptance-model)** — identity-matched + verified-email, **no token**.

## Project settings

Three project-level settings configure this bundle. They live under `workspaces` in your project's `settings` blob — set them in the dashboard under **Settings → project settings**, or via the update-project-settings endpoint. All three are **absent by default**, and absent means the strict behavior in the table below.

```json Project settings theme={null}
{
  "workspaces": {
    "inviteAcceptUrl": "https://app.example.com/invites",
    "inheritsDefault": false,
    "inheritEnforced": false
  }
}
```

| Setting                      | Type      | Default  | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| ---------------------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaces.inviteAcceptUrl` | `string`  | *(none)* | The page in **your** app that surfaces workspace invitations. Invitation emails deep-link to it, with a fixed `?workspaceInviteId=<id>` appended (any query string you already have on the URL is preserved). **Required before any invitation can be created** — see the warning below.                                                                                                                                                                |
| `workspaces.inheritsDefault` | `boolean` | `false`  | The [`inheritsFromParent`](/data-models/workspace#reach-and-the-wall-door-rule) flag new workspaces are **born with**. `false` (the default) means every workspace starts **sealed** — no downward reach. Set it to `true` if your product wants an org's authority to flow into sub-workspaces by default. This is read at create time only; it is never accepted from client input, and changing it does not retroactively alter existing workspaces. |
| `workspaces.inheritEnforced` | `boolean` | `false`  | Locks `inheritsFromParent` to the project default so workspace **owners cannot flip it**. While `true`, [`useUpdateWorkspaceInheritFlag`](/hooks/workspaces/use-update-workspace-inherit-flag) fails with `403 workspace/inherit-enforced`. Use it when the wall/door policy is a product decision rather than a per-team one.                                                                                                                          |

<Warning>
  **Set `workspaces.inviteAcceptUrl` before you ship invitations.** It has no default, so a freshly added bundle has none — and an invitation is delivered as an email whose only call to action is that deep-link.

  Because of that, [Create Invite](/api-reference/workspaces/invitations/create-invite) and [Resend Invite](/api-reference/workspaces/invitations/resend-invite) **refuse outright** when it is unset, with `409 workspace/missing-invite-accept-url`. This applies **even when the invitee already has an account** — the in-app inbox is not a substitute for the emailed link, so the invite surface behaves the same for every invitee. Nothing is persisted and no email or webhook is emitted on the refusal; configure the setting and retry the identical request.
</Warning>

<Note>
  The accept link carries **nothing secret**. Acceptance is identity-matched — the signed-in caller must *be* the invitation's target and have a verified email — so there is no token to leak and no domain allowlist to maintain. The email is a plain nudge into your app. See the [acceptance model](/data-models/workspace-invitation#acceptance-model).
</Note>

## What a client app can do

**All 21 workspace endpoints are callable from a browser or mobile app with a normal signed-in user's bearer token** — including every management operation: editing a workspace, deleting it, transferring ownership, flipping the inherit flag, editing a member's capabilities/rank, removing a member, offboarding across a subtree, and the full invite lifecycle (create, list, revoke, resend). Every one of these is exposed as a `@sublay/core` hook and as a `@sublay/js` function. **A members-management panel needs no server-side code and no hand-rolled `fetch`.**

With a client token, every endpoint in the bundle resolves the actor from that token and authorizes the action against **that user's own standing** on the workspace. Most routes enforce this in guard middleware ahead of the controller; the self-scoped ones (create a workspace, list your own, leave, accept or decline an invite, read your own invites or your own authority) carry no guard middleware and authorize inside the controller instead. The one endpoint that does not require a token at all is the single-workspace read, [`GET /workspaces/:id`](/api-reference/workspaces/fetch-workspace) — it takes an *optional* token and returns the workspace when visibility permits. Everything else requires a signed-in user, and whether the call then succeeds depends on their standing:

| Rule                 | Meaning                                                                                                                                                                                                                                                                                                           |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Owner-only**       | The caller must be the workspace's own owner, or an owner of an ancestor workspace. Not a capability — no capability grants it.                                                                                                                                                                                   |
| **Capability-gated** | The caller's [resolved capability set](/hooks/workspaces/use-fetch-workspace-authority) (direct + reach + ownership) must contain the named capability, e.g. `invite`, `remove-member`, `edit-workspace`.                                                                                                         |
| **Rank-bounded**     | On top of a capability, the caller may only act on members ranked strictly below them, and may only grant capabilities/permissions they themselves hold ([no-escalation](/data-models/workspace-member#no-privilege-escalation)).                                                                                 |
| **Visibility**       | The caller must be able to see the workspace / its roster at all. This gates three reads — the single workspace, the roster, and one member's standing. **Any relation** to the workspace (owner, ancestor-owner, member, reach-holder) satisfies it. It does *not* gate the authority read (see the note below). |
| **Self-service**     | Anyone signed in can do it for themselves — accept/decline an invite addressed to them, leave a workspace, read their own authority.                                                                                                                                                                              |

<Note>
  **The authority read is the one read visibility does not gate.** [`GET /workspaces/:id/authority/me`](/api-reference/workspaces/fetch-authority) is mounted with authentication only — any signed-in user may call it for any workspace id. It returns `404` only when the workspace does not exist; a signed-in stranger gets `200` with `reasons: []` and empty `capabilities` / `permissions`, which is the correct "you have no standing here" answer for a UI gate. The consequence is that the route confirms a workspace *exists* to any signed-in user.

  Its sibling read, one member's standing ([`GET /workspaces/:id/members/:userId`](/api-reference/workspaces/members/fetch-member-standing)), *is* visibility-gated and deliberately `404`s instead, so it never leaks existence. Do not assume the two behave alike.
</Note>

<Warning>
  **Seeing the roster and seeing people's *authority* are two different tiers.** Any relation may read the roster and one member's standing — who is there, their `reasons`, `title` and `metadata`. But the authority-bearing fields — **`capabilities`, `permissions`, `rank`** — are **omitted** (absent, not `null`) unless the caller holds one of the four people-operating capabilities (`invite`, `remove-member`, `edit-member-access`, `edit-member-profile`) or is the `owner` / an `ancestor-owner`.

  A caller **always** sees their own — their own roster row, their own standing, and the [authority read](/api-reference/workspaces/fetch-authority), which is inherently a self read and never fenced. So a plain member can always discover their own access; they just cannot enumerate everyone else's. Service/master keys are never fenced. Type the three fields as optional on client surfaces.
</Warning>

<Note>
  **What actually needs a service key: acting on behalf of a *different* user.** Several endpoints accept an actor `userId` so a service/master key can perform an action *as* some named user. That parameter is **[node SDK](/node-sdk/workspaces) only** — `@sublay/core` and `@sublay/js` never expose it and strip it if it is smuggled in, because a client's actor is always the token's own user. This is the *only* difference in reach between the client SDKs and the node SDK for this bundle; it is **not** a restriction on which operations a client may perform.

  Note that a **target**-identifying `userId` is a different thing and is fully available on the client: the invitee `userId` on [`useCreateWorkspaceInvite`](/hooks/workspaces/use-create-workspace-invite), and the `targetUserId` path param on the member hooks.
</Note>

## Quick start

```tsx theme={null}
import {
  useCreateWorkspace,
  useCreateWorkspaceInvite,
  useFetchMyWorkspaceInvites,
  useAcceptWorkspaceInvite,
} from "@sublay/react-js";

function CreateTeam() {
  const createWorkspace = useCreateWorkspace();

  async function onCreate() {
    const workspace = await createWorkspace({ name: "Acme Inc" });
    console.log("owner:", workspace.ownerId);
  }

  return <button onClick={onCreate}>Create workspace</button>;
}
```

## Hooks

### Workspace lifecycle & ownership

| Hook                                                                                   | Purpose                                   | Authorization                                                        |
| -------------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------- |
| [`useCreateWorkspace`](/hooks/workspaces/use-create-workspace)                         | Create a root or child workspace          | Verified email (root) / `create-sub-workspace` on the parent (child) |
| [`useFetchWorkspace`](/hooks/workspaces/use-fetch-workspace)                           | Read a single workspace                   | Visibility                                                           |
| [`useFetchManyWorkspaces`](/hooks/workspaces/use-fetch-many-workspaces)                | Leaf fetcher for the caller's workspaces  | Self-service                                                         |
| [`useFetchManyWorkspacesWrapper`](/hooks/workspaces/use-fetch-many-workspaces-wrapper) | Paginated list with load-more             | Self-service                                                         |
| [`useUpdateWorkspace`](/hooks/workspaces/use-update-workspace)                         | Edit name / metadata                      | `edit-workspace` (or owner)                                          |
| [`useUpdateWorkspaceInheritFlag`](/hooks/workspaces/use-update-workspace-inherit-flag) | Flip `inheritsFromParent`                 | Owner-only                                                           |
| [`useDeleteWorkspace`](/hooks/workspaces/use-delete-workspace)                         | Delete a workspace (cascades the subtree) | Owner-only                                                           |
| [`useTransferWorkspaceOwnership`](/hooks/workspaces/use-transfer-workspace-ownership)  | Hand the workspace to a new owner         | Owner-only                                                           |

### Membership

| Hook                                                                                                | Purpose                            | Authorization                                               |
| --------------------------------------------------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------------- |
| [`useFetchWorkspaceMembers`](/hooks/workspaces/use-fetch-workspace-members)                         | The unified roster read            | Visibility                                                  |
| [`useFetchWorkspaceMemberStanding`](/hooks/workspaces/use-fetch-workspace-member-standing)          | One user's resolved standing       | Visibility                                                  |
| [`useUpdateWorkspaceMember`](/hooks/workspaces/use-update-workspace-member)                         | Edit a member's access / profile   | `edit-member-access` or `edit-member-profile`, rank-bounded |
| [`useRemoveWorkspaceMember`](/hooks/workspaces/use-remove-workspace-member)                         | Remove a member from this node     | `remove-member`, rank-bounded                               |
| [`useRemoveWorkspaceMemberFromSubtree`](/hooks/workspaces/use-remove-workspace-member-from-subtree) | Offboard a user across the subtree | `remove-member`, rank-bounded per node                      |
| [`useLeaveWorkspace`](/hooks/workspaces/use-leave-workspace)                                        | Leave a workspace yourself         | Self-service                                                |

### Invitations

| Hook                                                                             | Purpose                                      | Authorization                             |
| -------------------------------------------------------------------------------- | -------------------------------------------- | ----------------------------------------- |
| [`useCreateWorkspaceInvite`](/hooks/workspaces/use-create-workspace-invite)      | Create an invitation                         | `invite` (or owner), rank + no-escalation |
| [`useFetchWorkspaceInvites`](/hooks/workspaces/use-fetch-workspace-invites)      | **Outbox** — invites *this workspace issued* | `invite` (or owner)                       |
| [`useRevokeWorkspaceInvite`](/hooks/workspaces/use-revoke-workspace-invite)      | Revoke a pending invitation                  | `invite` (or owner)                       |
| [`useResendWorkspaceInvite`](/hooks/workspaces/use-resend-workspace-invite)      | Refresh + resend a pending invitation        | `invite` (or owner)                       |
| [`useFetchMyWorkspaceInvites`](/hooks/workspaces/use-fetch-my-workspace-invites) | **Inbox** — invites *addressed to me*        | Self-service                              |
| [`useAcceptWorkspaceInvite`](/hooks/workspaces/use-accept-workspace-invite)      | Accept an invitation                         | Self-service + verified email             |
| [`useDeclineWorkspaceInvite`](/hooks/workspaces/use-decline-workspace-invite)    | Decline an invitation                        | Self-service + verified email             |

<Warning>
  [`useFetchWorkspaceInvites`](/hooks/workspaces/use-fetch-workspace-invites) and [`useFetchMyWorkspaceInvites`](/hooks/workspaces/use-fetch-my-workspace-invites) are the two most confusable hooks in this bundle. The first is the **outbox** of one workspace (`GET /workspaces/:id/invites`) — who *this team* has invited and not yet heard back from; it powers the "Pending invitations" section of a members panel. The second is the signed-in user's **inbox** (`GET /me/workspace-invites`) across every workspace — it powers a "You've been invited" list.
</Warning>

### Authority

| Hook                                                                            | Purpose                             | Authorization |
| ------------------------------------------------------------------------------- | ----------------------------------- | ------------- |
| [`useFetchWorkspaceAuthority`](/hooks/workspaces/use-fetch-workspace-authority) | Resolve the current user's standing | Self-service  |

<Tip>
  Drive your UI off [`useFetchWorkspaceAuthority`](/hooks/workspaces/use-fetch-workspace-authority) rather than guessing: it returns `{ reasons, capabilities, permissions, rank }`, so showing the "Remove member" button is `authority.capabilities.includes("remove-member")`. `reasons` are structured `{ type, viaWorkspaceId? }` entries — the same shape a roster entry carries — so you can also show *which* ancestor grants an inherited standing. And because **`view` is implied by every other capability**, `capabilities.includes("view")` doubles as "does this user have any standing here at all".
</Tip>
