Errors
What the crouter daemon refuses, how scoped tokens are limited, the socket wire body, and how node creation survives a retry.
The error classes, the settled-outcome rule, retries and the app-facing error envelope are the same for every use of @north-light/sdk, and they are documented for app builders on the SDK docs site: Errors. This page covers what is specific to driving a crouter daemon with new Crouter().
An agent's own outcome is returned, never thrown. A node that declined the schema, hit its deadline, or crashed comes back as a settled NodeOutcome from waitForOutcome, createAndWait, or parse. Narrow on it — see Nodes.
Daemon error codes
A terminal SSE error event other than stream_gap rejects NodeStream.finalOutcome() and the iterator with APIError carrying that event's code, message, and details. stream_gap is delivered as an event and the stream continues. See Streaming.
import { ConflictError, NotFoundError, APIConnectionError } from '@north-light/sdk';
try {
await client.nodes.create({ prompt, node_id: 'my-run-42' });
} catch (error) {
if (error instanceof ConflictError && error.code === 'node_id_exists') {
// the run already exists — attach to it instead of spawning a second one
return client.nodes.waitForOutcome('my-run-42');
}
if (error instanceof NotFoundError) throw new Error('no such node');
if (error instanceof APIConnectionError) throw new Error('the daemon is not reachable');
throw error;
}Identifier validation
The SDK validates path-segment identifiers before it makes a request. Invalid node ids, cron ids, bash-job ids, human-request ids, inbox ticket ids, provider names, and profile names throw TypeError locally. Node ids apply to core node calls, lifecycle calls, and nested node resources. nodes.events() returns a NodeStream synchronously, so its invalid-id TypeError rejects stream.node, stream.finalOutcome(), and iteration instead. File paths are not identifiers; invalid or relative file paths reach the daemon and return its mapped API error. nodes.outcome(id, { wait }) separately throws RangeError when wait is not an integer from 0 through 25.
Scoped tokens
| Condition | Class, status, and code |
|---|---|
The bearer token's ceiling lacks the scope a route needs, or nodes.create asks for scopes outside it (details.scopes lists them) | PermissionDeniedError, 403 scope_denied |
| A scoped token reaches an owner-only route (daemon restart, attach, broker internals, canvas prune, profile pause/resume/delete, model credential install) | PermissionDeniedError, 403 owner_only |
The wire body
The daemon answers an error with:
{ "error": { "code": "node_id_exists", "message": "…", "details": { } } }code is the stable, machine-readable identity — node_id_exists, daemon_unavailable, request_timeout, node_dormant. Branch on it. There is no separate type field and no param field. Northlight's app-facing endpoints answer with a fuller body; see the error envelope.
Creating a node that survives a retry
nodes.create is a POST and is not retried automatically, because replaying it would spawn a second agent. Pass node_id so a duplicate fails loudly with 409 node_id_exists instead of quietly running twice:
const runId = `invoice-${invoice.id}`;
try {
await client.nodes.create({ prompt, node_id: runId, root: true });
} catch (error) {
if (!(error instanceof ConflictError && error.code === 'node_id_exists')) throw error;
}
const outcome = await client.nodes.waitForOutcome(runId);Which calls the SDK retries, and runs.start / runs.message idempotency keys, are in Retries.
In-repo callers
ApiError from @north-light/api is an alias of APIError, so existing crouter code and its transport checks keep working unchanged. Daemon handlers keep throwing { status, code, message } and never name an SDK class — the hierarchy is one status → constructor table on the client side and nothing else depends on it.