Skip to content
AbloAblo Docs
Esc
navigateopen⌘Jpreview
On this page

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 server-only issuer mints both. It is separate from Ablo(...) so the participant client keeps every schema model name, including ablo.sessions:

import Sessions from '@abloatai/ablo/sessions';

const sessions = Sessions({ schema, apiKey: process.env.ABLO_API_KEY });

// A logged-in person's browser session — only the operations this UI needs.
const userSession = await sessions.create({
  user: { id: currentUser.id },
  can: { records: ['read', 'update'], workspaces: ['read'] },
});

// An agent session — hand its rk_ to the agent runtime.
const agentSession = await sessions.create({
  agent: { id: crypto.randomUUID() },
  can: { records: ['read', 'update'], workspaces: ['read'] },
  userMeta: { name: 'record-writer' },
});

sessions.create({ user, can }) mints an ek_ (ephemeral key), while sessions.create({ agent, can }) mints an rk_ (restricted key). There is one issuance API; the subject selects the credential kind and attribution.

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.

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. Mount a session handler on a backend route and connect it to the authentication you already use:

import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
import { sessions } from '@/ablo/sessions';

export const POST = sessions.handler({
  async authenticate() {
    const session = await auth.api.getSession({ headers: await headers() });
    return session?.user ?? null;
  },
  async grant({ principal: user }) {
    const membership = await findActiveMembership(user.id);
    if (!membership) return null;

    return {
      user: { id: user.id },
      groups: [`workspace:${membership.workspaceId}`],
      can: { records: ['read', 'update'] },
    };
  },
});

authenticate adapts Better Auth (or any other auth system) to an application principal. grant derives Ablo identity, groups, and permissions server-side. Returning null from either step fails closed. The handler owns same-origin checks, no-store responses, and the credential envelope; the application never handles the token. can is required and cannot be empty.

Point a browser Ablo client’s session.endpoint 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/react';
import { AbloProvider } from '@abloatai/ablo/react';
import { schema } from '@/ablo.schema';

const ablo = Ablo({
  schema,
  session: { endpoint: '/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 only the thin reactive binding over it. 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? Pass an async session provider that performs the custom request and returns its credential response.

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:

The examples below reuse the server-only sessions issuer constructed above.

const session = await sessions.create({
  agent: { id: crypto.randomUUID() },
  can: { records: ['update'] },  // typed off the schema — no magic strings
  ttlSeconds: 600,
  userMeta: { name: 'record-writer' },
});

const agent = Ablo({ schema, session, transport: 'http' });
await agent.records.update({ id, data });
await agent.dispose();

The session is the credential; construct the agent client in the runtime that will use it. A write grant automatically includes the corresponding read, so can: { records: ['update'] } is enforced as record.update plus record.read. Operations are 'read' | 'create' | 'update' | 'delete'.

Give reusable access a domain name and keep it next to the worker that owns it:

const workerAccess = {
  records: ['update'],
} as const;

const session = await sessions.create({
  agent: { id: crypto.randomUUID() },
  can: workerAccess,
});

The SDK checks the model names and operations against the bound schema before minting, and the server validates them again against the active pushed schema.

For a long-running agent, pass an async session provider. It re-mints the same logical identity through the canonical resource; the client caches each result until it approaches expiresAt and uses the replacement for HTTP requests and WebSocket reconnects:

const session = () => sessions.create({
  agent: { id: stableWorkerId },
  can: workerAccess,
  groups: [workspaceGroup],
});

const agent = Ablo({ schema, session });

A static session resource does not invent authority to renew itself. It lives until expiresAt; use a provider when the client must outlive that credential. Session clients use one reconnecting WebSocket by default. Pass transport: 'http' only for bounded scoped work that must not hold a socket.

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.
organizationId user Select another Ablo organization, not an application account. Requires organization:act-as; uses the target root branch.
schemaProject user Override the schema project for a cross-org mint. Usually omitted because the owning key’s project is the default.
groups 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). A renewable browser or agent provider pre-mints ahead of expiry and reconnects with the replacement, so the logical session and durable observation continue even though an individual socket may be replaced. A static session ends at its credential’s expiry. 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 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 sessions.rotate({
  id: session.id,
  graceSeconds: 300,
  ttlSeconds: 900,
});

Browser ek_ sessions rotate through session.endpoint; 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 session.endpoint 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 5xx from 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 session endpoint responds 401 with the canonical { error: { code: 'session_expired' } } body. An unrelated 401 or 403 is 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 archive:<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 groups 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:

  1. Push your schema to your project.
  2. Mint an ek_ per user: sessions.create({ user: { id }, can }). Your users commit to that one schema with only the operations in can.

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.

For customers sharing one application connection, declare a model subject rule and mint verified account groups with the ordinary application key. Keep its organization, project and branch. The customer guide owns the complete tenancy model, migration requirements and isolation checks.

Separate Ablo organizations are separate data planes. A cross-organization user session can reuse the issuer’s schema artifact, but it uses the target organization’s default project and root branch. It does not inherit the issuer’s staging branch or database connection. Each target plane needs its own supported data-source setup.

const sessions = Sessions({ schema, apiKey: process.env.ABLO_PLATFORM_KEY! });
const session = await sessions.create({
  user: { id: userId },
  organizationId, // A separate Ablo organization and data plane.
  can: { records: ['read', 'update'] },
  ttlSeconds: 300,
});

For migrations or advanced routing, sessions.create also accepts an explicit schemaProject: { organizationId, projectId } override.

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

Session authority combines the organization/project/branch boundary, model subject rules, verified groups, the can allowlist and expiry. A short TTL limits exposure; it does not create row isolation. Keep issuer secret keys on the server and verify membership whenever issuing or renewing customer sessions.

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 sessions.create({ user: { id }, can }) sessions.create({ agent: { id }, can })
Lives where the user’s browser the agent runtime

Was this page helpful?