Teams
A team is a named group inside an organization — with its own members, its own roles, and its own bound resources. Teams let an org owner answer “who works on what, and what can they touch?” without giving anyone org-wide permissions they don’t need. This page covers managing teams from the console and carrying team context through the API and SDKs.
The console and this documentation say Teams. Everywhere in the
API, SDK types, and headers, the same construct is named workspace
— the routes are /v1/workspaces, the header is
X-Workspace-Id, the SDK client is client.workspaces. They
are one and the same thing; only the label differs.
What a Team Is
Your organization is the tenant — the isolation boundary that separates your data from every other org on Kapable. A team lives inside one org. It has:
- a name and a slug (unique within the org),
- an owner and its own member list,
- per-team roles (four built-ins, plus any custom roles you define), and
- resource bindings — apps, knowledge corpora, wiki pages, or data sources the team works on.
A person can belong to many teams. The same person can be an owner of one
team and a viewer of another. Teams never change the org itself: deleting
a team never deletes the underlying resources or removes anyone from the org.
Org Roles vs. Team Roles
These are two separate permission layers, and they never leak into each other.
| Layer | Scope | Roles | Grants |
|---|---|---|---|
| Org role | Org-wide | owner / admin / member / viewer (or a custom org role) |
What a person can do across the whole organization. |
| Team role | Within one team | owner / admin / member / viewer (plus custom team roles) |
What a person can do inside that team, expressed as capability grants. |
Adding someone to a team, changing their team role, or removing them from a team has
no effect on their org-wide role. A team owner is not an
org owner. Likewise, an org role never auto-grants team membership —
being an org admin doesn’t put you on any team; you’re added or invited
explicitly.
Custom team roles draw from a fixed capability vocabulary:
workspace.view, workspace.update,
workspace.members.manage, workspace.invitations.manage,
workspace.resources.manage, workspace.roles.manage,
content.read, and content.write.
Managing Org Members
Before you manage teams, you manage the people in your org. From the console at
{org}.kapable.ai/console, open Members
(/console/org/members). An org admin can:
- See the roster — every member with their role and active status.
- Invite by email — enter an email and pick a role. Kapable creates a single-use invitation and emails it.
- See pending invitations — everyone invited but not yet accepted.
- Revoke a pending invitation — the token stops working immediately.
- Copy the accept link for manual delivery.
When an invitee accepts, they appear in the roster automatically — no manual step.
The accept link is revealed exactly once, on the “Invitation Created” page, with a Copy button. Kapable stores invitation tokens hashed, so the raw link cannot be recovered afterward — the pending list shows the invitation but not its link. Lost a link? Revoke the invitation and send a new one.
Managing Teams
Open Teams in the console
({org}.kapable.ai/console/workspaces). An org admin can:
- List the org’s teams, and create one. You give a name; the slug is derived from it and editable at create time. Once the team exists, the slug is immutable.
- Open a team to see its members and their team roles, its pending invitations, and its bound resources.
- Invite a member — by email (a single-use link, shown once, exactly like org invitations), or by picking an existing org member.
- Change a team member’s role, including custom team roles.
- Remove a team member — effective on their very next request.
- Bind and unbind resources — declare which apps, knowledge corpora, wiki pages, or data sources the team works on.
- Rename a team, and delete one (with confirmation).
A plain org member who isn’t an admin still sees the teams they belong to.
When you delete a team, its slug becomes available again — you can immediately create a new team with the same slug. A binding whose resource no longer exists is shown as unresolvable, with an unbind control, rather than being hidden. Resource bindings are declarations: binding records that a team works on a resource, and the owning service resolves it — Kapable does not delete the resource when you unbind it or delete the team.
Team Context in the API
By default, every API call is scoped to your org — exactly as it
always has been. To act within a team, a caller declares which
team by sending the X-Workspace-Id header on an otherwise normal, validated
session. Kapable verifies membership and, when it holds, resolves the team context.
-
When the session belongs to an accepted member of a live team in the same
org, the validate response gains two fields:
workspace_id(the resolved team) andworkspace_capabilities(the capability union of the member’s team role). - When no header is sent, the response carries neither field — behavior is byte-identical to before teams existed. Absence of team context is always valid and means “org-scoped, as today.”
-
When the caller declares a team they are not an accepted member of
(or a deleted team, or a team in another org), the request is denied with a typed
403— Kapable never silently ignores an unauthorized declaration.
Team-bound API keys carry their team context implicitly: a key minted against a team resolves that team on every call, with no header needed. A bound key is subject to the same membership check — if its team is deleted or the binding no longer holds, it gets the same typed denial the header path returns.
# Declare team intent on a validated session with the X-Workspace-Id header:
curl https://api.kapable.ai/v1/me \
-H "Authorization: Bearer kses_..." \
-H "X-Workspace-Id: 3f9c2a10-..." # the team (workspace) id
# → 200 { ..., "workspace_id": "3f9c2a10-...",
# "workspace_capabilities": ["content.read","content.write","workspace.view"] }
# Same session, no header → org-scoped, no workspace fields (unchanged behavior):
curl https://api.kapable.ai/v1/me -H "Authorization: Bearer kses_..."
# → 200 { ... } (no workspace_id, no workspace_capabilities)
SDK Usage
Both SDKs set the X-Workspace-Id header for you and surface the resolved
workspace_id / workspace_capabilities on the validate response.
import { KapableClient } from '@kapable/sdk';
const client = new KapableClient({
baseUrl: 'https://api.kapable.ai',
token: sessionToken, // kses_...
});
// Derive a client scoped to a team. Every call it makes carries
// X-Workspace-Id: teamId — including validate/me.
const teamClient = client.withWorkspace(teamId);
const ctx = await teamClient.auth.me();
// ctx.workspace_id === teamId
// ctx.workspace_capabilities === ['content.read', 'content.write', 'workspace.view']
// No team intent → org-scoped, exactly as before:
const orgCtx = await client.auth.me();
// orgCtx.workspace_id is undefined
// Clear the scope again (org-wide client):
const backToOrg = teamClient.withWorkspace(undefined);
use kapable_sdk::KapableClient;
use kapable_sdk::auth::types::ValidateRequest;
// Declare team intent at build time with the .workspace_id(...) builder.
let team_client = KapableClient::new("https://api.kapable.ai")
.token(session_token) // kses_...
.workspace_id(team_id) // sends X-Workspace-Id on every call
.build();
let ctx = team_client.auth().validate(&ValidateRequest {
token: session_token.clone(),
}).await?;
// ctx.workspace_id -> Some(the resolved team's uuid)
// ctx.workspace_capabilities -> Some(["content.read", "content.write", "workspace.view"])
// Or re-scope an existing client without rebuilding auth:
let team_client = client.with_workspace_id(team_id);
let org_client = team_client.without_workspace_id(); // back to org-wide
Administering Teams via the SDK
Team administration (create, invite, roles) is a session-token flow — it’s a
human activity, so it uses a member session, not an API key. Both SDKs expose these under
the workspaces client. Resource bind/unbind is the exception: it lives on
the auth client — TS client.auth.bindWorkspaceResource() /
unbindWorkspaceResource(), Rust client.auth().bind_workspace_resource() /
unbind_workspace_resource().
// Create a team (the caller becomes its owner).
const team = await client.workspaces.create({
name: 'Growth',
slug: 'growth',
description: 'Growth & lifecycle experiments',
});
// Invite a member by email — the token is shown ONCE in the response.
const invite = await client.workspaces.createInvitation(team.id, {
email: 'teammate@example.com',
role: 'member',
});
// invite.token — deliver this now; it is not recoverable later.
// Add an existing org member directly (by user id):
await client.workspaces.addMember(team.id, { user_id: userId, role: 'admin' });
// Define a custom team role from the capability vocabulary:
await client.workspaces.createRole(team.id, {
role_name: 'editor',
capabilities: ['workspace.view', 'content.read', 'content.write'],
});
// Bind a resource — note this one is on the AUTH client, not workspaces:
await client.auth.bindWorkspaceResource(team.id, { resource_type: 'app', resource_id: appId });
// List the team with its members and bound resources:
const detail = await client.workspaces.get(team.id);
use kapable_sdk::workspaces::types::{
CreateWorkspaceRequest, CreateWsInvitationRequest, AddMemberRequest, CreateRoleRequest,
};
use kapable_sdk::auth::types::BindWorkspaceResourceRequest;
// Create a team (the caller becomes its owner).
let team = client.workspaces().create(&CreateWorkspaceRequest {
name: "Growth".into(),
slug: "growth".into(),
description: Some("Growth & lifecycle experiments".into()),
}).await?;
// Invite a member by email — the token is shown ONCE in the response.
let invite = client.workspaces().create_invitation(team.id, &CreateWsInvitationRequest {
email: "teammate@example.com".into(),
role: Some("member".into()),
}).await?;
// invite.token — deliver this now; it is not recoverable later.
// Add an existing org member directly (by user id):
client.workspaces().add_member(team.id, &AddMemberRequest {
user_id,
role: Some("admin".into()),
}).await?;
// Define a custom team role from the capability vocabulary:
client.workspaces().create_role(team.id, &CreateRoleRequest {
role_name: "editor".into(),
capabilities: vec!["workspace.view".into(), "content.read".into(), "content.write".into()],
}).await?;
// Bind a resource — note this one is on the AUTH client, not workspaces:
client.auth().bind_workspace_resource(team.id, &BindWorkspaceResourceRequest {
resource_type: "app".into(),
resource_id: app_id,
}).await?;
// List the team with members + bound resources:
let detail = client.workspaces().get(team.id).await?;
Denial Receipts
Every rejected action returns a typed, specific receipt — Kapable never fails silently. These are the exact shapes a builder sees on the team surface:
| Situation | Status | Body |
|---|---|---|
| Slug already in use by a live team in the org | 409 |
{ "error": "CONFLICT", "message": "workspace slug already exists in this org" } |
| Declared a team you are not an accepted member of (or a deleted / other-org team) | 403 |
{ "error": "FORBIDDEN", "message": "workspace_forbidden: not an accepted member of the requested workspace" } |
| Accepting an expired, revoked, or already-used invitation | 404 |
{ "error": "NOT_FOUND", "message": "invitation not found, expired, or already used" } |
| Deleting a team you don’t own | 403 |
{ "error": "FORBIDDEN", "message": "must be workspace owner or org owner" } |
The SDKs normalize these into a thrown KapableError (TypeScript) /
KapableError (Rust) carrying the status and message, so you handle them the
same way as any other API error.