NorthlightSDK
Guides

Many users

Keep each person's runtime connection separate when people interact in your app, and understand whose action a call records.

An app can bring people together without putting all of their agents or data on one runtime. Each person's runtime holds that person's agents, memory, files, conversations, AI subscription, phone and device. Your server holds its user table keyed by the Northlight sub, one stored Connection per person in a ConnectionStore (runtime URL, rotating refresh token, grant id), in-app roles and settings, and data shared between users. Northlight permissions decide what your app can do on each person's runtime. Your app decides what its users can do to each other.

Keep the connection and refresh token on the server, never in a visitor's browser. The server can call providers.call on a stored connection outside any run, even when the person is offline. Access tokens last 10 minutes; refresh tokens rotate on each use and expire after 90 days unused.

  • Give OAuth2Client a persistent ConnectionStore with a cross-process lock as store, and reach each person's runtime with await oauth.client(userId).
  • Schedule oauth.refreshIdleConnections({olderThanMs: 30 * 24 * 60 * 60 * 1000}) on a timer. It refreshes connections last saved before the cutoff, through the store's lock, and returns {refreshed, refused, failures}. The SDK starts no timer itself.
  • Act on a dead connection in one place, onRefusal. Delete a person's data only on grant_removed (kind: 'removed').
  • Learn when a background run ends with oauth.watchRuns(...): see Background runs.

Signing in a visitor

A visitor who only needs to be recognized, not connected, signs in with identity only. Request openid, adding email for their email and profile for their name and photo, and no runtime scope. The directory shows one line ("Know who you are on Northlight"), writes no grant, and answers with an ID token and an access token usable only at /userinfo: no refresh token, grant or runtime URL. Finish that sign-in with exchangeIdentity, not exchangeCode:

const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({scopes: ['openid', 'email', 'profile'], state: sessionState});
// Save codeVerifier, state and nonce in the visitor's session; redirect them to url.
// On the callback, read them back from the session and finish the sign-in:
const visitor = await oauth.exchangeIdentity({code: callbackCode, codeVerifier, state: callbackState, expectedState: state, nonce});
visitor.sub;           // 'user:…', the same id a Connection.userId carries for this person
visitor.email;         // present only when 'email' was requested and approved
visitor.emailVerified; // the directory's email_verified, when email was approved
visitor.name;          // present only when 'profile' was approved and the person has a name (never a phone number)
visitor.picture;       // an https URL, present only when 'profile' was approved and the account has a photo

exchangeIdentity returns an Identity: sub, email and emailVerified when granted, name, givenName and familyName when profile was granted and the person has one, picture when profile was granted and the account has a photo (only an https: URL), and every verified claim in claims.

The SDK checks the ID token's ES256 signature against the directory's JWKS, its issuer, that aud is your client id, that it has not expired, and that its nonce equals the one authorizeUrl returned. nonce is required. Any failure throws CrouterError code id_token_invalid. If the directory's JWKS can't be fetched or is malformed, that error is thrown as is.

An Identity is not a connection. Nothing is stored in a ConnectionStore and nothing refreshes; your app keeps its own session for the visitor, and the visitor's runtime can't be called on their behalf. To act for the visitor later, send them through authorizeUrl again with the runtime scopes you need and finish with exchangeCode, which returns a Connection. Each call refuses the other's answer with code oauth_exchange_mismatch. If exchangeIdentity refuses an answer that carries a connection, the directory has already recorded that grant; it shows in the person's approved apps until they or your app revoke it.

Whose action a call records

When visitor A causes your server to call person B's runtime using B's connection, the runtime records the app as grantee and B as sub. It does not know or record that A caused the call. Even a request outside a run, with nobody present, has this attribution. Keep your own record of the app user and action when you need it. Do not send an app-user identity as a runtime principal.

A visitor chatting with B's clone starts an app run on B's runtime and B's AI subscription, using the app's grant from B. Your app rate-limits visitors; B's per-app spend cap is the platform backstop. Northlight's developer terms allow spending B's connection because of another user's action only for what B turned on in your app, limited per person. B can control the app's spend cap or remove the app.

The app's agent and documents

A run your app starts is your app's agent, not a continuation of another app's agent. It starts in /apps/<app>/files, uses the person's AI under crtr:llm, and reaches your app's own documents (owned by app:<your app>) without a crtr:memory scope. Other people's or apps' data is not shared merely because the app has many users. The grant's resolved permissions govern access to the person's files (crtr:files:<access>:user:<dir>), the person's documents (crtr:memory:<access>:user), another app's space (only where that app allows it), and another app's conversations (crtr:conversations:read:app:<B>).

The exception is the person's public documents (owned by the person, read through memory; see Files and documents): an app with an active grant can read these even without a user-memory scope. A third-party app cannot see friends or private documents, even with crtr:memory:read:user. They look absent from lists, searches, reads and history. Only a holder of crtr:memory:manage:user (a first-party app), or the person, reads all three levels. A document without a privacy field reads as private. Public read access is not write access.

Every run is sandboxed: its shell runs as the app's OS user and sees only the app's space and the folders its permissions name. Normal runs have network access. Start a run with isolated: true when the agent should have no network and no /apps/<app>/files mount: its cwd is its own node folder, and all children inherit the restriction. The shell, stored conversation and model calls still work, and the run's scopes can narrow its commands. An isolated run cannot turn isolation off in a child or schedule, or use an outside cwd or worktree.

An isolated run can't see, or be seen by, the app's other runs. Starting one isolated run per visitor conversation keeps visitors apart. Another run's nodes, documents, conversation, trace, events and node folder answer not_found to it, and messaging, interrupting, cancelling or deleting another run does too. Each node of an isolated run gets its own home folder, /apps/<app>/nodes/<node-id>/home, in place of the shared /apps/<app>/home. An isolated run still reads your app's memory and the person's public memory. Your app's server is not confined: runs.list and runs.trace cover every run, so your server can show the person each visitor's conversation. When a call is refused by scope, a missing-scope error (scope_missing or forbidden) comes before any not_found, so the refusal reveals nothing about the target.

Messages to a running run

runs.message(runId, message, {start_turn: false}) stores an app-attributed message on an existing run without starting, reviving or reopening a turn. The message is delivered at the start of the next turn, before that turn's input, and the trace marks it custom / app_message. The default start_turn: true sends an ordinary follow-up.

runs.message is safe to retry. The SDK sends an Idempotency-Key (one per call, reused across its own retries; pass idempotencyKey to choose it) and the runtime delivers a given key once for 24 hours. A replay returns the original result; the same key with different text, start_turn or run is refused with 409 idempotency_conflict. An app that queues sends and may crash between the call and recording success should derive idempotencyKey from its own message id.

A run's first prompt is always its first turn's input. With the default start_turn: true, a message sent before the first turn has started queues behind it: the agent answers the runs.start prompt in turn one and your message in turn two. To answer a first message in the first turn, pass it with the prompt: runs.start({prompt, message}) stores message with the run, so it joins the first turn as context delivered ahead of the prompt, and one reply covers both. Follow the reply with runs.reply(runId, sent): see Stream a run.

On this page