@north-light/cli 0.3.472 → 0.3.474

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/api/dto/nodes.d.ts +1 -1
  2. package/dist/builtin-memory/crouter-sdk/README.md +4 -47
  3. package/dist/builtin-memory/crouter-sdk/client.md +2 -231
  4. package/dist/builtin-memory/crouter-sdk/errors.md +9 -50
  5. package/dist/builtin-memory/crouter-sdk/getting-started.md +3 -65
  6. package/dist/builtin-memory/crouter-sdk/guides/README.md +1 -3
  7. package/dist/builtin-memory/crouter-sdk/guides/typed-extraction.md +1 -1
  8. package/dist/builtin-memory/crouter-sdk/nodes.md +1 -1
  9. package/dist/builtin-memory/crouter-sdk/release-notes.md +20 -20
  10. package/dist/builtin-memory/crouter-sdk/streaming.md +1 -70
  11. package/dist/builtin-memory/northlight-apps/INDEX.md +15 -0
  12. package/dist/builtin-memory/northlight-apps/cli.md +41 -0
  13. package/dist/builtin-memory/northlight-apps/coding-agents.md +75 -0
  14. package/dist/builtin-memory/northlight-apps/concepts/apps-and-sign-in.md +109 -0
  15. package/dist/builtin-memory/northlight-apps/concepts/connections.md +109 -0
  16. package/dist/builtin-memory/northlight-apps/concepts/providers.md +76 -0
  17. package/dist/builtin-memory/northlight-apps/concepts/runs.md +101 -0
  18. package/dist/builtin-memory/northlight-apps/concepts/scopes-and-grants.md +64 -0
  19. package/dist/builtin-memory/northlight-apps/concepts/testing-mode.md +54 -0
  20. package/dist/builtin-memory/northlight-apps/errors.md +119 -0
  21. package/dist/builtin-memory/northlight-apps/guides/background-runs.md +80 -0
  22. package/dist/builtin-memory/northlight-apps/guides/files-and-documents.md +61 -0
  23. package/dist/builtin-memory/northlight-apps/guides/go-live.md +39 -0
  24. package/dist/builtin-memory/northlight-apps/guides/grant-changes.md +100 -0
  25. package/dist/builtin-memory/northlight-apps/guides/manage-your-app.md +77 -0
  26. package/dist/builtin-memory/northlight-apps/guides/many-users.md +63 -0
  27. package/dist/builtin-memory/northlight-apps/guides/stream-a-run.md +84 -0
  28. package/dist/builtin-memory/{crouter-sdk/testing.md → northlight-apps/guides/test-your-app.md} +6 -6
  29. package/dist/builtin-memory/northlight-apps/guides/typed-results.md +99 -0
  30. package/dist/builtin-memory/northlight-apps/how-it-fits.md +49 -0
  31. package/dist/builtin-memory/northlight-apps/providers/README.md +39 -0
  32. package/dist/builtin-memory/northlight-apps/providers/context.md +34 -0
  33. package/dist/builtin-memory/northlight-apps/providers/device.md +28 -0
  34. package/dist/builtin-memory/northlight-apps/providers/integrations.md +28 -0
  35. package/dist/builtin-memory/northlight-apps/providers/search.md +31 -0
  36. package/dist/builtin-memory/northlight-apps/providers/sms.md +39 -0
  37. package/dist/builtin-memory/northlight-apps/quickstart.md +210 -0
  38. package/dist/commands/node/create.js +2 -2
  39. package/docs/apps/cli.md +40 -0
  40. package/docs/apps/coding-agents.md +72 -0
  41. package/docs/apps/concepts/apps-and-sign-in.md +82 -0
  42. package/docs/apps/concepts/connections.md +106 -0
  43. package/docs/apps/concepts/providers.md +59 -0
  44. package/docs/apps/concepts/runs.md +94 -0
  45. package/docs/apps/concepts/scopes-and-grants.md +62 -0
  46. package/docs/apps/concepts/testing-mode.md +36 -0
  47. package/docs/apps/errors.md +117 -0
  48. package/docs/apps/guides/background-runs.md +78 -0
  49. package/docs/apps/guides/files-and-documents.md +59 -0
  50. package/docs/apps/guides/go-live.md +37 -0
  51. package/docs/apps/guides/grant-changes.md +98 -0
  52. package/docs/apps/guides/manage-your-app.md +75 -0
  53. package/docs/apps/guides/many-users.md +61 -0
  54. package/docs/apps/guides/stream-a-run.md +82 -0
  55. package/docs/{sdk/testing.md → apps/guides/test-your-app.md} +5 -5
  56. package/docs/apps/guides/typed-results.md +72 -0
  57. package/docs/apps/how-it-fits.md +46 -0
  58. package/docs/apps/providers/README.md +26 -0
  59. package/docs/apps/providers/context.md +32 -0
  60. package/docs/apps/providers/device.md +26 -0
  61. package/docs/apps/providers/integrations.md +26 -0
  62. package/docs/apps/providers/search.md +29 -0
  63. package/docs/apps/providers/sms.md +37 -0
  64. package/docs/apps/quickstart.md +120 -2
  65. package/docs/cli/README.md +1 -0
  66. package/docs/cli/join-northlight.md +58 -0
  67. package/docs/sdk/README.md +4 -47
  68. package/docs/sdk/client.md +2 -231
  69. package/docs/sdk/errors.md +8 -49
  70. package/docs/sdk/getting-started.md +3 -65
  71. package/docs/sdk/guides/README.md +1 -3
  72. package/docs/sdk/guides/typed-extraction.md +1 -1
  73. package/docs/sdk/nodes.md +1 -1
  74. package/docs/sdk/release-notes.md +20 -20
  75. package/docs/sdk/streaming.md +1 -70
  76. package/package.json +2 -2
  77. package/packages/crouter-identity/package.json +1 -1
  78. package/runtime.lock.json +12 -12
  79. package/dist/builtin-memory/crouter-sdk/error-envelope.md +0 -38
  80. package/dist/builtin-memory/crouter-sdk/guides/apps-on-northlight.md +0 -423
  81. package/dist/builtin-memory/crouter-sdk/guides/apps-with-many-users.md +0 -61
  82. package/dist/builtin-memory/crouter-sdk/memory.md +0 -49
  83. package/docs/sdk/error-envelope.md +0 -36
  84. package/docs/sdk/guides/apps-on-northlight.md +0 -419
  85. package/docs/sdk/guides/apps-with-many-users.md +0 -59
  86. package/docs/sdk/memory.md +0 -47
@@ -44,7 +44,7 @@ export interface CreateNodeRequest {
44
44
  parent?: NodeIdDTO | null;
45
45
  /** Node that created this request; absent for an external process. */
46
46
  creator?: NodeIdDTO;
47
- /** Per-run allow-list. Absent inherits everything. Some scopes are recorded but not enforced in beta: llm, files:<dir>, net, provider groups, and peers. */
47
+ /** Per-run allow-list. Absent inherits everything. A provider group (`<provider>:<group>`, e.g. `device:messages_send`) is enforced: a call to a group the list omits is refused `scope_missing` before any request leaves the machine; children inherit the list. Enforced on the broker route; on an unsandboxed host a same-UID process can bypass it by calling the daemon socket directly (crouter#146). */
48
48
  scopes?: string[];
49
49
  /** Sticky isolation; a child cannot turn off an isolated run. */
50
50
  isolated?: boolean;
@@ -26,48 +26,9 @@ const run = await client.nodes.parse({
26
26
  if (run.kind === 'result') console.log(run.output_parsed.root_cause);
27
27
  ```
28
28
 
29
- ## Go live with an app key
29
+ ## Building an app for other people
30
30
 
31
- Once you have installed the SDK and tried the local flow in [[crouter-sdk/getting-started]], build against production: [[crouter-sdk/guides/apps-on-northlight]] takes you from signing up at `https://account.northlight.ai` and joining your laptop (`crtr sys join`) to a running app on your laptop, registered with `crtr cloud apps register`. Generate the key and pull the settings with:
32
-
33
- ```bash
34
- npx @north-light/sdk keygen --app <client id>
35
- npx @north-light/sdk env --app <client id>
36
- ```
37
-
38
- `keygen` saves the private JWK to `local/app-key.json` (mode `0600`); keep that file out of git. With `--app`, it offers to add the public key to your registered app through your own `crtr`, as an additional key that never replaces the current one. Otherwise, add the public JWK printed on stdout with `crtr cloud apps keys add --app-id <client id> --jwk '<public JWK>'`. An app authenticates only with its key; the directory issues no client secret.
39
-
40
- `env` reads the registration through `crtr cloud apps get` and writes `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env` (`--out` picks another file), keeping every other line. Both commands need this machine's `crtr` joined to your Northlight account; on a machine that isn't, they print a join step and exit 2. An app is configured from four variables:
41
-
42
- | Variable | Value |
43
- |---|---|
44
- | `CROUTER_ISSUER` | The directory's issuer URL |
45
- | `CROUTER_CLIENT_ID` | The app's client id |
46
- | `APP_ORIGIN` | The app's origin; its redirect URI is `${APP_ORIGIN}/auth/callback` by convention |
47
- | `CROUTER_KEY_FILE` | Path to the private JWK, default `local/app-key.json`. `env` never writes it |
48
-
49
- The SDK reads no environment itself: load the values in your own config and pass them to `OAuth2Client`.
50
-
51
- ```ts
52
- import {readFile} from 'node:fs/promises';
53
- import {OAuth2Client} from '@north-light/sdk';
54
-
55
- const privateKey = JSON.parse(await readFile(process.env.CROUTER_KEY_FILE ?? 'local/app-key.json', 'utf8'));
56
- const oauth = new OAuth2Client({
57
- clientId: process.env.CROUTER_CLIENT_ID!,
58
- issuer: process.env.CROUTER_ISSUER!,
59
- redirectUri: `${process.env.APP_ORIGIN}/auth/callback`,
60
- privateKey,
61
- store, // your ConnectionStore
62
- });
63
- const {url, state, codeVerifier, nonce} = await oauth.authorizeUrl({scopes: ['openid', 'crtr:llm', 'crtr:act'], state: sessionState});
64
- // Save state, codeVerifier, nonce in the user's session, then redirect to url.
65
- const connection = await oauth.exchangeCode({code: callbackCode, state: callbackState, expectedState: state, codeVerifier, nonce});
66
- await store.save(connection);
67
- const client = await oauth.client(connection.userId); // Refreshes through the store from here on.
68
- ```
69
-
70
- The directory rejects the key until its public JWK is added to the app. To change keys later, follow [[crouter-sdk/getting-started]]: add the new key, switch the app to it, verify a real sign-in, then retire the old key. An app can hold two active keys at once, so there is no downtime.
31
+ An app that signs people in with Northlight and runs agents on their runtimes is documented on the SDK docs site, which starts at [Quick setup](https://sdk.docs.northlight.ai/docs/quickstart). This section and the pages below cover driving a crouter daemon you run yourself.
71
32
 
72
33
  ## Pages
73
34
 
@@ -79,14 +40,10 @@ The directory rejects the key until its public JWK is added to the app. To chang
79
40
  | [[crouter-sdk/streaming]] | The event table, `stream()` / `events()`, the activity helper, resume with `after`, `stream_gap` and `stream_dropped` |
80
41
  | [[crouter-sdk/files]] | Absolute-path reads, writes, and one-level lists |
81
42
  | [[crouter-sdk/bash]] | One bounded command and its output result |
82
- | [[crouter-sdk/memory]] | `memory.list` and `memory.get`: an app reads the person's `public` documents with no scope |
83
43
  | [[crouter-sdk/resources]] | Every namespace (including `customObjects`, `runs`, `memory`, `shares`, `providers`) with its routes, what is deliberately excluded, and the `client.request()` escape hatch |
84
- | [[crouter-sdk/errors]] | The error class table and the retry policy |
44
+ | [[crouter-sdk/errors]] | The daemon's scoped-token and wire errors, identifier validation, and node retries |
85
45
  | [[crouter-sdk/docker]] | `start()`, `attach()`, `connection()` from `@north-light/env-docker` |
86
- | [[crouter-sdk/testing]] | `FakeCloud` from `@north-light/sdk/testing`: a fake directory and runtime your real `OAuth2Client` and `Crouter` talk to through `fetch` |
87
- | [[crouter-sdk/release-notes]] | Behavior changes an existing app must act on, newest first |
88
- | [[crouter-sdk/guides/apps-on-northlight]] | Building and registering an app against production (joining a laptop, `crtr cloud apps` registration, keys, env file, scopes, running locally, the `crtr cloud apps` commands); typed results from app runs with `runs.parse`, declines, bounding a run, `run_idle`, and waiting on a provider your app requires |
89
- | [[crouter-sdk/guides/apps-with-many-users]] | Per-person runtime connections, app-owned user access, privacy, and spending |
46
+ | [Release notes](https://sdk.docs.northlight.ai/docs/release-notes) | Behavior changes an existing app must act on, newest first |
90
47
 
91
48
  ## Phases
92
49
 
@@ -17,236 +17,9 @@ const customSocketClient = new Crouter({ socketPath: '/custom/path/crtrd.sock' }
17
17
 
18
18
  `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.
19
19
 
20
- ## An app connection and refresh
20
+ ## Apps that sign people in
21
21
 
22
- ```ts
23
- import { OAuth2Client } from '@north-light/sdk';
24
-
25
- const oauth = new OAuth2Client({
26
- clientId, privateKey, keyId, redirectUri, issuer, // issuer is required; no localhost fallback
27
- store, // where every person's Connection lives (see below)
28
- onRefusal: async (refusal) => { /* see "When a refresh is refused" */ },
29
- });
30
- const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({scopes: ['openid', 'crtr:act'], state: sessionState});
31
- // Save codeVerifier, state, and nonce in the app session; redirect the user to url.
32
- const connection = await oauth.exchangeCode({code: callbackCode, codeVerifier, state: callbackState, expectedState: state, nonce});
33
- await store.save(connection);
34
-
35
- // Later, in any process that shares the store:
36
- const client = await oauth.client(connection.userId);
37
- ```
38
-
39
- `oauth.client(userId)` loads the person's stored connection and returns a `ConnectedCrouter`: a client on their runtime that offers only what the app listener serves (`AppClient`: `runs`, `uploads`, `attachments`, `shares`, `questions`, `memory`, `providers`, `grant`, `usage`, `health`, `files.download`, `profiles.list` and `request`; the owner-daemon namespaces such as `crons`, `nodes`, `canvas`, `bash` and `files.read/write/list` are absent from its type and throw `CrouterError` if reached) and that refreshes its access token through the store, plus `userId` and `runtimeUrl` (the runtime it calls, as the stored connection names it). When nothing is stored for `userId` it throws `CrouterError` code `not_connected`: show "sign in again". `store` is required: `new OAuth2Client` without one throws a `CrouterError`.
40
-
41
- `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 [[crouter-sdk/guides/apps-with-many-users]]. `exchangeCode` given a sign-in-only answer throws `CrouterError` code `oauth_exchange_mismatch` and names `exchangeIdentity`.
42
-
43
- `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.
44
-
45
- ### When sign-in is refused: apps in testing
46
-
47
- A new app is in testing until Northlight verifies it. When someone who is not the app's admin or a named tester signs in, the directory redirects to your `redirectUri` with `error=access_denied`, `error_description=app_in_testing` and your `state`, and issues no code. `authorizationRefusalOf` reads it from the callback's query (a `URL`, `URLSearchParams`, query string, or parsed object) and returns `{code: 'app_in_testing', error: 'access_denied', state}`, else `null`:
48
-
49
- ```ts
50
- import {authorizationRefusalOf} from '@north-light/sdk';
51
-
52
- const refused = authorizationRefusalOf(new URL(request.url));
53
- if (refused) {
54
- // check refused.state against the saved state, then show "this app is in testing; ask its developer to add you"
55
- }
56
- ```
57
-
58
- A person who pressed Cancel (`access_denied` with no reason) returns `null`. Test the path with [[crouter-sdk/testing]].
59
-
60
- ### Keep sign-in secrets out of HTTP logs
61
-
62
- 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:
63
-
64
- ```ts
65
- LoggerModule.forRoot({
66
- pinoHttp: {
67
- serializers: {
68
- req: (req: { method?: string; url?: string; id?: unknown }) => ({
69
- method: req.method,
70
- path: req.url?.split('?')[0],
71
- id: req.id,
72
- }),
73
- res: (res: { statusCode?: number }) => ({ statusCode: res.statusCode }),
74
- },
75
- redact: ['req.headers.authorization', 'req.headers.cookie'],
76
- },
77
- });
78
- ```
79
-
80
- 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.
81
-
82
- `ConnectionStore` provides `load(userId)`, `save(connection)`, `lock(userId, fn)` and `remove(userId, refreshToken)`; it may also implement `listStale(before: Date)`. `lock` must hold across every process that shares the store and commit what `fn` wrote when `fn` resolves: 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. The SDK never runs your code (`onRefusal`) under the lock, and never throws out of it. `remove(userId, refreshToken)` deletes the connection only while it still holds that refresh token and answers whether it deleted a row; the SDK calls it under `lock` after a refused refresh's `onRefusal` resolves, so a connection a fresh sign-in saved meanwhile is kept. `MemoryConnectionStore` locks only within one process, and its lock is not reentrant; it is for local development and tests.
83
-
84
- For Postgres, install `pg` in the app and use the optional SDK subpath instead of writing the lock yourself:
85
-
86
- ```ts
87
- import {Pool} from 'pg';
88
- import {PostgresConnectionStore} from '@north-light/sdk/stores/postgres';
89
-
90
- const store = new PostgresConnectionStore(new Pool({connectionString: process.env.DATABASE_URL}), {table: 'connection'});
91
- ```
92
-
93
- Create the table before using it (or adapt this schema in a migration):
94
-
95
- ```sql
96
- CREATE TABLE connection (
97
- user_id text PRIMARY KEY,
98
- runtime_url text NOT NULL,
99
- refresh_token text NOT NULL,
100
- grant_id text NOT NULL,
101
- access_token text NOT NULL,
102
- access_token_expires_at timestamptz NOT NULL,
103
- id_token text,
104
- updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
105
- );
106
- CREATE INDEX connection_updated_at_idx ON connection (updated_at, user_id);
107
- ```
108
-
109
- 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`.
110
-
111
- `runtime_provisioning` means the user's runtime is still being set up; the SDK retries the token request.
112
-
113
- `service_unavailable` (503, `retryable: true`, `origin: 'runtime'`) on an app call means the runtime could not confirm the grant the access token names: the token carries a grant revision newer than the runtime's grant mirror, and the pull that would bring the mirror up to date failed. Nothing was refused and nothing ran. The SDK retries `GET`, `runs.start`, and `runs.message` on it; on any other call, retry it yourself. Never treat it as a refused connection.
114
-
115
- ### When a refresh is refused
116
-
117
- When the directory refuses a stored connection's refresh token, the SDK runs your `onRefusal(refusal)` once the store lock is released, then removes the stored connection (only while it still holds the refused token), then rejects the call that needed the refresh with the directory's `AuthenticationError` (status 401, `type: 'authentication_error'`, `retryable: false`, `userAction: 'reconnect'`, `origin: 'directory'`). `refusalOf(error)` on that error returns the same `Refusal`. Every caller goes through this one place: a request on `oauth.client(userId)`, `refreshIdleConnections`, and a `RunWatcher`. A client whose connection was refused then raises the same error on every later call without contacting the directory; call `oauth.client(userId)` again after the person signs in.
118
-
119
- A `Refusal` is `{kind, code, userId, grantId, error}`. `kind` reads this table; `code` is the directory's code. **Delete what you keep for a person only on `grant_removed`** (`kind: 'removed'`). On every other code, keep it.
120
-
121
- | `code` | `kind` | What happened | What your app does |
122
- |---|---|---|---|
123
- | `grant_removed` | `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. | 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. |
124
- | `grant_suspended` | `paused` | 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, 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. |
125
- | `app_in_testing` | `paused` | Your app is still in testing (not verified by Northlight) and this person is not its admin or a named tester: they were removed as a tester, or their email or phone stopped matching one. Their grant is revoked, not removed; nothing was deleted and your app's sandboxes are stopped. | Keep everything you store for that person, stop calling for them, and tell them the app is in testing and the developer must add them. If the developer adds them back and they sign in again, the same space comes back. |
126
- | `refresh_token_expired` | `reconnect` | 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). |
127
- | `refresh_token_reused` | `reconnect` | 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. |
128
- | `grant_revoked` | `reconnect` | 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. |
129
-
130
- In every case the SDK removes the stored connection after your handler resolves, because its refresh token can never work again; your handler does not delete it.
131
-
132
- ```ts
133
- import { OAuth2Client, refusalOf } from '@north-light/sdk';
134
-
135
- const oauth = new OAuth2Client({
136
- clientId, privateKey, keyId, redirectUri, issuer, store,
137
- onRefusal: async ({kind, userId}) => {
138
- if (kind === 'removed') await forgetUser(userId); // the runtime already deleted the app's runs
139
- else if (kind === 'paused') await markPaused(userId); // keep everything
140
- else await askToSignIn(userId); // keep everything
141
- },
142
- });
143
-
144
- // In a request handler, answer the one request; the handler above already acted on the connection.
145
- try { await (await oauth.client(userId)).runs.start({prompt}); }
146
- catch (error) { if (refusalOf(error)) return showSignInAgain(); throw error; }
147
- ```
148
-
149
- `onRefusal` runs **at least once** per dead connection: make it idempotent. Two processes can each be refused on the same token, and a handler that throws runs again. It runs outside the store lock, so it may take as long as it needs and may use the same database pool.
150
-
151
- - **If it throws**, the stored connection is kept, and the caller receives the directory's refusal error (not the handler's): `refusalOf(error)` is set and `error.cause` is what the handler threw. The client does not latch that refusal, so the next use of the connection refreshes, is refused again, and calls the handler again. `refreshIdleConnections` reports it in `failures`.
152
- - **It must not call the runtime for `refusal.userId`**: that connection is dead. `oauth.client(userId)` (or a refresh) for that same user inside the handler throws `CrouterError` code `refusal_handler_reentry` instead of waiting on itself. Calls for other users are fine. (Detection uses Node's `AsyncLocalStorage`; where it is unavailable the guard is off.)
153
-
154
- `refusalOf(error)` returns a `Refusal` only for an error the SDK raised while refreshing a stored connection. A 429, `cap_exhausted`, `scope_missing`, a runtime-origin `grant_revoked`, or an `invalid_client` refusal of your app's key is not a refused connection, and `refusalOf` returns `null` for it.
155
-
156
- ### Keep offline users connected
157
-
158
- A refresh token expires after 90 days without refresh. Schedule a worker (for example, daily) to call `oauth.refreshIdleConnections({olderThanMs: 30 * 24 * 60 * 60 * 1000})`. It lists connections last saved before the cutoff, refreshes each through the store's cross-process `lock`, and returns `{refreshed, refused, failures}`. A refused connection goes through `onRefusal` and is removed, and is counted in `refused`. `failures` (`[{userId, error}]`) holds every connection still stored that could not be refreshed: an error other than a refusal, or a refusal whose `onRefusal` threw (`refusalOf(error)` is set); the next run retries them. A connection removed before its turn is skipped. 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. `MemoryConnectionStore` and `PostgresConnectionStore` implement it. Store credentials securely.
159
-
160
- ### Learn when a background run ends
161
-
162
- A run the app starts and does not wait on (a draft, a scheduled job) ends while nobody is holding a request. `oauth.watchRuns(...)` returns a `RunWatcher` that follows those runs and hands each ending to `onEnded`, so the app writes no poll loop of its own:
163
-
164
- ```ts
165
- const watcher = oauth.watchRuns({
166
- pending: () => db.drafts.findMany({where: {status: 'drafting'}, select: {userId: true, runId: true}}),
167
- onEnded: async ({userId, runId, ended, run}) => {
168
- // Idempotent: only a row still waiting on this run moves.
169
- if (ended === 'gone') await db.drafts.updateMany({where: {runId, status: 'drafting'}, data: {status: 'failed'}});
170
- else await db.drafts.updateMany({where: {runId, status: 'drafting'}, data: {status: 'ready', result: run.result}});
171
- },
172
- onError: (error, run) => log.warn({error, run}, 'run watcher'),
173
- });
174
- await watcher.start();
175
- // After starting a run the app will watch:
176
- watcher.nudge();
177
- // On shutdown:
178
- await watcher.stop();
179
- ```
180
-
181
- - **`pending()`** returns `{userId, runId}` for every run the app still waits on, read from its own rows. The rows are the durable record: a restarted watcher re-derives everything from them. The watcher calls it at `start()`, every `sweepMs` (default 30 000), after each delivery, and on `nudge()`. Stop returning a run once `onEnded` has landed it.
182
- - **Each run** is followed with `runs.get(runId, {wait})` on `oauth.client(userId)` until its status is `settled`, `idle` or `waiting_on_user`; then `onEnded({userId, runId, ended, run})` receives that status as `ended` and the `RunObject`. At most `concurrency` (default 50) long-polls are open at once; the rest wait their turn.
183
- - **Delivery is at least once per `(runId, status, latest_sequence)`.** A run still in `pending()` after delivery is re-read once per sweep without `wait`, and delivered again only when that key changes: it worked again and came to rest at a new sequence, or an `idle` run was later canceled to `settled`. A sweep that reads `running` goes back to the long-poll. The key is held in memory, so a restart may deliver an ending again: guard the write on the row still waiting on that run.
184
- - **`gone`** (`run: null`) is delivered once when the runtime answers 404 `not_found` with `origin: 'runtime'` (the run was deleted). A 404 from the hosting router (`origin: 'router'`) is a transient failure, not `gone`.
185
- - **If `onEnded` throws**, the same ending is retried after 5 s, doubling to 5 min, and the error goes to `onError`. A transient runtime error backs off the same way. A missing connection (`not_connected`) or a refused one (which `onRefusal` already handled) drops that run's loop and reports to `onError`; the next sweep decides again from `pending()`.
186
- - **`stop()`** stops sweeping, aborts every open long-poll, and resolves once every loop has ended; no handler starts afterwards.
187
- - **Live progress (`onActivity`, optional).** To show what a run is doing (a "reading your memories" / "writing the page" badge) without a second `runs.get` per page read, pass `onActivity: async ({userId, runId, activity, run}) => …`. It is called with each read of a run that is still `running` whose `activity` (see `RunObject.activity`: `starting`, `thinking`, `tool`, `writing`, `submitting_result`, `between_turns`) differs from the last one handed over for that run, compared as JSON, so a new `tool.name` or `tool.summary` counts as a change and a new `latest_sequence` alone does not. It is never called for a run at rest. While `onActivity` is set, running runs are read with `wait: activityWait` (integer 1–25 s, default 2) instead of 25 s, because the runtime's long-poll answers early only when the run comes to rest; a followed run is read at least that often, at one read per run per `activityWait` seconds. A run waiting for a `concurrency` slot or backing off after a failure is not read, and a phase that starts and ends between two reads is not seen. The hook is best effort and never awaited by the watcher: at most one call per run runs at a time, a newer reading taken meanwhile replaces any still waiting, a throw goes to `onError` and is not retried, and it never delays, repeats or changes `onEnded`. A call can finish after that run's `onEnded`, or arrive for a run the app just dropped from `pending()`, so guard the write on the row still waiting on that run and show the stored phase only while the row is waiting; `stop()` does not wait for a call already running.
188
-
189
- ### Learn when a person's grant changes
190
-
191
- What an app reads from `grant.get()` changes while nobody is holding a request: a required provider's setup turns `ready` (`dependencies`), the person changes the app's scopes or spend cap, or the grant is revoked. `oauth.watchGrants(...)` returns a `GrantWatcher` that follows the grants of the people the app names and hands each change to `onChanged`, so the app writes no poll loop and needs no browser tab open:
192
-
193
- ```ts
194
- const watcher = oauth.watchGrants({
195
- // Only people whose change the app will act on: here, owners still waiting on Context.
196
- users: () => db.owners.findMany({where: {connected: true, drafted: false}}).then((rows) => rows.map((row) => row.userId)),
197
- onChanged: async ({userId, grant}) => {
198
- // Level-triggered: decide from the grant as it is now, never from "did it just change".
199
- if (grant.dependencies['context']?.state !== 'ready') return;
200
- // Idempotent: only a row still waiting moves, so a redelivery or a second instance starts nothing.
201
- const claimed = await db.owners.updateMany({where: {userId, drafted: false}, data: {drafted: true}});
202
- if (claimed.count === 1) await startDraft(userId);
203
- },
204
- onError: (error, userId) => log.warn({error, userId}, 'grant watcher'),
205
- });
206
- await watcher.start();
207
- // After the app starts waiting on someone:
208
- watcher.nudge();
209
- // On shutdown:
210
- await watcher.stop();
211
- ```
212
-
213
- - **`users()`** returns the Northlight user ids whose grant the app follows, read from its own rows. The watcher calls it at `start()`, every `sweepMs` (default 30 000), after each delivery, and on `nudge()`. A user it no longer returns is forgotten: no further reads, and a user who comes back is delivered their current grant again. Each followed person holds one request on their runtime per turn, so return only people whose change the app will act on.
214
- - **Each turn** is one `grant.get` on `oauth.client(userId)`; then the user goes to the back of the queue, so with more followed users than `concurrency` (default 50) every user still gets a turn in round-robin order. A user whose last reading was delivered reads `grant.get({wait, since: version})` (`wait` integer 1–25, default 25): the runtime holds the read until the grant's `version` changes or `wait` seconds pass. A user with nothing delivered yet (their first turn, or after `onChanged` threw) reads at once.
215
- - **`onChanged({userId, grant, previous})`** is level-triggered. The first event for a user in a process carries the current grant and `previous: null`, so a restart delivers current state again; later events carry the grant last delivered as `previous`. Delivery is at least once per `(userId, version)` and never repeated for an unchanged `version`; make the handler idempotent (guard the write on the row still waiting).
216
- - **If `onChanged` throws**, the same version is delivered again after 5 s, doubling to 5 min, and the error goes to `onError`. A transient runtime error (5xx, `runtime_starting`, a router 504, a network failure), or a runtime 401 `grant_revoked` the client's one forced refresh did not clear, backs off the same way. An answer without `version` (a runtime older than 0.3.400) is reported and backs off. A missing connection (`not_connected`) or a refused one (which `onRefusal` already handled, once) drops that user and reports to `onError`; the next sweep decides again from `users()`.
217
- - **`stop()`** stops sweeping, aborts every open read, and resolves once every turn has ended; no handler starts afterwards.
218
- - **One person, one request.** Without the watcher, `runtime.grant.get({wait: 25, since: grant.version})` holds a single read the same way; the request timeout is extended by `wait`. `wait` outside integer 1–25 throws `RangeError` before sending.
219
-
220
- ### Show what a person's month has cost
221
-
222
- A person's grant caps what your app may spend of their AI subscription each UTC month (`grant.spend_cap`; Northlight sets $50). `usage.get()` answers where the month stands:
223
-
224
- ```ts
225
- const usage = await runtime.usage.get(); // or {month: '2026-09'}
226
- // {grantee, month, usd_equivalent, by_model, app_paid, spend_cap, reset_at}
227
- ```
228
-
229
- - **`usd_equivalent`** is user-paid spend at list price: model completions and provider receipts. Receipts have no `by_model` row, so it can exceed the sum of `by_model`. A model with no list price reports `usd_equivalent: null` on its row and adds nothing.
230
- - **`app_paid`** is usage your app pays for. It never counts toward `spend_cap`.
231
- - **At the cap.** Once `usd_equivalent` reaches `spend_cap`, user-paid model and provider calls answer 402 `cap_exhausted` (a `RateLimitError` with `retryable: false`, `userAction: 'raise_cap'` and `resetAt`) until `reset_at`, the first instant of the next UTC month. A run whose model call is refused ends with outcome reason `cap_exhausted`. The check runs before each call, so the call that crosses the cap completes and the next one is refused. Do not retry; tell the person when the count resets.
232
- - `spend_cap: null` means the grant is not capped.
233
-
234
- ### Link to the app's settings on Northlight
235
-
236
- Each person who uses your app has a page on their Northlight account where they change or remove what the app may do. Link to it with the URL the directory advertises; never compose the path yourself:
237
-
238
- ```ts
239
- const href = await oauth.accountAppUrl(); // e.g. https://<directory>/account/apps/<your client id>
240
- ```
241
-
242
- - **What it is.** The discovery document's `account_app_uri_template` with its one `{client_id}` replaced by your encoded client id. It is the same URL for every person: the directory shows whoever is signed in to it their own page for your app (and sends someone without a directory session through sign-in first). Someone without an active grant, a sign-in-only visitor for one, sees that the app is not connected to their account.
243
- - **What it needs.** No user and no connection, so it serves owners and visitors alike. Its only request is the discovery read that `authorizeUrl`, `verifyIdToken` and `revoke` share.
244
- - **Errors.** Each is a `CrouterError` (code `crouter_error`) whose message names the fault: the directory advertises no `account_app_uri_template`, the template does not contain `{client_id}` exactly once, the expansion is not a URL on the issuer's origin, or an endpoint field of the discovery document (`authorization_endpoint`, `token_endpoint`, `jwks_uri`, `revocation_endpoint`) is not an absolute https URL (`http:` when the issuer is `http:`) or `token_endpoint_auth_methods_supported` is not an array of strings. A discovery read that fails throws `Directory discovery failed: <status>` or the fetch error; one that takes longer than `timeoutMs` (default 10 seconds) throws `DeadlineExceededError`. A failed or timed-out read is not kept, so the next call reads again; a successful one is kept for the client's life, so a template the directory changes reaches your app at its next start.
245
- - **Warm it at startup and bound it on page loads.** Call `void oauth.accountAppUrl().catch(log)` when the process starts, so the discovery read is usually done before the first page needs it. A page that must not wait on the directory races it against a short timer (a couple of seconds, under the SDK's 10 s) and hides the link when the race is lost; the read keeps running and a later page load reuses its result.
246
-
247
- ### Testing your app
248
-
249
- To test code built on `OAuth2Client` or `Crouter` without a real cloud, pass `fetch` and `issuer` from a `FakeCloud` (`@north-light/sdk/testing`): the fake directory and runtime answer your real client, refusals and refreshes included. See [[crouter-sdk/testing]].
22
+ 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](https://sdk.docs.northlight.ai/docs/concepts/apps-and-sign-in), [connections](https://sdk.docs.northlight.ai/docs/concepts/connections), [background runs](https://sdk.docs.northlight.ai/docs/guides/background-runs), [grant changes and usage](https://sdk.docs.northlight.ai/docs/guides/grant-changes) and [testing your app](https://sdk.docs.northlight.ai/docs/guides/test-your-app).
250
23
 
251
24
  ## Options
252
25
 
@@ -342,5 +115,3 @@ await client.runs.message(runId, text, { budgetMs: 20_000 });
342
115
  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`).
343
116
 
344
117
  Timestamps are ISO-8601 strings (`created`, `settled_at`, `finalized_at`, `deadline_at`), not Unix seconds.
345
-
346
- 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`).
@@ -1,45 +1,17 @@
1
1
  ---
2
2
  kind: knowledge
3
3
  when-and-why-to-read: When you need Errors, this knowledge should be read
4
- because Handle API and connection errors separately from settled agent
5
- outcomes.
4
+ because What the crouter daemon refuses, how scoped tokens are limited, the
5
+ socket wire body, and how node creation survives a retry.
6
6
  ---
7
7
 
8
8
  # Errors
9
9
 
10
- ## What throws and what does not
10
+ 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](https://sdk.docs.northlight.ai/docs/errors). This page covers what is specific to driving a crouter daemon with `new Crouter()`.
11
11
 
12
- **An agent's own outcome is returned, never thrown.** A run that declined the schema, hit its deadline, or crashed comes back as a settled `NodeOutcome` from `waitForOutcome`, `createAndWait`, or `parse`. Narrow on it — see [[crouter-sdk/nodes]].
12
+ **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 [[crouter-sdk/nodes]].
13
13
 
14
- Exceptions are for the layer underneath: the SDK rejected an invalid local identifier or configuration, the daemon refused the request, the connection failed, the request timed out, or you aborted.
15
-
16
- An error without `origin` was produced by the SDK because the request reached no server (runtime, router, or directory). Show it as a connection failure, never as a refusal. An OAuth ID token that fails verification (signature, issuer, audience, expiry, or a missing or mismatched nonce), or a sign-in-only answer with no ID token, raises the SDK-only `CrouterError` code `id_token_invalid`. A directory JWKS that can't be fetched or is malformed is not `id_token_invalid`: that error is thrown as is. `oauth_exchange_mismatch` means the wrong exchange call was used: `exchangeCode` on a sign-in-only answer, or `exchangeIdentity` on an answer that carries a connection. `oauth_nonce_missing` means `exchangeIdentity` was called without the `nonce` from `authorizeUrl`. `not_connected` means `oauth.client(userId)` (or a refresh) found no stored connection for that person: show "sign in again". `refusal_handler_reentry` means an `onRefusal` handler called `oauth.client` (or a refresh) for the same person whose connection was just refused; see [[crouter-sdk/client]]. When the directory refuses the app's client assertion (`invalid_client`) on `exchangeCode`, a refresh, or `revoke`, `OAuth2Client` throws an `APIError` with code `unauthorized` and status 401. It is an `AuthenticationError`, including when the refusal comes from the token refresh inside a client returned by `oauth.client(userId)`. The message names the key id (`kid`) the SDK signed with and asks whether that key's public JWK is registered on the app's Sign-in tab. `details` is `{oauth_error: 'invalid_client', kid}`. For a key with no `kid`, `kid` is `null` and `details.key_thumbprint` holds the key's RFC 7638 thumbprint instead. See [[crouter-sdk/getting-started]].
17
-
18
- Every code the SDK raises itself is listed in `sdkErrorCodes`, and `CrouterError.code` is typed `SdkErrorCode`; both are exported from `@north-light/sdk/errors` alongside the daemon's `errorCodes` / `ErrorCode`. That subpath also exports `mapError(error)`, which turns a plain `APIError` into its status class below (`NotFoundError`, `ConflictError`, …), for test doubles that build errors by hand.
19
-
20
- For the full app-facing HTTP envelope and the SDK's fixed code tuples, see [[crouter-sdk/error-envelope]]. The socket/v0 wire body below is deliberately smaller.
21
-
22
- ## Classes and mappings
23
-
24
- Every class below extends `APIError`, which carries `status`, `code`, `message`, `details`, and `headers`.
25
-
26
- | Condition | Class |
27
- |---|---|
28
- | SDK configuration error | `CrouterError` with `status: 0`, `code: 'crouter_error'` |
29
- | The runtime does not serve the contract level the SDK requires, or names none. The hosting router or the runtime refuses this *before* handling (the SDK sends `Crouter-Required-Contract` and a frozen `Crouter-Minimum-Runtime-Version`): nothing was accepted and no idempotency key was recorded. A runtime that predates the contract level handles the request first, and the SDK then raises after | `RuntimeVersionError` with `status: 426`, `code: 'runtime_version_unsupported'`; `requiredContract` and `runtimeContract` (or `null`) name both levels, `sdkVersion` and `runtimeVersion` stay for logging, and it is never retryable. A level is `MAJOR.MINOR`; the runtime satisfies it when it serves the same major and a minor at or above. See [[crouter-sdk/release-notes]] |
30
- | HTTP 400 | `BadRequestError` |
31
- | HTTP 401 | `AuthenticationError` |
32
- | HTTP 403 | `PermissionDeniedError` |
33
- | HTTP 404 | `NotFoundError` |
34
- | HTTP 409 | `ConflictError` |
35
- | HTTP 413, 422 | `UnprocessableEntityError` |
36
- | HTTP 429 | `RateLimitError` |
37
- | Code `deadline_exceeded` | `DeadlineExceededError` (extends `APIConnectionTimeoutError`; status 504; `reason` `elapsed` or `retry_after`, `deadlineAt`, `cause`) |
38
- | HTTP 504 or code `request_timeout` | `APIConnectionTimeoutError` (extends `APIConnectionError`) |
39
- | Other HTTP ≥ 500 | `InternalServerError` |
40
- | Code `daemon_unavailable`, `transport_error`, `daemon_request_interrupted`, or `daemon_health_unavailable`; or a non-API transport failure | `APIConnectionError` |
41
- | Code `request_aborted` | `APIUserAbortError` |
42
- | Any other daemon response | `APIError` |
14
+ ## Daemon error codes
43
15
 
44
16
  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 [[crouter-sdk/streaming]].
45
17
 
@@ -59,8 +31,6 @@ try {
59
31
  }
60
32
  ```
61
33
 
62
- The subclasses add **no fields**. `status` and `code` on the base class already decide everything there is to branch on. They exist so that `catch (error) { if (error instanceof NotFoundError) … }` — what an OpenAI SDK user writes without thinking about it — works here too.
63
-
64
34
  ## Identifier validation
65
35
 
66
36
  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.
@@ -80,20 +50,11 @@ The daemon answers an error with:
80
50
  { "error": { "code": "node_id_exists", "message": "…", "details": { } } }
81
51
  ```
82
52
 
83
- `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.
84
-
85
- ## Retries
86
-
87
- `maxRetries` defaults to `2`, with exponential backoff. It applies to:
53
+ `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](https://sdk.docs.northlight.ai/docs/errors#the-error-envelope).
88
54
 
89
- - connection errors, and
90
- - HTTP 429 and 5xx responses, **on `GET`, `HEAD`, and `DELETE` only**.
55
+ ## Creating a node that survives a retry
91
56
 
92
- **`POST` and `PATCH` are not retried automatically, with two exceptions: `runs.start` and `runs.message`.** Creating a node and pushing a report are not idempotent, and a mutation whose response was interrupted may already have been applied — replaying it would spawn a second agent or push a second report.
93
-
94
- `runs.start` and `runs.message` carry an `Idempotency-Key` header: the SDK generates one key per call (or uses the `idempotencyKey` option you pass) and reuses it on every retry of that call. The daemon records the key in the same transaction that accepts the request and holds it for 24 hours, so a retry after a lost response returns the original result and delivers once. Reusing a key with a different body is refused with `409 idempotency_conflict`. Both calls retry transport failures and retryable refusals. A key the SDK generates does not survive a restart of your process: to dedupe a send across restarts, derive `idempotencyKey` from your own message id.
95
-
96
- If you want a create that survives a retry, pass `node_id`. A duplicate then fails loudly with `409 node_id_exists` instead of quietly running twice:
57
+ `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:
97
58
 
98
59
  ```ts
99
60
  const runId = `invoice-${invoice.id}`;
@@ -107,9 +68,7 @@ try {
107
68
  const outcome = await client.nodes.waitForOutcome(runId);
108
69
  ```
109
70
 
110
- A call with `budgetMs` (or `deadlineAt`) has one deadline over all of these attempts and waits, a server's `Retry-After` included: a wait longer than the time left rejects at once with `DeadlineExceededError` instead of sleeping (see [[crouter-sdk/client]]). Without one, a keyed send honours any `retry_after_s`.
111
-
112
- Override the policy per request with `{ maxRetries: 0 }` or `{ maxRetries: 5 }`. Raising it on a `POST` other than `runs.start` and `runs.message` still does not make the client retry that `POST`.
71
+ Which calls the SDK retries, and `runs.start` / `runs.message` idempotency keys, are in [Retries](https://sdk.docs.northlight.ai/docs/errors#retries).
113
72
 
114
73
  ## In-repo callers
115
74
 
@@ -27,7 +27,7 @@ npm i --save-exact @north-light/sdk@0.3.451
27
27
 
28
28
  One dependency. It re-exports every data type you need, so you do not also install `@north-light/api`.
29
29
 
30
- `@latest` always works against production: a release reaches `latest` only once production serves its contract level. The `next` tag carries every release the moment it is published and may need a newer runtime than production runs (see [[crouter-sdk/errors]] for `RuntimeVersionError`). Keep the exact pin: the compatibility gate protects the wire, not the TypeScript API. To move it, check `npm view @north-light/sdk dist-tags` and read the [[crouter-sdk/release-notes]] first.
30
+ `@latest` always works against production: a release reaches `latest` only once production serves its contract level. The `next` tag carries every release the moment it is published and may need a newer runtime than production runs (see [[crouter-sdk/errors]] for `RuntimeVersionError`). Keep the exact pin: the compatibility gate protects the wire, not the TypeScript API. To move it, check `npm view @north-light/sdk dist-tags` and read the [release notes](https://sdk.docs.northlight.ai/docs/release-notes) first.
31
31
 
32
32
  ## 3. Node, on the owner's own daemon
33
33
 
@@ -174,71 +174,9 @@ if (run.kind === 'result') {
174
174
 
175
175
  An outcome is returned, never thrown — including a decline and a failure. Only transport faults, daemon errors, and your own abort throw. See [[crouter-sdk/errors]].
176
176
 
177
- ## Go live with an app key
177
+ ## Build an app for other people
178
178
 
179
- Build against production from your laptop: [[crouter-sdk/guides/apps-on-northlight]] walks the whole path. In short, sign up at `https://account.northlight.ai`, create a join token at `https://account.northlight.ai/account/machines` and run `crtr sys join --directory https://account.northlight.ai --token <token>` on your laptop, register the app with `crtr cloud apps register` using a `http://localhost:<port>` origin and a `http://localhost:<port>/auth/callback` redirect URI while you develop, then generate the key and pull its settings with:
180
-
181
- ```bash
182
- npx @north-light/sdk keygen --app <client id>
183
- npx @north-light/sdk env --app <client id>
184
- ```
185
-
186
- `keygen` creates `local/app-key.json` with mode `0600` (keep it out of git) and prints the public JWK on stdout. With `--app`, `keygen` offers to add that public key to your app through your own `crtr` (`crtr cloud apps keys add`). It asks first and `--yes` skips the question; with no terminal to ask on (CI, piped input) and no `--yes`, it adds nothing. The upload only ever adds a key: it never replaces or retires the key your app already has. If `crtr` isn't installed or the add fails, `keygen` says why and prints the manual steps instead: click **Add key** on your app's **Sign-in** tab and paste the public JWK, or paste it under **Register an app** for a new app. An app authenticates only with its key: the directory issues no client secret.
187
-
188
- `env` runs `crtr cloud apps get --app-id <client id>` and writes `CROUTER_ISSUER`, `CROUTER_CLIENT_ID`, and `APP_ORIGIN` into `local/app.env` (`--out <path>` writes another file). It updates those three lines in place, keeps every other line and comment, and creates the file with mode `0600` when it doesn't exist. It never writes `CROUTER_KEY_FILE` or anything secret. It prints the values it set and the key file your app will read.
189
-
190
- Both commands reach your app through this machine's `crtr`, so the machine must be joined to your Northlight account. On one that isn't, they say so, print a join step and exit 2. Against production, [[crouter-sdk/guides/apps-on-northlight]] first.
191
-
192
- An app built on the SDK is configured from four variables:
193
-
194
- | Variable | Value | Where it comes from |
195
- |---|---|---|
196
- | `CROUTER_ISSUER` | The directory's issuer URL | `env` |
197
- | `CROUTER_CLIENT_ID` | The app's client id | `env` |
198
- | `APP_ORIGIN` | The app's origin. Its redirect URI is `${APP_ORIGIN}/auth/callback` by convention | `env` (the registration's origin) |
199
- | `CROUTER_KEY_FILE` | Path to the private JWK, default `local/app-key.json` | You; it differs per deployment (a container might read `/run/secrets/app_key`) |
200
-
201
- The SDK reads no environment and has no `fromEnv` helper: your app loads the values and passes them to `OAuth2Client`:
202
-
203
- ```ts
204
- import {readFile} from 'node:fs/promises';
205
- import {OAuth2Client} from '@north-light/sdk';
206
-
207
- const config = {
208
- issuer: process.env.CROUTER_ISSUER!,
209
- clientId: process.env.CROUTER_CLIENT_ID!,
210
- origin: process.env.APP_ORIGIN!,
211
- keyFile: process.env.CROUTER_KEY_FILE ?? 'local/app-key.json',
212
- };
213
- const privateKey = JSON.parse(await readFile(config.keyFile, 'utf8'));
214
- const oauth = new OAuth2Client({clientId: config.clientId, issuer: config.issuer, redirectUri: `${config.origin}/auth/callback`, privateKey, store}); // store: your ConnectionStore
215
- const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({scopes: ['openid', 'crtr:llm', 'crtr:act'], state: sessionState});
216
- // Save codeVerifier, state, nonce in the user's session; redirect them to url.
217
- const connection = await oauth.exchangeCode({code: callbackCode, codeVerifier, state: callbackState, expectedState: state, nonce});
218
- await store.save(connection);
219
- const client = await oauth.client(connection.userId); // Refreshes through the store from here on.
220
- ```
221
-
222
- The directory accepts this key only after its public JWK is added to the app. Until then every `exchangeCode` and refresh throws an `AuthenticationError` (`code: 'unauthorized'`, status 401). Its message names the key id the SDK signed with and asks whether that key's public JWK is registered, and `error.details.kid` carries the same id.
223
-
224
- ### Rotating your key
225
-
226
- An app can hold two active keys at once, and the directory accepts a sign-in signed by either one, so rotating a key has no downtime. Rotate in four steps:
227
-
228
- 1. **Add the new key.** Generate it beside the current key file and add it to the app:
229
-
230
- ```bash
231
- npx @north-light/sdk keygen --out local/app-key-next.json --app <client id>
232
- ```
233
-
234
- Or run it without `--app` and paste the printed public JWK with **Add key** on the app's **Sign-in** tab. Both keys now work.
235
- 2. **Switch.** Point `privateKey` at `local/app-key-next.json` and deploy.
236
- 3. **Verify.** Complete one real sign-in through the callback, or refresh a stored connection. Opening your login route only redirects to the directory; it never reaches the token endpoint, so it proves nothing about the key.
237
- 4. **Retire the old key.** On the **Sign-in** tab, or with `crtr cloud apps keys retire --app-id <client id> --kid <old kid>` (`crtr cloud apps keys list --app-id <client id>` shows the kids). Then delete the old key file.
238
-
239
- Until step 4 the old key is your rollback: point the app back at the old file and deploy. After you retire it, a sign-in signed with the old key fails with an `unauthorized` error that names the key as retired.
240
-
241
- An app registered with a **JWKS URL** instead of added keys rotates the same way through the URL: publish the new key there beside the old one, switch the app, verify, then remove the old key from the URL. The directory re-reads the URL within about a minute.
179
+ Registering an app, its key pair, the env file and going live are covered for app builders on the SDK docs site: [Quick setup](https://sdk.docs.northlight.ai/docs/quickstart), [Go live](https://sdk.docs.northlight.ai/docs/guides/go-live) and [Manage your app](https://sdk.docs.northlight.ai/docs/guides/manage-your-app).
242
180
 
243
181
  ## Where to go next
244
182
 
@@ -6,7 +6,7 @@ when-and-why-to-read: When composing crouter SDK namespaces into an application,
6
6
 
7
7
  # SDK recipes
8
8
 
9
- These recipes turn the SDK namespaces into complete applications: a bounded extraction, an assistant that waits for events, a research pipeline, a human approval step, and application-owned documents.
9
+ These recipes turn the SDK namespaces into complete applications: a bounded extraction, an assistant that waits for events, a research pipeline, and a human approval step.
10
10
 
11
11
  | Recipe | Use it when | It ends with |
12
12
  |---|---|---|
@@ -14,7 +14,5 @@ These recipes turn the SDK namespaces into complete applications: a bounded extr
14
14
  | [[crouter-sdk/guides/event-driven-assistant]] | Events arrive over time from a webhook, queue, or watcher. | A resident node that wakes for each event. |
15
15
  | [[crouter-sdk/guides/fan-out-pipeline]] | One task needs independent research before a synthesis. | An orchestrator's final report. |
16
16
  | [[crouter-sdk/guides/human-approval]] | A person must decide before work continues. | A node resumed by an inbox answer. |
17
- | [[crouter-sdk/guides/apps-on-northlight]] | You are building and registering an app, or it starts runs on a person's runtime and needs typed data back. | A validated result from `runs.parse`, or an explicit decline or `run_idle`. |
18
- | [[crouter-sdk/guides/apps-with-many-users]] | People interact using separate runtime connections. | App-owned access controls, attribution, and per-person spending. |
19
17
 
20
18
  Start with typed extraction for a request/response job. Use a resident node when the same assistant should react again later. Use an orchestrator only when child work can proceed independently; otherwise keep the composition in your application.
@@ -50,6 +50,6 @@ package @north-light/cli@0.3.332
50
50
 
51
51
  Handle every outcome arm. A result contains `output_parsed`; a decline says the agent could not honestly meet the schema; another failure carries its reason and detail. Transport and daemon failures still throw, so let those reach your normal application error handling.
52
52
 
53
- **From an app.** An app holding a person's connection does the same thing with `runs.parse({prompt, output_schema})`, which returns the run outcome union (`finalized` with `output_parsed` typed from the schema, `declined` with `details`, `canceled`, `deadline_exceeded`, `runtime_error`). It also covers `run_idle` and questions. See [[crouter-sdk/guides/apps-on-northlight]].
53
+ **From an app.** An app holding a person's connection does the same thing with `runs.parse({prompt, output_schema})`, which returns the run outcome union (`finalized` with `output_parsed` typed from the schema, `declined` with `details`, `canceled`, `deadline_exceeded`, `runtime_error`). It also covers `run_idle` and questions. See [Typed results](https://sdk.docs.northlight.ai/docs/guides/typed-results).
54
54
 
55
55
  This is the right shape for bounded work: one prompt, one answer, and no future message. If a user or external event must return to the same agent later, use a resident node instead. See [[crouter-concepts/nodes-and-the-canvas]] for why an SDK node has durable identity, and [[crouter-concepts/lifecycle-and-wakes]] for the terminal-versus-resident decision.
@@ -64,7 +64,7 @@ Wire fields are `snake_case`. Every `NodeCreateParams` property is optional; `pa
64
64
  | `description` | `string` | Display description. |
65
65
  | `parent` | node id | Graph placement. An external caller leaves this unset. |
66
66
  | `creator` | node id | Graph placement. An external caller leaves this unset. |
67
- | `scopes` | `string[]` | Per-run allow-list. Omit it to inherit every scope, or under a scoped token to receive that token's ceiling; a list outside the ceiling answers `403 scope_denied` with the offending scopes in `details.scopes`. Scopes are prefixed (`crtr:llm`, `crtr:act`, `crtr:schedule`, `crtr:memory:…`, `crtr:files:…`); `crtr:act` requires `crtr:llm`, and the list must stay inside what the creator's grant holds. Some scopes are recorded rather than enforced, per the `CreateNodeRequest` contract. See [[crouter-sdk/getting-started]]. |
67
+ | `scopes` | `string[]` | Per-run allow-list. Omit it to inherit every scope, or under a scoped token to receive that token's ceiling; a list outside the ceiling answers `403 scope_denied` with the offending scopes in `details.scopes`. Scopes are prefixed (`crtr:llm`, `crtr:act`, `crtr:schedule`, `crtr:memory:…`, `crtr:files:…`); `crtr:act` requires `crtr:llm`, and the list must stay inside what the creator's grant holds. Provider-group scopes (`device:messages_send`) are enforced on the broker route; on an unsandboxed host a same-UID process can bypass the list by calling the daemon socket directly, so it guards against a mistaken call, not a hostile node. Keep the list short: it travels in a request header on every device call. See [[crouter-sdk/getting-started]]. |
68
68
  | `worktree` | `string \| boolean` | Create a managed git worktree for the run. |
69
69
  | `fork_from` | `string` | Start from an existing conversation. |
70
70
  | `no_kickoff` | `boolean` | Create the node without sending the first message. |