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

> Full server-side workspace surface — CRUD, membership, invitations, and authority-as-a-service

The `workspaces` module is the developer's **authorization-server SDK** for the [Workspaces bundle](/data-models/workspace). It exposes the full surface: workspace CRUD, membership management, the invitation lifecycle, and the service-key **authority read** you use to make your own app-level decisions.

<Note>
  **Requires the `workspaces` bundle.** See [Bundles](/bundles) to install it. Because this is a **service-key** SDK, most calls take an explicit `userId` — the user the action is performed **as** (the service key can [act as any user](/node-sdk/overview)). For `fetchWorkspaceAuthority`, `userId` is the target whose standing to resolve.
</Note>

<Note>
  **This SDK is not required in order to manage workspaces.** All 21 endpoints are equally available to a browser or mobile app through the [`@sublay/core` hooks](/sdk/workspaces/overview) and [`@sublay/js`](/js-sdk/workspaces), acting as the signed-in user and subject to the same ownership / capability / rank rules. What the node SDK adds is the actor `userId` — performing an action **as some other named user** — which only a service/master key may do.
</Note>

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

***

## Workspace lifecycle

### createWorkspace

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

Root creation requires the acting user to have a **verified email**. Child creation requires the `create-sub-workspace` capability on the parent; the creator becomes the child's owner.

<Warning>
  **A service key does NOT bypass the verified-email gate.** The `userId` you act as must itself be verified, or the call returns `403 workspace/email-not-verified`. This matches `acceptWorkspaceInvite` and `transferWorkspaceOwnership` — all three doors into ownership check it, so **every** workspace owner has a confirmed email. A key lets you act *as* a user; it does not make that user verified.
</Warning>

<ParamField body="userId" type="string" required>The user the workspace is created as (becomes the owner).</ParamField>
<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; absent/`null` → root.</ParamField>

### fetchWorkspace

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

<ParamField body="workspaceId" type="string" required>The workspace UUID.</ParamField>
<ParamField body="include" type="string">Comma-separated include flags; `memberCount` adds the direct member count.</ParamField>

### fetchManyWorkspaces

Lists a user's direct-membership + owned workspaces (paginated).

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

<ParamField body="userId" type="string" required>The user whose workspaces to list.</ParamField>

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

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

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

### updateWorkspace

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

Edits name/metadata (gated by `edit-workspace` or owner). Does **not** flip the inherit flag.

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

<ParamField body="userId" type="string" required>The acting user.</ParamField>

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

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

### updateWorkspaceInheritFlag

```typescript theme={null}
await sublay.workspaces.updateWorkspaceInheritFlag({
  workspaceId: "ws_child",
  userId: "usr_owner",       // own owner or ancestor owner
  inheritsFromParent: true,
});
```

Owner-only in both directions; subject to the enforced-default lock.

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

<ParamField body="userId" type="string" required>Must be own owner or an ancestor owner.</ParamField>

<ParamField body="inheritsFromParent" type="boolean" required />

### deleteWorkspace

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

Owner-only; cascades the subtree and fires `workspace.deleted` per deleted workspace — and **no** `workspace.member.removed` events for the memberships it tears down (see [Webhooks](/sdk/workspaces/webhooks)).

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

<ParamField body="userId" type="string" required>Must be own owner or an ancestor owner.</ParamField>

### transferWorkspaceOwnership

```typescript theme={null}
const workspace = await sublay.workspaces.transferWorkspaceOwnership({
  workspaceId: "ws_abc",
  userId: "usr_owner",                  // own owner or ancestor owner
  newOwnerId: "usr_new",                // must be verified
  previousOwnerDisposition: "demote",
  previousOwnerRank: 0,
});
```

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

<ParamField body="userId" type="string" required>The acting user (own owner or ancestor owner).</ParamField>
<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

Returns the [unified roster](/api-reference/workspaces/members/list-members) (`WorkspaceRosterResponse`), or `WorkspaceRosterCountsResponse` when `countOnly` is set.

<Note>
  The roster fences the authority-bearing reason fields (`rank`, `capabilities`, `permissions`) from callers who cannot manage people on the workspace. A **service key is never fenced** — it always sees them — so this only matters for the [client-side](/js-sdk/workspaces) and [hook](/hooks/workspaces/use-fetch-workspace-members) surfaces. The SDK types mark the three fields optional for that reason.
</Note>

```typescript theme={null}
const roster = await sublay.workspaces.fetchWorkspaceMembers({
  workspaceId: "ws_abc",
  include: "ancestorOwners,descendants",
});

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

```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 user whose standing to read.</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 not just the kind of standing but which ancestor grants it. `capabilities` / `permissions` / `rank` are typed **optional** because the server omits them for callers who cannot manage people; a **service key is never fenced**, so they are always present on this SDK's responses.

### updateWorkspaceMember

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

Powerful fields require `edit-member-access` + rank rules + no-escalation; cosmetic fields require `edit-member-profile`.

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

<ParamField body="targetUserId" type="string" required>The member being edited (path).</ParamField>
<ParamField body="userId" type="string" required>The acting user (body).</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

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

Requires `remove-member` (rank-bounded); sole-owner protected.

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

<ParamField body="targetUserId" type="string" required>The member to remove.</ParamField>
<ParamField body="userId" type="string" required>The acting user.</ParamField>

### leaveWorkspace

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

Removes the given user's own membership on this node only.

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

<ParamField body="userId" type="string" required>The user leaving.</ParamField>

### removeWorkspaceMemberFromSubtree

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

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

Blocks with `409 workspace/owns-descendants` (and a report) if the target owns any descendant workspace.

<Note>
  **`skippedCount` is `0` from this SDK.** The sweep is partial only for a **non-owner** actor, and a service key reaches the whole subtree via the god-path regardless of which acting `userId` you name — so a server-side sweep always clears every descendant. The field is part of the shared response shape (see the [browser SDK](/js-sdk/workspaces) and the [hook](/hooks/workspaces/use-remove-workspace-member-from-subtree), where a non-owner actor *can* get a partial sweep). Assert on it anyway if you proxy this call on behalf of an end user, rather than assuming `removedCount` means the user is fully offboarded.
</Note>

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

<ParamField body="targetUserId" type="string" required>The user to offboard.</ParamField>
<ParamField body="userId" type="string" required>The acting user.</ParamField>

***

## Invitations

### createWorkspaceInvite

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

Requires the `invite` capability. Here `userId`/`username` (if used) address the invite **target**; the inviter is the service-key subject. 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. It has no default, and a service key does not exempt you. Applies even when the invitee already has an account. See [Project settings](/sdk/workspaces/overview#project-settings).
</Warning>

<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

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

The workspace's **outbox** — the live pending invites *it has issued*. Requires `invite` (or owner). Not to be confused with [`fetchMyWorkspaceInvites`](#fetchmyworkspaceinvites), which is one user's **inbox** of invites addressed *to them* across every workspace.

### revokeWorkspaceInvite

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

### resendWorkspaceInvite

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

Refreshes a lapsed pending invite (14-day expiry) and resends the email. Terminal invites → `409`. 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.

### acceptWorkspaceInvite

```typescript theme={null}
const { workspaceId } = await sublay.workspaces.acceptWorkspaceInvite({
  inviteId: "inv_1",
  userId: "usr_pat",   // must BE the target and be verified
});
```

Identity-matched + verified-email. Idempotent when already a member/owner.

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

<ParamField body="userId" type="string" required>The accepting user (must be the target and verified).</ParamField>

### declineWorkspaceInvite

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

Identity-matched + verified-email — the same gate as accept, so an unverified squatter on someone else's address cannot burn their invite. A service key does **not** bypass it: the `userId` you act as must itself be verified, or the call returns `403 workspace/email-not-verified`.

### fetchMyWorkspaceInvites

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

The user's **inbox** — their live pending invites addressed *to them* across every workspace (matched by `userId`). The per-workspace **outbox** counterpart is [`fetchWorkspaceInvites`](#fetchworkspaceinvites).

***

## Authority-as-a-service

### fetchWorkspaceAuthority

The **service-key authority read** — the crux of the server-side surface. Resolve any user's standing on a workspace, then run your own permission check with a one-line `.includes()`.

```typescript theme={null}
const authority = await sublay.workspaces.fetchWorkspaceAuthority({
  workspaceId: "ws_abc",
  userId: "usr_pat",     // the target whose standing to resolve
});

// { reasons: [{ type, viaWorkspaceId? }], capabilities, permissions, rank }
if (authority.permissions.includes("deploy")) {
  // your app allows the action
}
```

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

<ParamField body="userId" type="string" required>The target user whose resolved standing to read.</ParamField>

`reasons` entries carry `viaWorkspaceId` on `ancestor-owner` / `reach-holder`, naming the ancestor responsible — so when a user has authority you did not expect, you can tell exactly which node to go fix. `capabilities` is the fully-resolved set, and **`view` is implied by every other capability**: any user with standing carries it, while a user with no relation at all comes back with `reasons: []` and an empty capability set.

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