Teams, roles, and permissions
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
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 |
|---|---|---|
|
| Every permission. Only role holding |
|
| Everything except those three. Day-to-day operator. |
|
| 18 permissions: read/write on conversations, messages, contacts; assign, close and reopen; internal notes; read-only on channels, KB, triggers and integrations; |
|
| Read-only across conversations, contacts, members and KB, plus |
|
| Six permissions. Sees only conversations assigned to them; no global reports, no KB writing. For seasonal staff, interns, contractors. |
Warning —
agentcannot write the KBThe agent role holds
kb:readonly — notkb:writeand notkb: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
rolecolumn 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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 bycontacts: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 bycontacts:exportandreports:export.•
workflows:read/:write/:run— workflows are gated ontriggers:read/triggers:write. (A separate per-workflowview/edit/publish/deletevocabulary 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:
role === 'owner'→ allowed, unconditionallypermissionOverrides.deny→ deniedpermissionOverrides.grant→ allowedthe custom role's permission set, if the membership has one
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
denyon 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
/v1route sets it — there is noPATCH /v1/memberships/{id}/permissions. Today it is written by API-key issuance (a key mints a syntheticrestrictedmembership 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 |
|---|---|---|
|
| Display + URL identifier. |
|
| The agents in the team, via |
|
| A key inside the team's |
|
| Also a JSON key rather than a column. |
Warning — Skills, capacity and overflow live on the INBOX, not the team
The
teamstable has noskills,capacityoroverflowTeamIdcolumn. Those are inbox settings (inbox_settings), and a team is what they point at —capacity_overflow_team_idandskills_unmatched_team_idare both FKs toteams. 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 |
|---|---|---|
|
| Strict round-robin across available, online members. Fairness at the cost of skill matching. |
|
| Assign to the member with fewest open conversations. Best general-purpose. |
|
| Don't auto-assign; conversations land in the queue and someone claims them. |
|
| Note the spelling — |
|
| 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}, andGET/POST /v1/conversations/{id}/messages. Everything else — the unattended queue, similar and linked threads, participants, tickets, cobrowse, inbox views — still requires plainconversations:readand 403s for arestrictedmember.
"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 |
|---|---|---|
|
| Who did it. |
|
|
|
|
| Nullable, because platform-level actions have no workspace. |
|
| e.g. |
|
| What was changed. |
|
| Diff payload (the changed fields, not the entire row). |
|
| Request metadata. |
|
| When. |
Warning — audit_log is append-only at the DB role
UPDATEandDELETEare 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_logis 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 throughGET /v1/audit, which filters by workspace in the query and is gated onaudit: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:readGET /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-defaultsdoes not exist. You cannot re-shape what the built-inagentrole 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.