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

# Workspaces

> Framework-agnostic client surface for workspaces — the full 21-endpoint bundle

The `workspaces` module in `@sublay/js` is the **client-side** surface of the [Workspaces bundle](/data-models/workspace). It covers **all 21 endpoints** — workspace CRUD, membership management, the full invitation lifecycle, and the authority read — so a members-management UI needs no server-side code.

The actor is always derived from the **bearer token**. **No actor `userId` is ever sent from the client**, and the wrappers strip one if a caller smuggles it in. Acting on behalf of a *different* user is the [node SDK](/node-sdk/workspaces) service-key capability, and it is the only difference in reach between the two SDKs for this bundle — **not** a restriction on which operations a client may perform.

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

Every call is authorized against the **signed-in user's own standing** on the workspace: owner-only, capability-gated, rank-bounded, visibility-gated, or self-service. Each function below states which. (`fetchWorkspace` is the one read that also works without a token, returning the workspace when visibility permits.) Read the concepts first: [capabilities vs permissions](/data-models/workspace-member#the-two-arrays-capabilities-vs-permissions), [rank](/data-models/workspace-member#rank-semantics), [reach & ownership](/data-models/workspace#reach-and-the-wall-door-rule), and the [identity-matched, no-token invite model](/data-models/workspace-invitation#acceptance-model).

<Tip>
  A `userId` that names a **target** is fully available on the client — the invitee `userId` on `createWorkspaceInvite`, and the `targetUserId` path param on the member functions. Only the *actor* `userId` is node-SDK-only.
</Tip>

***

## Workspace lifecycle

### createWorkspace

The bearer-token user becomes the owner. Root creation requires a **verified email**; child creation requires `create-sub-workspace` on the parent.

```typescript theme={null}
const workspace = await sublay.workspaces.createWorkspace({
  name: "Acme Inc",
  parentWorkspaceId: null,   // omit/null → root; set → child
});
```

<ParamField body="name" type="string" required>Display name (1–100 characters).</ParamField>
<ParamField body="metadata" type="object">Opaque developer data.</ParamField>
<ParamField body="parentWorkspaceId" type="string">Parent for child creation.</ParamField>

### fetchWorkspace

```typescript theme={null}
const workspace = await sublay.workspaces.fetchWorkspace({
  workspaceId: "ws_abc",
  include: "memberCount",
});
```

<ParamField body="workspaceId" type="string" required />

<ParamField body="include" type="string">`memberCount` adds the direct member count.</ParamField>

### fetchManyWorkspaces

Lists the bearer-token user's direct-membership + owned workspaces (paginated). Self-service.

```typescript theme={null}
const { data, pagination } = await sublay.workspaces.fetchManyWorkspaces({ page: 1, limit: 20 });
```

<ParamField body="page" type="number" />

<ParamField body="limit" type="number" />

<ParamField body="include" type="string" />

### updateWorkspace

Edits `name`/`metadata`. Requires the **`edit-workspace`** capability (or ownership). Does **not** flip the inherit flag.

```typescript theme={null}
const workspace = await sublay.workspaces.updateWorkspace({
  workspaceId: "ws_abc",
  name: "Acme Corp",
});
```

<ParamField body="workspaceId" type="string" required />

<ParamField body="name" type="string" />

<ParamField body="metadata" type="object" />

### updateWorkspaceInheritFlag

Flips whether authority held on an ancestor reaches into this node. **Owner-only** in both directions (own owner or an ancestor owner) — not a capability. Blocked with `403 workspace/inherit-enforced` when the project enforces its default.

```typescript theme={null}
const workspace = await sublay.workspaces.updateWorkspaceInheritFlag({
  workspaceId: "ws_child",
  inheritsFromParent: true,
});
```

<ParamField body="workspaceId" type="string" required />

<ParamField body="inheritsFromParent" type="boolean" required>`true` = open/door, `false` = sealed/wall.</ParamField>

### deleteWorkspace

**Owner-only** (own owner or an ancestor owner). Cascades the whole subtree transactionally and fires `workspace.deleted` per deleted workspace — and **no** `workspace.member.removed` events for the memberships it tears down (see [Webhooks](/sdk/workspaces/webhooks)).

```typescript theme={null}
const { message } = await sublay.workspaces.deleteWorkspace({ workspaceId: "ws_abc" });
```

<ParamField body="workspaceId" type="string" required />

### transferWorkspaceOwnership

**Owner-only** (own owner or an ancestor owner). `newOwnerId` addresses the **target** — any verified user in the tenant, who need not already be a member.

```typescript theme={null}
const workspace = await sublay.workspaces.transferWorkspaceOwnership({
  workspaceId: "ws_abc",
  newOwnerId: "usr_new",
  previousOwnerDisposition: "demote",
  previousOwnerRank: 0,
});
```

<ParamField body="workspaceId" type="string" required />

<ParamField body="newOwnerId" type="string" required>The new owner (any verified user).</ParamField>
<ParamField body="previousOwnerDisposition" type="string">`"demote"` or `"remove"`.</ParamField>
<ParamField body="previousOwnerRank" type="number">On demote; defaults to `0`.</ParamField>

<ParamField body="previousOwnerCapabilities" type="string[]" />

***

## Membership

### fetchWorkspaceMembers

The [unified roster](/api-reference/workspaces/members/list-members) — `WorkspaceRosterResponse`, or `WorkspaceRosterCountsResponse` with `countOnly`. Never paginated. Requires roster visibility — **any relation** to the workspace.

<Warning>
  **`rank`, `capabilities` and `permissions` are fenced on other users' entries.** They are omitted (absent — not `null`) unless the bearer-token user holds one of the four people-operating capabilities (`invite`, `remove-member`, `edit-member-access`, `edit-member-profile`) or is the `owner` / an `ancestor-owner`. The caller's own entry always carries them. Who is on the roster — `user`, reason `type`s, `title`, `metadata`, `viaWorkspaceId` — stays visible to any relation, so read the fenced fields defensively (`reason.capabilities ?? []`).
</Warning>

```typescript theme={null}
const roster = await sublay.workspaces.fetchWorkspaceMembers({ workspaceId: "ws_abc" });
const counts = await sublay.workspaces.fetchWorkspaceMembers({ workspaceId: "ws_abc", countOnly: true });
```

<ParamField body="workspaceId" type="string" required />

<ParamField body="include" type="string">`ancestorOwners`, `reachHolders`, `descendants`.</ParamField>

<ParamField body="countOnly" type="boolean" />

### fetchWorkspaceMemberStanding

One user's resolved standing, addressed by user id — works for any relation, including users with no direct member row. Requires roster visibility — **any relation** to the workspace.

<Warning>
  **`capabilities`, `permissions` and `rank` are fenced.** They are omitted unless the bearer-token user holds one of the four people-operating capabilities (`invite`, `remove-member`, `edit-member-access`, `edit-member-profile`), is the `owner` / an `ancestor-owner`, or is reading **their own** standing. `user`, `reasons`, `title` and `metadata` are always returned.
</Warning>

```typescript theme={null}
const standing = await sublay.workspaces.fetchWorkspaceMemberStanding({
  workspaceId: "ws_abc",
  targetUserId: "usr_pat",
});
```

<ParamField body="workspaceId" type="string" required />

<ParamField body="targetUserId" type="string" required>The **target** whose standing to read (path param, not an actor).</ParamField>

Returns `WorkspaceMemberStanding`. On its `user` field **only `id` is guaranteed** (`WorkspaceStandingUser`): the server returns the full user record when the user row still exists and falls back to `{ id }` alone when it does not — a deleted user with a lingering membership row is a reachable case. Read the rest defensively (`standing.user.username ?? "Deleted user"`).

`reasons` is an array of structured `{ type, viaWorkspaceId? }` entries — the same shape a roster entry's `reasons` carries — so you learn which ancestor grants an `ancestor-owner` / `reach-holder` standing.

### updateWorkspaceMember

Two capability tiers: **powerful** fields (`capabilities`, `permissions`, `rank`) need `edit-member-access` plus the rank rule and [no-escalation](/data-models/workspace-member#no-privilege-escalation); **cosmetic** fields (`title`, `metadata`) need only `edit-member-profile`, and a member editing their own `title` needs nothing.

```typescript theme={null}
const member = await sublay.workspaces.updateWorkspaceMember({
  workspaceId: "ws_abc",
  targetUserId: "usr_pat",
  capabilities: ["view", "invite"],
  rank: 3,
});
```

<ParamField body="workspaceId" type="string" required />

<ParamField body="targetUserId" type="string" required>The member being edited (path param, not an actor).</ParamField>

<ParamField body="capabilities" type="string[]" />

<ParamField body="permissions" type="string[]" />

<ParamField body="rank" type="number" />

<ParamField body="title" type="string" />

<ParamField body="metadata" type="object" />

### removeWorkspaceMember

Removes a member from **this node only**. Requires `remove-member` and is **rank-bounded**; sole-owner protected.

```typescript theme={null}
await sublay.workspaces.removeWorkspaceMember({
  workspaceId: "ws_abc",
  targetUserId: "usr_pat",
});
```

<ParamField body="workspaceId" type="string" required />

<ParamField body="targetUserId" type="string" required>The member to remove (path param, not an actor).</ParamField>

### removeWorkspaceMemberFromSubtree

Removes the target's direct memberships on this workspace and every descendant you can reach. Requires `remove-member`, rank-bounded **per node**. Blocks with `409 workspace/owns-descendants` (and reports them) if the target owns any descendant workspace.

```typescript theme={null}
const { removedCount, removed, skippedCount, skipped } =
  await sublay.workspaces.removeWorkspaceMemberFromSubtree({
    workspaceId: "ws_root",
    targetUserId: "usr_pat",
  });

if (skippedCount > 0) {
  // NOT fully offboarded — memberships survived in branches you cannot reach.
  // `id`/`name` are null where you have no standing on that workspace.
  console.warn("Still a member of", skippedCount, "workspace(s):", skipped);
}
```

<Warning>
  **A non-owner's sweep may be partial.** It stops at sealed sub-workspaces (`inheritsFromParent = false`), so the target can keep a membership you never saw. Always check `skippedCount` / `skipped` — `removedCount` alone does **not** mean the user is gone from the subtree. An owner or ancestor owner always gets `skippedCount: 0`. Where you have no standing on a skipped workspace its `id` and `name` come back `null`: you learn *that* a membership survived, not *where* — escalate to an owner of that branch.
</Warning>

<ParamField body="workspaceId" type="string" required />

<ParamField body="targetUserId" type="string" required>The user to offboard (path param, not an actor).</ParamField>

### leaveWorkspace

**Self-service** — removes the bearer-token user's *own* direct membership on this node only. No capability required. An owner cannot leave (`409 workspace/sole-owner`); transfer or delete first.

```typescript theme={null}
await sublay.workspaces.leaveWorkspace({ workspaceId: "ws_abc" });
```

<ParamField body="workspaceId" type="string" required />

***

## Invitations

### createWorkspaceInvite

Requires the **`invite`** capability (or ownership); the inviter is the bearer-token user. Address the invitee by **exactly one** of `email`, `userId`, or `username` — here `userId`/`username` address the invite **target**, not an actor. Email matching against existing accounts is **case-insensitive**.

<Warning>
  **The project must have `workspaces.inviteAcceptUrl` configured**, or this call fails with `409 workspace/missing-invite-accept-url` and persists nothing — the invitation email would have nowhere to deep-link. It has no default. Applies even when the invitee already has an account. See [Project settings](/sdk/workspaces/overview#project-settings).
</Warning>

```typescript theme={null}
const invite = await sublay.workspaces.createWorkspaceInvite({
  workspaceId: "ws_abc",
  email: "pat@example.com",
  capabilities: ["view"],
  rank: 5,
});
```

<ParamField body="workspaceId" type="string" required />

<ParamField body="email" type="string">One of `email`/`userId`/`username`.</ParamField>
<ParamField body="userId" type="string">Invite **target** user id.</ParamField>
<ParamField body="username" type="string">Invite **target** username.</ParamField>

<ParamField body="capabilities" type="string[]" />

<ParamField body="permissions" type="string[]" />

<ParamField body="rank" type="number" required />

<ParamField body="title" type="string" />

### fetchWorkspaceInvites

The workspace's **outbox** — the live pending invites *it has issued* (`status='pending' AND expiresAt > now`). Requires the `invite` capability (or ownership). Always returned in full; deliberately unpaginated.

```typescript theme={null}
const { data } = await sublay.workspaces.fetchWorkspaceInvites({ workspaceId: "ws_abc" });
```

<ParamField body="workspaceId" type="string" required />

<Warning>
  Do not confuse this with [`fetchMyWorkspaceInvites`](#fetchmyworkspaceinvites). `fetchWorkspaceInvites` is scoped to **one workspace** and lists what **it sent out** (needs `invite`). `fetchMyWorkspaceInvites` is scoped to **the caller**, spans every workspace, and lists what was **addressed to them** (needs nothing).
</Warning>

### revokeWorkspaceInvite

Marks a pending invitation `revoked`. Requires the `invite` capability (or ownership). Note the verb-suffixed **`POST .../revoke`** route — this is not a `DELETE`.

```typescript theme={null}
await sublay.workspaces.revokeWorkspaceInvite({ workspaceId: "ws_abc", inviteId: "inv_1" });
```

<ParamField body="workspaceId" type="string" required />

<ParamField body="inviteId" type="string" required />

### resendWorkspaceInvite

Refreshes a pending invite — resets a 14-day expiry and resends the email — valid even on one already past `expiresAt`. Terminal invites → `409`. Requires the `invite` capability (or ownership). Also a verb-suffixed **`POST .../resend`**. Returns the refreshed `WorkspaceInvitation`, carrying the new `expiresAt`. Like `createWorkspaceInvite`, it requires [`workspaces.inviteAcceptUrl`](/sdk/workspaces/overview#project-settings) on the project → otherwise `409 workspace/missing-invite-accept-url` with `expiresAt` untouched.

```typescript theme={null}
const invite = await sublay.workspaces.resendWorkspaceInvite({ workspaceId: "ws_abc", inviteId: "inv_1" });
```

<ParamField body="workspaceId" type="string" required />

<ParamField body="inviteId" type="string" required />

### fetchMyWorkspaceInvites

The caller's **inbox** — the bearer-token user's live pending invites across every workspace, matched by `userId` from the token. **Self-service**, and surfacing is not verification-gated — a user sees pending invites right after signup (which nudges them to verify). The verified check applies when they act on one (accept **or** decline).

```typescript theme={null}
const { data } = await sublay.workspaces.fetchMyWorkspaceInvites();
```

### acceptWorkspaceInvite

**Self-service** — identity-matched to the bearer-token user, and a **verified email is required**. The `inviteId` is non-secret. Idempotent when already a member/owner.

```typescript theme={null}
const { workspaceId } = await sublay.workspaces.acceptWorkspaceInvite({ inviteId: "inv_1" });
```

<ParamField body="inviteId" type="string" required />

### declineWorkspaceInvite

**Self-service** — identity-matched, and a **verified email is required** (the same gate as accept, so an unverified squatter on someone else's address cannot burn their invite). Returns `403 workspace/email-not-verified` otherwise.

```typescript theme={null}
await sublay.workspaces.declineWorkspaceInvite({ inviteId: "inv_1" });
```

<ParamField body="inviteId" type="string" required />

***

## Authority-as-a-service

### fetchWorkspaceAuthority

The bearer-token user's resolved standing on a workspace — `{ reasons, capabilities, permissions, rank }`. **Self-service**, so nothing is fenced here. Drive your UI off this rather than guessing: a permission check is a one-line `.includes()` on the result.

`reasons` is an array of structured `{ type, viaWorkspaceId? }` entries (`viaWorkspaceId` on `ancestor-owner` / `reach-holder` only). `capabilities` is the fully-resolved set and **`view` is implied by every other capability** — any user with standing carries it, while a stranger gets `reasons: []` and an empty set.

```typescript theme={null}
const authority = await sublay.workspaces.fetchWorkspaceAuthority({ workspaceId: "ws_abc" });
if (authority.capabilities.includes("invite")) { /* show the invite button */ }
```

<ParamField body="workspaceId" type="string" required />

Sublay never consumes your opaque `permissions` — there is deliberately no `?permission=` check or `can()` helper.
