Client construction
Configure local socket and remote HTTP connections, authentication, and request options.
import Crouter from '@north-light/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 @north-light/api is an implementation detail and is not re-exported.
Apps that sign people in
An app that signs people in with Northlight uses OAuth2Client and oauth.client(userId), not new Crouter(). Those pages live on the SDK docs site: apps and sign-in, connections, background runs, grant changes and usage and testing your app.
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. |
budgetMs | number (ms) | One overall deadline for the call, from now. It covers every attempt, every retry wait (a server's Retry-After too) and the response body; timeout still bounds each attempt but never past it. See One deadline per call. |
deadlineAt | number (epoch ms) | The same deadline as an absolute instant, for passing one deadline to several calls. With budgetMs too, the earlier wins. |
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.
One deadline per call
Without budgetMs/deadlineAt a call has one timeout per attempt, so a keyed send (runs.start, runs.message) that is retried twice can take three timeouts plus the waits between them, and a server's retry_after_s: 120 is waited out in full. Give the call a deadline and its total time is bounded:
await client.runs.message(runId, text, { budgetMs: 20_000 });- The deadline is fixed once, when the call starts, and every attempt, every backoff, every
retry_after_s/Retry-Afterwait, the token fetch and the refresh after a 401 share it. The call rejects at the deadline (plus the scheduling of one timer) whether the time goes to the headers, the body, a retry wait or the token hook, even when yourfetchignores itsAbortSignal. The compound helpers (runs.wait,runs.parse,nodes.waitForOutcome,nodes.createAndWait,nodes.parse) fix one deadline for the whole helper, not one per poll. - A retry wait that would reach the deadline is not slept: the call rejects at once with
DeadlineExceededError(code: 'deadline_exceeded', status 504).reasonis'retry_after'when the server asked for a wait longer than the time left (retryAfterRequestSis that request), and'elapsed'when time simply ran out;deadlineAtis the instant, andcauseis the last refusal. - A stream (
nodes.stream,nodes.events):budgetMs/deadlineAtbound opening it (the create or retrieve, the token hook and the connection); once open it lives until it ends or itssignalaborts. budgetMsanddeadlineAtmust fit a timer: at most 2,147,483,647 ms (about 24.8 days) from now, or the call throwsRangeError.- Holding a lease while you call. An app that takes a lease or lock for an SDK call sets
budgetMsand keeps the lease longer than it, with margin, so the call has ended or been abandoned before the lease can be taken over:LEASE_MS >= BUDGET_MS + MARGIN_MS. State that inequality next to the constants (or assert it at module load) rather than renewing the lease while a call is unbounded. OAuth2Clientgives each directory operation (discovery, a code exchange or refresh, a revocation, the JWKS read behindverifyIdToken) the same kind of deadline,OAuth2Options.timeoutMs(default 10 s). A refresh runs under the user's store lock, so the lock is held no longer thantimeoutMsplus the store's own time.
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.