@north-light/cli 0.3.473 → 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.
- package/dist/builtin-memory/crouter-sdk/README.md +3 -46
- package/dist/builtin-memory/crouter-sdk/client.md +2 -231
- package/dist/builtin-memory/crouter-sdk/errors.md +9 -50
- package/dist/builtin-memory/crouter-sdk/getting-started.md +2 -64
- package/dist/builtin-memory/crouter-sdk/guides/README.md +1 -3
- package/dist/builtin-memory/crouter-sdk/guides/typed-extraction.md +1 -1
- package/dist/builtin-memory/crouter-sdk/streaming.md +1 -70
- package/dist/builtin-memory/northlight-apps/INDEX.md +15 -0
- package/dist/builtin-memory/northlight-apps/cli.md +41 -0
- package/dist/builtin-memory/northlight-apps/coding-agents.md +75 -0
- package/dist/builtin-memory/northlight-apps/concepts/apps-and-sign-in.md +109 -0
- package/dist/builtin-memory/northlight-apps/concepts/connections.md +109 -0
- package/dist/builtin-memory/northlight-apps/concepts/providers.md +76 -0
- package/dist/builtin-memory/northlight-apps/concepts/runs.md +101 -0
- package/dist/builtin-memory/northlight-apps/concepts/scopes-and-grants.md +64 -0
- package/dist/builtin-memory/northlight-apps/concepts/testing-mode.md +54 -0
- package/dist/builtin-memory/northlight-apps/errors.md +119 -0
- package/dist/builtin-memory/northlight-apps/guides/background-runs.md +80 -0
- package/dist/builtin-memory/northlight-apps/guides/files-and-documents.md +61 -0
- package/dist/builtin-memory/northlight-apps/guides/go-live.md +39 -0
- package/dist/builtin-memory/northlight-apps/guides/grant-changes.md +100 -0
- package/dist/builtin-memory/northlight-apps/guides/manage-your-app.md +77 -0
- package/dist/builtin-memory/northlight-apps/guides/many-users.md +63 -0
- package/dist/builtin-memory/northlight-apps/guides/stream-a-run.md +84 -0
- package/dist/builtin-memory/{crouter-sdk/testing.md → northlight-apps/guides/test-your-app.md} +6 -6
- package/dist/builtin-memory/northlight-apps/guides/typed-results.md +99 -0
- package/dist/builtin-memory/northlight-apps/how-it-fits.md +49 -0
- package/dist/builtin-memory/northlight-apps/providers/README.md +39 -0
- package/dist/builtin-memory/northlight-apps/providers/context.md +34 -0
- package/dist/builtin-memory/northlight-apps/providers/device.md +28 -0
- package/dist/builtin-memory/northlight-apps/providers/integrations.md +28 -0
- package/dist/builtin-memory/northlight-apps/providers/search.md +31 -0
- package/dist/builtin-memory/northlight-apps/providers/sms.md +39 -0
- package/dist/builtin-memory/northlight-apps/quickstart.md +210 -0
- package/docs/apps/guides/background-runs.md +1 -1
- package/docs/apps/guides/manage-your-app.md +1 -1
- package/docs/cli/README.md +1 -0
- package/docs/cli/join-northlight.md +58 -0
- package/docs/sdk/README.md +3 -46
- package/docs/sdk/client.md +2 -231
- package/docs/sdk/errors.md +8 -49
- package/docs/sdk/getting-started.md +2 -64
- package/docs/sdk/guides/README.md +1 -3
- package/docs/sdk/guides/typed-extraction.md +1 -1
- package/docs/sdk/streaming.md +1 -70
- package/package.json +2 -2
- package/packages/crouter-identity/package.json +1 -1
- package/runtime.lock.json +12 -12
- package/dist/builtin-memory/crouter-sdk/error-envelope.md +0 -38
- package/dist/builtin-memory/crouter-sdk/guides/apps-on-northlight.md +0 -423
- package/dist/builtin-memory/crouter-sdk/guides/apps-with-many-users.md +0 -61
- package/dist/builtin-memory/crouter-sdk/memory.md +0 -49
- package/docs/sdk/error-envelope.md +0 -36
- package/docs/sdk/guides/apps-on-northlight.md +0 -419
- package/docs/sdk/guides/apps-with-many-users.md +0 -59
- package/docs/sdk/memory.md +0 -47
- package/docs/sdk/testing.md +0 -191
package/docs/sdk/client.md
CHANGED
|
@@ -15,236 +15,9 @@ const customSocketClient = new Crouter({ socketPath: '/custom/path/crtrd.sock' }
|
|
|
15
15
|
|
|
16
16
|
`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.
|
|
17
17
|
|
|
18
|
-
##
|
|
18
|
+
## Apps that sign people in
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
import { OAuth2Client } from '@north-light/sdk';
|
|
22
|
-
|
|
23
|
-
const oauth = new OAuth2Client({
|
|
24
|
-
clientId, privateKey, keyId, redirectUri, issuer, // issuer is required; no localhost fallback
|
|
25
|
-
store, // where every person's Connection lives (see below)
|
|
26
|
-
onRefusal: async (refusal) => { /* see "When a refresh is refused" */ },
|
|
27
|
-
});
|
|
28
|
-
const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({scopes: ['openid', 'crtr:act'], state: sessionState});
|
|
29
|
-
// Save codeVerifier, state, and nonce in the app session; redirect the user to url.
|
|
30
|
-
const connection = await oauth.exchangeCode({code: callbackCode, codeVerifier, state: callbackState, expectedState: state, nonce});
|
|
31
|
-
await store.save(connection);
|
|
32
|
-
|
|
33
|
-
// Later, in any process that shares the store:
|
|
34
|
-
const client = await oauth.client(connection.userId);
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
`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`.
|
|
38
|
-
|
|
39
|
-
`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 [Signing in a visitor](./guides/apps-with-many-users.md#signing-in-a-visitor). `exchangeCode` given a sign-in-only answer throws `CrouterError` code `oauth_exchange_mismatch` and names `exchangeIdentity`.
|
|
40
|
-
|
|
41
|
-
`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.
|
|
42
|
-
|
|
43
|
-
### When sign-in is refused: apps in testing
|
|
44
|
-
|
|
45
|
-
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`:
|
|
46
|
-
|
|
47
|
-
```ts
|
|
48
|
-
import {authorizationRefusalOf} from '@north-light/sdk';
|
|
49
|
-
|
|
50
|
-
const refused = authorizationRefusalOf(new URL(request.url));
|
|
51
|
-
if (refused) {
|
|
52
|
-
// check refused.state against the saved state, then show "this app is in testing; ask its developer to add you"
|
|
53
|
-
}
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
A person who pressed Cancel (`access_denied` with no reason) returns `null`. Test the path with [`cloud.testing`](./testing.md#testing-mode).
|
|
57
|
-
|
|
58
|
-
### Keep sign-in secrets out of HTTP logs
|
|
59
|
-
|
|
60
|
-
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:
|
|
61
|
-
|
|
62
|
-
```ts
|
|
63
|
-
LoggerModule.forRoot({
|
|
64
|
-
pinoHttp: {
|
|
65
|
-
serializers: {
|
|
66
|
-
req: (req: { method?: string; url?: string; id?: unknown }) => ({
|
|
67
|
-
method: req.method,
|
|
68
|
-
path: req.url?.split('?')[0],
|
|
69
|
-
id: req.id,
|
|
70
|
-
}),
|
|
71
|
-
res: (res: { statusCode?: number }) => ({ statusCode: res.statusCode }),
|
|
72
|
-
},
|
|
73
|
-
redact: ['req.headers.authorization', 'req.headers.cookie'],
|
|
74
|
-
},
|
|
75
|
-
});
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
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.
|
|
79
|
-
|
|
80
|
-
`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.
|
|
81
|
-
|
|
82
|
-
For Postgres, install `pg` in the app and use the optional SDK subpath instead of writing the lock yourself:
|
|
83
|
-
|
|
84
|
-
```ts
|
|
85
|
-
import {Pool} from 'pg';
|
|
86
|
-
import {PostgresConnectionStore} from '@north-light/sdk/stores/postgres';
|
|
87
|
-
|
|
88
|
-
const store = new PostgresConnectionStore(new Pool({connectionString: process.env.DATABASE_URL}), {table: 'connection'});
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
Create the table before using it (or adapt this schema in a migration):
|
|
92
|
-
|
|
93
|
-
```sql
|
|
94
|
-
CREATE TABLE connection (
|
|
95
|
-
user_id text PRIMARY KEY,
|
|
96
|
-
runtime_url text NOT NULL,
|
|
97
|
-
refresh_token text NOT NULL,
|
|
98
|
-
grant_id text NOT NULL,
|
|
99
|
-
access_token text NOT NULL,
|
|
100
|
-
access_token_expires_at timestamptz NOT NULL,
|
|
101
|
-
id_token text,
|
|
102
|
-
updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
|
103
|
-
);
|
|
104
|
-
CREATE INDEX connection_updated_at_idx ON connection (updated_at, user_id);
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
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`.
|
|
108
|
-
|
|
109
|
-
`runtime_provisioning` means the user's runtime is still being set up; the SDK retries the token request.
|
|
110
|
-
|
|
111
|
-
`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.
|
|
112
|
-
|
|
113
|
-
### When a refresh is refused
|
|
114
|
-
|
|
115
|
-
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.
|
|
116
|
-
|
|
117
|
-
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.
|
|
118
|
-
|
|
119
|
-
| `code` | `kind` | What happened | What your app does |
|
|
120
|
-
|---|---|---|---|
|
|
121
|
-
| `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. |
|
|
122
|
-
| `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. |
|
|
123
|
-
| `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. |
|
|
124
|
-
| `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). |
|
|
125
|
-
| `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. |
|
|
126
|
-
| `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. |
|
|
127
|
-
|
|
128
|
-
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.
|
|
129
|
-
|
|
130
|
-
```ts
|
|
131
|
-
import { OAuth2Client, refusalOf } from '@north-light/sdk';
|
|
132
|
-
|
|
133
|
-
const oauth = new OAuth2Client({
|
|
134
|
-
clientId, privateKey, keyId, redirectUri, issuer, store,
|
|
135
|
-
onRefusal: async ({kind, userId}) => {
|
|
136
|
-
if (kind === 'removed') await forgetUser(userId); // the runtime already deleted the app's runs
|
|
137
|
-
else if (kind === 'paused') await markPaused(userId); // keep everything
|
|
138
|
-
else await askToSignIn(userId); // keep everything
|
|
139
|
-
},
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// In a request handler, answer the one request; the handler above already acted on the connection.
|
|
143
|
-
try { await (await oauth.client(userId)).runs.start({prompt}); }
|
|
144
|
-
catch (error) { if (refusalOf(error)) return showSignInAgain(); throw error; }
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
`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.
|
|
148
|
-
|
|
149
|
-
- **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`.
|
|
150
|
-
- **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.)
|
|
151
|
-
|
|
152
|
-
`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.
|
|
153
|
-
|
|
154
|
-
### Keep offline users connected
|
|
155
|
-
|
|
156
|
-
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.
|
|
157
|
-
|
|
158
|
-
### Learn when a background run ends
|
|
159
|
-
|
|
160
|
-
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:
|
|
161
|
-
|
|
162
|
-
```ts
|
|
163
|
-
const watcher = oauth.watchRuns({
|
|
164
|
-
pending: () => db.drafts.findMany({where: {status: 'drafting'}, select: {userId: true, runId: true}}),
|
|
165
|
-
onEnded: async ({userId, runId, ended, run}) => {
|
|
166
|
-
// Idempotent: only a row still waiting on this run moves.
|
|
167
|
-
if (ended === 'gone') await db.drafts.updateMany({where: {runId, status: 'drafting'}, data: {status: 'failed'}});
|
|
168
|
-
else await db.drafts.updateMany({where: {runId, status: 'drafting'}, data: {status: 'ready', result: run.result}});
|
|
169
|
-
},
|
|
170
|
-
onError: (error, run) => log.warn({error, run}, 'run watcher'),
|
|
171
|
-
});
|
|
172
|
-
await watcher.start();
|
|
173
|
-
// After starting a run the app will watch:
|
|
174
|
-
watcher.nudge();
|
|
175
|
-
// On shutdown:
|
|
176
|
-
await watcher.stop();
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
- **`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.
|
|
180
|
-
- **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.
|
|
181
|
-
- **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.
|
|
182
|
-
- **`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`.
|
|
183
|
-
- **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()`.
|
|
184
|
-
- **`stop()`** stops sweeping, aborts every open long-poll, and resolves once every loop has ended; no handler starts afterwards.
|
|
185
|
-
- **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.
|
|
186
|
-
|
|
187
|
-
### Learn when a person's grant changes
|
|
188
|
-
|
|
189
|
-
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:
|
|
190
|
-
|
|
191
|
-
```ts
|
|
192
|
-
const watcher = oauth.watchGrants({
|
|
193
|
-
// Only people whose change the app will act on: here, owners still waiting on Context.
|
|
194
|
-
users: () => db.owners.findMany({where: {connected: true, drafted: false}}).then((rows) => rows.map((row) => row.userId)),
|
|
195
|
-
onChanged: async ({userId, grant}) => {
|
|
196
|
-
// Level-triggered: decide from the grant as it is now, never from "did it just change".
|
|
197
|
-
if (grant.dependencies['context']?.state !== 'ready') return;
|
|
198
|
-
// Idempotent: only a row still waiting moves, so a redelivery or a second instance starts nothing.
|
|
199
|
-
const claimed = await db.owners.updateMany({where: {userId, drafted: false}, data: {drafted: true}});
|
|
200
|
-
if (claimed.count === 1) await startDraft(userId);
|
|
201
|
-
},
|
|
202
|
-
onError: (error, userId) => log.warn({error, userId}, 'grant watcher'),
|
|
203
|
-
});
|
|
204
|
-
await watcher.start();
|
|
205
|
-
// After the app starts waiting on someone:
|
|
206
|
-
watcher.nudge();
|
|
207
|
-
// On shutdown:
|
|
208
|
-
await watcher.stop();
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
- **`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.
|
|
212
|
-
- **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.
|
|
213
|
-
- **`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).
|
|
214
|
-
- **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()`.
|
|
215
|
-
- **`stop()`** stops sweeping, aborts every open read, and resolves once every turn has ended; no handler starts afterwards.
|
|
216
|
-
- **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.
|
|
217
|
-
|
|
218
|
-
### Show what a person's month has cost
|
|
219
|
-
|
|
220
|
-
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:
|
|
221
|
-
|
|
222
|
-
```ts
|
|
223
|
-
const usage = await runtime.usage.get(); // or {month: '2026-09'}
|
|
224
|
-
// {grantee, month, usd_equivalent, by_model, app_paid, spend_cap, reset_at}
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
- **`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.
|
|
228
|
-
- **`app_paid`** is usage your app pays for. It never counts toward `spend_cap`.
|
|
229
|
-
- **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.
|
|
230
|
-
- `spend_cap: null` means the grant is not capped.
|
|
231
|
-
|
|
232
|
-
### Link to the app's settings on Northlight
|
|
233
|
-
|
|
234
|
-
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:
|
|
235
|
-
|
|
236
|
-
```ts
|
|
237
|
-
const href = await oauth.accountAppUrl(); // e.g. https://<directory>/account/apps/<your client id>
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
- **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.
|
|
241
|
-
- **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.
|
|
242
|
-
- **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.
|
|
243
|
-
- **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.
|
|
244
|
-
|
|
245
|
-
### Testing your app
|
|
246
|
-
|
|
247
|
-
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 [Testing your app](./testing.md).
|
|
20
|
+
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).
|
|
248
21
|
|
|
249
22
|
## Options
|
|
250
23
|
|
|
@@ -340,5 +113,3 @@ await client.runs.message(runId, text, { budgetMs: 20_000 });
|
|
|
340
113
|
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`).
|
|
341
114
|
|
|
342
115
|
Timestamps are ISO-8601 strings (`created`, `settled_at`, `finalized_at`, `deadline_at`), not Unix seconds.
|
|
343
|
-
|
|
344
|
-
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`).
|
package/docs/sdk/errors.md
CHANGED
|
@@ -1,43 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Errors
|
|
3
|
-
description:
|
|
3
|
+
description: What the crouter daemon refuses, how scoped tokens are limited, the socket wire body, and how node creation survives a retry.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Errors
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
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()`.
|
|
9
9
|
|
|
10
|
-
**An agent's own outcome is returned, never thrown.** A
|
|
10
|
+
**An agent's own outcome is returned, never thrown.** A node that declined the schema, hit its deadline, or crashed comes back as a settled `NodeOutcome` from `waitForOutcome`, `createAndWait`, or `parse`. Narrow on it — see [Nodes](./nodes.md#outcomes).
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
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 [When a refresh is refused](./client.md#when-a-refresh-is-refused). 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 [Rotating your key](./getting-started.md#rotating-your-key).
|
|
15
|
-
|
|
16
|
-
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.
|
|
17
|
-
|
|
18
|
-
For the full app-facing HTTP envelope and the SDK's fixed code tuples, see [App-facing error envelope](./error-envelope.md). The socket/v0 wire body below is deliberately smaller.
|
|
19
|
-
|
|
20
|
-
## Classes and mappings
|
|
21
|
-
|
|
22
|
-
Every class below extends `APIError`, which carries `status`, `code`, `message`, `details`, and `headers`.
|
|
23
|
-
|
|
24
|
-
| Condition | Class |
|
|
25
|
-
|---|---|
|
|
26
|
-
| SDK configuration error | `CrouterError` with `status: 0`, `code: 'crouter_error'` |
|
|
27
|
-
| 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 [Release notes](https://sdk.docs.northlight.ai/docs/release-notes) |
|
|
28
|
-
| HTTP 400 | `BadRequestError` |
|
|
29
|
-
| HTTP 401 | `AuthenticationError` |
|
|
30
|
-
| HTTP 403 | `PermissionDeniedError` |
|
|
31
|
-
| HTTP 404 | `NotFoundError` |
|
|
32
|
-
| HTTP 409 | `ConflictError` |
|
|
33
|
-
| HTTP 413, 422 | `UnprocessableEntityError` |
|
|
34
|
-
| HTTP 429 | `RateLimitError` |
|
|
35
|
-
| Code `deadline_exceeded` | `DeadlineExceededError` (extends `APIConnectionTimeoutError`; status 504; `reason` `elapsed` or `retry_after`, `deadlineAt`, `cause`) |
|
|
36
|
-
| HTTP 504 or code `request_timeout` | `APIConnectionTimeoutError` (extends `APIConnectionError`) |
|
|
37
|
-
| Other HTTP ≥ 500 | `InternalServerError` |
|
|
38
|
-
| Code `daemon_unavailable`, `transport_error`, `daemon_request_interrupted`, or `daemon_health_unavailable`; or a non-API transport failure | `APIConnectionError` |
|
|
39
|
-
| Code `request_aborted` | `APIUserAbortError` |
|
|
40
|
-
| Any other daemon response | `APIError` |
|
|
12
|
+
## Daemon error codes
|
|
41
13
|
|
|
42
14
|
A terminal SSE `error` event other than `stream_gap` rejects `NodeStream.finalOutcome()` and the iterator with `APIError` carrying that event's code, message, and details. `stream_gap` is delivered as an event and the stream continues. See [Streaming](./streaming.md).
|
|
43
15
|
|
|
@@ -57,8 +29,6 @@ try {
|
|
|
57
29
|
}
|
|
58
30
|
```
|
|
59
31
|
|
|
60
|
-
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.
|
|
61
|
-
|
|
62
32
|
## Identifier validation
|
|
63
33
|
|
|
64
34
|
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.
|
|
@@ -78,20 +48,11 @@ The daemon answers an error with:
|
|
|
78
48
|
{ "error": { "code": "node_id_exists", "message": "…", "details": { } } }
|
|
79
49
|
```
|
|
80
50
|
|
|
81
|
-
`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.
|
|
82
|
-
|
|
83
|
-
## Retries
|
|
84
|
-
|
|
85
|
-
`maxRetries` defaults to `2`, with exponential backoff. It applies to:
|
|
51
|
+
`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).
|
|
86
52
|
|
|
87
|
-
|
|
88
|
-
- HTTP 429 and 5xx responses, **on `GET`, `HEAD`, and `DELETE` only**.
|
|
53
|
+
## Creating a node that survives a retry
|
|
89
54
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
`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.
|
|
93
|
-
|
|
94
|
-
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:
|
|
55
|
+
`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:
|
|
95
56
|
|
|
96
57
|
```ts
|
|
97
58
|
const runId = `invoice-${invoice.id}`;
|
|
@@ -105,9 +66,7 @@ try {
|
|
|
105
66
|
const outcome = await client.nodes.waitForOutcome(runId);
|
|
106
67
|
```
|
|
107
68
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
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`.
|
|
69
|
+
Which calls the SDK retries, and `runs.start` / `runs.message` idempotency keys, are in [Retries](https://sdk.docs.northlight.ai/docs/errors#retries).
|
|
111
70
|
|
|
112
71
|
## In-repo callers
|
|
113
72
|
|
|
@@ -172,71 +172,9 @@ if (run.kind === 'result') {
|
|
|
172
172
|
|
|
173
173
|
An outcome is returned, never thrown — including a decline and a failure. Only transport faults, daemon errors, and your own abort throw. See [Errors](./errors.md).
|
|
174
174
|
|
|
175
|
-
##
|
|
175
|
+
## Build an app for other people
|
|
176
176
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
```bash
|
|
180
|
-
npx @north-light/sdk keygen --app <client id>
|
|
181
|
-
npx @north-light/sdk env --app <client id>
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
`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.
|
|
185
|
-
|
|
186
|
-
`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.
|
|
187
|
-
|
|
188
|
-
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, [join your laptop](./guides/apps-on-northlight.md#1-account-and-join) first.
|
|
189
|
-
|
|
190
|
-
An app built on the SDK is configured from four variables:
|
|
191
|
-
|
|
192
|
-
| Variable | Value | Where it comes from |
|
|
193
|
-
|---|---|---|
|
|
194
|
-
| `CROUTER_ISSUER` | The directory's issuer URL | `env` |
|
|
195
|
-
| `CROUTER_CLIENT_ID` | The app's client id | `env` |
|
|
196
|
-
| `APP_ORIGIN` | The app's origin. Its redirect URI is `${APP_ORIGIN}/auth/callback` by convention | `env` (the registration's origin) |
|
|
197
|
-
| `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`) |
|
|
198
|
-
|
|
199
|
-
The SDK reads no environment and has no `fromEnv` helper: your app loads the values and passes them to `OAuth2Client`:
|
|
200
|
-
|
|
201
|
-
```ts
|
|
202
|
-
import {readFile} from 'node:fs/promises';
|
|
203
|
-
import {OAuth2Client} from '@north-light/sdk';
|
|
204
|
-
|
|
205
|
-
const config = {
|
|
206
|
-
issuer: process.env.CROUTER_ISSUER!,
|
|
207
|
-
clientId: process.env.CROUTER_CLIENT_ID!,
|
|
208
|
-
origin: process.env.APP_ORIGIN!,
|
|
209
|
-
keyFile: process.env.CROUTER_KEY_FILE ?? 'local/app-key.json',
|
|
210
|
-
};
|
|
211
|
-
const privateKey = JSON.parse(await readFile(config.keyFile, 'utf8'));
|
|
212
|
-
const oauth = new OAuth2Client({clientId: config.clientId, issuer: config.issuer, redirectUri: `${config.origin}/auth/callback`, privateKey, store}); // store: your ConnectionStore
|
|
213
|
-
const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({scopes: ['openid', 'crtr:llm', 'crtr:act'], state: sessionState});
|
|
214
|
-
// Save codeVerifier, state, nonce in the user's session; redirect them to url.
|
|
215
|
-
const connection = await oauth.exchangeCode({code: callbackCode, codeVerifier, state: callbackState, expectedState: state, nonce});
|
|
216
|
-
await store.save(connection);
|
|
217
|
-
const client = await oauth.client(connection.userId); // Refreshes through the store from here on.
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
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.
|
|
221
|
-
|
|
222
|
-
### Rotating your key
|
|
223
|
-
|
|
224
|
-
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:
|
|
225
|
-
|
|
226
|
-
1. **Add the new key.** Generate it beside the current key file and add it to the app:
|
|
227
|
-
|
|
228
|
-
```bash
|
|
229
|
-
npx @north-light/sdk keygen --out local/app-key-next.json --app <client id>
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
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.
|
|
233
|
-
2. **Switch.** Point `privateKey` at `local/app-key-next.json` and deploy.
|
|
234
|
-
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.
|
|
235
|
-
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.
|
|
236
|
-
|
|
237
|
-
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.
|
|
238
|
-
|
|
239
|
-
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.
|
|
177
|
+
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).
|
|
240
178
|
|
|
241
179
|
## Where to go next
|
|
242
180
|
|
|
@@ -5,7 +5,7 @@ description: When composing crouter SDK namespaces into an application, read thi
|
|
|
5
5
|
|
|
6
6
|
# SDK recipes
|
|
7
7
|
|
|
8
|
-
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
|
|
8
|
+
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.
|
|
9
9
|
|
|
10
10
|
| Recipe | Use it when | It ends with |
|
|
11
11
|
|---|---|---|
|
|
@@ -13,7 +13,5 @@ These recipes turn the SDK namespaces into complete applications: a bounded extr
|
|
|
13
13
|
| [Event-driven assistant](./event-driven-assistant.md) | Events arrive over time from a webhook, queue, or watcher. | A resident node that wakes for each event. |
|
|
14
14
|
| [Fan-out pipeline](./fan-out-pipeline.md) | One task needs independent research before a synthesis. | An orchestrator's final report. |
|
|
15
15
|
| [Human approval](./human-approval.md) | A person must decide before work continues. | A node resumed by an inbox answer. |
|
|
16
|
-
| [Apps on Northlight](./apps-on-northlight.md) | 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`. |
|
|
17
|
-
| [Apps with many users](./apps-with-many-users.md) | People interact using separate runtime connections. | App-owned access controls, attribution, and per-person spending. |
|
|
18
16
|
|
|
19
17
|
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.
|
|
@@ -19,6 +19,6 @@ package @north-light/cli@0.3.332
|
|
|
19
19
|
|
|
20
20
|
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.
|
|
21
21
|
|
|
22
|
-
**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 [
|
|
22
|
+
**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).
|
|
23
23
|
|
|
24
24
|
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 [nodes and the canvas](../../concepts/nodes-and-the-canvas.md) for why an SDK node has durable identity, and [lifecycle and wakes](../../concepts/lifecycle-and-wakes.md) for the terminal-versus-resident decision.
|
package/docs/sdk/streaming.md
CHANGED
|
@@ -7,76 +7,7 @@ description: Follow assistant text, tool activity, reports, and outcomes as an a
|
|
|
7
7
|
|
|
8
8
|
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.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
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.
|
|
13
|
-
|
|
14
|
-
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:
|
|
15
|
-
|
|
16
|
-
```ts
|
|
17
|
-
const source = new EventSource(`/api/runs/${runId}/events?after=${cursor}`);
|
|
18
|
-
source.onmessage = ({data}) => {
|
|
19
|
-
const event = JSON.parse(data);
|
|
20
|
-
if (event.type === 'stream.refused') { showRefusal(event.error); source.close(); return; }
|
|
21
|
-
if (event.type === 'stream.reconnecting') { showConnectionFailure(event.error); return; }
|
|
22
|
-
if (event.type === 'stream.reconnected') { clearConnectionFailure(); return; }
|
|
23
|
-
if (event.type === 'stream.idle') { source.close(); markIdle(event.cursor); return; } // reopen on your own trigger
|
|
24
|
-
showRunEvent(event);
|
|
25
|
-
};
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
The four `stream.*` records are forwarded-stream notices, not daemon run events; they have no `sequence_number` or SSE id.
|
|
29
|
-
|
|
30
|
-
`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.
|
|
31
|
-
|
|
32
|
-
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.
|
|
33
|
-
|
|
34
|
-
## Following the reply to one message
|
|
35
|
-
|
|
36
|
-
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:
|
|
37
|
-
|
|
38
|
-
```ts
|
|
39
|
-
const sent = await client.runs.message(runId, 'What did the report say?');
|
|
40
|
-
for await (const event of client.runs.reply(runId, sent)) {
|
|
41
|
-
if (event.type === 'text.delta') append(event.delta);
|
|
42
|
-
if (event.type === 'turn.completed' && event.error) showError(event.error); // {code: 'content_refused' | 'model_error', message}
|
|
43
|
-
}
|
|
44
|
-
// or collect it: text as it streams, then the whole text and how the turn ended
|
|
45
|
-
const {text, ended, error, outcome} = await client.runs.reply(runId, sent).collect({onDelta: (delta) => append(delta)});
|
|
46
|
-
// ended: 'completed' | 'error' (error set) | 'waiting_on_user' | 'settled' (outcome set)
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
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:
|
|
50
|
-
|
|
51
|
-
| Event | Fields | From |
|
|
52
|
-
|---|---|---|
|
|
53
|
-
| `text.delta` | `delta` | `node.output_text.delta` |
|
|
54
|
-
| `message.done` | `text` (the message's whole text, possibly empty), `stop_reason` | `node.output_text.done`, once per assistant message |
|
|
55
|
-
| `turn.completed` | `run_status`, `stop_reason`, `error?`, `message_ids` | `node.turn.completed`; always last |
|
|
56
|
-
| `run.settled` | `outcome` | `node.settled`, when the run settles before the turn completes; always last |
|
|
57
|
-
|
|
58
|
-
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.
|
|
59
|
-
|
|
60
|
-
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`.
|
|
61
|
-
|
|
62
|
-
## A quiet run: `event: idle`
|
|
63
|
-
|
|
64
|
-
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:
|
|
65
|
-
|
|
66
|
-
```ts
|
|
67
|
-
const stream = client.runs.events(runId, {after: cursor});
|
|
68
|
-
for await (const event of stream) handle(event);
|
|
69
|
-
const end = await stream.whenEnded(); // rejects on a transport failure or abort()
|
|
70
|
-
if (end.reason === 'idle') {
|
|
71
|
-
// the run is quiet and nothing was missed; reopen later with client.runs.events(runId, {after: end.cursor})
|
|
72
|
-
}
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
`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.
|
|
76
|
-
|
|
77
|
-
## Typed run events
|
|
78
|
-
|
|
79
|
-
`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.
|
|
10
|
+
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).
|
|
80
11
|
|
|
81
12
|
## The SDK surface
|
|
82
13
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.474",
|
|
4
4
|
"description": "crtr — agent runtime with memory, plugins, and marketplaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
},
|
|
138
138
|
"license": "UNLICENSED",
|
|
139
139
|
"dependencies": {
|
|
140
|
-
"@north-light/identity": "0.3.
|
|
140
|
+
"@north-light/identity": "0.3.474",
|
|
141
141
|
"@earendil-works/pi-agent-core": "1.1.0",
|
|
142
142
|
"@earendil-works/pi-ai": "1.1.0",
|
|
143
143
|
"@earendil-works/pi-coding-agent": "1.1.0",
|