Skip to main content
Workspaces 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 (a community shape), Workspaces is invite-only with an owner apex and a capability/permission model.
Requires the workspaces bundle. See Bundles to add it.
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, matching house style).

Key concepts

  • capabilities vs permissions — Sublay enforces the closed capabilities vocabulary; it is blind to your opaque permissions.
  • Ownership — one owner per workspace (the creator); ancestor owners are gods over descendants.
  • Reach & the wall/door rule — strict per-node by default; opt-in downward reach via the child-owned inheritsFromParent flag.
  • Invitations — 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.
Project settings
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 and 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.
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.

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 — 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:
The authority read is the one read visibility does not gate. GET /workspaces/:id/authority/me 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), is visibility-gated and deliberately 404s instead, so it never leaks existence. Do not assume the two behave alike.
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, 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.
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 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, and the targetUserId path param on the member hooks.

Quick start

Hooks

Workspace lifecycle & ownership

Membership

Invitations

useFetchWorkspaceInvites and useFetchMyWorkspaceInvites 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.

Authority

Drive your UI off useFetchWorkspaceAuthority 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”.