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.

Teams are called workspaces in the API

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

LayerScopeRolesGrants
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.
Team membership never changes org-wide permissions

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:

When an invitee accepts, they appear in the roster automatically — no manual step.

Invitation links are shown once

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:

A plain org member who isn’t an admin still sees the teams they belong to.

Deleting a team releases its slug

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.

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:

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