@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
@@ -0,0 +1,109 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Connections, this knowledge should be read
4
+ because A connection is the stored token set for one person. How
5
+ ConnectionStore keeps it, how it refreshes, and what to do when the directory
6
+ refuses a refresh.
7
+ ---
8
+
9
+ # Connections
10
+
11
+ A connection is what your server stores for one person after they sign in: their user id, the URL of their runtime, a refresh token, and a short-lived access token. `oauth.client(userId)` loads it and gives you a client on that person's runtime. You keep connections in a `ConnectionStore`.
12
+
13
+ ```ts
14
+ const connection = await oauth.exchangeCode({code, codeVerifier, state, expectedState, nonce});
15
+ await store.save(connection);
16
+
17
+ // Later, in any process that shares the store:
18
+ const runtime = await oauth.client(connection.userId);
19
+ ```
20
+
21
+ Access tokens last 10 minutes. Refresh tokens rotate on every use and expire after 90 days without one. `oauth.client(userId)` refreshes the access token when it needs to. When nothing is stored for the id it throws `CrouterError` with code `not_connected`: send the person through sign-in again.
22
+
23
+ ## The store
24
+
25
+ `ConnectionStore` has `load(userId)`, `save(connection)`, `lock(userId, fn)` and `remove(userId, refreshToken)`, and may have `listStale(before)`.
26
+
27
+ - `MemoryConnectionStore` keeps connections in one process. Use it for development and tests. Every sign-in is lost when the process restarts, and its lock does not reach other processes.
28
+ - `PostgresConnectionStore` from `@north-light/sdk/stores/postgres` is the production store. Install `pg` in your app. It locks across every process that shares the database.
29
+
30
+ ```ts
31
+ import {Pool} from 'pg';
32
+ import {PostgresConnectionStore} from '@north-light/sdk/stores/postgres';
33
+
34
+ const store = new PostgresConnectionStore(new Pool({connectionString: process.env.DATABASE_URL}), {table: 'connection'});
35
+ ```
36
+
37
+ Create the table first:
38
+
39
+ ```sql
40
+ CREATE TABLE connection (
41
+ user_id text PRIMARY KEY,
42
+ runtime_url text NOT NULL,
43
+ refresh_token text NOT NULL,
44
+ grant_id text NOT NULL,
45
+ access_token text NOT NULL,
46
+ access_token_expires_at timestamptz NOT NULL,
47
+ id_token text,
48
+ updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
49
+ );
50
+ CREATE INDEX connection_updated_at_idx ON connection (updated_at, user_id);
51
+ ```
52
+
53
+ The table holds credentials. Restrict database access and encrypt backups. All workers must use the same database. The store holds an advisory lock for the whole refresh, so size the pool for concurrent refreshes and do not set a transaction timeout shorter than a token request.
54
+
55
+ ### Why the lock matters
56
+
57
+ Two processes refreshing one person at once would spend the same refresh token twice, and the directory ends the grant's refresh tokens when it sees that (`refresh_token_reused`). `lock` makes the SDK reload the connection, refresh only if no other process already did, and save the result while it holds the lock. If you write your own store, `lock` must hold across every process that shares it and commit what `fn` wrote when `fn` resolves.
58
+
59
+ ## When a refresh is refused
60
+
61
+ When the directory refuses to refresh a stored connection, the SDK runs your `onRefusal(refusal)` once the lock is released, then removes the stored connection, then rejects the call that needed the refresh with an `AuthenticationError` (status 401, `userAction: 'reconnect'`). Handle every dead connection in that one place:
62
+
63
+ ```ts
64
+ const oauth = new OAuth2Client({
65
+ clientId, privateKey, redirectUri, issuer, store,
66
+ onRefusal: async ({kind, userId}) => {
67
+ if (kind === 'removed') await forgetUser(userId); // the runtime already deleted the app's runs
68
+ else if (kind === 'paused') await markPaused(userId); // keep everything
69
+ else await askToSignIn(userId); // keep everything
70
+ },
71
+ });
72
+
73
+ // In a request handler, answer the one request:
74
+ try { await (await oauth.client(userId)).runs.start({prompt}); }
75
+ catch (error) { if (refusalOf(error)) return showSignInAgain(); throw error; }
76
+ ```
77
+
78
+ A `Refusal` is `{kind, code, userId, grantId, error}`. **Delete what you keep for a person only on `grant_removed`** (`kind: 'removed'`). On every other code, keep it.
79
+
80
+ | `code` | `kind` | What happened | What your app does |
81
+ |---|---|---|---|
82
+ | `grant_removed` | `removed` | The person removed your app or deleted their account. 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 gone. If they connect again, they see the consent page and start fresh. |
83
+ | `grant_suspended` | `paused` | Your app's access for that person is paused. Nothing was deleted. | Keep everything, stop calling for them, show the app as paused. If they connect again, the same grant comes back. |
84
+ | `app_in_testing` | `paused` | Your app is in testing and this person is not a tester (they were removed, or their email or phone stopped matching). The grant is revoked, not removed. | Keep everything, stop calling for them, and tell them the developer must add them. If you add them back and they sign in again, the same space returns. |
85
+ | `refresh_token_expired` | `reconnect` | The refresh token went 90 days unused. The grant is still active. | Keep everything. Run sign-in again; the consent page is skipped. Prevent it with `refreshIdleConnections`. |
86
+ | `refresh_token_reused` | `reconnect` | Two processes raced on one refresh token. | Keep everything. Fix the store's `lock`, then reconnect. |
87
+ | `grant_revoked` | `reconnect` | The directory refused without naming a reason. | Keep everything and reconnect. Never delete data on this code. |
88
+
89
+ `onRefusal` runs at least once per dead connection, so make it idempotent. If it throws, the connection stays stored, the caller still receives the directory's refusal, and the next use calls the handler again. It must not call the runtime for `refusal.userId`: `oauth.client(userId)` for that user inside the handler throws `CrouterError` code `refusal_handler_reentry`.
90
+
91
+ `refusalOf(error)` returns a `Refusal` only for an error raised while refreshing a stored connection. A 429, `cap_exhausted`, `scope_missing`, or an `invalid_client` refusal of your app's key is not a refused connection, and `refusalOf` returns `null` for it.
92
+
93
+ ## Keep offline people connected
94
+
95
+ A refresh token expires after 90 days without use. Run a worker, for example daily, that calls:
96
+
97
+ ```ts
98
+ await oauth.refreshIdleConnections({olderThanMs: 30 * 24 * 60 * 60 * 1000});
99
+ ```
100
+
101
+ It refreshes every connection last saved before the cutoff, through the store's lock, and returns `{refreshed, refused, failures}`. A refused connection goes through `onRefusal` and is removed. `failures` holds connections that could not be refreshed and are still stored; the next run retries them. The SDK starts no timer. The store needs `listStale(before)`, which both `MemoryConnectionStore` and `PostgresConnectionStore` have.
102
+
103
+ ## Signing in a visitor
104
+
105
+ A person who only needs to be recognized, not connected, signs in with identity only: request `openid`, optionally with `email` and `profile`, and no runtime scope. Finish with `exchangeIdentity`, not `exchangeCode`. An `Identity` is not a connection: no runtime URL, grant or refresh token, nothing goes in the store, and you keep your own session. To act for that person later, send them through `authorizeUrl` again with the runtime scopes you need.
106
+
107
+ ## Checked and not checked
108
+
109
+ From the SDK source and tests. The refresh and refusal paths against `account.northlight.ai` are not yet checked on production.
@@ -0,0 +1,76 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Providers, this knowledge should be read
4
+ because A provider is a service the person's runtime calls for your app.
5
+ Provider scopes, providers.tools, providers.call, and apps that require a
6
+ provider.
7
+ ---
8
+
9
+ # Providers
10
+
11
+ A provider is a service that a person's runtime calls on your app's behalf: sending a text, searching the web, reading their connected accounts, or using their device. Northlight runs the first-party providers (sms, search, integrations, context and device). Your server asks the person's runtime to call one with `providers.call`.
12
+
13
+ ## Scopes
14
+
15
+ Each provider's tools are grouped, and each group is a scope named `<provider>:<group>`. The `sms` provider has one: `sms:send`. Your app can call a provider only if all three of these hold:
16
+
17
+ 1. The scope is on the app's **What it asks for** list (console → **Add permissions**).
18
+ 2. Your sign-in requested it (`authorizeUrl({scopes: […]})`).
19
+ 3. The person approved it on the consent page.
20
+
21
+ Take scope strings from the **Add permissions** list. Do not guess them. [[northlight-apps/providers/README]] lists the first-party providers.
22
+
23
+ ## Find the tools, then call one
24
+
25
+ `providers.tools('<provider>')` lists the tools the person has granted your app, with their descriptions and parameters. Tool names and arguments come from there, never from memory:
26
+
27
+ ```ts
28
+ import type {ConnectedCrouter} from '@north-light/sdk';
29
+
30
+ /** List the tools a provider offers this person, then call one. Scope strings come from the console's
31
+ * "Add permissions" list; tool names and arguments come from `providers.tools`. Never invent either. */
32
+ export async function textMe(runtime: ConnectedCrouter, body: string) {
33
+ const sms = await runtime.providers.tools('sms');
34
+ console.log(JSON.stringify(sms.groups, null, 2)); // each leaf: tool, description, params
35
+
36
+ return runtime.providers.call({provider: 'sms', tool: 'send', arguments: {body}});
37
+ }
38
+ ```
39
+
40
+ `providers.call({provider, tool, arguments})` makes the call and returns the tool's output. The quickstart sends a text this way:
41
+
42
+ ```ts
43
+ // Texts the signed-in person's own verified phone through the sms provider (50 per person per UTC day).
44
+ const sent = await runtime.providers.call({provider: 'sms', tool: 'send', arguments: {body: 'Hello from my first Northlight app.'}});
45
+ ```
46
+
47
+ `send` takes a required `body` and an optional `file`. It has no recipient argument: it texts the verified phone of the person who signed in to your app, from Northlight's number, at most 50 texts per person per UTC day. Calling `providers.tools()` with no provider lists every provider the person's runtime can reach.
48
+
49
+ A call is refused when the scope is missing from the grant. The refusal is an `APIError` whose code says why (`scope_missing` for a scope the run or grant lacks).
50
+
51
+ ## Providers your app requires
52
+
53
+ Some apps do not work until a provider is set up for the person. Name it under **What your app requires** on the **What it asks for** tab. The person is then taken through that provider's setup the first time they sign in. Context, for example, asks for a profile URL, and its state stays `pending` for a few minutes afterwards.
54
+
55
+ Read the state on each request that depends on it. `runtime.grant.get()` returns `dependencies`, one entry per required provider, keyed by provider id (or `{}` when the app requires none):
56
+
57
+ ```ts
58
+ const {dependencies} = await runtime.grant.get();
59
+ const context = dependencies['context']; // {state, message?, next} or undefined
60
+ if (context?.state !== 'ready') {
61
+ // Show context.message and link context.next; never hard-code either.
62
+ }
63
+ ```
64
+
65
+ | `state` | Meaning |
66
+ |---|---|
67
+ | `not_started` | The person has not done their part. |
68
+ | `pending` | The provider is working. |
69
+ | `ready` | Calls that depend on it work. |
70
+ | `failed` | Setup failed. `message` says why. |
71
+
72
+ `message` is the provider's own sentence, present for `pending` and `failed`. `next` is the absolute URL of the setup page for this provider and your app; add a `return_to` query parameter with your app's registered origin so the person returns to you. An entry is missing when the runtime has not synced the grant since the app began requiring the provider: treat that as `not_started`. The runtime does not block your calls for you, so refuse the dependent operation yourself. To act the moment a state turns `ready`, follow it with `oauth.watchGrants`; see [[northlight-apps/guides/grant-changes]].
73
+
74
+ ## Checked and not checked
75
+
76
+ From the SDK source (`providers.tools`, `providers.call`, `grant.get`) and the directory source. The sms limits come from the existing SDK guide. A real `providers.call` for `sms` on production is not yet checked.
@@ -0,0 +1,101 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Runs, this knowledge should be read because
4
+ A run is an agent working on the person's runtime, with their AI subscription,
5
+ under the grant's scopes. Status, outcome, result, activity, and how to bound
6
+ one.
7
+ ---
8
+
9
+ # Runs
10
+
11
+ A run is an agent that works on a person's Northlight runtime, on your behalf and with their AI subscription, under the scopes of their grant. Your server starts it with `runs.start`, and the run keeps going on the runtime whether or not your server is waiting.
12
+
13
+ ```ts
14
+ const runtime = await oauth.client(userId); // a client on this person's runtime, from their stored connection
15
+ const started = await runtime.runs.start({prompt: 'Reply with exactly the word: pong', scopes: ['crtr:llm', 'crtr:act']});
16
+ const run = await runtime.runs.wait(started.run_id); // status is 'idle' once the reply is done
17
+ ```
18
+
19
+ `runs.start` returns at once with a `run_id`. `runs.wait(runId)` long-polls until the run is no longer `running`.
20
+
21
+ ## Status
22
+
23
+ `RunObject.status` is one of:
24
+
25
+ | `status` | Meaning |
26
+ |---|---|
27
+ | `running` | The run's root agent is working. |
28
+ | `waiting_on_user` | The agent asked the person a question (`pending_question_ids` lists them). |
29
+ | `idle` | The agent finished a turn and is waiting. A reply to your prompt leaves the run `idle`. Send `runs.message` for another turn, or `runs.cancel` to end it. |
30
+ | `settled` | The run has ended. `outcome` says how. |
31
+
32
+ `runs.wait` returns on `waiting_on_user`, `idle` and `settled`, so check the status before you read a result.
33
+
34
+ ## Outcome and result
35
+
36
+ A `settled` run has an `outcome`:
37
+
38
+ | `outcome.kind` / `reason` | Meaning |
39
+ |---|---|
40
+ | `result` | The run finished. If you passed an `output_schema`, `result` holds the validated value. |
41
+ | `declined` | The agent could not honestly satisfy the schema. `details` has `reason`, `code` and `retryable`. |
42
+ | `canceled` | You or the person ended it. |
43
+ | `deadline_exceeded` | A deadline ended it. |
44
+ | `runtime_error` | The runtime failed. `details.crouter_reason` says why. |
45
+
46
+ `last_report` is the agent's last report text. To get data back, give the run an `output_schema` and read it with `runs.parse`: see [[northlight-apps/guides/typed-results]]. Do not parse the assistant's text out of `runs.trace`. The trace is what the model said, not a value anything checked.
47
+
48
+ ## Scopes
49
+
50
+ `runs.start({prompt, scopes})` starts the run with only those scopes. `crtr:llm` goes in every run. See [[northlight-apps/concepts/scopes-and-grants]].
51
+
52
+ ## Showing progress
53
+
54
+ While a run is `running`, `runs.get(runId).activity` says what the root agent is doing now. It is `null` in any other status.
55
+
56
+ | `phase` | The root agent is |
57
+ |---|---|
58
+ | `starting` | launching; its first turn has not begun |
59
+ | `thinking` | reasoning, or waiting on the model before any output |
60
+ | `tool` | calling a tool: `tool.name`, and `tool.summary` (the command's first line or the path it acts on) |
61
+ | `submitting_result` | writing or recording its `output_schema` result |
62
+ | `writing` | streaming its answer text |
63
+ | `between_turns` | between turns while the run still works: a child agent is running, a message is about to start the next turn, or the runtime is reminding it to submit its result |
64
+
65
+ ```ts
66
+ const run = await runtime.runs.get(runId);
67
+ const badge = run.activity?.phase === 'tool' ? `Running ${run.activity.tool.name}` : run.activity?.phase ?? run.status;
68
+ ```
69
+
70
+ `activity` covers the root agent only and is read each time you call `runs.get`, so a phase shorter than your poll interval can be missed. To show text as it streams, follow `runs.events` or `runs.reply` instead of polling. Do not read `runs.trace` for progress.
71
+
72
+ ## Bounding a run
73
+
74
+ `runs.wait` and `runs.parse` take a `signal`. When it aborts, the helper rejects with the signal's reason and leaves the run alone. Call `runs.cancel` if you want the run ended:
75
+
76
+ ```ts
77
+ const controller = new AbortController();
78
+ const timer = setTimeout(() => controller.abort(new Error('took too long')), 10 * 60_000);
79
+ let runId: string | undefined;
80
+ try {
81
+ const started = await runtime.runs.start({prompt, scopes: ['crtr:llm']});
82
+ runId = started.run_id;
83
+ const run = await runtime.runs.wait(runId, {signal: controller.signal});
84
+ // read run.status, run.outcome, run.result
85
+ } catch (error) {
86
+ if (controller.signal.aborted && runId) await runtime.runs.cancel(runId);
87
+ throw error;
88
+ } finally {
89
+ clearTimeout(timer);
90
+ }
91
+ ```
92
+
93
+ `runs.parse` rejects with `APIError` code `run_idle` when the agent stopped without submitting a result. The runtime reminds the agent of the schema up to three times, then leaves the run `idle` with the schema still armed. Send `runs.message` to nudge it, or `runs.cancel` to end it. A question from the run rejects with `waiting_on_user` unless you pass `onQuestion`.
94
+
95
+ ## Learn when a background run ends
96
+
97
+ A run you start and do not wait on ends while nobody holds a request. `oauth.watchRuns(...)` follows those runs for you and calls `onEnded` for each one that comes to rest. See [[northlight-apps/guides/background-runs]].
98
+
99
+ ## Checked and not checked
100
+
101
+ From the SDK source and its tests. The `runs.start`, `runs.wait` and `activity` behavior against a real runtime on production is not yet checked.
@@ -0,0 +1,64 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Scopes and grants, this knowledge should be
4
+ read because A scope is what your app may ask for, a grant is what one person
5
+ allowed, and a run's scopes narrow it further.
6
+ ---
7
+
8
+ # Scopes and grants
9
+
10
+ Three things limit what your app can do for a person: the scopes your app **may ask for**, the **grant** that person approved, and the `scopes` list of each run you start. Each can only narrow the one before.
11
+
12
+ | Layer | Set by | Where |
13
+ |---|---|---|
14
+ | What the app may ask for | You | The console, app page, **What it asks for** tab → **Add permissions** |
15
+ | The grant | One person | The consent page, when they connect your app |
16
+ | A run's `scopes` | Your code | `runs.start({scopes: […]})` |
17
+
18
+ ## What your app may ask for
19
+
20
+ An app can only ask for scopes it has been given. A new app starts with `crtr:llm` and `crtr:act`. On the **What it asks for** tab, **Add permissions** searches the full list: agent access, parts of the person's space, and the tools of each provider. Take scope strings from that list or from `providers.tools`. Do not invent them. Without `sms:send` on the list, the consent page does not offer it and `providers.call` for `sms` is refused.
21
+
22
+ The same tab holds **What your app requires** (providers the app does not work without: see [[northlight-apps/concepts/providers]]) and **Monthly spend cap**, the most your app can spend on each person's AI subscription in a month. People see the cap on the consent page.
23
+
24
+ ## The protocol scopes
25
+
26
+ `openid`, `email` and `profile` are available to every app and are not saved in the list. You request them in `authorizeUrl` for sign-in: `openid` for the person's id, `email` for their email, `profile` for their name and photo. A request with only these scopes signs the person in without creating a grant or a connection.
27
+
28
+ ## The grant
29
+
30
+ When a person connects your app, the consent page shows each scope you asked for and the spend cap. What they approve is the **grant**. Your `authorizeUrl` should request only the scopes your app uses, because the grant holds what was requested and approved. A person can change or remove the grant from their Northlight account. Link to that page with `await oauth.accountAppUrl()`; never build the path yourself.
31
+
32
+ If you change what the app asks for, people already connected approve the new list the next time they sign in. Lowering the spend cap applies to everyone at once; raising it reaches each person only after they approve again.
33
+
34
+ ## What each scope gates
35
+
36
+ A run can only do what the grant holds and the run's own `scopes` allow. Narrowing a run's `scopes` removes abilities, so pass the smallest list that works.
37
+
38
+ | Scope | A run (or your server) with it can | Without it |
39
+ |---|---|---|
40
+ | `crtr:llm` | Use the person's AI subscription for the run's model calls. List it in every run's `scopes`. | The run has no model access. |
41
+ | `crtr:act` | Start runs (`runs.start`) and use a shell. Every bash call on the hosted (Linux) runtime needs it. | `runs.start` is refused with `403 scope_missing`. Add it on the **What it asks for** tab and list it in the run's `scopes`. |
42
+ | `crtr:schedule` | Create, pause, resume and run schedules. | Schedule calls are refused. |
43
+ | `crtr:memory:read:user`, `crtr:files:read:user:.`, `crtr:files:rw:user:.` | Read the person's documents beyond their public ones; read or write the person's files. | The run sees only the person's `public` documents and its own app space. |
44
+ | `sms:send` (a provider scope) | Call the `sms` provider's `send` tool, which texts the person's own verified phone. | `providers.call` is refused. |
45
+
46
+ `crtr:act` requires `crtr:llm`, so drop both together. A run that only reasons and submits a typed result needs only `crtr:llm`.
47
+
48
+ Other scopes follow the same shape. A provider scope is `<provider>:<tool group>`; see [[northlight-apps/concepts/providers]]. Scopes for the person's files and documents are named `crtr:files:…` and `crtr:memory:…`; copy them from **Add permissions**.
49
+
50
+ ## A run's `scopes`
51
+
52
+ ```ts
53
+ await runtime.runs.start({prompt, scopes: ['crtr:llm']});
54
+ ```
55
+
56
+ A run's list must stay inside the grant, and a child agent never regains a scope its parent's list dropped. Leaving `scopes` out gives the run everything the grant holds. Prefer a list.
57
+
58
+ ## Your app's own space
59
+
60
+ Your app has its own space on each person's runtime: documents and files owned by your app. Your server and runs started without a `scopes` list read and write it freely. A run started with a `scopes` list needs `crtr:memory:read:app:<your client id>` or `crtr:memory:rw:app:<your client id>` to touch it. Another app's space is reachable only where that app allows it.
61
+
62
+ ## Checked and not checked
63
+
64
+ From the SDK source, the directory console source and the existing SDK guide. `403 scope_missing` for a missing `crtr:act` and the consent page's behavior are not yet checked on production.
@@ -0,0 +1,54 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Testing mode, this knowledge should be read
4
+ because A new app is in testing. You and up to 100 testers can sign in. How to
5
+ add testers, what everyone else sees, and how to get an app verified.
6
+ ---
7
+
8
+ # Testing mode
9
+
10
+ A new app is in testing. Only you and up to 100 testers can sign in to it, and it is not shown in the marketplace. You can sign in as yourself with no setup. Verification, which opens the app to everyone, is by email to support@northlight.ai.
11
+
12
+ ## Add testers
13
+
14
+ On the app's page in the console at `https://account.northlight.ai/console`, open the **Testers** tab, enter the **Email or phone number** on the person's Northlight account, and choose **Add tester**. An email matches only while it is verified on that account. The person can sign in right away. Nobody gets a message, so tell them. The tab lists everyone on the list and lets you remove them.
15
+
16
+ Removing a tester stops their access at once, including their refresh tokens, and keeps their data. Adding them back and having them sign in again restores it.
17
+
18
+ ## What a person who is not a tester sees
19
+
20
+ The directory shows "*app name* is still in testing" and redirects to your `redirectUri` with `error=access_denied`, `error_description=app_in_testing` and your `state` instead of a `code`. `authorizationRefusalOf` reads it from the callback's query and returns `{code: 'app_in_testing', error: 'access_denied', state}`. A person who pressed Cancel (`access_denied` with no description) returns `null`.
21
+
22
+ ```ts
23
+ if (url.pathname === '/auth/callback') {
24
+ if (authorizationRefusalOf(url)?.code === 'app_in_testing') {
25
+ 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>');
26
+ }
27
+ const state = url.searchParams.get('state') ?? '';
28
+ const code = url.searchParams.get('code');
29
+ const saved = pending.get(state);
30
+ if (!saved || !code) return page(res, 400, '<p>Start sign-in again at <a href="/auth/login">/auth/login</a>.</p>');
31
+ pending.delete(state);
32
+ const connection = await oauth.exchangeCode({code, codeVerifier: saved.codeVerifier, state, expectedState: state, nonce: saved.nonce});
33
+ await store.save(connection);
34
+ const sid = randomBytes(16).toString('base64url');
35
+ sessions.set(sid, connection.userId);
36
+ return void res.writeHead(303, {location: '/', 'set-cookie': `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`}).end();
37
+ }
38
+ ```
39
+
40
+ The quickstart server shows "This app is still in testing" and asks the person to contact the owner. Check `refused.state` against the saved state before showing anything.
41
+
42
+ A person already connected who is later removed as a tester has their grant revoked, not removed. Their next refresh is refused with `app_in_testing` (`kind: 'paused'`). Keep their data and show the app as paused. See [[northlight-apps/concepts/connections]].
43
+
44
+ ## Go public
45
+
46
+ Email support@northlight.ai to have the app verified. Only Northlight staff can verify an app, and registering never sets it. Include the app name and what it does. A testing app also stays out of the marketplace listing, even once you turn on **Listed** on the **Profile** tab. Apps that existed before testing mode are already verified.
47
+
48
+ ## Test the refusal in code
49
+
50
+ `@north-light/sdk/testing` has a fake directory that can refuse a sign-in as an app in testing, so you can test this path without a second account. See [[northlight-apps/guides/test-your-app]].
51
+
52
+ ## Checked and not checked
53
+
54
+ From the SDK source, the console source and the testing-mode specification. Adding a tester, a second person signing in, the `app_in_testing` redirect and removing a tester have not been run on production (not yet checked on production). Those steps need a second account.
@@ -0,0 +1,119 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Errors, this knowledge should be read
4
+ because What throws and what settles as an outcome, the error classes,
5
+ refusals, the error envelope, and which calls the SDK retries.
6
+ ---
7
+
8
+ # Errors
9
+
10
+ ## What throws and what does not
11
+
12
+ **A run's own outcome is returned, never thrown.** A run that declined the schema, hit its deadline or failed comes back as an outcome from `runs.wait` or `runs.parse`. Narrow on it: see [[northlight-apps/guides/typed-results]]. Only transport faults, runtime or directory refusals, bad local configuration, and your own abort throw.
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.
15
+
16
+ Codes the SDK raises itself are listed in `sdkErrorCodes`, and `CrouterError.code` is typed `SdkErrorCode`. Both are exported from `@north-light/sdk/errors` alongside `errorCodes` and `ErrorCode`. That subpath also exports `mapError(error)`, which turns a plain `APIError` into its status class below, for test doubles that build errors by hand.
17
+
18
+ | Code | Means | What to do |
19
+ |---|---|---|
20
+ | `id_token_invalid` | An ID token failed verification (signature, issuer, audience, expiry, missing or mismatched nonce), or a sign-in-only answer had no ID token. A JWKS that can't be fetched or is malformed is not this code: that error is thrown as is. | Restart the sign-in. |
21
+ | `oauth_exchange_mismatch` | The wrong exchange call: `exchangeCode` on a sign-in-only answer, or `exchangeIdentity` on an answer that carries a connection. | Use the call that matches the scopes you requested ([[northlight-apps/guides/many-users]]). |
22
+ | `oauth_nonce_missing` | `exchangeIdentity` was called without the `nonce` from `authorizeUrl`. | Save the nonce in the visitor's session and pass it back. |
23
+ | `oauth_state_mismatch` | The callback's `state` was missing or differed from the saved one. | Refuse the callback. |
24
+ | `not_connected` | `oauth.client(userId)` or a refresh found no stored connection for that person. | Show "sign in again". |
25
+ | `refusal_handler_reentry` | An `onRefusal` handler called `oauth.client` (or a refresh) for the person whose connection it is handling. | Don't call the runtime for `refusal.userId` inside the handler. |
26
+ | `unauthorized` (401, `AuthenticationError`) | The directory refused your app's client assertion (`invalid_client`) on `exchangeCode`, a refresh or `revoke`, including a refresh inside a client from `oauth.client(userId)`. The message names the key id (`kid`) you signed with and asks whether that key's public JWK is registered. `details` is `{oauth_error: 'invalid_client', kid}`, or `key_thumbprint` instead of `kid` for a key with no `kid`. | Check that the key's public JWK is on the app's **Sign-in** tab ([[northlight-apps/guides/manage-your-app]]). |
27
+ | `run_idle`, `waiting_on_user` | `runs.parse`: the run stopped without submitting, or waits on a question and you passed no `onQuestion`. | See [[northlight-apps/guides/typed-results]]. |
28
+ | `request_timeout`, `deadline_exceeded` | A timeout, or the call's overall deadline passed. | Retry later, or raise the budget. |
29
+
30
+ A refused stored connection raises the directory's `AuthenticationError`, and `refusalOf(error)` returns the `Refusal`. See [[northlight-apps/concepts/connections]].
31
+
32
+ ## Classes and mappings
33
+
34
+ Every class extends `APIError`, which carries `status`, `code`, `message`, `details` and `headers`.
35
+
36
+ | Condition | Class |
37
+ |---|---|
38
+ | SDK configuration error | `CrouterError` with `status: 0`, `code: 'crouter_error'` |
39
+ | The runtime does not serve the contract level the SDK requires, or names none | `RuntimeVersionError`, `status: 426`, `code: 'runtime_version_unsupported'`. See below. |
40
+ | HTTP 400 | `BadRequestError` |
41
+ | HTTP 401 | `AuthenticationError` |
42
+ | HTTP 403 | `PermissionDeniedError` |
43
+ | HTTP 404 | `NotFoundError` |
44
+ | HTTP 409 | `ConflictError` |
45
+ | HTTP 413, 422 | `UnprocessableEntityError` |
46
+ | HTTP 429 | `RateLimitError` |
47
+ | Code `deadline_exceeded` | `DeadlineExceededError` (extends `APIConnectionTimeoutError`; status 504; `reason` `elapsed` or `retry_after`, `deadlineAt`, `cause`) |
48
+ | HTTP 504 or code `request_timeout` | `APIConnectionTimeoutError` (extends `APIConnectionError`) |
49
+ | Other HTTP ≥ 500 | `InternalServerError` |
50
+ | A transport failure, or code `transport_error` | `APIConnectionError` |
51
+ | Code `request_aborted` | `APIUserAbortError` |
52
+ | Any other response | `APIError` |
53
+
54
+ The subclasses add no fields. `status` and `code` on the base class decide everything there is to branch on. They exist so that `catch (error) { if (error instanceof NotFoundError) … }` works.
55
+
56
+ ```ts
57
+ import {ConflictError, NotFoundError, APIConnectionError} from '@north-light/sdk';
58
+
59
+ try {
60
+ await runtime.runs.get(runId);
61
+ } catch (error) {
62
+ if (error instanceof NotFoundError) return showRunGone();
63
+ if (error instanceof APIConnectionError) return showTryAgain();
64
+ throw error;
65
+ }
66
+ ```
67
+
68
+ ### `426 runtime_version_unsupported`
69
+
70
+ The SDK sends `Crouter-Required-Contract` and a frozen `Crouter-Minimum-Runtime-Version` with each call. If the runtime does not serve the contract level the SDK requires, the hosting router or the runtime refuses it before handling: nothing was accepted and no idempotency key was recorded. The error is a `RuntimeVersionError`: `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. A runtime that predates the contract level handles the request first, and the SDK then raises after.
71
+
72
+ In practice it means your SDK is newer than the runtimes, which can happen briefly after an SDK release. Install the previous version or wait, and tell support@northlight.ai. See the [release notes](https://sdk.docs.northlight.ai/docs/release-notes).
73
+
74
+ ## The error envelope
75
+
76
+ Northlight's app-facing HTTP endpoints answer errors with one body shape. Your app's own endpoints can use the same shape with `origin: 'app'`. The SDK publishes the fixed code tuples:
77
+
78
+ ```ts
79
+ import {errorCodes, type ErrorEnvelope} from '@north-light/sdk/errors';
80
+
81
+ const row = errorCodes.invalid_request;
82
+ // { type: 'invalid_request_error', status: 400, retryable: false }
83
+
84
+ const body: ErrorEnvelope = {
85
+ error: {
86
+ code: 'invalid_request',
87
+ message: 'Missing prompt',
88
+ type: row.type,
89
+ origin: 'app',
90
+ retryable: row.retryable,
91
+ request_id: 'req-123',
92
+ param: 'prompt',
93
+ },
94
+ };
95
+ // Send body with HTTP status row.status.
96
+ ```
97
+
98
+ The body is `{ error: { code, message, details?, type, param?, origin, retryable, retry_after_s?, reset_at?, request_id, run_id?, question_id?, user_action?, scope? } }`.
99
+
100
+ - `origin` is `directory`, `router`, `runtime`, `provider` or `app`.
101
+ - `type` is `invalid_request_error`, `authentication_error`, `permission_error`, `rate_limit_error` or `server_error`.
102
+ - **Status belongs only in the HTTP status line**, not in the body.
103
+ - Send `Retry-After` when `retry_after_s` is set. Keep `request_id` in the body and echo it as a request-id response header. Do not expose stack traces in a 5xx message.
104
+
105
+ `errorCodes` is keyed by code, and each row gives that code's fixed `{ type, status, retryable }`. An unsatisfiable byte range is `range_not_satisfiable` (416), an upstream model failure is `upstream_model_unavailable` (503, retryable), and an upstream vendor sign-in refusal is `upstream_refused` (502). If no existing code fits an app-specific failure, declare an app code with a consistent type and status and document it in your app's API description.
106
+
107
+ Not every error has a fixed row. `provider_error` carries the provider's varying 4xx status and is not in `errorCodes`. `stream_gap`, `stream_dropped` and `stream_error` are stream protocol codes, not ordinary refusals. `runtime_provisioning`, `grant_removed`, `grant_suspended`, `refresh_token_expired` and `refresh_token_reused` are raised by the SDK for directory token responses ([[northlight-apps/concepts/connections]]).
108
+
109
+ `output_parsed` from `runs.parse` is non-null on a result. A Zod schema supplies its inferred output type; a JSON Schema literal makes it `unknown`.
110
+
111
+ ## Retries
112
+
113
+ `maxRetries` defaults to `2`, with exponential backoff. It applies to connection errors, and to HTTP 429 and 5xx responses **on `GET`, `HEAD` and `DELETE` only**. Override it per request with `{maxRetries: 0}` or `{maxRetries: 5}`.
114
+
115
+ **`POST` and `PATCH` are not retried automatically, with two exceptions: `runs.start` and `runs.message`.** A mutation whose response was interrupted may already have been applied, and replaying it would start a second run or deliver a second message. Those two carry an `Idempotency-Key` header: the SDK generates one key per call (or uses the `idempotencyKey` option) and reuses it on every retry of that call. The runtime 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 across restarts, derive `idempotencyKey` from your own id.
116
+
117
+ A call with `budgetMs` (or `deadlineAt`) has one deadline over all attempts and waits, a server's `Retry-After` included. A wait longer than the time left rejects at once with `DeadlineExceededError` instead of sleeping. Without a deadline, a keyed send waits out any `retry_after_s`.
118
+
119
+ A `503 service_unavailable` (`retryable: true`, `origin: 'runtime'`) means the runtime could not confirm the grant the access token names. 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.
@@ -0,0 +1,80 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Background runs, this knowledge should be
4
+ read because Learn when a run your app started and did not wait on has ended,
5
+ with RunWatcher or run notices.
6
+ ---
7
+
8
+ # Background runs
9
+
10
+ A run your app starts and does not wait on (a draft, a scheduled job) ends while nobody is holding a request. Two tools tell your app. Use `RunWatcher` while your server is running. Add run notices when the person's runtime sleeps and your app must wake on output.
11
+
12
+ ## Follow runs with `RunWatcher`
13
+
14
+ `oauth.watchRuns(...)` returns a `RunWatcher` that follows those runs and hands each ending to `onEnded`, so you write no poll loop:
15
+
16
+ ```ts
17
+ const watcher = oauth.watchRuns({
18
+ pending: () => db.drafts.findMany({where: {status: 'drafting'}, select: {userId: true, runId: true}}),
19
+ onEnded: async ({userId, runId, ended, run}) => {
20
+ // Idempotent: only a row still waiting on this run moves.
21
+ if (ended === 'gone') await db.drafts.updateMany({where: {runId, status: 'drafting'}, data: {status: 'failed'}});
22
+ else await db.drafts.updateMany({where: {runId, status: 'drafting'}, data: {status: 'ready', result: run.result}});
23
+ },
24
+ onError: (error, run) => log.warn({error, run}, 'run watcher'),
25
+ });
26
+ await watcher.start();
27
+ // After starting a run the app will watch:
28
+ watcher.nudge();
29
+ // On shutdown:
30
+ await watcher.stop();
31
+ ```
32
+
33
+ - **`pending()`** returns `{userId, runId}` for every run the app still waits on, read from your own rows. The rows are the durable record: a restarted watcher re-derives everything from them. It is called at `start()`, every `sweepMs` (default 30 000), after each delivery and on `nudge()`. Stop returning a run once `onEnded` has landed it.
34
+ - **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.
35
+ - **Delivery is at least once** per `(runId, status, latest_sequence)`. 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.
36
+ - **`gone`** (`run: null`) is delivered once when the runtime answers 404 `not_found` with `origin: 'runtime'` (the run was deleted). A 404 with `origin: 'router'` is a transient failure, not `gone`.
37
+ - **If `onEnded` throws**, the same ending is retried after 5 s, doubling to 5 min, and the error goes to `onError`. 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()`.
38
+ - **`stop()`** aborts every open long-poll and resolves once every loop has ended.
39
+
40
+ ### Live progress
41
+
42
+ To show what a run is doing without a second `runs.get` per page, pass `onActivity: async ({userId, runId, activity, run}) => …`. It is called with each read of a still-`running` run whose `activity` differs from the last one handed over, and never for a run at rest. While it is set, running runs are read with `wait: activityWait` (integer 1–25 s, default 2) instead of 25 s. The hook is best effort and never awaited: a throw goes to `onError` and is not retried, and it never delays or changes `onEnded`. A call can finish after that run's `onEnded`, so guard the write on the row still waiting. The phases are listed in [[northlight-apps/guides/typed-results]].
43
+
44
+ ## Run notices
45
+
46
+ A hosted runtime that sleeps cannot be polled, so it can tell your app when a run has output nobody is reading. If your app has a run notice URL, the runtime sends `POST <run notice URL>` with `Authorization: Bearer <directory token>` and body `{run_id, latest_sequence}`. It sends one when events are appended to one of your runs and no event stream is open, or a stream ended with events unread. A notice carries no content and never keeps the runtime awake. If it cannot be delivered after three quick attempts the runtime drops it; a lost notice delays output until your app next reads from its cursor, and never loses output.
47
+
48
+ Check each notice with `verifyRunNotice`:
49
+
50
+ ```ts
51
+ import {RunNoticeError, verifyRunNotice} from '@north-light/sdk';
52
+
53
+ app.post('/run-notices', async (req, res) => {
54
+ try {
55
+ const notice = await verifyRunNotice(toFetchRequest(req), {
56
+ runNoticeUrl: 'https://app.example.com/run-notices', // exactly as registered
57
+ issuer: 'https://account.northlight.ai',
58
+ grantee: 'app:<your client id>',
59
+ });
60
+ await storeAndReadFrom(notice.userId, notice.runId, notice.latestSequence); // then read events from your cursor
61
+ res.sendStatus(204);
62
+ } catch (error) {
63
+ if (error instanceof RunNoticeError) return res.sendStatus(401); // store nothing
64
+ throw error;
65
+ }
66
+ });
67
+ ```
68
+
69
+ `verifyRunNotice` checks the token's signature, issuer, audience (your run notice URL), expiry, scope `crtr:run_notice` and grantee, and returns `{userId, runtime, runId, latestSequence}`. `runtime` comes from the token, never the body. Answer 2xx once the notice is stored. The same or a lower `latest_sequence` is a no-op. In tests, pass `fetch: cloud.fetch` from `FakeCloud` ([[northlight-apps/guides/test-your-app]]).
70
+
71
+ ### Set the run notice URL
72
+
73
+ The console has no field for this yet. Until it does, set it with crtr on a machine joined to your Northlight account (`crtr sys join`; see [joining a machine](https://crouter.docs.northlight.ai/docs/cli/join-northlight) in the crouter docs):
74
+
75
+ ```bash
76
+ crtr cloud apps set-run-notice-url --app-id <client id> --url https://app.example.com/run-notices
77
+ crtr cloud apps set-run-notice-url --app-id <client id> --clear # stop notices
78
+ ```
79
+
80
+ The URL must be https (plain http only on localhost in development) and at most 512 characters. `crtr cloud apps get --app-id <client id>` shows it as `run_notice_url`.