Sessions
Short-lived scoped credentials your backend mints for a browser or an agent.
A session is a short-lived credential your backend mints with its sk_ and
hands to one actor — a signed-in person’s browser or a scoped agent. It’s
the same primitive in both cases (backend-minted, short-lived, scoped); the only
difference is the subject and how much authority it carries.
One resource mints both:
// A logged-in person's browser session — only the operations this UI needs.
const userSession = await ablo.sessions.create({
user: { id: currentUser.id },
can: { tasks: ['read', 'update'], workspaces: ['read'] },
});
// Recommended agent path — returns a ready, scoped client.
const agent = await ablo.agents.create({
name: 'task-writer',
can: { tasks: ['read', 'update'], workspaces: ['read'] },
});
sessions.create({ user, can }) mints an ek_ (ephemeral key).
agents.create({ can }) mints and manages an rk_ (restricted key). Use the
lower-level sessions.create({ agent, can }) only when another runtime needs
the raw agent token.
It exists because of one rule: the browser can never hold a secret. Your
sk_ lives on the server; the browser only ever holds a minted session token
(which already names your org). So the per-actor credential is minted
server-side, scoped, and expires in minutes — the model Stripe uses for
client-side SDKs.
Why
Ablo doesn’t authenticate your users — you do, however you like (your own
sessions, an IdP, anything). Ablo authenticates your project (the sk_ that
minted the session) and trusts the identity you asserted at mint time. The
session token is that assertion: “this connection is acting as U, in org
O, until it expires.”
End-user sessions (ek_)
For a logged-in person using your app. Mint on a backend route that has already authenticated the user:
import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth';
const { token, expiresAt } = await ablo.sessions.create({
user: { id: currentUser.id }, // who the session acts as
can: { tasks: ['read', 'update'] },
// syncGroups: [...], // optional; defaults to the user's org + user
});
return Response.json(
credentialEndpointSuccessSchema.parse({
token,
expiresAt,
credentialKind: 'ephemeral',
}),
{ headers: { 'Cache-Control': 'no-store' } },
);
can is required. The mint fails closed when the grant is absent or empty.
Point a browser Ablo client’s authEndpoint at that route, and pass
the instance to <AbloProvider>. The client fetches the token,
opens the connection, and re-mints before expiry — your app writes no token
plumbing:
'use client';
import Ablo from '@abloatai/ablo';
import { AbloProvider } from '@abloatai/ablo/react';
import { schema } from '@/ablo.schema';
const ablo = Ablo({
schema,
authEndpoint: '/api/ablo-session',
});
export function Providers({ children }: { children: React.ReactNode }) {
return <AbloProvider client={ablo}>{children}</AbloProvider>;
}
The client owns auth, the credential lifecycle, and the connection; the provider
is the thin reactive binding over it (Stripe’s <Elements stripe={...}> model).
Build the client once at module scope — a new instance per render tears down
the socket. Need custom headers or a
body on the exchange? authEndpoint also accepts an async resolver that
returns the token.
Agents (rk_)
For a non-human actor — an agent or automation that should only do specific
operations. The can map is the permission boundary, and it’s typed against
your schema — the model keys are your schema’s models, so a typo is a compile
error, not a silent over-grant:
const agent = await ablo.agents.create({
name: 'task-writer',
can: { tasks: ['update'] }, // typed off the schema — no magic strings
ttlSeconds: 600,
});
await agent.tasks.update({ id, data });
await agent.dispose();
The returned client refreshes its own short-lived credential. A write grant
automatically includes the corresponding read, so
can: { tasks: ['update'] } is enforced as task.update plus task.read.
Operations are 'read' | 'create' | 'update' | 'delete'.
For a reusable grant, use TypeScript’s satisfies. It checks the object against
the schema while preserving its narrow literals—there is no string parser or
second permission model:
import type { CapabilityGrant } from '@abloatai/ablo/auth';
import { schema } from './ablo.schema';
const taskWriterCan = {
tasks: ['update'],
} satisfies CapabilityGrant<typeof schema>;
const agent = await ablo.agents.create({ can: taskWriterCan });
documents instead of tasks, or 'write' instead of 'update', is a compile
error. At runtime the SDK parses the same grant with the schema-bound Zod
contract before minting, and the server validates it again against the active
pushed schema.
Mint
Only a secret key (sk_) can mint a session — never another session token.
The sk_ is the trust anchor; minting is your backend vouching
for the actor.
| Param | For | Meaning |
|---|---|---|
user / agent |
both | The actor. id becomes the token’s participantId. Pass exactly one. |
can |
both | Required non-empty per-model operation allowlist, typed off the schema. |
syncGroups |
both | Narrow the session below its default scope. Omit to inherit. |
ttlSeconds |
both | Lifetime in seconds. Defaults to 900 (15m). |
userMeta |
both | Opaque identity blob echoed back to the client. |
Lifecycle
Sessions are short-lived by design (~15 minutes) and, for browsers, auto-refreshed — the provider re-mints ahead of expiry, so a session never drops at the boundary. Signing out stops refresh and the old token expires on its own.
Revoke immediately when a token is exposed or an actor loses access:
await ablo.sessions.revoke({ id: session.id });
Agent sessions can rotate with overlap so a worker can adopt the replacement before the previous token expires:
const replacement = await ablo.sessions.rotate({
id: session.id,
graceSeconds: 300,
ttlSeconds: 900,
});
Browser ek_ sessions rotate through authEndpoint; do not distribute rotated
browser tokens manually.
Offline & sign-out
The short session token is not your user’s login — it’s a minutes-long
credential layered on top of whatever long-lived auth your authEndpoint
already enforces (your own session cookie, an IdP, etc.). The provider keeps
those two lifetimes separate, which means:
- Going offline never signs the user out. The provider keeps working from
its local cache and treats a failed re-mint (no network, a timeout, a
5xxfrom your endpoint) as transient — it retries, and re-mints the instant connectivity or tab focus returns. The user stays signed in for as long as your underlying session is valid, however brief or long the network drop. - The user is signed out only when the underlying session is genuinely
gone — your
authEndpointresponds401with the canonical{ error: { code: 'session_expired' } }body. An unrelated401or403is a policy/configuration failure, not proof that the login ended.
This mirrors the OAuth refresh-token rule (Okta/Auth0/Authgear): only a rejection of the long-lived credential ends the session — a network failure never does.
Scope
A user session carries the user’s base sync-groups (org:/user:/team:),
derived from the identity you minted it for. Dynamic, relation-driven
membership (e.g. a dataroom:<id> the user was just added to) is resolved
server-side at connect and unioned on top — so scope stays live, not frozen
at mint time. Pass syncGroups only when you want to narrow below the default.
Your schema, your users (the default)
Your schema lives in a project — you push it once (npx ablo push) and every
session you mint resolves against it. The flow for serving end-users:
- Push your schema to your project.
- Mint an
ek_per user:sessions.create({ user: { id }, can }). Your users commit to that one schema with only the operations incan.
Your users do not have Ablo accounts. You authenticate them however you
already do; your server’s sk_ mints the ek_. By default the session lands in
your project’s own org, so all your users share one schema and one data tenant,
isolated from each other by sync-groups. For most apps (the Cursor shape) that’s
the whole story — nothing below is needed.
Org-per-customer isolation (the add-on)
Some apps need each customer to be its own tenant — a hard data boundary (separate row-level isolation, optionally a separate database), not just per-user scoping. The law-firm shape (Legora): every firm is its own org, many users inside it.
The problem that creates: if each customer is a separate org, a naïve setup would make you re-push your schema into every new customer’s org. You don’t have to. Keep one project as the home of your schema, and point each customer’s session’s schema at it while its data stays in the customer’s own org:
const { token } = await mintUserSessionKey({
apiKey: process.env.ABLO_PLATFORM_KEY, // sk_ with the ephemeral:mint-any-org scope
userId,
organizationId, // DATA → this customer's org (its own isolated tenant)
schemaProject: { // SCHEMA → the project that owns your schema
organizationId: schemaOwnerOrgId,
projectId: schemaProjectId,
},
operations: ['task.read', 'task.update'],
ttlSeconds: 3600,
});
Server-side the split is clean: the model shape loads from your schema
project, but column enrichment and the tenant connection target the customer’s
organizationId — so the shared schema only describes the shape; the data
plane (connection + row-level isolation) stays the customer’s. A shared schema
can’t leak data across orgs.
Security
The whole safety argument is the short TTL: a session token leaked from a
browser (XSS) is valid for minutes, scoped to one actor’s data, and can’t mint
anything or touch the control plane. Contrast sk_, which would be a full org
compromise — which is exactly why it never leaves your server.
User vs. agent sessions
User session (ek_) |
Agent session (rk_) |
|
|---|---|---|
| For | a person in the browser | an agent / automation |
| Authority | narrow (explicit can allowlist) |
narrow (explicit can allowlist) |
| Mint | ablo.sessions.create({ user: { id }, can }) |
ablo.sessions.create({ agent: { id }, can }) |
| Lives where | the user’s browser | the agent runtime |