Northlight
SDK

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 socket

Crouter 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

OptionTypeDefaultMeaning
baseURLstringCRTR_BASE_URL, else unsethttp(s)://host:port of a daemon TCP listener.
socketPathstringCRTR_SOCKET, else ${CRTR_HOME}/crtrd.sock, else ~/.crouter/canvas/crtrd.sockUnix socket. Node only; throws in a browser.
tokenstringCRTRD_TOKENSent 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.
timeoutnumber (ms)30_000Per-request wall clock. Does not apply to a stream.
maxRetriesnumber2Transient-failure retries. Never applied to POST or PATCH — see Errors.
defaultHeadersRecord<string, string>{}Merged into every request.
headersRecord<string, string>unsetMerged after defaultHeaders; a matching name wins unless token is set, in which case the generated Authorization header wins at construction.
fetchtypeof fetchglobal fetch, or the socket implementation when socketPath is usedTransport override, for proxies and instrumentation.
autostartbooleantrue for a socket, false for baseURLOn 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.

VariableFills
CRTR_BASE_URLbaseURL
CRTR_SOCKETsocketPath
CRTR_HOMEthe directory the default socket path is resolved under ($CRTR_HOME/crtrd.sock)
CRTRD_TOKENtoken

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
});
FieldTypeEffect
signalAbortSignalAborts the underlying fetch. Raises APIUserAbortError.
timeoutnumber (ms)Overrides the client timeout.
maxRetriesnumberOverrides the client maxRetries.
budgetMsnumber (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.
deadlineAtnumber (epoch ms)The same deadline as an absolute instant, for passing one deadline to several calls. With budgetMs too, the earlier wins.
headersRecord<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-After wait, 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 your fetch ignores its AbortSignal. 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). reason is 'retry_after' when the server asked for a wait longer than the time left (retryAfterRequestS is that request), and 'elapsed' when time simply ran out; deadlineAt is the instant, and cause is the last refusal.
  • A stream (nodes.stream, nodes.events): budgetMs / deadlineAt bound opening it (the create or retrieve, the token hook and the connection); once open it lives until it ends or its signal aborts.
  • budgetMs and deadlineAt must fit a timer: at most 2,147,483,647 ms (about 24.8 days) from now, or the call throws RangeError.
  • Holding a lease while you call. An app that takes a lease or lock for an SDK call sets budgetMs and 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.
  • OAuth2Client gives each directory operation (discovery, a code exchange or refresh, a revocation, the JWKS read behind verifyIdToken) 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 than timeoutMs plus the store's own time.

What is not on the client

ConventionWhy 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().
apiKeyThe daemon's credential is a token (CRTRD_TOKEN) everywhere in the product. apiKey would be a second name for one thing.
Resource-prefixed idsNode 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.

On this page