@north-light/cli 0.3.473 → 0.3.475

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 (57) hide show
  1. package/dist/builtin-memory/crouter-sdk/README.md +3 -46
  2. package/dist/builtin-memory/crouter-sdk/client.md +2 -231
  3. package/dist/builtin-memory/crouter-sdk/errors.md +9 -50
  4. package/dist/builtin-memory/crouter-sdk/getting-started.md +2 -64
  5. package/dist/builtin-memory/crouter-sdk/guides/README.md +1 -3
  6. package/dist/builtin-memory/crouter-sdk/guides/typed-extraction.md +1 -1
  7. package/dist/builtin-memory/crouter-sdk/streaming.md +1 -70
  8. package/dist/builtin-memory/northlight-apps/INDEX.md +15 -0
  9. package/dist/builtin-memory/northlight-apps/cli.md +41 -0
  10. package/dist/builtin-memory/northlight-apps/coding-agents.md +75 -0
  11. package/dist/builtin-memory/northlight-apps/concepts/apps-and-sign-in.md +109 -0
  12. package/dist/builtin-memory/northlight-apps/concepts/connections.md +109 -0
  13. package/dist/builtin-memory/northlight-apps/concepts/providers.md +76 -0
  14. package/dist/builtin-memory/northlight-apps/concepts/runs.md +101 -0
  15. package/dist/builtin-memory/northlight-apps/concepts/scopes-and-grants.md +64 -0
  16. package/dist/builtin-memory/northlight-apps/concepts/testing-mode.md +54 -0
  17. package/dist/builtin-memory/northlight-apps/errors.md +119 -0
  18. package/dist/builtin-memory/northlight-apps/guides/background-runs.md +73 -0
  19. package/dist/builtin-memory/northlight-apps/guides/files-and-documents.md +61 -0
  20. package/dist/builtin-memory/northlight-apps/guides/go-live.md +39 -0
  21. package/dist/builtin-memory/northlight-apps/guides/grant-changes.md +100 -0
  22. package/dist/builtin-memory/northlight-apps/guides/manage-your-app.md +66 -0
  23. package/dist/builtin-memory/northlight-apps/guides/many-users.md +63 -0
  24. package/dist/builtin-memory/northlight-apps/guides/stream-a-run.md +84 -0
  25. package/dist/builtin-memory/{crouter-sdk/testing.md → northlight-apps/guides/test-your-app.md} +6 -6
  26. package/dist/builtin-memory/northlight-apps/guides/typed-results.md +99 -0
  27. package/dist/builtin-memory/northlight-apps/how-it-fits.md +49 -0
  28. package/dist/builtin-memory/northlight-apps/providers/README.md +39 -0
  29. package/dist/builtin-memory/northlight-apps/providers/context.md +34 -0
  30. package/dist/builtin-memory/northlight-apps/providers/device.md +28 -0
  31. package/dist/builtin-memory/northlight-apps/providers/integrations.md +28 -0
  32. package/dist/builtin-memory/northlight-apps/providers/search.md +31 -0
  33. package/dist/builtin-memory/northlight-apps/providers/sms.md +39 -0
  34. package/dist/builtin-memory/northlight-apps/quickstart.md +210 -0
  35. package/docs/apps/guides/background-runs.md +1 -8
  36. package/docs/apps/guides/manage-your-app.md +7 -18
  37. package/docs/cli/README.md +1 -0
  38. package/docs/cli/join-northlight.md +58 -0
  39. package/docs/sdk/README.md +3 -46
  40. package/docs/sdk/client.md +2 -231
  41. package/docs/sdk/errors.md +8 -49
  42. package/docs/sdk/getting-started.md +2 -64
  43. package/docs/sdk/guides/README.md +1 -3
  44. package/docs/sdk/guides/typed-extraction.md +1 -1
  45. package/docs/sdk/streaming.md +1 -70
  46. package/package.json +2 -2
  47. package/packages/crouter-identity/package.json +1 -1
  48. package/runtime.lock.json +12 -12
  49. package/dist/builtin-memory/crouter-sdk/error-envelope.md +0 -38
  50. package/dist/builtin-memory/crouter-sdk/guides/apps-on-northlight.md +0 -423
  51. package/dist/builtin-memory/crouter-sdk/guides/apps-with-many-users.md +0 -61
  52. package/dist/builtin-memory/crouter-sdk/memory.md +0 -49
  53. package/docs/sdk/error-envelope.md +0 -36
  54. package/docs/sdk/guides/apps-on-northlight.md +0 -419
  55. package/docs/sdk/guides/apps-with-many-users.md +0 -59
  56. package/docs/sdk/memory.md +0 -47
  57. package/docs/sdk/testing.md +0 -191
@@ -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,73 @@
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
+ In the [console](https://account.northlight.ai/console), open your app, choose the **What it asks for** tab, and fill in **Notice URL** under **Run notice URL**. Choose **Save**. The URL must be `https` and at most 512 characters. To stop notices, empty the field and save. The full list of console fields is in [[northlight-apps/guides/manage-your-app]].
@@ -0,0 +1,61 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Files and documents, this knowledge should
4
+ be read because Download a file from a person's runtime with files.download,
5
+ and read the person's public documents with memory.list and memory.get.
6
+ ---
7
+
8
+ # Files and documents
9
+
10
+ ## Download a file
11
+
12
+ `runtime.files.download(path, options?)` returns the file's raw bytes as a Fetch `Response` (200, or 206 for a `range`) with `Content-Type`, `Content-Length` and `Content-Range`, so you can pipe its body on. `path` is an absolute path on the person's runtime that your app's grant lets it read. Options: `range` (one `Range` header value such as `bytes=0-0` or `bytes=-500`) and `signal`.
13
+
14
+ ```ts
15
+ const response = await runtime.files.download('/apps/<your client id>/files/report.pdf');
16
+ res.setHeader('content-type', response.headers.get('content-type') ?? 'application/octet-stream');
17
+ await pipeline(Readable.fromWeb(response.body!), res);
18
+ ```
19
+
20
+ A non-2xx answer is thrown as an `APIError`, never returned as bytes. A bad `range` is `416`, `invalid_request` with `param: 'range'`. A body cut short mid-stream means the file could not be read to the end. What a path may reach depends on the grant: see [[northlight-apps/concepts/scopes-and-grants]]. The client your app gets from `oauth.client(userId)` exposes only `download` from `files`; reading, writing and listing are not available to apps.
21
+
22
+ ## Read the person's public documents
23
+
24
+ `runtime.memory` is a read-only view of the `knowledge` and `preference` documents the person owns (`memory` and the `/v1/memory` routes are the API's names for this). An app reads the person's `public` documents with `memory.list` and `memory.get`, and needs no scope to do it. It never sees a `friends` or `private` document and cannot tell one from a document that does not exist: both answer `404 not_found`. The list is what the app's agents can read about the person, so an app can show the person exactly which of their documents it draws on.
25
+
26
+ ```ts
27
+ const page = await runtime.memory.list({store: 'user'});
28
+ for (const item of page.items) console.log(item.name, item.preview);
29
+
30
+ const doc = await runtime.memory.get({store: 'user', name: page.items[0].name});
31
+ console.log(doc.body);
32
+ ```
33
+
34
+ `store` is `'user'` in this release; any other value answers `400 invalid_request` with `param: "store"`. An app whose grant holds `crtr:memory:manage:user` (a first-party app) sees every privacy level. Reading records nothing: no watch, no `read` event, no audit row. Both methods are `GET` requests and follow the SDK's retry rule for safe requests.
35
+
36
+ ### List
37
+
38
+ `list({store, path?, kind?, limit?, cursor?}, options?)` sends `GET /v1/memory` and returns `{items, next_cursor}`.
39
+
40
+ | Field | Meaning |
41
+ |---|---|
42
+ | `path` | A folder: lists documents whose name starts with `<path>/`, at any depth |
43
+ | `kind` | `knowledge` or `preference`; both when omitted |
44
+ | `limit` | 1 to 100; default 50 |
45
+ | `cursor` | The previous page's `next_cursor`, with the same `store`, `path` and `kind` |
46
+
47
+ Items are ordered by `name`. Each item is `{name, folder, kind, preview, summary, privacy, revision, created_at, updated_at}`: `name` has no `user/` prefix, `folder` is the name up to its last `/` (`""` at the top level), and `updated_at` is the current revision's time. Folders are not items, and deleted and unlisted documents are left out. `next_cursor` is `null` on the last page.
48
+
49
+ `listAll(params, options?)` takes the same fields without `cursor` and yields every item across pages, fetching the next page only as you iterate:
50
+
51
+ ```ts
52
+ for await (const item of runtime.memory.listAll({store: 'user', path: 'work'})) render(item);
53
+ ```
54
+
55
+ ### Get
56
+
57
+ `get({store, name}, options?)` sends `GET /v1/memory/document` and returns a list item's fields plus `body`, `size`, `truncated` and `grantee`.
58
+
59
+ - `body` is the current markdown as stored; `[[links]]` are not resolved. A body over 1 MiB is cut to its longest whole-character prefix of at most 1 MiB, with `truncated: true`; `size` is always the full length in UTF-8 bytes.
60
+ - `grantee` is `app:<id>` of the app whose agent wrote the first revision, or `null` when the person wrote it.
61
+ - An absent, deleted, `friends` or `private` name rejects with `NotFoundError` (`code: 'not_found'`); a malformed field rejects with `BadRequestError`.
@@ -0,0 +1,39 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Go live, this knowledge should be read
4
+ because Move an app from your laptop to a server you choose, and ask
5
+ Northlight to verify it so anyone can sign in.
6
+ ---
7
+
8
+ # Go live
9
+
10
+ Northlight does not host your app's server. Deploy it wherever you like. What changes between your laptop and production is a short list.
11
+
12
+ ## Production checklist
13
+
14
+ - **A persistent connection store.** `MemoryConnectionStore` loses every sign-in on restart and works for one process only. Use a store that survives restarts and locks across processes, such as `PostgresConnectionStore` from `@north-light/sdk/stores/postgres`, and pass it as `store`. Two processes that refresh the same connection with different stores race, and the directory refuses the loser with `refresh_token_reused`. The store holds credentials: restrict database access and encrypt backups.
15
+ - **The key file in a secret manager.** The private key is the app's identity. Keep it out of git and out of images. Mount it where your host keeps secrets, and point `CROUTER_KEY_FILE` at that path (a container might read `/run/secrets/app_key`). The SDK reads no environment: your code loads the values and passes them to `OAuth2Client`.
16
+ - **A production redirect URI and origin.** In the console, add `https://<your domain>/auth/callback` under **Sign-in → Redirect URIs** and set the **Origin URL** on the Profile tab to your domain. Set `APP_ORIGIN` to it on the server. Keep `http://localhost:3000/auth/callback` if you still develop locally.
17
+ - **An exact SDK pin.** `npm i --save-exact @north-light/sdk@<version>`. A compatibility gate protects the wire between the SDK and Northlight's runtimes, not the TypeScript API, so a range or `latest` can pick up a type change on its next install. If a call fails with `426 runtime_version_unsupported`, the SDK is newer than the runtimes: install the previous version or wait. To move on purpose, run `npm view @north-light/sdk dist-tags` and read the [release notes](https://sdk.docs.northlight.ai/docs/release-notes) for the versions in between.
18
+ - **One place for dead connections.** Pass `onRefusal` to `OAuth2Client`, and schedule `oauth.refreshIdleConnections` so offline people stay connected. See [[northlight-apps/guides/many-users]].
19
+ - **Redact sign-in secrets from logs.** An authorization code and a refresh token in a log line is a credential. Keep them out of HTTP logs.
20
+ - **Listing, if you want it.** In **Profile → Marketplace**, set a sign-in start URL and a text welcome, and turn on **Listed in the marketplace**. A testing app stays out of the marketplace.
21
+
22
+ Four variables configure an app built on the SDK:
23
+
24
+ | Variable | Value |
25
+ |---|---|
26
+ | `CROUTER_ISSUER` | `https://account.northlight.ai` |
27
+ | `CROUTER_CLIENT_ID` | The client ID the console shows |
28
+ | `APP_ORIGIN` | Your app's origin. Its redirect URI is `${APP_ORIGIN}/auth/callback` by convention |
29
+ | `CROUTER_KEY_FILE` | Path to the private JWK; `local/app-key.json` by default |
30
+
31
+ ## Let people other than your testers sign in
32
+
33
+ A new app is in testing: only you and up to 100 testers can sign in. To open it to everyone, ask Northlight to verify it by email to support@northlight.ai. Include your app's name and client ID, where it runs, and what it asks for and why. Until then, anyone else is refused with `app_in_testing`. See [[northlight-apps/concepts/testing-mode]].
34
+
35
+ This site does not state how long verification takes or what Northlight checks beyond that email address (not yet checked on production).
36
+
37
+ ## Rotate the key later
38
+
39
+ Add a second key, deploy with it, verify a real sign-in, then retire the old one. Rotation has no downtime. The steps are in [[northlight-apps/guides/manage-your-app]].
@@ -0,0 +1,100 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Grant changes, this knowledge should be read
4
+ because Follow a person's grant, wait for a required provider, show their
5
+ month's cost, and link to the app's settings.
6
+ ---
7
+
8
+ # Grant changes
9
+
10
+ What an app reads from `grant.get()` changes while nobody is holding a request: a required provider's setup turns `ready`, the person changes the app's scopes or spend cap, or the grant is revoked.
11
+
12
+ ## Follow grants with `watchGrants`
13
+
14
+ `oauth.watchGrants(...)` returns a `GrantWatcher` that follows the grants of the people you name and hands each change to `onChanged`:
15
+
16
+ ```ts
17
+ const watcher = oauth.watchGrants({
18
+ // Only people whose change the app will act on: here, owners still waiting on Context.
19
+ users: () => db.owners.findMany({where: {connected: true, drafted: false}}).then((rows) => rows.map((row) => row.userId)),
20
+ onChanged: async ({userId, grant}) => {
21
+ // Level-triggered: decide from the grant as it is now, never from "did it just change".
22
+ if (grant.dependencies['context']?.state !== 'ready') return;
23
+ // Idempotent: only a row still waiting moves, so a redelivery or a second instance starts nothing.
24
+ const claimed = await db.owners.updateMany({where: {userId, drafted: false}, data: {drafted: true}});
25
+ if (claimed.count === 1) await startDraft(userId);
26
+ },
27
+ onError: (error, userId) => log.warn({error, userId}, 'grant watcher'),
28
+ });
29
+ await watcher.start();
30
+ // After the app starts waiting on someone:
31
+ watcher.nudge();
32
+ // On shutdown:
33
+ await watcher.stop();
34
+ ```
35
+
36
+ - **`users()`** returns the Northlight user ids whose grant you follow, read from your own rows. It is called at `start()`, every `sweepMs` (default 30 000), after each delivery and on `nudge()`. A user it no longer returns is forgotten. Each followed person holds one request on their runtime per turn, so return only people whose change you will act on.
37
+ - **Each turn** is one `grant.get` on `oauth.client(userId)`. 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. With more users than `concurrency` (default 50), every user still gets a turn in round-robin order.
38
+ - **`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. Delivery is at least once per `(userId, version)`; make the handler idempotent.
39
+ - **If `onChanged` throws**, the same version is delivered again after 5 s, doubling to 5 min. A transient runtime error backs off the same way. A missing or refused connection drops that user and reports to `onError`; the next sweep decides again from `users()`.
40
+ - For one person without the watcher, `runtime.grant.get({wait: 25, since: grant.version})` holds a single read the same way. `wait` outside 1–25 throws `RangeError` before sending.
41
+
42
+ ## Wait on a provider your app requires
43
+
44
+ An app whose registration `requires` a provider (the console's **What your app requires**) needs that provider set up for the person before the operations that depend on it work. The runtime does not check this for you. `grant.get()` returns `dependencies`: one entry per required provider, keyed by provider id, or `{}` when the app requires none. Read it on each request that needs the provider; do not store it.
45
+
46
+ ```ts
47
+ import type {ProviderDependency} from '@north-light/sdk';
48
+
49
+ // ProviderDependency is { state: 'not_started' | 'pending' | 'ready' | 'failed'; message?: string; next: string }.
50
+ const {dependencies} = await runtime.grant.get();
51
+ const context: ProviderDependency | undefined = dependencies['context'];
52
+ if (context?.state !== 'ready') {
53
+ // Show the provider's own sentence and link its setup page; never hard-code either.
54
+ const setup = context && new URL(context.next);
55
+ setup?.searchParams.set('return_to', 'https://app.example.com'); // your registered redirect origin
56
+ throw new AppError('provider_setup_pending', context?.message ?? 'Finish setting up Context, then try again.', {
57
+ status: 403,
58
+ retryable: false,
59
+ details: {provider: 'context', state: context?.state ?? 'not_started', message: context?.message, next: setup?.href},
60
+ });
61
+ }
62
+ ```
63
+
64
+ `AppError` stands for your app's own typed error ([[northlight-apps/errors]]).
65
+
66
+ - `state` is `not_started` (the person has not done their part), `pending` (the provider is working), `ready` or `failed`.
67
+ - `message` is the provider's own sentence for `pending` and `failed`. It is absent for `not_started` and `ready`, so keep a short fallback.
68
+ - `next` is the absolute URL of the directory's setup page for this provider and your app. Append `return_to` (your app's registered redirect origin) so the person comes back to you.
69
+ - An entry is missing when the runtime has not synced the grant since the app began requiring the provider. Treat it as `not_started`.
70
+
71
+ Refuse the dependent operation with `provider_setup_pending` (403), carrying `state`, `message` and `next` in `details`. To act when the state turns `ready`, use `watchGrants` above instead of polling.
72
+
73
+ ## Show what a person's month has cost
74
+
75
+ A person's grant caps what your app may spend of their AI subscription each UTC month (`grant.spend_cap`). `usage.get()` answers where the month stands:
76
+
77
+ ```ts
78
+ const usage = await runtime.usage.get(); // or {month: '2026-09'}
79
+ // {grantee, month, usd_equivalent, by_model, app_paid, spend_cap, reset_at}
80
+ ```
81
+
82
+ - **`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.
83
+ - **`app_paid`** is usage your app pays for. It never counts toward `spend_cap`.
84
+ - **At the cap.** Once `usd_equivalent` reaches `spend_cap`, user-paid model and provider calls answer 402 `cap_exhausted` (`retryable: false`, `userAction: 'raise_cap'`, with `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.
85
+ - `spend_cap: null` means the grant is not capped.
86
+
87
+ You set the default cap for your app in the console's **What it asks for** tab (**Monthly spend cap**); the person can change it for themselves.
88
+
89
+ ## Link to the app's settings on Northlight
90
+
91
+ 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. Do not compose the path yourself:
92
+
93
+ ```ts
94
+ const href = await oauth.accountAppUrl(); // e.g. https://<directory>/account/apps/<your client id>
95
+ ```
96
+
97
+ - It is the same URL for every person: the directory shows whoever is signed in their own page for your app, and sends someone without a directory session through sign-in first. Someone without an active grant sees that the app is not connected to their account.
98
+ - 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.
99
+ - A failure is a `CrouterError` (code `crouter_error`) whose message names the fault, such as a discovery document that advertises no `account_app_uri_template`. A slow discovery read throws `DeadlineExceededError` after `timeoutMs` (default 10 s). A failed read is not kept; a successful one is kept for the client's life.
100
+ - Warm it at startup with `void oauth.accountAppUrl().catch(log)`. A page that must not wait on the directory races it against a short timer and hides the link if the race is lost.
@@ -0,0 +1,66 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Manage your app, this knowledge should be
4
+ read because What each tab of the Northlight console does for an app you have
5
+ registered, including setting the run notice URL and deleting the app.
6
+ ---
7
+
8
+ # Manage your app
9
+
10
+ You manage an app in the console at `https://account.northlight.ai/console`. Choose your app in the list to open its page. The tabs are Sign-in, What it asks for, Profile, Sharing, Testers and Provider. (Only the first-party apps use Provider.)
11
+
12
+ Your **client ID** is shown on the app's page. Copy it into `CROUTER_CLIENT_ID`, or write it to `local/app.env` with `npx @north-light/sdk env --client-id <client id>`.
13
+
14
+ ## Sign-in
15
+
16
+ - **Redirect URIs.** Sign-in returns only to a URI listed here, matched exactly: `localhost` and `127.0.0.1` are different URIs, and the port counts. Each sign-in names the URI to return to, so an app can hold several (for example your deployed callback and a localhost one). **Save redirect URIs** replaces the whole list, so keep the ones you still use.
17
+ - **Keys.** An app proves who it is with a signing key. There is no client secret. **Add a key** takes a public JWK; the form refuses a pasted private key. You can instead **Use a JWKS URL**: the directory reads the keys from that URL. An app holds at most 2 active keys. **Retire** stops a key signing in; a retired key can be **Deleted** so it can be added again.
18
+
19
+ ### Rotate a key
20
+
21
+ Rotating has no downtime, because the directory accepts a sign-in signed by either active key.
22
+
23
+ 1. Generate the new key beside the current one: `npx @north-light/sdk keygen --out local/app-key.next.json`. It prints the public JWK.
24
+ 2. In **Sign-in → Keys**, choose **Add a key** and paste the JWK. Both keys are now active.
25
+ 3. Point `CROUTER_KEY_FILE` (or your `privateKey`) at `local/app-key.next.json` and deploy.
26
+ 4. Complete one real sign-in through the callback, or refresh a stored connection. Opening your login route only redirects to the directory and never reaches the token endpoint, so it proves nothing about the key.
27
+ 5. **Retire** the old key, then delete the old key file.
28
+
29
+ Until you retire the old key it is your rollback. After you retire it, a sign-in signed with it fails with `unauthorized`, and the message names the key as retired. An app registered with a JWKS URL rotates the same way through the URL: publish the new key beside the old one, switch, verify, then remove the old one. The directory re-reads the URL within about a minute.
30
+
31
+ ## What it asks for
32
+
33
+ - **What your app asks for.** The scopes people are asked to approve. **Add permissions** searches the catalog; take scope strings from it and from `providers.tools`, and do not invent them. `crtr:act` needs `crtr:llm`. See [[northlight-apps/concepts/scopes-and-grants]].
34
+ - **What your app requires.** Providers the app cannot work without. A person is taken through that provider's setup the first time they sign in. See [[northlight-apps/guides/grant-changes]].
35
+ - **Monthly spend cap.** The most your app may spend of each person's AI subscription per month, in whole US dollars.
36
+ - **Run notice URL.** Where Northlight posts a signed notice when one of your app's background runs ends. Type it in **Notice URL** and choose **Save**; empty it and save to turn notices off. The URL must be `https`, at most 512 characters, with no `#fragment`. The directory refuses a plain `http` URL with `run_notice_url` as the field. The field's hint says `http://localhost` is accepted while developing, but the production directory refuses it (checked in the directory source, not yet checked on production), so test notices with a tunnel that gives you an `https` URL. What your app does with a notice is in [[northlight-apps/guides/background-runs]].
37
+
38
+ ## Profile
39
+
40
+ - **How people see your app**: Name, Origin URL, Logo URL, and **Why your app needs access** (quoted on the consent page as your words). **Save details**.
41
+ - **Marketplace**: **Listed in the marketplace**, Price, **Sign-in start URL** and **Text welcome**. Agents can install a listed app for a person by text. A testing app stays out of the marketplace even when listed. The sign-in start URL is where Install sends people; your `/auth/login` route is a valid one, and a `localhost` URL is accepted. The text welcome is one or two plain sentences, at most 120 characters, with no links.
42
+ - **Delete this app.** At the bottom of the tab; see [Delete an app](#delete-an-app).
43
+
44
+ ## Testers
45
+
46
+ A new app is in testing: only you and up to 100 testers can sign in. Add a tester by the email or phone number on their Northlight account. An email matches only while it is verified on that account. Nobody gets a message, so tell them. Removing a tester revokes their grant for the app. See [[northlight-apps/concepts/testing-mode]].
47
+
48
+ ## Sharing
49
+
50
+ **What other apps may use** lists parts of your app's space (memory, files, conversations) that other apps may ask for, and at what access (read or read/write). Add a folder to share a part of your app's files.
51
+
52
+ ## Develop beside a live app
53
+
54
+ Once your app is live you can keep developing on your laptop against production while the live app keeps running. There are two ways.
55
+
56
+ **Add your localhost callback to the live app.** This is simplest and fine for most apps. In **Sign-in → Redirect URIs**, list both `https://app.example.com/auth/callback` and `http://localhost:3000/auth/callback`. Give your laptop its own signing key beside the deployed one (an app holds at most 2 active keys), so you never copy the production private key to your laptop. Both servers share the same people, grants, space and scopes. A scope you add while developing changes what the live app asks of everyone. The origin, sign-in start URL and run notice URL each hold one value, so they keep pointing at the deployed app.
57
+
58
+ **Register a separate development app.** Do this when you are changing what the app asks for, or need grants and data that live people never touch. Register another app in the console, for example "Texty (dev)", with a `localhost` origin and redirect URI and its own key. It starts in testing and stays there, so only you and your testers can sign in, and it never appears in the marketplace. Point your laptop's environment file at its client ID.
59
+
60
+ ## Delete an app
61
+
62
+ On the **Profile** tab, the **Delete this app** card lists what goes: every person's connection to the app, yours included (each person's runtime deletes the app's runs, space and schedules), the app's sign-ins and keys, and its testers and marketplace listing. This cannot be undone, and the client ID is never reused.
63
+
64
+ To delete, type the app's full name in **Type the app's name, *name*, to confirm**. **Delete app** stays disabled until what you typed matches the name (surrounding spaces are ignored), and the directory checks the name again when you submit. After it deletes, the console returns to your app list. The app's next refresh for any person fails with `invalid_grant`, its key stops working and its page answers 404.
65
+
66
+ The card is not shown for a provider or a first-party app, and the directory refuses to delete a provider (`app_is_provider`). If you only want a clean slate for tests, changing the app's redirect URIs and keys is often simpler.