Identity & Sync Groups
Who is connecting, and which slice of state they are allowed to see.
This is the doc the Quickstart skips: who is connecting, and which slice
of shared state do they get? If you’ve wired <AbloProvider client={ablo}>
and wondered where org / team / user actually come from — start here.
Ablo does not do auth
Ablo is not an identity provider. It has no login, no password store, no session of its own. You keep whatever you already use — Clerk, Auth0, NextAuth, WorkOS, your own session table. Ablo’s job begins after you’ve authenticated the user: you hand Ablo the already-authenticated identity, and Ablo decides which sync groups that identity may read and write.
Inspect the credential the application is actually using
ablo whoami describes the developer running the CLI. Runtime code should
inspect ablo.identity, which is the server-confirmed EffectiveAuthority of
the credential attached to that client. It is never decoded or reconstructed
locally.
import { Ablo } from '@abloatai/ablo';
import { CapabilityError } from '@abloatai/ablo';
const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
await ablo.ready();
console.log(ablo.identity?.operations);
console.log(ablo.identity?.syncGroups);
The HTTP and stateful clients expose the same value after ready(). A denied
scoped operation throws CapabilityError; compare its
requiredCapability.scope directly with ablo.identity.operations.
try {
await ablo.records.update({ id, data: { status: 'done' } });
} catch (error) {
if (error instanceof CapabilityError) {
console.error('missing grant', error.requiredCapability);
}
}
Do not broaden the credential in the client. A backend holding the project secret mints a replacement, least-privilege agent credential with the schema- typed grant:
const session = await control.sessions.create({
agent: { id: agentId },
can: { records: ['read', 'update'] },
syncGroups: [syncGroup('workspace', workspaceId)],
});
Install session.token in the agent process and call ready() again. The next
ablo.identity is the authority the server will enforce; no automatic grant
escalation occurs.
So the integration question is never “how do I log into Ablo?” It’s: “My app
already knows this request is user U in org O. How do I tell Ablo, so it
scopes their realtime data correctly?” The rest of this doc answers exactly
that.
What a sync group is
A sync group is a named channel of shared state — a string like
org:acme or workspace:abc123. It is simultaneously:
- the unit of fan-out: a confirmed write to a row publishes a delta to every participant subscribed to that row’s sync group(s), and
- the unit of access: a participant receives a row’s deltas only if the row’s sync group is in their allowed set.
There is no built-in org / team / user concept in the engine. Those are
your domain words. Ablo only knows sync-group strings. The mapping from “this
is user U in org O” to “they may subscribe to org:acme and user:U” is
something you declare in your schema. Here is that whole declaration in one
runnable place, so the concepts below have code to attach to.
Declare it, end to end
The entire declaration surface is: identityRoles (who may see what), and on
each model scope / parent / grants (which group a row fans out on), plus
optional syncGroups at session-mint time (narrowing). Read the three blocks first —
a human gets their org / team scope, an agent gets one workspace — then the
sections after explain each.
// 1. src/ablo/schema.ts — map identity → groups, and anchor each model to a group
import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/schema';
export const schema = defineSchema(
{
// A scope root: its rows form the group `workspace:<id>` (kind from `groups.root`).
// Tenant isolation defaults to a row-local `organization_id` column, so no
// `policy` is needed here.
workspaces: model(
{ title: z.string(), status: z.enum(['draft', 'published']) },
{ groups: { root: 'workspace' } },
),
// A child: it has no group of its own; it inherits its workspace's group via the
// `parent` edge. A write to a document reaches everyone viewing the workspace.
records: model(
{ workspaceId: z.string() },
{ relations: { workspace: relation.belongsTo('workspaces', 'workspaceId', { parent: true }) } },
),
},
{
// Each role is pure data: a `kind` (the group prefix) and the identity
// `source` field to read. No closures — so the schema stays JSON-serializable.
identityRoles: [
identityRole({ kind: 'org', source: 'organizationId' }),
identityRole({ kind: 'user', source: 'userId' }),
identityRole({ kind: 'team', source: 'teamIds', multi: true }),
],
},
);
// 2. app/providers.tsx — a HUMAN gets their full org / team scope.
// teamIds is set on the client you build (Ablo({ schema, teamIds: user.teamIds })),
// not passed to the provider; the provider just takes that client.
<AbloProvider client={ablo} userId={user.id}>
{children}
</AbloProvider>
// 3. an AGENT run inherits its user, narrowed to the entities in play.
// You narrow at SESSION-MINT time: your backend calls `sessions.create` with the
// agent's allowed `syncGroups`, built from each model's scope via the
// `syncGroup(kind, id)` helper — never a hand-built `workspace:<id>` string. The agent's
// runtime then connects with the minted token.
const session = await server.sessions.create({
agent: { id: agentId },
can: { Workspace: ['read', 'update'] },
syncGroups: [syncGroup('workspace', workspaceId)], // floor: just the workspace it's working on
});
// the agent runtime authenticates with the minted token
const ablo = Ablo({ schema, apiKey: session.token });
That’s the whole surface. The rest of this doc is the why behind each line.
Two kinds of group: the whole mental model
You just saw a human get org / team groups and an agent get one workspace
group. That split is the model. Every sync group is named after one of two
things:
- Membership groups: named after who you are:
org:{id},team:{id},user:{id}. Produced from identity (identityRoles, Half 1). They’re standing and durable — they don’t change as you work. - Entity groups: named after a thing:
archive:{id},workspace:{id},document:{id}. Produced from a row’s id (a model’s entity scope, Half 2). They’re granular — one per record — and any participant can be pointed at a specific set of them.
Agents and people fill that same space differently, and you declare the two in different places. An agent’s groups come from what it’s working on right now, so you pass them in code when you start the run. A person’s groups come from who they are, so you declare them once in the schema.
| Subscribed by | Declared where | Gets | |
|---|---|---|---|
| Human | who they are: membership | the schema (identityRoles): a rule, written once |
every org / team / user group their identity implies: their whole standing world |
| Agent | what it’s been given: entities | code, at the spawn site: chosen per run | a handful of entity groups: the archive it’s in, the documents it has read: never beyond what its user’s membership could reach |
One line: humans subscribe by who they are; agents subscribe by what they’ve been given.
That’s why you never write per-user scope code, but you always choose an agent’s
groups at the dispatch site. A user’s org/team/user don’t change per request, so
their scope is a rule the schema derives automatically. An agent’s reach
depends on what it’s working on, which is only knowable at dispatch — so you
pass its syncGroups when your backend mints the agent session
(sessions.create({ agent, can, syncGroups })). The schema’s
only job for entities is to declare that a model is
entity-scopable and what its group is named (scope: 'workspace' → workspace:{id});
it never declares which entities a given agent gets. (A human can opt into the
same runtime narrowing — a page scoped to one workspace — but by default a human’s
scope is fully schema-derived.)
So an agent doesn’t need a user:{id} standing grant. It’s a participant pointed
at a few entity groups, bounded above by its triggering user’s membership. That
boundary is the whole safety story, and it’s covered in
Agents are participants too.
your auth → identity { kind, userId|agentId, organizationId, teamIds }
→ identityRoles (schema) → allowed sync groups
→ participant receives deltas for rows in those groups
The identity is a participant — and a participant is either a human
(kind: 'user') or an agent (kind: 'agent'). Same shape, same path; see
Agents are participants too below. Everything in
the next two sections applies to both.
Your schema lives in a project; your users commit to it
The default is simple: your schema lives in a project, you push it once, and
every session you mint resolves against it. Your end-users don’t have Ablo
accounts — your server’s sk_ mints an ek_ per user, and by default that
session lands in your project’s own org. All your users share one schema, one
data tenant, and receive targeted realtime changes through sync-groups. Model
policy declarations govern which rows they may read; sync-groups are delivery
routing, not read authorization. That’s the whole story for most apps.
Add-on — org-per-customer isolation. If you need each customer to be its own hard tenant (separate row-level isolation, optionally a separate database) you’d otherwise have to re-push your schema into every customer’s org. Instead, keep one project as the home of your schema. A cross-organization mint automatically uses the owning key’s project for the session’s schema while its data stays in the customer’s org:
const server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
await server.sessions.create({
user: { id: userId },
organizationId, // DATA → this customer's RLS-isolated organization
can: { records: ['read', 'update'] },
ttlSeconds: 3600,
});
Server-side, the model shape loads from your schema project but column
enrichment and the tenant connection still target organizationId — so the
shared schema only describes the shape; the data plane stays the customer’s and
can’t cross-leak. schemaProject: { organizationId, projectId } remains
available as an explicit override for migrations or advanced routing. Omit
organizationId for the single-organization default above. Requires a dedicated
sk_ with organization:act-as; see
Customer Organizations.
The two halves of scoping
Delivery scoping is two declarations that meet in the middle. One describes the participant (what may I subscribe to?), the other describes each row (which group does this row belong to?). A participant receives a row’s realtime changes when the row’s sync groups intersect the participant’s allowed set.
That intersection does not itself authorize an HTTP read. A model’s policy
governs read access. Treat sync-groups as change routing and policy (plus the
organization boundary beneath it) as authorization; declaring one never
silently creates the other.
Half 1 (identityRoles): identity → allowed groups
Declared once, on the schema, via the identityRole({ kind, source }) factory.
Each role is pure data: a kind (the group’s prefix — org, user, team)
and the source — the identity field to read. The engine reads source off the
identity you supply and mints <kind>:<value> for each value, building the
participant’s allowed set. There is no hardcoded org: / user: anywhere in the
engine — the kinds and sources are entirely yours.
// src/ablo/schema.ts
import { defineSchema, identityRole, model, z } from '@abloatai/ablo/schema';
export const schema = defineSchema(
{
workspaces: model({
title: z.string(),
status: z.enum(['draft', 'published']),
}),
},
{
identityRoles: [
identityRole({ kind: 'org', source: 'organizationId' }),
identityRole({ kind: 'user', source: 'userId' }),
// `multi: true` reads an array field — one `team:<id>` group per id.
identityRole({ kind: 'team', source: 'teamIds', multi: true }),
],
},
);
The identity these source fields read is what your app resolves from its own
auth — Ablo never invents it. Roles are pure data (no closures) on purpose: a
Schema stays JSON-serializable end to end, so the same declaration works
in-process and on a hosted server that only ever sees the compiled JSON.
Single field per role.
sourcereads one field. An agent doesn’t need its own role: it runs on behalf of a user and carries that user’suserId, so theuser:{id}role above already covers it — see Agents are participants too.
Half 2 (per-model scope): row → group
You never write a sync-group string for a row. You declare a model’s place in the entity graph and the engine derives the groups its rows fan out on. Three declarations, in order of how often you reach for them:
groups.root — this model is a scope root. Its rows form a group of their
own. The kind comes from the model’s typename by default, or pass a string to
set it explicitly (use the string form when the wire kind differs from the
typename, e.g. typename EntryCollection but group workspace:<id>):
workspaces: model({ title: z.string() }, { groups: { root: 'workspace' } });
// a workspace row → group `workspace:<id>`
parent — this row lives inside another entity. Mark the belongsTo edge
to its owner; the row inherits that owner’s group. This is the Zanzibar/ReBAC
parent relation — “access inherits from parent” — and it chains transitively
(a block → its document → its workspace), so a write to any descendant reaches everyone
viewing the root. A reference (a provenance/template pointer, not ownership)
must not be marked parent, or the row would leak into an unrelated scope:
records: model(
{ workspaceId: z.string(), sourceEntryId: z.string().optional() },
{
// default policy: row-local organization_id
relations: {
workspace: relation.belongsTo('workspaces', 'workspaceId', { parent: true }), // ownership → inherit workspace:<id>
sourceEntry: relation.belongsTo('documents', 'sourceEntryId'), // reference → NOT routed
},
},
);
Declare the parent edge — don’t infer it. Optionality is not a proxy for ownership: many
parentFKs are optional (a root folder, an inbox record), and some required FKs are mere references. Containment is a fact only you know, so it’s declared, exactly as it is in OpenFGA/Zanzibar.
groups.grants — a membership edge. On a join model (e.g. archiveMember),
it says “this row grants a subject access to a scope root.” Both are relation
names on the model. The server resolves it at connect time — for user U, it
finds the scope-root groups U is a member of and adds them to U’s allowed
set (Linear’s /sync/user_sync_groups). Use this for sub-org sharing; plain
org membership is already covered by the org: identity role.
archiveMember: model(
{ userId: z.string(), archiveId: z.string() },
{
relations: {
member: relation.belongsTo('users', 'userId'),
room: relation.belongsTo('archives', 'archiveId'),
},
groups: { grants: { subject: 'member', scope: 'room' } },
},
);
For the rare group keyed on a plain field rather than a relation (per-recipient
inbox fan-out, say), there’s a groups: { roles: [entityRole({ kind, source })] }
escape hatch. For rows that inherit tenancy (not a sync group) through a
foreign key without carrying organization_id, use policy: { by: 'parent', fk, parent } rather than opting out of isolation. The old orgScoped: false
exposed the whole table cross-tenant, so validate_schema rejects the removed
options as tenancy-option-removed errors and steers you to policy: { by: 'parent' } (FK inheritance) or, for genuinely global reference data, the
explicit policy: { by: 'none' }. See
packages/transaction/src/schema/model.ts for the full option set.
How identity reaches Ablo: the proxy model
This is the part the README’s “authenticates with the signed-in user’s session” glossed over. Concretely:
- Your
ABLO_API_KEYlives only on your trusted server, scoped to your account. It signs your app’s relationship with Ablo. It must never reach a browser bundle. - Your server authenticates the user with your own system. That’s the
request that knows “this is user
U, orgO, teams[...]”. - Your server hands that authenticated identity to Ablo, and the browser talks to the realtime plane as an already-scoped participant. The browser never holds the API key and cannot widen its own scope — the security boundary is the identity your server vouched for, not anything the client asserts.
- Ablo runs your
identityRolesover that identity to compute the allowed sync groups, and the participant subscribes to exactly that set.
The Ablo web app (apps/web) is the reference implementation of this shape:
its server resolves the signed-in user and active organization from its own
auth, and the sync layer composes the participant’s sync groups from that
resolved identity — the API key stays server-side throughout. The generic,
library-agnostic name for “my server tells Ablo which of my users is acting” is
the Ablo-Acting-User request dimension; the web app realizes it through its
own session, but the contract is the same: identity is asserted by your
server, never by the browser.
Why the proxy, not a client API key? A browser is a hostile runtime. If the client could name its own org or sync groups, any user could read another tenant’s data by editing a request. By keeping the API key server-side and deriving scope from the identity your server already authenticated, the trust boundary lands in the one place you control. Scope resolution and session minting therefore stay server-side.
Wiring the provider
The identity your server resolved is carried by the client you build and the
userId prop. In a Next.js app, resolve the user in a Server Component and pass
it down. Build the client once (the schema, teamIds, and the apiKey resolver
live here; entity narrowing rides the minted session’s syncGroups), then hand
it to the provider:
// lib/ablo.ts
import Ablo from '@abloatai/ablo';
import { schema } from '@/ablo/schema';
// Build the client from the identity your server already resolved.
// teamIds → team sync groups via identityRoles.
export function makeAblo(user: { teamIds: string[] }) {
return Ablo({
schema,
// The browser holds no secret — `authEndpoint` points at the route that
// mints the short-lived session token, and the client keeps it fresh
// before expiry.
authEndpoint: '/api/ablo-session',
teamIds: user.teamIds,
});
}
// app/providers.tsx
'use client';
import { useMemo } from 'react';
import { AbloProvider } from '@abloatai/ablo/react';
import { makeAblo } from '@/lib/ablo';
export function Providers({
children,
user, // { id, teamIds } — resolved server-side from YOUR auth
}: {
children: React.ReactNode;
user: { id: string; teamIds: string[] };
}) {
const ablo = useMemo(() => makeAblo(user), [user.id]);
return (
<AbloProvider client={ablo} userId={user.id} fallback={<AppSkeleton />}>
{children}
</AbloProvider>
);
}
What carries identity — and just as importantly, what does not set the boundary:
| Where | Purpose |
|---|---|
userId prop |
App-level participant id, used for app-owned fields and read by your identityRole source. Not the security boundary: the server enforces scope from the authenticated request. |
teamIds (on the client) |
Team ids expanded into team sync groups via your identityRoles. |
syncGroups (at session mint) |
Optional. Narrows a minted session’s subscription to a subset of what auth already allows: it can never widen it. Passed to sessions.create({ user | agent, syncGroups }); build entries with syncGroup(kind, id). Use it to scope an agent (or a focused page’s session) to one entity, e.g. [syncGroup('workspace', 'abc123')]. |
Because the server is the boundary, a client that changes userId to another
user’s id does not gain their data — the server resolves and enforces the real
identity on the connection. These are how your app tells Ablo who it
already authenticated, not how it proves it.
Agents are participants too
An agent and a human authenticate through the exact same path — same proxy,
same identityRoles, same server-enforced boundary. An agent is a participant;
the only data difference is that it carries kind: 'agent' and an agentId
where a human carries userId. There is no separate identity model to learn.
What differs is authority, not identity — and the distinction is the whole point. An agent always runs on behalf of the user who set it off, so its ceiling is exactly that user’s access: the same conversations, messages, and models the triggering user can reach, and nothing that user couldn’t. But within that ceiling it is narrowed to the model instances it is touching, or has touched — never the user’s whole org.
Scope is therefore an intersection:
agent authority = (triggering user's allowed set) ← ceiling, inherited (on-behalf-of)
∩ (the model instances it touches) ← floor, least privilege per run
Concretely: each model an agent edits declares a scope
(Half 2), so each row forms its own
group. The agent subscribes only to the groups for the rows it touches. Declare
an entity anchor on the models an agent operates on:
// each scope-root model an agent edits forms a per-entity group
records: model({ /* … */ }, { groups: { root: 'document' } }),
workspaces: model({ /* … */ }, { groups: { root: 'workspace' } }),
Then a run subscribes only to the entity groups for the rows it works on — a subset of what its user could see:
// agent run triggered by `user`, working on one document + one workspace.
// Your backend mints the agent session narrowed to just the entities in play
// (the floor). Build each group from the model's scope with `syncGroup(kind, id)`.
const session = await server.sessions.create({
agent: { id: agentId },
can: { Document: ['read', 'update'], Workspace: ['read', 'update'] },
syncGroups: [syncGroup('document', recordId), syncGroup('workspace', workspaceId)],
});
// identity (the ceiling) is inherited from the triggering user via your
// session-mint logic; the agent runtime connects with the minted token.
const ablo = Ablo({ schema, apiKey: session.token });
As the run touches more entities, claim or read them and the client auto-enrolls
in their entity groups — its set accretes to cover them; it never
widens past the user’s ceiling, and it carries no standing access to entities it
isn’t working on. The identityRoles need no agent-specific entry: the agent
carries the triggering user’s userId, so the same user:{id} role that scopes
a human already scopes the agent. Nothing about the identity declaration
branches on agent vs human.
kind is what attribution uses — not access. kind: 'agent' plus agentId is
connection metadata that tags every write with the executing agent and the
user it ran on behalf of, so audit answers “who did this, and on whose behalf.”
It never appears in an identityRole, because it changes who’s accountable,
not what’s reachable.
Three rules make agent access safe, and they fall out of the model above rather than needing a separate agent permission system:
- Inherit the user, and no more: the OAuth on-behalf-of model: the agent’s reach is tied to the consenting user, never the org.
- Least privilege, just-in-time: scoped to the record’s entities, not standing org-wide access (the over-privilege pattern OWASP’s NHI Top 10 flags as the dominant agent risk).
- Dual-principal attribution: record both the executing agent and the triggering human.
Identity is 1 with a human participant; authority is narrowed to the work. That
split is what lets Ablo keep one model API for every actor without ever
granting an agent standing access to everything its user can see. The agent that
runs the Coordination claim loop is, to the scoping layer,
that same participant — scoped to the row it claimed.
Narrowing to specific entities
A human gets their full membership automatically (identityRoles). There are
three ways to narrow a participant to specific entities — a page on one workspace, or
an agent pointed at the entities it’s working on. You never hand-write
workspace:<id>; build groups from the model’s scope (Half 2) with the typed
syncGroup(kind, id) helper from @abloatai/ablo/schema.
-
At session mint —
syncGroups. When your backend mints a session, pass the exact groups it may subscribe to. This is the floor for a delegated agent (and the way to scope a focused page’s session):// an agent working across two workspaces and a document const session = await server.sessions.create({ agent: { id: agentId }, can: { Workspace: ['read', 'update'], Document: ['read'] }, syncGroups: [ syncGroup('workspace', collectionA), syncGroup('workspace', collectionB), syncGroup('document', docId), ], }); const ablo = Ablo({ schema, apiKey: session.token }); -
Automatically, on read or claim. Reading a row (
get/claim.state) auto-enrolls the client in that row’s entity group (read-interest), andclaim-ing it pins a write-intent subscription. So an agent’s reachable set accretes as it works — no extra subscribe call. -
Explicitly, for presence —
join. To hold presence on a known set of rows and react to peers, use the WebSocket-onlyablo.<model>.join(ids, { ttl })(it returns a participant handle with.peers). See Coordination.
groups.rootis the schema model option, not a client setting.groups: { root: 'workspace' }inmodel(...)declares a scope root (Half 2) — it names the group (workspace:<id>) that the mechanisms above then subscribe to. There is noAblo({ scope })constructor option. The lifecycle filter onlist()is a separate axis namedstate('live' | 'archived' | 'all', GitHub’s open/closed/all), precisely so it doesn’t share the word.
Requested groups never grant. At connect, the server intersects the session’s
syncGroupswith what the identity is actually allowed (requested ∩ allowed). SosyncGroupsonly ever narrows within a participant’s ceiling — an agent can’t reach a workspace its capability doesn’t already permit, no matter what it passes. Smaller bootstrap, less fan-out, same server-enforced boundary.
How this compares, and the best practices it follows
Ablo’s identity model is not novel; it’s the convergent answer every serious realtime / sync SDK arrived at. Knowing which industry pattern it is tells you how to reason about it.
Realtime authorization splits into two shapes. Ablo is firmly in the first:
- Server derives scope from authenticated identity: the server decides what a participant may read/write and the client cannot override it. This is Ablo’s proxy model. It’s the same shape as Supabase Realtime’s RLS-on-connect (policies evaluated at subscribe, cached for the connection), Liveblocks ID tokens (“Liveblocks checks the permissions for you” — recommended for production), and ElectricSQL proxy auth (a reverse-proxy sets shape params server-side before forwarding).
- Client proposes, server authorizes the exact request: the client names
the room/shape and the server signs off, as in
Pusher’s channel authorization endpoint,
ElectricSQL gatekeeper auth,
and Liveblocks access tokens. Ablo’s session-mint
syncGroupsis the narrowing half of this — but it can only ever shrink the server-derived set, never grow it.
The best practices Ablo inherits from that lineage:
-
The secret never reaches the client. Your
ABLO_API_KEYlives only on a trusted server — exactly as Ably mandates (“never use API keys in client-side code; they don’t expire, so once compromised they grant indefinite access”) and PowerSync’s flow (app auth → backend mints a signed token → client connects with the token). -
Trusted vs untrusted claims is the whole security argument. PowerSync draws the line precisely: token parameters are trusted and usable for access control; client parameters are not. In Ablo terms, the identity your server vouches for — and the session’s
syncGroups, minted server-side — are the trusted claims that set scope; theuserIdprop is untrusted client input — convenient for app-owned fields, but never the boundary. This is why changinguserIdin the browser grants nothing. -
Scope by a hierarchical naming convention, declared once. Ablo’s
kind:idgroup naming (org:…/team:…fromidentityRoles,workspace:…from a model’sscope) is the same idea as Liveblocks’ recommended room-id naming pattern (org:*,org:group:*) and Ably’s channel capabilities. Declaring the convention in one place — never composing scope strings in consumer code — is the practice all three enforce. -
Attribution and presence ride the authenticated identity. Just as Pusher attaches
channel_datato presence at auth time, Ablo’s participant identity (the one your server vouched for) is what powers presence and per-write attribution — not a value the client asserts after the fact.
The one practice that differs by deployment: short-lived, auto-refreshed bearer
tokens (Ably,
Supabase’s access_token refresh)
are the right shape when an untrusted client holds a credential directly. Ablo’s
proxy model keeps the credential server-side instead, so token rotation is the
server’s concern, not the browser’s — the same trade ElectricSQL’s proxy pattern
makes versus its gatekeeper tokens.
See also
- Integration Guide —
identityRoles, backing modes, and the full app path. - React — the complete
<AbloProvider>prop surface. - API Keys — server-side keys for the public API.