Client construction
Configure local socket and remote HTTP connections, authentication, and request options.
import Crouter from '@crouter/sdk';
const localClient = new Crouter(); // owner's own daemon, unix socket
const remoteClient = new Crouter({ baseURL: 'http://localhost:8787', token: 'token' }); // TCP with a bearer token
const customSocketClient = new Crouter({ socketPath: '/custom/path/crtrd.sock' }); // an explicit socketCrouter is the default export and the only class an application constructs. CrtrClient from @crouter/api is an implementation detail and is not re-exported.
An app connection and refresh
import { OAuth2Client } from '@crouter/sdk';
const oauth = new OAuth2Client({clientId, privateKey, keyId, redirectUri, issuer}); // issuer is required; no localhost fallback
const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({scopes: ['openid', 'crtr:act'], state: sessionState});
// Save codeVerifier, state, and nonce in the app session; redirect the user to url.
const connection = await oauth.exchangeCode({code: callbackCode, codeVerifier, state: callbackState, expectedState: state, nonce});
const client = oauth.client(connection, store);exchangeCode returns the person's Connection and is for a sign-in that requests runtime scopes. A sign-in-only request (openid, optionally with email and profile, and no runtime scope) gets no connection from the directory: finish it with oauth.exchangeIdentity({code, codeVerifier, state, expectedState, nonce}), which returns the verified Identity (sub, email when granted, name/givenName/familyName when profile was granted and the person has a name, picture (an https photo URL) when profile was granted and the account has a photo, and claims, typed IdTokenClaims, which the SDK also exports). When scopes is a literal list that includes openid, authorizeUrl types its nonce as string; a list held in a variable keeps its literal type only when declared as const (const scopes = ['openid', 'email'] as const). Pass prompt: 'consent' to authorizeUrl to show the directory's consent screen even when the person already approved these scopes. See Signing in a visitor. exchangeCode given a sign-in-only answer throws CrouterError code oauth_exchange_mismatch and names exchangeIdentity.
state: callbackState comes from the redirect query, while expectedState is the value saved in the app's session when authorizeUrl() ran. exchangeCode and exchangeIdentity refuse a missing or different value with CrouterError code oauth_state_mismatch before sending the code to the token endpoint. Persist the expected value across redirects and tie it to the user's session; never use the callback's state for both arguments.
Keep sign-in secrets out of HTTP logs
If your app uses nestjs-pino / pino-http, its default serializers can log the full /auth/callback?code=…&state=… URL and query, plus response headers including the session set-cookie and a redirect location containing OAuth state. Redacting only authorization and cookie request headers does not protect these values. Use bounded serializers for all routes, not just the callback: log only request method, path without query string, and id; log only response status. For example:
LoggerModule.forRoot({
pinoHttp: {
serializers: {
req: (req: { method?: string; url?: string; id?: unknown }) => ({
method: req.method,
path: req.url?.split('?')[0],
id: req.id,
}),
res: (res: { statusCode?: number }) => ({ statusCode: res.statusCode }),
},
redact: ['req.headers.authorization', 'req.headers.cookie'],
},
});Do not add the raw query, URL, response headers, or body elsewhere in your logs. Test an auth callback and a redirect with representative secret values and assert that neither the code, state, nor session cookie appears in the output.
ConnectionStore provides load(userId) and save(connection); it may also implement lock(userId, fn) and listStale(before: Date). A multi-process app must implement the lock across every process: the SDK reloads the connection, sends the refresh request only if no other process rotated its token, and saves the result while the lock is held. MemoryConnectionStore locks only within one process and is for local development.
For Postgres, install pg in the app and use the optional SDK subpath instead of writing the lock yourself:
import {Pool} from 'pg';
import {PostgresConnectionStore} from '@crouter/sdk/stores/postgres';
const store = new PostgresConnectionStore(new Pool({connectionString: process.env.DATABASE_URL}), {table: 'connection'});Create the table before using it (or adapt this schema in a migration):
CREATE TABLE connection (
user_id text PRIMARY KEY,
runtime_url text NOT NULL,
refresh_token text NOT NULL,
grant_id text NOT NULL,
access_token text NOT NULL,
access_token_expires_at timestamptz NOT NULL,
id_token text,
updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX connection_updated_at_idx ON connection (updated_at, user_id);The store holds pg_advisory_xact_lock on a dedicated pooled connection for the entire refresh callback, including the token request and save. All workers must use the same database; provision pool capacity for concurrent refreshes, and do not set a transaction timeout shorter than a token request. updated_at records every save, not necessarily a refresh if your app also saves for another reason. The table holds credentials: restrict database access and encrypt backups. This subpath is isolated from the SDK's main import, so apps without Postgres do not load pg.
runtime_provisioning means the user's runtime is still being set up; the SDK retries the token request.
When a refresh is refused
When the directory refuses a connection's refresh token, the SDK raises an AuthenticationError (status 401, type: 'authentication_error', retryable: false, userAction: 'reconnect', origin: 'directory') whose code says why. The client returned by oauth.client(connection, store) then stops refreshing that connection: later calls on it raise the same error without contacting the directory, until you build a new client from a new connection.
Delete what you keep for a person only on grant_removed. On every other code, keep it.
code | What happened | What your app does |
|---|---|---|
grant_removed | The person removed your app, deleted their account, or a provider-connected registration disconnected them. The runtime has deleted your app's space, runs, schedules and shares. | Forget the connection. You may delete what you keep for that person. Do not call runs.delete: the runs are already gone. If they connect again, they see the consent page and start fresh. |
grant_suspended | Your app's access for that person is paused. Nothing was deleted; your app's sandboxes are stopped. | Keep everything you store for that person. Drop the dead refresh token, stop calling for them, and show that the app is paused. If they connect again, the same grant comes back with every run, file and schedule. |
refresh_token_expired | The refresh token went 90 days without use. The grant is still active. | Keep everything. Run the connect flow again; the consent page is skipped. Prevent it with refreshIdleConnections (below). |
refresh_token_reused | Two of your app's processes raced on one refresh token, and the directory ended the grant's refresh tokens. The grant is still active. | Keep everything. Fix the store's lock, then reconnect (consent is skipped); without the fix the race recurs. |
grant_revoked | The directory refused without naming a reason: a refresh token it never issued to your app, one your app revoked through revoke, or one whose record was purged. The runtime also answers grant_revoked when the grant is not active in its last sync; the connected client then refreshes once, so you normally receive the directory's specific code instead. | Keep everything and reconnect. Never delete data on grant_revoked: it does not say anything was removed. |
Every code in this table carries userAction: 'reconnect', so an app that only needs to prompt a reconnect can switch on userAction; an app that deletes or pauses must switch on code.
refreshRefusal(error) reads this table for you: it returns 'removed' (grant_removed), 'paused' (grant_suspended), 'reconnect' (refresh_token_expired, refresh_token_reused, grant_revoked), or null when error is not the directory refusing a refresh.
import { refreshRefusal } from '@crouter/sdk';
switch (refreshRefusal(error)) {
case 'removed': await forgetUser(userId); break; // the runtime already deleted the app's runs
case 'paused': await markPaused(userId); break; // keep everything
case 'reconnect': await askToSignIn(userId); break; // keep everything
default: throw error;
}Keep offline users connected
A refresh token expires after 90 days without refresh. Schedule a worker (for example, daily) to call oauth.refreshIdleConnections(store, {olderThanMs: 30 * 24 * 60 * 60 * 1000}). It lists connections last saved before the cutoff, refreshes each using the store's cross-process lock, and returns {refreshed, failures: [{userId, error, reason}]}, where reason is refreshRefusal(error). Act on each failure's reason (or its error.code) as When a refresh is refused describes. The SDK does not start a timer. A store without listStale(before: Date): Promise<Connection[]> gets a CrouterError naming that missing method; a nonpositive or nonfinite olderThanMs also gets a CrouterError. listStale should reflect the most recent successful save/rotation, not the last read of a connection. An app with custom stores can implement that optional method; MemoryConnectionStore implements it for local development. Store credentials securely.
Options
| Option | Type | Default | Meaning |
|---|---|---|---|
baseURL | string | CRTR_BASE_URL, else unset | http(s)://host:port of a daemon TCP listener. |
socketPath | string | CRTR_SOCKET, else ${CRTR_HOME}/crtrd.sock, else ~/.crouter/canvas/crtrd.sock | Unix socket. Node only; throws in a browser. |
token | string | CRTRD_TOKEN | Sent as Authorization: Bearer <token>. The owner token or a scoped token from crtr sys connect; a scoped token's ceiling is enforced per request (see Getting started). Ignored by a unix-socket daemon, which authenticates by filesystem permission. |
timeout | number (ms) | 30_000 | Per-request wall clock. Does not apply to a stream. |
maxRetries | number | 2 | Transient-failure retries. Never applied to POST or PATCH — see Errors. |
defaultHeaders | Record<string, string> | {} | Merged into every request. |
headers | Record<string, string> | unset | Merged after defaultHeaders; a matching name wins unless token is set, in which case the generated Authorization header wins at construction. |
fetch | typeof fetch | global fetch, or the socket implementation when socketPath is used | Transport override, for proxies and instrumentation. |
autostart | boolean | true for a socket, false for baseURL | On a cold socket, run crtr sys daemon start and retry once. Node only. |
Environment-variable fallbacks
Every fallback above is read at construction, not at request time.
| Variable | Fills |
|---|---|
CRTR_BASE_URL | baseURL |
CRTR_SOCKET | socketPath |
CRTR_HOME | the directory the default socket path is resolved under ($CRTR_HOME/crtrd.sock) |
CRTRD_TOKEN | token |
An explicit option always beats its environment variable.
Transport selection
baseURL wins if it is set; otherwise socketPath; otherwise the default socket path. Passing both baseURL and socketPath throws TypeError at construction — exactly one transport per client. In a browser, new Crouter({}) has no daemon transport; give it the saved baseURL and token before making requests.
There is one request path. The client issues every request through the Web fetch API, which a browser and Node both supply globally. The unix socket is not a second transport — it is a fetch implementation the SDK installs when socketPath is set, built on node:http with { socketPath }, returning a standard Response whose body is a ReadableStream. Streaming, abort, headers, and error parsing therefore have exactly one implementation, and the browser build never sees Node code.
Autostart
With autostart on (the default for a socket), a request that finds a cold socket runs crtr sys daemon start, waits for the daemon to serve, and retries the request once. This is what makes npm i -g crouter followed by new Crouter() work on a machine that has never run the daemon.
Autostart is Node-only and applies only to the socket transport. A baseURL client cannot start a daemon it may not even share a machine with, so autostart defaults to false there; setting it to true on a baseURL client throws CrouterError (autostart is only valid for the local socket transport).
Per-request options
Every request-capable method accepts an options object as its last argument, after any path, body, or query arguments. Each field overrides the client-level default for that one request.
const id = 'example-node';
const signal = new AbortController().signal;
await client.nodes.retrieve(id, {
signal, // AbortSignal — real cancellation, passed straight to fetch
timeout: 5_000, // ms, this request only
maxRetries: 0, // this request only
headers: { 'x-trace': 't-9' } // merged over defaultHeaders
});| Field | Type | Effect |
|---|---|---|
signal | AbortSignal | Aborts the underlying fetch. Raises APIUserAbortError. |
timeout | number (ms) | Overrides the client timeout. |
maxRetries | number | Overrides the client maxRetries. |
headers | Record<string, string> | Merged over defaultHeaders, client headers, and the token-generated header; a matching name, including Authorization, wins. |
signal is real cancellation. The long-polling helpers (waitForOutcome, createAndWait, parse) honour it between polls and during the in-flight request, and aborting them stops the client — it does not cancel the node. To stop the run itself, call client.nodes.cancel(id) or client.nodes.interrupt(id). Streams use NodeEventsOptions: the same fields except timeout, plus after for nodes.events; see Streaming.
What is not on the client
| Convention | Why it is absent |
|---|---|
.withResponse() / .asResponse() | They exist to hand back the raw Response and an x-request-id. The daemon emits no request id, and a caller who needs raw bytes uses client.request(). |
apiKey | The daemon's credential is a token (CRTRD_TOKEN) everywhere in the product. apiKey would be a second name for one thing. |
| Resource-prefixed ids | Node ids have their own format. The SDK validates unsafe path identifiers locally with TypeError, and the daemon validates the request too; nothing is re-prefixed. |
Wire naming
Wire fields are snake_case — output_schema, root_lifecycle, pin_cwd, final_report_path. The SDK does not camelize them. Namespaces and method names are camelCase (client.nodes.waitForOutcome, client.nodes.worktree).
Timestamps are ISO-8601 strings (created, settled_at, finalized_at, deadline_at), not Unix seconds.
For a sign-in that requested runtime scopes plus profile, read the name and picture from the verified ID token: if (connection.idToken) profileFromClaims(await oauth.verifyIdToken(connection.idToken, {nonce})) (idToken is optional on Connection).