@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
@@ -73,28 +73,28 @@ The SDK no longer refuses a runtime because its release number is lower than the
73
73
 
74
74
  Not breaking: no type or wire shape an app reads is removed or changed.
75
75
 
76
- - An app Northlight has not verified is in testing: only its admin and named testers can sign in. A refused sign-in redirects with `error=access_denied`, `error_description=app_in_testing`; a refused refresh answers `invalid_grant` `app_in_testing`. The refresh refusal is a new `Refusal` with `code: 'app_in_testing'` and `kind: 'paused'`, like `grant_suspended`: keep the person's data. If you `switch` exhaustively on `refusal.code`, add the case. See [[crouter-sdk/client]].
77
- - New `authorizationRefusalOf(callback)` reads the sign-in refusal from the callback's query and returns `AuthorizationRefusal` (`{code: 'app_in_testing', error: 'access_denied', state}`), else `null`. See [[crouter-sdk/client]].
78
- - `@north-light/sdk/testing`: `cloud.testing` (`enable`, `disable`, `active`, `addTester`, `removeTester`, `testers`, `allowed`), `cloud.authorize(userId, {redirectUri, state})`, `user.setAccount({email, emailVerified, phone})`, and `'app_in_testing'` in `FakeRefusalCode`. The fake directory now serves `grant_type=authorization_code` for codes `cloud.authorize` issued. See [[crouter-sdk/testing]].
76
+ - An app Northlight has not verified is in testing: only its admin and named testers can sign in. A refused sign-in redirects with `error=access_denied`, `error_description=app_in_testing`; a refused refresh answers `invalid_grant` `app_in_testing`. The refresh refusal is a new `Refusal` with `code: 'app_in_testing'` and `kind: 'paused'`, like `grant_suspended`: keep the person's data. If you `switch` exhaustively on `refusal.code`, add the case. See [When a refresh is refused](/docs/concepts/connections#when-a-refresh-is-refused).
77
+ - New `authorizationRefusalOf(callback)` reads the sign-in refusal from the callback's query and returns `AuthorizationRefusal` (`{code: 'app_in_testing', error: 'access_denied', state}`), else `null`. See [When sign-in is refused](/docs/concepts/apps-and-sign-in#when-the-directory-refuses-to-sign-someone-in).
78
+ - `@north-light/sdk/testing`: `cloud.testing` (`enable`, `disable`, `active`, `addTester`, `removeTester`, `testers`, `allowed`), `cloud.authorize(userId, {redirectUri, state})`, `user.setAccount({email, emailVerified, phone})`, and `'app_in_testing'` in `FakeRefusalCode`. The fake directory now serves `grant_type=authorization_code` for codes `cloud.authorize` issued. See [Testing mode](/docs/guides/test-your-app#testing-mode).
79
79
 
80
80
  ## 0.3.415
81
81
 
82
82
  ### Monthly spend cap enforced; `usage.get` (additive; one behavior change)
83
83
 
84
84
  - **Behavior change:** user-paid model calls (`/v1/llm`) and provider calls now count against the grant's `spend_cap` (from `grant.get()`; Northlight sets $50) for the current UTC month. Once the month reaches it, the next call answers 402 `cap_exhausted` (a `RateLimitError`, `retryable: false`, `userAction: 'raise_cap'`, `resetAt` the first instant of next month) before any model or provider is called. A run whose model call is refused this way ends with outcome reason `cap_exhausted`. The check runs before each call, so the call that crosses the cap still completes. App-paid usage never counts. A grant with `spend_cap: null` is not capped.
85
- - New `client.usage.get({month?})` (`month` is `YYYY-MM`, UTC; default the current month) answers `SpendUsageDTO`: `usd_equivalent` (user-paid spend, provider receipts included), `by_model`, `app_paid`, `spend_cap` and `reset_at`. See [[crouter-sdk/client]].
85
+ - New `client.usage.get({month?})` (`month` is `YYYY-MM`, UTC; default the current month) answers `SpendUsageDTO`: `usd_equivalent` (user-paid spend, provider receipts included), `by_model`, `app_paid`, `spend_cap` and `reset_at`. See [Show what a person's month has cost](/docs/guides/grant-changes#show-what-a-persons-month-has-cost).
86
86
  - A completion's `usage.usd_equivalent` is now the served model's list-price cost; before it was always `null`. It stays `null` for a model with no list price, and that usage adds nothing to the month.
87
87
 
88
88
  ### `npx @north-light/sdk env` (additive); keygen on an unjoined machine exits 2
89
89
 
90
- - New `npx @north-light/sdk env --app <client id> [--out <path>]` writes `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` from `crtr cloud apps get` into `local/app.env`, keeping every other line. See [[crouter-sdk/getting-started]].
90
+ - New `npx @north-light/sdk env --app <client id> [--out <path>]` writes `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` from `crtr cloud apps get` into `local/app.env`, keeping every other line. See [Go live with an app key](/docs/guides/go-live).
91
91
  - `keygen --app` on a machine whose `crtr` isn't joined to a Northlight account now prints the join step and the manual steps and exits 2. A missing `crtr` keeps its message and exit 0.
92
92
 
93
93
  ### One deadline per SDK call, and a deadline on every OAuth/JWKS fetch (additive, with one behaviour change)
94
94
 
95
95
  Not breaking: no type or wire shape an app reads is removed or changed. Apps pinned to the previous release keep working unchanged.
96
96
 
97
- - `RequestOptions` gains `budgetMs` and `deadlineAt`: one absolute deadline for the call covering every attempt, every retry wait (including a `Retry-After`) and the token fetch/refresh. A wait that would pass the deadline rejects at once with the new `DeadlineExceededError` (`code: 'deadline_exceeded'`, `reason: 'elapsed' | 'retry_after'`, `deadlineAt`, `cause`), which extends `APIConnectionTimeoutError`, so code catching that still catches it. A transport that ignores its `AbortSignal` can no longer outlive the deadline. Without `budgetMs`/`deadlineAt` nothing about retries changes. See [[crouter-sdk/client]].
97
+ - `RequestOptions` gains `budgetMs` and `deadlineAt`: one absolute deadline for the call covering every attempt, every retry wait (including a `Retry-After`) and the token fetch/refresh. A wait that would pass the deadline rejects at once with the new `DeadlineExceededError` (`code: 'deadline_exceeded'`, `reason: 'elapsed' | 'retry_after'`, `deadlineAt`, `cause`), which extends `APIConnectionTimeoutError`, so code catching that still catches it. A transport that ignores its `AbortSignal` can no longer outlive the deadline. Without `budgetMs`/`deadlineAt` nothing about retries changes. See [One deadline per call](/docs/errors#retries).
98
98
  - An app that holds a lease or lock while it makes an SDK call should pass `budgetMs` and keep the lease longer than it, with margin, and state that in code.
99
99
  - `OAuth2Options.timeoutMs` (default 10 s): one deadline per directory operation (discovery, token exchange, refresh, revoke, `runtime_provisioning` retries and their `retry_after_s`). A refresh under the connection-store lock now holds the lock at most `timeoutMs`. `JwksCache` (and so `verifyIdToken`) takes `{timeoutMs}` (default 10 s) over its discovery and key-set reads together.
100
100
  - **Behaviour change:** these directory fetches used to have no deadline (only discovery had one). A directory that takes longer than 10 s now fails with `DeadlineExceededError` where it used to wait; set `timeoutMs` to change it.
@@ -114,11 +114,11 @@ Not breaking: no type or wire shape an app reads is removed or changed. Apps pin
114
114
 
115
115
  ### `@north-light/sdk/testing` (additive)
116
116
 
117
- New `@north-light/sdk/testing`: `FakeCloud`, a fake directory and runtime your real `OAuth2Client` and `Crouter` talk to through `fetch`. It covers runs (events, replies, long-poll, idempotency, endings), grant watch, refusals, directory failures, memory, providers and run notices, and is checked against the real runtime on every release. Additive: `verifyRunNotice` accepts `fetch`. No behaviour change. See [[crouter-sdk/testing]].
117
+ New `@north-light/sdk/testing`: `FakeCloud`, a fake directory and runtime your real `OAuth2Client` and `Crouter` talk to through `fetch`. It covers runs (events, replies, long-poll, idempotency, endings), grant watch, refusals, directory failures, memory, providers and run notices, and is checked against the real runtime on every release. Additive: `verifyRunNotice` accepts `fetch`. No behaviour change. See [Testing your app](/docs/guides/test-your-app).
118
118
 
119
119
  ### `oauth.accountAppUrl()` (additive), and a failed discovery read is retried (behaviour fix)
120
120
 
121
- - New `OAuth2Client.accountAppUrl()` returns the app's page on the signed-in person's Northlight account, as the directory's discovery document advertises it (`account_app_uri_template`). Use it instead of building `<issuer>/account/apps/<client id>` yourself. It needs no user or connection and throws `CrouterError` naming the fault when the directory advertises no template or an invalid one. See [[crouter-sdk/client]].
121
+ - New `OAuth2Client.accountAppUrl()` returns the app's page on the signed-in person's Northlight account, as the directory's discovery document advertises it (`account_app_uri_template`). Use it instead of building `<issuer>/account/apps/<client id>` yourself. It needs no user or connection and throws `CrouterError` naming the fault when the directory advertises no template or an invalid one. See [Link to the app's settings on Northlight](/docs/guides/grant-changes#link-to-the-apps-settings-on-northlight).
122
122
  - **Behaviour fix:** `OAuth2Client`'s discovery read now times out after 10 seconds, and a read that failed or timed out is retried by the next call that needs it. Before, one failed read was kept for the client's life, so every later `authorizeUrl`, `exchangeCode`, refresh and `verifyIdToken` failed until the process restarted, and a hung directory stalled them indefinitely. A successful read is still kept for the client's life.
123
123
 
124
124
  ## 0.3.405
@@ -169,25 +169,25 @@ The runtime gains what a sandbox that sleeps needs; no app-facing type or route
169
169
 
170
170
  ### Follow a person's grant with `oauth.watchGrants` (additive)
171
171
 
172
- `grant.get()` answers a new field, `version`: opaque, it changes exactly when `rev`, `resolved_scopes`, `spend_cap`, `payer` or `dependencies` changes. `grant.get({wait, since})` (`wait` an integer 1–25; anything else throws `RangeError` before sending) holds the read until the grant's `version` differs from `since` or `wait` seconds pass, and extends the request timeout by `wait`. A grant revoked or removed during the hold answers 401 `grant_revoked`. `oauth.watchGrants({users, onChanged, onError?, sweepMs?, concurrency?, wait?})` returns a `GrantWatcher` that follows the grants of the people `users()` names and calls `onChanged({userId, grant, previous})` level-triggered, at least once per version, so an app learns when a required provider's setup turns `ready` without a browser tab or a poll loop. New exports `GrantWatcher`, `GrantWatchOptions`, `GrantChanged`. Not breaking: nothing existing changes shape, and `RunWatcher` behaves exactly as before. The hold needs a runtime at this release and, for a hosted runtime, a hosting router that lets `GET /v1/grant?wait=N` through (before then a held read answers 504 after 10 s and the watcher backs off). See [[crouter-sdk/client]].
172
+ `grant.get()` answers a new field, `version`: opaque, it changes exactly when `rev`, `resolved_scopes`, `spend_cap`, `payer` or `dependencies` changes. `grant.get({wait, since})` (`wait` an integer 1–25; anything else throws `RangeError` before sending) holds the read until the grant's `version` differs from `since` or `wait` seconds pass, and extends the request timeout by `wait`. A grant revoked or removed during the hold answers 401 `grant_revoked`. `oauth.watchGrants({users, onChanged, onError?, sweepMs?, concurrency?, wait?})` returns a `GrantWatcher` that follows the grants of the people `users()` names and calls `onChanged({userId, grant, previous})` level-triggered, at least once per version, so an app learns when a required provider's setup turns `ready` without a browser tab or a poll loop. New exports `GrantWatcher`, `GrantWatchOptions`, `GrantChanged`. Not breaking: nothing existing changes shape, and `RunWatcher` behaves exactly as before. The hold needs a runtime at this release and, for a hosted runtime, a hosting router that lets `GET /v1/grant?wait=N` through (before then a held read answers 504 after 10 s and the watcher backs off). See [Learn when a person's grant changes](/docs/guides/grant-changes#follow-grants-with-watchgrants).
173
173
 
174
174
  ## 0.3.399
175
175
 
176
176
  ### `onActivity` no longer blocks the watcher
177
177
 
178
- In 0.3.398 the watcher awaited `onActivity` before its next read, so a slow or hung hook delayed that run's `onEnded`, held its `concurrency` slot, and kept `stop()` from resolving. The watcher now never awaits it: at most one call per run runs at a time, a newer reading taken meanwhile replaces any still waiting, and `stop()` does not wait for a call already running. A call can now finish after that run's `onEnded`: guard the write on the row still waiting on the run. Runs that leave `pending()` without an ending no longer keep their last phase in memory. See [[crouter-sdk/client]].
178
+ In 0.3.398 the watcher awaited `onActivity` before its next read, so a slow or hung hook delayed that run's `onEnded`, held its `concurrency` slot, and kept `stop()` from resolving. The watcher now never awaits it: at most one call per run runs at a time, a newer reading taken meanwhile replaces any still waiting, and `stop()` does not wait for a call already running. A call can now finish after that run's `onEnded`: guard the write on the row still waiting on the run. Runs that leave `pending()` without an ending no longer keep their last phase in memory. See [Learn when a background run ends](/docs/guides/background-runs#follow-runs-with-runwatcher).
179
179
 
180
180
  ## 0.3.398
181
181
 
182
182
  ### `RunWatcher` reports live progress with `onActivity` (additive)
183
183
 
184
- `oauth.watchRuns({..., onActivity, activityWait?})` calls `onActivity({userId, runId, activity, run})` with each read of a still-`running` run whose `activity` differs from the last one handed over for that run (compared as JSON: `phase`, `tool.name`, `tool.summary`), so an app can show a progress badge from the watcher it already runs instead of a second `runs.get` per page read. It is never called for a run at rest. With `onActivity` set, running runs are read with `wait: activityWait` (integer 1–25, default 2) rather than 25, because the runtime's long-poll answers early only at rest. Best effort: a throwing `onActivity` goes to `onError` and is not retried, and `onEnded` delivery is unchanged. Not breaking: without `onActivity` the watcher behaves exactly as before. New exported type `RunActivityEvent`. See [[crouter-sdk/client]].
184
+ `oauth.watchRuns({..., onActivity, activityWait?})` calls `onActivity({userId, runId, activity, run})` with each read of a still-`running` run whose `activity` differs from the last one handed over for that run (compared as JSON: `phase`, `tool.name`, `tool.summary`), so an app can show a progress badge from the watcher it already runs instead of a second `runs.get` per page read. It is never called for a run at rest. With `onActivity` set, running runs are read with `wait: activityWait` (integer 1–25, default 2) rather than 25, because the runtime's long-poll answers early only at rest. Best effort: a throwing `onActivity` goes to `onError` and is not retried, and `onEnded` delivery is unchanged. Not breaking: without `onActivity` the watcher behaves exactly as before. New exported type `RunActivityEvent`. See [Learn when a background run ends](/docs/guides/background-runs#follow-runs-with-runwatcher).
185
185
 
186
186
  ## 0.3.397
187
187
 
188
188
  ### A runtime older than the SDK is refused before it handles the request
189
189
 
190
- The SDK now sends `Crouter-Minimum-Runtime-Version: <SDK_VERSION>` on every request except `/healthz`. The hosting router in front of a hosted runtime, and a runtime at this release or later, answer a request that names a newer release than the runtime's with `RuntimeVersionError` (`status` 426, `code` `runtime_version_unsupported`) **before any handler runs**: nothing is accepted, no `Idempotency-Key` is recorded, and a retry after the runtime is rolled forward is a first send. Previously an older runtime handled the request and the SDK raised afterwards. Not breaking: no app code changes. An app that worked around the late check, such as a `runs.list` read before every send, can delete it once its runtimes are at or after this release behind a hosting router that has the check. Back off on `RuntimeVersionError` and retry under the same key. A browser app's preflight now lists `crouter-minimum-runtime-version` in `Access-Control-Allow-Headers`. See [[crouter-sdk/errors]].
190
+ The SDK now sends `Crouter-Minimum-Runtime-Version: <SDK_VERSION>` on every request except `/healthz`. The hosting router in front of a hosted runtime, and a runtime at this release or later, answer a request that names a newer release than the runtime's with `RuntimeVersionError` (`status` 426, `code` `runtime_version_unsupported`) **before any handler runs**: nothing is accepted, no `Idempotency-Key` is recorded, and a retry after the runtime is rolled forward is a first send. Previously an older runtime handled the request and the SDK raised afterwards. Not breaking: no app code changes. An app that worked around the late check, such as a `runs.list` read before every send, can delete it once its runtimes are at or after this release behind a hosting router that has the check. Back off on `RuntimeVersionError` and retry under the same key. A browser app's preflight now lists `crouter-minimum-runtime-version` in `Access-Control-Allow-Headers`. See [Errors](/docs/errors).
191
191
 
192
192
  ## 0.3.396
193
193
 
@@ -201,7 +201,7 @@ The SDK now sends `Crouter-Minimum-Runtime-Version: <SDK_VERSION>` on every requ
201
201
 
202
202
  ### `OAuth2Client` owns the connection store; one `onRefusal` for a dead connection (breaking)
203
203
 
204
- `OAuth2Client` now takes the app's `ConnectionStore` once, as `store` (required), plus an optional `onRefusal(refusal)` handler, and every refused refresh goes through that one handler: a request on a connected client, `refreshIdleConnections`, and the new `RunWatcher`. Apps no longer keep their own refusal table or delete dead connections themselves. See [[crouter-sdk/client]].
204
+ `OAuth2Client` now takes the app's `ConnectionStore` once, as `store` (required), plus an optional `onRefusal(refusal)` handler, and every refused refresh goes through that one handler: a request on a connected client, `refreshIdleConnections`, and the new `RunWatcher`. Apps no longer keep their own refusal table or delete dead connections themselves. See [When a refresh is refused](/docs/concepts/connections#when-a-refresh-is-refused).
205
205
 
206
206
  - **`oauth.client(connection, store)` is now `await oauth.client(userId)`.** It loads the stored connection and returns a `ConnectedCrouter` (a `Crouter` with `userId` and `runtimeUrl`). Nothing stored throws `CrouterError` code `not_connected`. Save `exchangeCode`'s connection with `store.save(connection)` first.
207
207
  - **`oauth.refreshIdleConnections(store, {olderThanMs})` is now `oauth.refreshIdleConnections({olderThanMs})`** and returns `{refreshed, refused, failures}`. `RefreshFailure.reason` is removed: a refused connection is handled by `onRefusal`, removed, and counted in `refused`; `failures` holds only connections still stored (any other error, or a refusal whose handler threw).
@@ -215,25 +215,25 @@ The SDK now sends `Crouter-Minimum-Runtime-Version: <SDK_VERSION>` on every requ
215
215
 
216
216
  ### `RunWatcher`: learn when a background run ends (additive)
217
217
 
218
- `oauth.watchRuns({pending, onEnded, onError?, sweepMs?, concurrency?})` returns a `RunWatcher` (`start()`, `nudge()`, `stop()`) that follows the runs `pending()` reads from the app's own rows with `runs.get(wait)` and calls `onEnded({userId, runId, ended, run})` when each comes to rest (`settled`, `idle`, `waiting_on_user`) or is deleted (`gone`, only for a 404 `not_found` from the runtime, never the router). Delivery is at least once per `(runId, status, latest_sequence)`: a delivered run is re-read once per sweep and delivered again only when that key changes (an `idle` run later canceled is delivered as `settled`). A throwing `onEnded` is retried after 5 s, doubling to 5 min. Runs are opened with `oauth.client(userId)`, so a refused connection reaches `onRefusal`. There is no runtime-to-app webhook: the runtime cannot mint an app-audience token, a directory relay would add a hop, and the app's rows already make a pull durable and work on localhost. See [[crouter-sdk/client]].
218
+ `oauth.watchRuns({pending, onEnded, onError?, sweepMs?, concurrency?})` returns a `RunWatcher` (`start()`, `nudge()`, `stop()`) that follows the runs `pending()` reads from the app's own rows with `runs.get(wait)` and calls `onEnded({userId, runId, ended, run})` when each comes to rest (`settled`, `idle`, `waiting_on_user`) or is deleted (`gone`, only for a 404 `not_found` from the runtime, never the router). Delivery is at least once per `(runId, status, latest_sequence)`: a delivered run is re-read once per sweep and delivered again only when that key changes (an `idle` run later canceled is delivered as `settled`). A throwing `onEnded` is retried after 5 s, doubling to 5 min. Runs are opened with `oauth.client(userId)`, so a refused connection reaches `onRefusal`. There is no runtime-to-app webhook: the runtime cannot mint an app-audience token, a directory relay would add a hop, and the app's rows already make a pull durable and work on localhost. See [Learn when a background run ends](/docs/guides/background-runs#follow-runs-with-runwatcher).
219
219
 
220
220
  ## 0.3.394
221
221
 
222
222
  ### `grant.get` dependencies are one `{ state, message?, next }` entry per provider (breaking)
223
223
 
224
- `GrantGetDTO.dependencies[provider]` was a bare `SetupState` string; it is now a `ProviderDependency` (exported from `@north-light/sdk` and `@north-light/api`): `{ state, message?, next }`. `state` is the setup state the string used to be. `message` is the provider's own sentence for `pending` and `failed` (its `message_pending` / `message_failed`), so delete any copy of a provider's prose your app hard-codes and show `message` instead. `next` is the absolute URL of the directory's setup page for that provider and your app; link the person there (append `return_to` with your registered redirect origin) instead of building the URL yourself. **Change every read of `dependencies[p]` to `dependencies[p].state`** (or `dependencies[p]?.state` where the entry may be missing), and carry `message` and `next` into your `provider_setup_pending` details. The runtime reads only this shape from the directory's grant sync: a runtime on this release paired with a directory that still sends bare strings reports `dependencies` as `{}`, so roll the directory change with it. See [[crouter-sdk/guides/apps-on-northlight]].
224
+ `GrantGetDTO.dependencies[provider]` was a bare `SetupState` string; it is now a `ProviderDependency` (exported from `@north-light/sdk` and `@north-light/api`): `{ state, message?, next }`. `state` is the setup state the string used to be. `message` is the provider's own sentence for `pending` and `failed` (its `message_pending` / `message_failed`), so delete any copy of a provider's prose your app hard-codes and show `message` instead. `next` is the absolute URL of the directory's setup page for that provider and your app; link the person there (append `return_to` with your registered redirect origin) instead of building the URL yourself. **Change every read of `dependencies[p]` to `dependencies[p].state`** (or `dependencies[p]?.state` where the entry may be missing), and carry `message` and `next` into your `provider_setup_pending` details. The runtime reads only this shape from the directory's grant sync: a runtime on this release paired with a directory that still sends bare strings reports `dependencies` as `{}`, so roll the directory change with it. See [Waiting on a provider your app requires](/docs/guides/grant-changes#wait-on-a-provider-your-app-requires).
225
225
 
226
226
  ## 0.3.393
227
227
 
228
228
  ### The runtime orders grant state by revision; a failed grant check is `service_unavailable` (breaking)
229
229
 
230
- A runtime on this release requires the `rev` claim in every app access token, so it needs a directory that issues it; a token without `rev` is refused 401 `unauthorized`, "access token has no rev claim: the directory is older than this runtime". A freshly consented or re-consented grant is now authorized on the first call, with no wait: the runtime pulls grant state once whenever a token is newer than its mirror. **Delete any retry your app added for a 401 `grant_revoked` right after consent.** When that pull fails, the call answers 503 `service_unavailable` (`retryable: true`) instead of 401 `grant_revoked`, so a directory outage no longer looks like a refused connection. `service_unavailable` was already in `errorCodes`; app calls can now receive it. See [[crouter-sdk/client]].
230
+ A runtime on this release requires the `rev` claim in every app access token, so it needs a directory that issues it; a token without `rev` is refused 401 `unauthorized`, "access token has no rev claim: the directory is older than this runtime". A freshly consented or re-consented grant is now authorized on the first call, with no wait: the runtime pulls grant state once whenever a token is newer than its mirror. **Delete any retry your app added for a 401 `grant_revoked` right after consent.** When that pull fails, the call answers 503 `service_unavailable` (`retryable: true`) instead of 401 `grant_revoked`, so a directory outage no longer looks like a refused connection. `service_unavailable` was already in `errorCodes`; app calls can now receive it. See [An app connection and refresh](/docs/concepts/connections).
231
231
 
232
232
  ## 0.3.391
233
233
 
234
234
  ### The SDK refuses a runtime older than itself (breaking)
235
235
 
236
- `@north-light/sdk` now accepts only a runtime at or above its own version, and fails loudly otherwise instead of handing an app data its types do not describe. Every runtime answer carries `Crouter-Runtime-Version`; when that version is older than `SDK_VERSION`, or the header is absent (every runtime before this release), the call throws `RuntimeVersionError` (`code: 'runtime_version_unsupported'`, `status: 426`, `retryable: false`) whose message and `sdkVersion` / `runtimeVersion` name both versions. A refusal without the header (a router in front of the runtime answering 401 or 503) is still raised as that refusal. There is no capability negotiation: `RunObject.activity` and every other field are typed for a runtime at this release. **Upgrade every runtime the app talks to before upgrading the SDK**, and pin the SDK to an exact version. A test double that fakes the runtime with `fetch` must send `Crouter-Runtime-Version` at the SDK's version on its 2xx answers. See [[crouter-sdk/errors]].
236
+ `@north-light/sdk` now accepts only a runtime at or above its own version, and fails loudly otherwise instead of handing an app data its types do not describe. Every runtime answer carries `Crouter-Runtime-Version`; when that version is older than `SDK_VERSION`, or the header is absent (every runtime before this release), the call throws `RuntimeVersionError` (`code: 'runtime_version_unsupported'`, `status: 426`, `retryable: false`) whose message and `sdkVersion` / `runtimeVersion` name both versions. A refusal without the header (a router in front of the runtime answering 401 or 503) is still raised as that refusal. There is no capability negotiation: `RunObject.activity` and every other field are typed for a runtime at this release. **Upgrade every runtime the app talks to before upgrading the SDK**, and pin the SDK to an exact version. A test double that fakes the runtime with `fetch` must send `Crouter-Runtime-Version` at the SDK's version on its 2xx answers. See [Errors](/docs/errors).
237
237
 
238
238
  ### One release is one version set
239
239
 
@@ -261,7 +261,7 @@ A runtime on this release requires the `rev` claim in every app access token, so
261
261
 
262
262
  ### `runs.get` reports what a running run is doing (additive)
263
263
 
264
- `RunObject.activity` is `{phase: 'starting' | 'thinking' | 'writing' | 'between_turns'}` or `{phase: 'tool', tool: {name, summary}}` while the run is `running`, and `null` otherwise, so a polling app can show progress from `runs.get` without reading `runs.trace`. `runs.list` entries carry it too. See [[crouter-sdk/guides/apps-on-northlight]]. A test double that builds a `RunObject` literal must add `activity`.
264
+ `RunObject.activity` is `{phase: 'starting' | 'thinking' | 'writing' | 'between_turns'}` or `{phase: 'tool', tool: {name, summary}}` while the run is `running`, and `null` otherwise, so a polling app can show progress from `runs.get` without reading `runs.trace`. `runs.list` entries carry it too. See [Showing a background run's progress](/docs/guides/typed-results#showing-progress). A test double that builds a `RunObject` literal must add `activity`.
265
265
 
266
266
  ## 0.3.374
267
267
 
@@ -273,7 +273,7 @@ A sign-in that grants the `profile` scope returns `name`, `givenName`, and `fami
273
273
 
274
274
  ### `CrouterError.code` is typed `SdkErrorCode`
275
275
 
276
- Every code the SDK raises itself is now listed in `sdkErrorCodes` (exported with `SdkErrorCode` and `mapError` from `@north-light/sdk/errors`), and `CrouterError`'s `code` and constructor are typed with it. Runtime behavior is unchanged. **An app that constructs `new CrouterError(message, code)` with a code of its own, for example in a test double, no longer typechecks:** throw its own error class or an `APIError` instead. See [[crouter-sdk/errors]].
276
+ Every code the SDK raises itself is now listed in `sdkErrorCodes` (exported with `SdkErrorCode` and `mapError` from `@north-light/sdk/errors`), and `CrouterError`'s `code` and constructor are typed with it. Runtime behavior is unchanged. **An app that constructs `new CrouterError(message, code)` with a code of its own, for example in a test double, no longer typechecks:** throw its own error class or an `APIError` instead. See [Errors](/docs/errors).
277
277
 
278
278
  ### Run ergonomics (additive)
279
279
 
@@ -289,7 +289,7 @@ When the directory refuses a connection's refresh token, the SDK now raises the
289
289
  - `grant_suspended`: your app is paused for that person. Nothing was deleted; keep their data.
290
290
  - `refresh_token_expired`: the refresh token went 90 days unused. The grant is still active; reconnecting skips the consent page.
291
291
 
292
- Each is a 401 `AuthenticationError` with `retryable: false`, `userAction: 'reconnect'`, and `origin: 'directory'`, and the connected client from `oauth.client(...)` stops refreshing after it, as it already did for `grant_revoked` and `refresh_token_reused`. `errorCodes` and the `ErrorCode` type gain the three codes. The table of codes and app actions is in [[crouter-sdk/client]].
292
+ Each is a 401 `AuthenticationError` with `retryable: false`, `userAction: 'reconnect'`, and `origin: 'directory'`, and the connected client from `oauth.client(...)` stops refreshing after it, as it already did for `grant_revoked` and `refresh_token_reused`. `errorCodes` and the `ErrorCode` type gain the three codes. The table of codes and app actions is in [When a refresh is refused](/docs/concepts/connections#when-a-refresh-is-refused).
293
293
 
294
294
  **Behavior changes for existing apps:**
295
295
 
@@ -9,76 +9,7 @@ when-and-why-to-read: When you need Streaming, this knowledge should be read
9
9
 
10
10
  Watch a run as it works: assistant text as it is produced, tool calls as they start and finish, reports as they are pushed, and the settled outcome.
11
11
 
12
- ## Forwarding a run to a browser
13
-
14
- The app server keeps the runtime token and connection; the browser connects only to the app. The app server calls `forwardRunEvents(client, runId, {after: cursorFromRequest(request), signal: request.signal})` and returns the Fetch `Response`. After a host cuts the browser connection, EventSource sends its last SSE id as `Last-Event-ID`; `cursorFromRequest` prefers it over the URL's `after` query parameter.
15
-
16
- Every forwarded run event is an unnamed SSE record with its `sequence_number` as `id` and the complete event object as `data`. Receive all event types with one handler:
17
-
18
- ```ts
19
- const source = new EventSource(`/api/runs/${runId}/events?after=${cursor}`);
20
- source.onmessage = ({data}) => {
21
- const event = JSON.parse(data);
22
- if (event.type === 'stream.refused') { showRefusal(event.error); source.close(); return; }
23
- if (event.type === 'stream.reconnecting') { showConnectionFailure(event.error); return; }
24
- if (event.type === 'stream.reconnected') { clearConnectionFailure(); return; }
25
- if (event.type === 'stream.idle') { source.close(); markIdle(event.cursor); return; } // reopen on your own trigger
26
- showRunEvent(event);
27
- };
28
- ```
29
-
30
- The four `stream.*` records are forwarded-stream notices, not daemon run events; they have no `sequence_number` or SSE id.
31
-
32
- `stream.idle` (`{type: 'stream.idle', cursor}`) means the runtime closed the stream after 60 s with no running turn and no new event. The helper writes it and then ends the response, so a browser `EventSource` would otherwise reconnect at once and keep a hosted runtime awake indefinitely. Close the `EventSource` on `stream.idle` and reopen it with `after=cursor` on your own trigger: the person sends a message, or your server handles a run notice (see `verifyRunNotice`). A `stream.refused` carries the full error envelope, including `stream_gap`'s `details.latest_sequence` and `details.earliest_sequence`. The helper ends that response after a refusal. The SDK does not recover a gap by skipping missing events.
33
-
34
- When the page reloads mid-reply or gets `stream_gap`, the app reads `runs.trace(runId)` through its server and shows each node's `in_progress_message` (`{thinking, text}` or `null`) as that node's open message. The trace's `latest_sequence` is read consistently with its conversations and open messages. Resume `runs.events(runId, {after: trace.latest_sequence})` (or forward with that cursor); do not use a separately read `runs.get` cursor. Continue appending new deltas to the open message, but replace its thinking or text with the complete content repeated by `node.thinking.done` or `node.output_text.done`. This shows the message from its beginning without duplicating content. `runs.get` and every `runs.list` entry expose `latest_sequence` and `last_activity_at`; a cursor equal to `latest_sequence` is not a gap even when older events have expired.
35
-
36
- ## Following the reply to one message
37
-
38
- A chat app sends a message and shows the answer to it. `runs.reply(runId, sent, {signal?})` takes what `runs.message` returned and follows only the turn that delivers that message:
39
-
40
- ```ts
41
- const sent = await client.runs.message(runId, 'What did the report say?');
42
- for await (const event of client.runs.reply(runId, sent)) {
43
- if (event.type === 'text.delta') append(event.delta);
44
- if (event.type === 'turn.completed' && event.error) showError(event.error); // {code: 'content_refused' | 'model_error', message}
45
- }
46
- // or collect it: text as it streams, then the whole text and how the turn ended
47
- const {text, ended, error, outcome} = await client.runs.reply(runId, sent).collect({onDelta: (delta) => append(delta)});
48
- // ended: 'completed' | 'error' (error set) | 'waiting_on_user' | 'settled' (outcome set)
49
- ```
50
-
51
- It reads `runs.events(runId, {after: sent.sequence_number})`, skips child nodes' events, starts at the root's `node.turn.started` whose `input.message_ids` holds `sent.message_id`, and yields:
52
-
53
- | Event | Fields | From |
54
- |---|---|---|
55
- | `text.delta` | `delta` | `node.output_text.delta` |
56
- | `message.done` | `text` (the message's whole text, possibly empty), `stop_reason` | `node.output_text.done`, once per assistant message |
57
- | `turn.completed` | `run_status`, `stop_reason`, `error?`, `message_ids` | `node.turn.completed`; always last |
58
- | `run.settled` | `outcome` | `node.settled`, when the run settles before the turn completes; always last |
59
-
60
- Every event also carries its `sequence_number`. The reply ends after `turn.completed` or `run.settled` and closes its event stream. A resident run never settles when it goes idle, so a plain `runs.events` stream stays open, but a reply does not. `.text()` consumes the reply and resolves to the text of its `message.done` events joined with a blank line, so a turn that speaks, calls a tool and speaks again returns both texts. `.collect({onDelta?})` consumes the reply too: it passes each streamed piece to `onDelta`, starting a later assistant message's first piece with a blank line, and resolves to `RunReplyResult` `{text, ended, error?, outcome?}`, where `text` is the trimmed messages joined with a blank line. `ended` is `completed` (text may be empty when the model wrote nothing), `error` (the turn's last message failed; `error` says why), `waiting_on_user` (the turn ended on a question to the person), or `settled` (the run settled first; `outcome` says how). `.text({onDelta?})` is `collect`'s `text`. A reply can be consumed once.
61
-
62
- Several messages delivered in one turn, including a `start_turn: false` message that joined it, all resolve to that turn. To have a reply to the run's first message, pass it as `runs.start({prompt, message})` and follow `runs.reply(run.run_id, run.message!)`: a message stored with the run always joins the first turn. A refused or failed model call ends the turn with an empty `message.done` (`stop_reason: 'error'`) and `turn.completed.error`; the run stays usable, and `runs.get` shows the same error as `last_turn_error` until a later turn completes without one. A provider failure the runtime retries on its own (connection, rate limit, overloaded) does not end the reply: the recovery attempt continues the same turn, so the reply yields its answer and one `turn.completed` without an error, and only a turn whose retries run out ends with `model_error`. An interrupt, `runs.cancel`, or a closed node ends the turn with `stop_reason: 'aborted'` and no error. An expired cursor throws `APIError` with code `stream_gap`; aborting `signal` rejects with `APIUserAbortError`.
63
-
64
- ## A quiet run: `event: idle`
65
-
66
- A runtime that sleeps when nothing holds a connection to it ends a run's SSE stream after 60 s in which the run has no running turn and no new event: it sends `event: idle` (`data: {}`) and closes. `RunStream` reports that and does not reconnect:
67
-
68
- ```ts
69
- const stream = client.runs.events(runId, {after: cursor});
70
- for await (const event of stream) handle(event);
71
- const end = await stream.whenEnded(); // rejects on a transport failure or abort()
72
- if (end.reason === 'idle') {
73
- // the run is quiet and nothing was missed; reopen later with client.runs.events(runId, {after: end.cursor})
74
- }
75
- ```
76
-
77
- `stream.idle` and `stream.position` read the same facts synchronously. A settled run ends with `reason: 'settled'`; a transport error rejects as before. `runs.reply` reopens through an idle close on its own.
78
-
79
- ## Typed run events
80
-
81
- `RunEvent`, exported by the SDK, is the union of every run event, discriminated on `type`. Its types are exactly the daemon's run event vocabulary, declared once. `RunEventOf<'node.turn.completed'>` picks one event, and `RunStream.on(type, listener)` passes the listener that event. An event type a newer daemon adds and this SDK does not declare is still delivered as-is.
12
+ Streaming a run of an app (forwarding it to a browser, following the reply to one message, `event: idle`, typed run events) is on the SDK docs site: [Stream a run](https://sdk.docs.northlight.ai/docs/guides/stream-a-run).
82
13
 
83
14
  ## The SDK surface
84
15
 
@@ -0,0 +1,15 @@
1
+ ---
2
+ kind: knowledge
3
+ description: Building a third-party app on Northlight with @north-light/sdk — register it, sign people in, start runs on their runtime, call providers
4
+ when-and-why-to-read: When you are building, debugging or shipping an app that signs people in with Northlight, starts runs on a person's Northlight runtime, or calls Northlight providers (sms, search, integrations, context, device) with @north-light/sdk, this reference should be read because the app runs on the developer's own server while its agent work runs on each person's runtime, and the sign-in, scope and connection rules differ from driving a local daemon with `new Crouter()`.
5
+ short-form: The app-builder guide from sdk.docs.northlight.ai as pages — quick setup, concepts (sign-in, scopes and grants, connections, runs, testing mode, providers), guides, provider pages, errors and the CLI.
6
+ surfaces:
7
+ - on: boot
8
+ at: name
9
+ ---
10
+
11
+ # `northlight-apps/` — build an app on Northlight
12
+
13
+ An app built on Northlight runs on the developer's own server. People sign in to it with Northlight, its agent work runs on each person's Northlight-hosted runtime, and it can call that person's providers. Northlight does not host the app. The app uses `OAuth2Client` and `oauth.client(userId)` from `@north-light/sdk`, never `new Crouter()`, which drives a daemon you run yourself (`crouter-sdk/`).
14
+
15
+ Start at `northlight-apps/quickstart` for the path from a new account to a running app, then `northlight-apps/how-it-fits` for where each part runs. The directory listing names every page. The same pages are published at https://sdk.docs.northlight.ai, where the generated API reference also lives.
@@ -0,0 +1,41 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need CLI reference, this knowledge should be read
4
+ because The npx @north-light/sdk commands for scaffolding an app, writing its
5
+ signing key and environment file, and installing the agent skill.
6
+ ---
7
+
8
+ # CLI reference
9
+
10
+ `npx @north-light/sdk <command>` runs the package's one command-line tool. Run it with no command, or with `--help`, to print the list below. An unknown command prints the list and exits 1.
11
+
12
+ | Command | What it does | Needs crtr |
13
+ |---|---|---|
14
+ | `npx @north-light/sdk init [dir] [--agent claude\|agents\|all] [--global-skill]` | Scaffold a Northlight app in an empty or new directory (server.ts, key, env file, skill), or add the SDK, a key and the skill to an existing project. Never overwrites a file. | No |
15
+ | `npx @north-light/sdk skill [--agent claude\|agents\|all] [--global] [--print]` | Write the packaged `northlight` skill into `.agents/skills` and `.claude/skills` (or `~/` with `--global`), replacing an older copy. `--print` writes it to stdout. | No |
16
+ | `npx @north-light/sdk keygen [--out <path>] [--app <client id>] [--yes]` | Write a private signing key to `local/app-key.json` (mode 0600) and print the public JWK to register. | Only with `--app` |
17
+ | `npx @north-light/sdk env --client-id <client id> [--origin <url>] [--issuer <url>] [--out <path>]`<br/>`npx @north-light/sdk env --app <client id> [--out <path>]` | Write `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env`, keeping every other line. `--client-id` makes no network call; `--app` reads them through crtr. | Only with `--app` |
18
+
19
+ `keygen` and `env` are the only commands that can call `crtr`, and only with `--app`. The documented path on this site never uses `--app`, so you do not need crtr.
20
+
21
+ ## Details
22
+
23
+ ### `init`
24
+
25
+ In an empty or new directory it writes `package.json` (with `@north-light/sdk` pinned to the CLI's own exact version, plus `tsx`), `server.ts` (the quickstart server), `.gitignore` (`local/`, `node_modules/`) and `local/app.env`, and runs `keygen` into `local/app-key.json`. It installs the skill. Then it prints the public JWK, the console URL `https://account.northlight.ai/console/new`, the values to type, and `npx @north-light/sdk env --client-id <client id>` as the next step.
26
+
27
+ In a directory that already has a `package.json` it writes no app code. It runs `npm i --save-exact @north-light/sdk@<its own version>`, runs `keygen` unless `local/app-key.json` exists, and installs the skill.
28
+
29
+ It never overwrites a file. On a refused step it exits non-zero and names the file it refused.
30
+
31
+ ### `skill`
32
+
33
+ Writes the `northlight` Agent Skill that ships in the package, replacing an older copy. `--agent` picks `.claude/skills`, `.agents/skills` or both. `--global` writes under your home directory instead of the project. The skill's `metadata.sdk-version` is the package version, so run `skill` again after you change the pinned SDK version.
34
+
35
+ ### `keygen`
36
+
37
+ Writes a private signing key to `local/app-key.json` with mode `0600`, refuses to overwrite an existing file (pass `--out` for another path), and prints the public JWK on stdout. Register the JWK in the console with **Add key** on the app's **Sign-in** tab, or paste it under **Register an app**. With `--app <client id>`, it offers to add the key to your app through your own `crtr` (`crtr cloud apps keys add`); `--yes` skips the question. This needs a joined crtr, which this site does not otherwise require.
38
+
39
+ ### `env`
40
+
41
+ `env --client-id <client id>` writes `CROUTER_ISSUER` (default `https://account.northlight.ai`), `CROUTER_CLIENT_ID` and `APP_ORIGIN` (default: the existing value in the file, else `http://localhost:3000`) into `local/app.env`. It keeps every other line and makes no network call. `--out <path>` writes another file. `env --app <client id>` reads the same values through crtr instead. Passing both `--client-id` and `--app` is a usage error.
@@ -0,0 +1,75 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Use your coding agent, this knowledge should
4
+ be read because What npx @north-light/sdk init and skill install for Claude
5
+ Code, Codex, Cursor and Copilot, where the skill lives, and how it stays in
6
+ step with your SDK version.
7
+ ---
8
+
9
+ # Use your coding agent
10
+
11
+ The `northlight` skill teaches a coding agent the whole path in the [[northlight-apps/quickstart]]: the commands, the console fields, the code patterns and the rules. `npx @north-light/sdk init` installs it into your project. The skill needs no account and no crtr.
12
+
13
+ ## Install it
14
+
15
+ For a new app, `init` installs it with everything else:
16
+
17
+ ```bash
18
+ npx @north-light/sdk@latest init my-app
19
+ ```
20
+
21
+ For a project that already has a `package.json`, run `npx @north-light/sdk init` in it. It adds `@north-light/sdk` at an exact version, makes a key if `local/app-key.json` does not exist, installs the skill, and writes no app code. It never overwrites a file: if one is in the way, it names the file and exits non-zero.
22
+
23
+ To install only the skill, or to replace an older copy:
24
+
25
+ ```bash
26
+ npx @north-light/sdk skill
27
+ ```
28
+
29
+ `skill` always replaces the `northlight` skill it finds. `--print` writes the skill to stdout instead of to files.
30
+
31
+ ## Where it goes
32
+
33
+ | Agent | In the project (default) | With `--global` |
34
+ |---|---|---|
35
+ | Codex, Cursor, GitHub Copilot, VS Code | `.agents/skills/northlight/SKILL.md` | `~/.agents/skills/northlight/SKILL.md` |
36
+ | Claude Code | `.claude/skills/northlight/SKILL.md` | `~/.claude/skills/northlight/SKILL.md` |
37
+
38
+ `--agent claude`, `--agent agents` or `--agent all` (the default) picks which. `all` writes both places. Cursor and Copilot also read `.claude/skills`, so they may list the skill twice. Use `--agent agents` or `--agent claude` to write one place. `init` takes `--global-skill` where `skill` takes `--global`.
39
+
40
+ The project copy is the default so it is committed with the project, and teammates get it from git. It matches the SDK version your project pins.
41
+
42
+ Checked: the files `init` writes, and that `skill` rewrites both, from the SDK source built on this branch. Which agent loads which directory is from each agent's documentation and is not yet checked on production for every agent.
43
+
44
+ ## Without a project: the hosted skill
45
+
46
+ An agent that has no project yet can read the hosted copy:
47
+
48
+ > Read https://sdk.docs.northlight.ai/SKILL.md and follow it.
49
+
50
+ The agent runs `npx @north-light/sdk@latest init` and then follows the quick setup. It asks you to do the browser steps: sign up, register the app at `https://account.northlight.ai/console/new`, and add permissions. The hosted copy matches the SDK version the docs site was last built from.
51
+
52
+ ## How it stays current
53
+
54
+ The skill's frontmatter carries `metadata.sdk-version`. Its first instruction tells the agent to compare that number with `node_modules/@north-light/sdk/package.json`. If they differ, the agent runs `npx @north-light/sdk skill`, which rewrites the skill from the installed package. A skill therefore always documents the SDK your project pins, and a newer SDK never gets documented against an older one.
55
+
56
+ ## Example prompts
57
+
58
+ After `init`:
59
+
60
+ > Add a "Summarize my day" button to the app. It starts a run on the signed-in person's runtime with `runs.start`, waits with `runs.wait`, and shows the report. Use the northlight skill.
61
+
62
+ Starting from nothing:
63
+
64
+ > Read https://sdk.docs.northlight.ai/SKILL.md and build me an app where people sign in with Northlight and the app texts them a daily summary. Tell me which console steps I need to do.
65
+
66
+ ## Rules the skill gives your agent
67
+
68
+ Your agent reads them from the skill. They are worth knowing as the person reviewing its work:
69
+
70
+ - Pin the exact SDK version.
71
+ - Take scope strings from the console's Add permissions list, never from memory.
72
+ - Never print, paste or commit `local/app-key.json`.
73
+ - Reach a person's runtime with `oauth.client(userId)`, not `new Crouter()`.
74
+ - Use a persistent `ConnectionStore` in production.
75
+ - Get data back with `runs.parse`, not by reading `runs.trace`.
@@ -0,0 +1,109 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Apps and sign-in, this knowledge should be
4
+ read because What registering an app creates, how an app proves who it is with
5
+ a key pair, and the authorization-code flow with PKCE that OAuth2Client runs.
6
+ ---
7
+
8
+ # Apps and sign-in
9
+
10
+ An app is a registration in the Northlight directory: a client ID, a public signing key, the redirect URIs people return to after sign-in, and the list of scopes the app may ask for. You create it in the console at `https://account.northlight.ai/console/new`. Your server proves it is that app by signing its requests with the matching private key.
11
+
12
+ ## What registration holds
13
+
14
+ | Part | What it is |
15
+ |---|---|
16
+ | **Client ID** | The app's id. The console shows it after you register. Your code passes it as `clientId`. |
17
+ | **Origin URL** | Where your app runs. People see its domain as your app's maker. `http://localhost:<port>` is accepted for development. |
18
+ | **Redirect URIs** | The only places sign-in may return to. A URI must match exactly: `localhost` and `127.0.0.1` are different, and the port counts. `http://localhost`, `http://127.0.0.1` and `http://[::1]` are accepted over plain http; every other URI is `https`. |
19
+ | **Public key** | The half of your key pair the directory keeps. |
20
+ | **Scopes** | What the app may ask for. See [[northlight-apps/concepts/scopes-and-grants]]. |
21
+
22
+ Name, logo and "Why your app needs access" appear on the consent page.
23
+
24
+ ## A key pair, not a client secret
25
+
26
+ An app has no client secret. It signs a short assertion with its private key (`private_key_jwt`, the only token authentication method the directory supports) each time it exchanges a code or refreshes a token. The directory checks the signature against the public keys it holds.
27
+
28
+ - `npx @north-light/sdk keygen` writes the private key to `local/app-key.json` (mode `0600`) and prints the public key as JSON. `init` runs it for you. Paste the public key into **Public key** when you register, or under **Add key** on the app's **Sign-in** tab.
29
+ - The private key stays on your server. Never paste it, log it or commit it. The console refuses a pasted private key.
30
+ - An app holds at most 2 active keys, so you can add a new one beside the old one, switch, and retire the old one without downtime.
31
+ - Instead of pasting keys you can give the directory a **JWKS URL** (**Use a JWKS URL instead** on the **Sign-in** tab).
32
+ - Until a key is added, `exchangeCode` throws an `AuthenticationError`.
33
+
34
+ ## The sign-in flow
35
+
36
+ `OAuth2Client` runs the OAuth 2.0 authorization-code flow with PKCE (`S256`) and an OpenID Connect nonce. Your two routes are short:
37
+
38
+ ```ts
39
+ if (url.pathname === '/auth/login') {
40
+ const {url: authorizeUrl, codeVerifier, state, nonce} = await oauth.authorizeUrl({
41
+ scopes: ['openid', 'profile', 'crtr:llm', 'crtr:act', 'sms:send'],
42
+ state: randomBytes(16).toString('base64url'),
43
+ });
44
+ pending.set(state, {codeVerifier, nonce});
45
+ return void res.writeHead(303, {location: authorizeUrl}).end();
46
+ }
47
+ ```
48
+
49
+ `authorizeUrl` returns the URL to redirect to, plus three values to keep for this sign-in: `codeVerifier`, `state` and `nonce`. The example keeps them in a `Map` keyed by `state`. Use your own session store.
50
+
51
+ ```ts
52
+ if (url.pathname === '/auth/callback') {
53
+ if (authorizationRefusalOf(url)?.code === 'app_in_testing') {
54
+ return page(res, 403, '<h1>This app is still in testing</h1><p>Ask its owner to add you on the Testers tab in the console.</p>');
55
+ }
56
+ const state = url.searchParams.get('state') ?? '';
57
+ const code = url.searchParams.get('code');
58
+ const saved = pending.get(state);
59
+ if (!saved || !code) return page(res, 400, '<p>Start sign-in again at <a href="/auth/login">/auth/login</a>.</p>');
60
+ pending.delete(state);
61
+ const connection = await oauth.exchangeCode({code, codeVerifier: saved.codeVerifier, state, expectedState: state, nonce: saved.nonce});
62
+ await store.save(connection);
63
+ const sid = randomBytes(16).toString('base64url');
64
+ sessions.set(sid, connection.userId);
65
+ return void res.writeHead(303, {location: '/', 'set-cookie': `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`}).end();
66
+ }
67
+ ```
68
+
69
+ On the callback:
70
+
71
+ 1. Read `state` and `code` from the query. Find what you saved for that `state`. If nothing is there, send the person back to the login route.
72
+ 2. Call `exchangeCode` with the code, the saved `codeVerifier` and `nonce`, the `state` from the query as `state`, and the value you saved as `expectedState`. Never pass the callback's `state` for both. A missing or different `expectedState` throws `CrouterError` with code `oauth_state_mismatch` before the code is sent anywhere.
73
+ 3. `exchangeCode` verifies the ID token (ES256 signature, issuer, audience and nonce) when it has one, and returns a `Connection`. Save it in your [[northlight-apps/concepts/connections]].
74
+
75
+ Then set your own session for the person. The SDK does not manage your sessions.
76
+
77
+ ### Scopes decide what comes back
78
+
79
+ A sign-in that asks only for `openid`, optionally with `email` and `profile`, creates no connection. Finish it with `oauth.exchangeIdentity(...)`, which returns the verified `Identity` (`sub`, and `email`, `name` and `picture` when approved and present). A sign-in that asks for runtime scopes such as `crtr:llm` finishes with `exchangeCode`. Each method refuses the other's answer with `oauth_exchange_mismatch`.
80
+
81
+ ### When the directory refuses to sign someone in
82
+
83
+ If the app is in testing and the person is not a tester, the directory redirects to your callback with `error=access_denied` and `error_description=app_in_testing` instead of a `code`. `authorizationRefusalOf(url)` reads it. See [[northlight-apps/concepts/testing-mode]].
84
+
85
+ ## Keep sign-in secrets out of HTTP logs
86
+
87
+ The callback URL carries a one-use `code` and your `state`, and the response that follows carries a session cookie. Request loggers such as `pino-http` log full URLs and response headers by default. Log the method, the path without the query string, an id and the response status, for every route, not only the callback. Redacting only the `authorization` and `cookie` request headers does not cover these values.
88
+
89
+ ```ts
90
+ LoggerModule.forRoot({
91
+ pinoHttp: {
92
+ serializers: {
93
+ req: (req: { method?: string; url?: string; id?: unknown }) => ({
94
+ method: req.method,
95
+ path: req.url?.split('?')[0],
96
+ id: req.id,
97
+ }),
98
+ res: (res: { statusCode?: number }) => ({ statusCode: res.statusCode }),
99
+ },
100
+ redact: ['req.headers.authorization', 'req.headers.cookie'],
101
+ },
102
+ });
103
+ ```
104
+
105
+ Test it: send a callback with a recognizable fake code and assert that the code, the state and the cookie never appear in the log output.
106
+
107
+ ## Checked and not checked
108
+
109
+ Checked on production: the discovery document lists `S256` and `private_key_jwt`, and the login route of the quickstart server redirects to `https://account.northlight.ai/authorize` with the PKCE challenge and nonce. The rest is from the SDK and directory source. A full sign-in with a real account is not yet checked on production.