API Docs

Teams, roles, and permissions

AdminUpdated Sep 19, 2026

Teams, roles, and permissions

The RBAC model in Chatly — roles, fine-grained permissions, overrides, audit, and how teams drive routing.

Every member of a workspace has exactly one role, optionally a custom role, and optionally permission overrides (per-membership grants or denies). The built-in roles are a fixed set of five that map to real-world support-team archetypes; custom roles and overrides cover everything else.

Roles

Built-in roles (the membership_role enum)

Name

Type

Description

owner

role

Every permission. Only role holding workspace:delete, workspace:billing:write and members:remove. There must be ≥1 owner.

admin

role

Everything except those three. Day-to-day operator.

agent

role

18 permissions: read/write on conversations, messages, contacts; assign, close and reopen; internal notes; read-only on channels, KB, triggers and integrations; reports:read; ai:use_copilot.

light_agent

role

Read-only across conversations, contacts, members and KB, plus messages:internal_note. Can follow a thread and comment internally but cannot reply to a customer. Often free or cheaper seats for engineers and stakeholders.

restricted

role

Six permissions. Sees only conversations assigned to them; no global reports, no KB writing. For seasonal staff, interns, contractors.

Warning — agent cannot write the KB

The agent role holds kb:read only — not kb:write and not kb:publish. If your agents need to author articles, grant it through a custom role or a per-membership override.

Custom roles

Roles are not limited to the five above. A workspace can define its own:

GET /v1/custom-roles — Bearer token

GET /v1/custom-roles/catalog — Bearer token

POST /v1/custom-roles — Bearer token

PATCH /v1/custom-roles/{id} — Bearer token

DELETE /v1/custom-roles/{id} — Bearer token

PATCH /v1/memberships/{id}/custom-role — Bearer token

A custom role is a name (≤60 chars), a description (≤240), and a flat list of permissions. Reading needs members:read; authoring and assigning need members:write.

Warning — A custom role REPLACES the built-in role's permission set

It is an absolute set, not a delta. A membership carrying a custom role does not also keep what its role column would have granted — the custom set is the whole answer.

Two guardrails you cannot switch off:

  • No escalation. You cannot author or assign a role conferring a permission you do not hold yourself.

  • Owners cannot carry one. A database CHECK enforces role <> 'owner' OR custom_role_id IS NULL, so no custom role can narrow an owner and lock the workspace out.

Permission catalog

The full permission set is in packages/shared/src/permissions/index.ts — 47 strings, and that file is the single source of truth. The names follow the resource:action convention:

Permission groups

Name

Type

Description

workspace:*

group

read | write | delete | billing:read | billing:write

members:*

group

read | invite | write | remove

contacts:*

group

read | write | delete | export

conversations:*

group

read | read:assigned | write | assign | close | reopen

messages:*

group

read | write | internal_note

channels:*

group

read | write

kb:*

group

read | write | publish

triggers:*

group

read | write — this is what gates workflows too.

routing:*

group

read | write

macros:*

group

read | write

meetings:*

group

read | write

reports:*

group

read | export

ai:*

group

read | write | configure | use_copilot

integrations:*

group

read | write

audit:*

group

read only — there is no audit:write for anyone.

search:read, uploads:create, marketplace:publish

singletons

Search, attachment upload, and publishing to the marketplace.

Warning — Permissions that look like they should exist, and don't

None of the following are real permission strings, and a call naming one is rejected as unknown:

contacts:merge — merging is governed by contacts:write.

conversations:delete — deliberately removed. There is no delete surface for a conversation anywhere; GDPR erasure anonymises instead.

conversations:export — deliberately removed. Exporting is governed by contacts:export and reports:export.

workflows:read / :write / :run — workflows are gated on triggers:read / triggers:write. (A separate per-workflow view/edit/publish/delete vocabulary exists, but those are not workspace permissions.)

campaigns:read / :write / :send — no campaigns permission family exists.

sso:configure / scim:configure — no such strings; IdP wiring is gated on the workspace and members families.

A build gate (scripts/check-permissions-enforced.mjs) fails CI if any permission in the vocabulary gates nothing, or if any route names a string that is not in the vocabulary. Exactly one permission is allow-listed as unenforced by a route — workspace:delete, which is platform-operator-only and exists as the RBAC ceiling marker the no-escalation check relies on.

Permission overrides

A membership carries a permissionOverrides JSON column:

{
  "role": "agent",
  "permissionOverrides": {
    "grant": ["audit:read"],
    "deny":  ["contacts:export"]
  }
}

This is how you say "Jamie is an agent, but they're our compliance lead, so they can see the audit log."

Resolution order, highest priority first:

  1. role === 'owner' → allowed, unconditionally

  2. permissionOverrides.deny → denied

  3. permissionOverrides.grant → allowed

  4. the custom role's permission set, if the membership has one

  5. the built-in role's permission set

Warning — Owners are exempt from deny — all of them, not just the last one

An owner is allowed before overrides are even consulted, so a deny on an owner has no effect whatsoever. This is what stops a misplaced deny locking the workspace into an unrecoverable state.

Info — Overrides have no public write endpoint

The column is real and is read on every authorization decision, but no /v1 route sets it — there is no PATCH /v1/memberships/{id}/permissions. Today it is written by API-key issuance (a key mints a synthetic restricted membership whose overrides grant exactly the key's scopes and deny everything else) and by direct operator action. Reach for a custom role if you need per-person tailoring you can drive from the API.

Teams

A team is a routing target. Teams are how you organize agents into queues that scale.

Team fields — the teams table is deliberately thin

Name

Type

Description

name, slug

string

Display + URL identifier.

members

user[]

The agents in the team, via team_members.

settings.routing

enum?

A key inside the team's settings JSON, not a column. Three values only: round_robin | load_balanced | manual. Defaults to round_robin.

settings.businessHours

schedule?

Also a JSON key rather than a column.

Warning — Skills, capacity and overflow live on the INBOX, not the team

The teams table has no skills, capacity or overflowTeamId column. Those are inbox settings (inbox_settings), and a team is what they point atcapacity_overflow_team_id and skills_unmatched_team_id are both FKs to teams. Per-agent skills are their own table (agent_skills).

Routing strategies

The full five-value vocabulary is the inbox's routing_strategy; an inbox that leaves it NULL inherits the team's three-value settings.routing.

Parameters

Name

Type

Description

round_robin

inbox + team

Strict round-robin across available, online members. Fairness at the cost of skill matching.

load_balanced

inbox + team

Assign to the member with fewest open conversations. Best general-purpose.

manual

inbox + team

Don't auto-assign; conversations land in the queue and someone claims them.

skills_based

inbox only

Note the spelling — skills_based, not skill_based. Matches required skills against agent_skills rows, optionally from conversation tags or language. Unmatched work falls to skills_unmatched_team_id.

sticky

inbox only

Prefer the agent who last handled this contact.

Capacity is an inbox setting too: capacity_enabled, capacity_limit (default 5), and capacity_overflow — either queue or overflow_team.

Why read:assigned exists

The conversations:read:assigned permission is what powers the restricted role: members with only this permission see only conversations assigned to them, not the workspace-wide inbox.

Use it for:

  • Contractors who shouldn't see other customers' chats

  • Outsourced overflow support agents

  • Interns

  • Anyone you need to give a seat to without granting cross-customer visibility

It is a scope modifier, not a capability — holding plain conversations:read alongside it wins, and widens the holder back to the whole workspace.

Warning — It narrows exactly four routes

GET /v1/conversations, GET /v1/conversations/{id}, and GET / POST /v1/conversations/{id}/messages. Everything else — the unattended queue, similar and linked threads, participants, tickets, cobrowse, inbox views — still requires plain conversations:read and 403s for a restricted member.

"Assigned" is read at its narrowest: unassigned conversations (including the Unassigned queue), threads assigned to a teammate on a team they are on, and threads they merely follow as a participant are all invisible. Team-wide visibility is a bigger grant and would need its own permission.

Audit log

Every state-changing action writes a row to audit_log:

Parameters

Name

Type

Description

actorUserId

uuid?

Who did it. null for system/automation actions.

actorType

enum

user | system | api_key | bot. NOT NULL, so it is mandatory on every write — this is what distinguishes an automated action from a human one.

workspaceId

uuid?

Nullable, because platform-level actions have no workspace.

action

string

e.g. contact.update, conversation.close, member.invite

targetType, targetId

string + uuid

What was changed.

before, after

jsonb

Diff payload (the changed fields, not the entire row).

ip, userAgent

string

Request metadata.

createdAt

timestamp

When.

Warning — audit_log is append-only at the DB role

UPDATE and DELETE are revoked on this table for the application role (the migrator keeps full access). Tampering requires DBA access — which means your DBA, not a compromised app server.

Info — audit_log has no RLS policy — a known, deliberate gap

Unlike every other tenant table, audit_log is not row-level-secured. Around a dozen writers insert on connections with no workspace context set (admin lifecycle, marketplace review, the enrichment credential path), and a couple of readers — the platform admin listing and the GDPR export bundler — read cross-tenant on purpose. Tenant-facing reads are still scoped: they go through GET /v1/audit, which filters by workspace in the query and is gated on audit:read.

Querying

GET /v1/audit?action=contact.update&from=2026-05-01T00:00:00Z&to=2026-05-31T23:59:59Z — Bearer token

In practice audit:read resolves to owner and admin only.

Filters are flat, not bracketed: action (comma-separated action keys; unknown values are dropped rather than rejected), actorUserId, from, to, cursor, limit. from, to and cursor must be full ISO-8601 datetimes — a bare 2026-05-01 is rejected. The cursor is the boundary row's createdAt.

Token scopes

API keys carry scopes, and a scope is literally a permission string. But only eight of the 47 permissions are offered as key scopes, because API-key access is opt-in per route and most permissions gate nothing a key can reach:

contacts:read   contacts:write   conversations:read
messages:read   messages:write   kb:read
search:read     workspace:read

GET /v1/api-keys/scopes — Bearer token

POST /v1/api-keys — Bearer token

GET /v1/api-keys — Bearer token

DELETE /v1/api-keys/{id} — Bearer token

GET /v1/api-keys/whoami — Bearer token

{
  "name": "Internal CRM sync",
  "scopes": ["contacts:read", "contacts:write", "conversations:read"]
}

Under the hood a key resolves to a synthetic restricted membership whose permission overrides grant exactly its scopes and deny everything else. That is why a key fails closed: a key issued with only conversations:read:assigned resolves to scope assigned against its own key id, which no conversation is ever assigned to, so it sees nothing.

Mint API keys with explicit scopes at Settings → API → Keys → New key. See Authentication for the 22 routes that accept one.

Examples

Change a teammate's role

PATCH /v1/memberships/{membershipId}/role
Authorization: Bearer …

{ "role": "light_agent" }

The path parameter is the membership id, not the user id. Requires members:write. The last owner cannot be demoted.

Give agents KB write access

Overrides have no write endpoint, so the way to widen a group of people is a custom role:

POST /v1/custom-roles
Authorization: Bearer …

{
  "name": "Agent + KB author",
  "description": "Agent, plus permission to write and publish articles",
  "permissions": [
    "workspace:read", "members:read",
    "contacts:read", "contacts:write",
    "conversations:read", "conversations:write",
    "conversations:assign", "conversations:close", "conversations:reopen",
    "messages:read", "messages:write", "messages:internal_note",
    "channels:read", "triggers:read", "integrations:read",
    "reports:read", "ai:use_copilot",
    "kb:read", "kb:write", "kb:publish"
  ]
}

Then assign it:

PATCH /v1/memberships/{membershipId}/custom-role
Authorization: Bearer …

{ "customRoleId": "…" }

Remember the set is absolute — list every permission the person should have, including the ones their built-in role already granted.

Onboard a restricted contractor

There is no POST /v1/memberships. Membership is created by invitation, and the body is a batch — up to 50 per call:

POST /v1/invites
Authorization: Bearer …

{
  "invites": [
    { "email": "[email protected]", "role": "restricted" }
  ]
}

role accepts admin, agent, restricted or light_agent and defaults to agent — you cannot invite someone straight to owner.

The invitee then calls POST /v1/invites/accept with the token from their email, which is what creates the membership row. GET /v1/invites, POST /v1/invites/{id}/resend and POST /v1/invites/{id}/revoke manage the invite in the meantime.

Once they are in, the contractor sees only conversations assigned to them. No inbox-wide visibility.

Danger — There is no workspace-wide role-defaults endpoint

PATCH /v1/workspaces/{id}/role-defaults does not exist. You cannot re-shape what the built-in agent role means for your workspace. To change what a group of people can do, define a custom role and assign it.

Removing a member

DELETE /v1/memberships/{id} — Bearer token

Requires members:remove — which only an owner holds, not an admin. It soft-deletes the membership (deleted_at), so audit trails stay intact, and it refuses to remove the last remaining owner.

Was this page helpful?