Apps with many users
Keep each person's runtime connection separate while building an app where people interact.
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 app's server holds its user table keyed by the crouter cloud 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. Sign in with crouter cloud is the initial sign-in for every app user, including a visitor. Request the app's grants during sign-in from a person whose runtime your app will use. A visitor who only needs to be recognized signs in with identity only (see Signing in a visitor). crouter cloud 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, not in a visitor's browser. The server can call providers.call on a stored connection outside any run, even when nobody is present and the person is offline. Access tokens last 10 minutes; refresh tokens rotate on each use and expire after 90 days unused. Use OAuth2Client and a persistent ConnectionStore with a cross-process lock, and schedule oauth.refreshIdleConnections(store, { olderThanMs: 30 * 24 * 60 * 60 * 1000 }) on a timer; act on each failure's code as When a refresh is refused describes, and delete a person's data only on grant_removed. See Client construction.
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 crouter cloud"), 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; // 'Sam Rivera', present only when 'profile' was approved and the person has a name (never a phone number); also givenName, familyName
visitor.picture; // 'https://lh3.googleusercontent.com/…', present only when 'profile' was approved and the account has a Google photo; show initials otherwiseexchangeIdentity returns an Identity: sub, email and emailVerified when granted, name, givenName, 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; any other value is dropped, though it stays in claims), 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, including a response with no ID token, throws CrouterError code id_token_invalid; if the directory's JWKS can't be fetched or is malformed, that error is thrown as is, not id_token_invalid.
An Identity is not a connection. It has no runtime URL, grant, or refresh token, nothing is stored in a ConnectionStore, and nothing refreshes; your app keeps its own session for the visitor. 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: exchangeCode on a sign-in-only answer, and exchangeIdentity on an answer that carries a connection. A visitor is never mistaken for a connected user. When exchangeIdentity refuses an answer that carries a connection, the directory has already recorded that grant; the SDK leaves it in place, and 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. The app rate-limits visitors; B's per-app spend cap is the platform backstop. crouter cloud'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. crouter cloud records nothing about the other user. 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. Its 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 for the person's documents is their public ones (owned by the person, read through client.memory): 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, individual reads, history, and automatic loads. 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. Your app's own documents are separate and is not filtered by these levels.
In v1, every run is sandboxed: its shell runs as the app's OS user and sees only the app's space and folders its permissions name. Memory and conversations are accessed through the daemon, not by opening their storage directly. 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, crtr commands, stored conversation, and daemon-mediated model calls still work; 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, so starting one isolated run per visitor conversation keeps visitors apart. A node of an isolated run reaches only its own run's nodes: the root and the children it spawns. Another run's nodes, documents, conversation, trace, events and node folder answer not_found, and they are left out of runs.list, canvas list and history search. Messaging, interrupting, cancelling or deleting another run also answers not_found. The app's other, non-isolated runs can't see into an isolated run either, though they still see each other. 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. No run's sandbox mounts every node folder in /apps/<app>/nodes, so a non-isolated run's shell can't read a visitor's folder either. Custom objects a node of an isolated run registers stay inside its run, and it can't spawn a child under, or fork, another run's node. 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 still cover every run, so your server can show the person each visitor's conversation. A scheduled job an isolated run creates starts a new isolated run, and that run can't see the run that created the schedule. 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.
On v0 (the Mac local runtime), there is no sandbox: a run's shell has the Mac user's access; scopes gate only daemon and CLI calls.
runs.message(runId, message, { start_turn: false }) stores an app-attributed message on an existing run without starting, reviving, or reopening a turn. The run's status does not change; the message is delivered at the start of the next turn, before that turn's input, and the trace marks it as custom / app_message. This lets an app add context for the agent to see on its next turn without waking it now. The default start_turn: true sends an ordinary follow-up.
A run's first prompt is always its first turn's input, however soon after runs.start a message arrives:
- With the default
start_turn: true, a message sent before the first turn has started queues behind it: the agent answers theruns.startprompt in turn one and your message in turn two, in the order you sent them. So an app can start a run with standing instructions and send the user's first message at once, without waiting for the first turn to begin. - To answer a first message in the first turn, pass it with the prompt:
runs.start({prompt, message})storesmessagewith the run, so it always joins the first turn as context delivered ahead of the prompt (acustom/app_messageentry), and the prompt is still that turn's input, so one reply covers both. The result'smessageis whatruns.replytakes:runs.reply(run.run_id, run.message!). Aruns.message(runId, message, {start_turn: false})sent separately afterruns.startjoins the first turn only if it is stored before the run claims that turn's messages; sent later, it waits for the next turn.
Follow the reply to your message with runs.reply(runId, sent), where sent is what runs.message (or runs.start with message) returned; .text() gives its text, and .collect({onDelta}) streams it and says how the turn ended. It matches the turn that lists the message's message_id in node.turn.started.input.message_ids and ends when that turn completes. See Streaming.