@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
@@ -9,76 +9,7 @@ when-and-why-to-read: When you need Streaming, this knowledge should be read
9
9
 
10
10
  Watch a run as it works: assistant text as it is produced, tool calls as they start and finish, reports as they are pushed, and the settled outcome.
11
11
 
12
- ## Forwarding a run to a browser
13
-
14
- The app server keeps the runtime token and connection; the browser connects only to the app. The app server calls `forwardRunEvents(client, runId, {after: cursorFromRequest(request), signal: request.signal})` and returns the Fetch `Response`. After a host cuts the browser connection, EventSource sends its last SSE id as `Last-Event-ID`; `cursorFromRequest` prefers it over the URL's `after` query parameter.
15
-
16
- Every forwarded run event is an unnamed SSE record with its `sequence_number` as `id` and the complete event object as `data`. Receive all event types with one handler:
17
-
18
- ```ts
19
- const source = new EventSource(`/api/runs/${runId}/events?after=${cursor}`);
20
- source.onmessage = ({data}) => {
21
- const event = JSON.parse(data);
22
- if (event.type === 'stream.refused') { showRefusal(event.error); source.close(); return; }
23
- if (event.type === 'stream.reconnecting') { showConnectionFailure(event.error); return; }
24
- if (event.type === 'stream.reconnected') { clearConnectionFailure(); return; }
25
- if (event.type === 'stream.idle') { source.close(); markIdle(event.cursor); return; } // reopen on your own trigger
26
- showRunEvent(event);
27
- };
28
- ```
29
-
30
- The four `stream.*` records are forwarded-stream notices, not daemon run events; they have no `sequence_number` or SSE id.
31
-
32
- `stream.idle` (`{type: 'stream.idle', cursor}`) means the runtime closed the stream after 60 s with no running turn and no new event. The helper writes it and then ends the response, so a browser `EventSource` would otherwise reconnect at once and keep a hosted runtime awake indefinitely. Close the `EventSource` on `stream.idle` and reopen it with `after=cursor` on your own trigger: the person sends a message, or your server handles a run notice (see `verifyRunNotice`). A `stream.refused` carries the full error envelope, including `stream_gap`'s `details.latest_sequence` and `details.earliest_sequence`. The helper ends that response after a refusal. The SDK does not recover a gap by skipping missing events.
33
-
34
- When the page reloads mid-reply or gets `stream_gap`, the app reads `runs.trace(runId)` through its server and shows each node's `in_progress_message` (`{thinking, text}` or `null`) as that node's open message. The trace's `latest_sequence` is read consistently with its conversations and open messages. Resume `runs.events(runId, {after: trace.latest_sequence})` (or forward with that cursor); do not use a separately read `runs.get` cursor. Continue appending new deltas to the open message, but replace its thinking or text with the complete content repeated by `node.thinking.done` or `node.output_text.done`. This shows the message from its beginning without duplicating content. `runs.get` and every `runs.list` entry expose `latest_sequence` and `last_activity_at`; a cursor equal to `latest_sequence` is not a gap even when older events have expired.
35
-
36
- ## Following the reply to one message
37
-
38
- A chat app sends a message and shows the answer to it. `runs.reply(runId, sent, {signal?})` takes what `runs.message` returned and follows only the turn that delivers that message:
39
-
40
- ```ts
41
- const sent = await client.runs.message(runId, 'What did the report say?');
42
- for await (const event of client.runs.reply(runId, sent)) {
43
- if (event.type === 'text.delta') append(event.delta);
44
- if (event.type === 'turn.completed' && event.error) showError(event.error); // {code: 'content_refused' | 'model_error', message}
45
- }
46
- // or collect it: text as it streams, then the whole text and how the turn ended
47
- const {text, ended, error, outcome} = await client.runs.reply(runId, sent).collect({onDelta: (delta) => append(delta)});
48
- // ended: 'completed' | 'error' (error set) | 'waiting_on_user' | 'settled' (outcome set)
49
- ```
50
-
51
- It reads `runs.events(runId, {after: sent.sequence_number})`, skips child nodes' events, starts at the root's `node.turn.started` whose `input.message_ids` holds `sent.message_id`, and yields:
52
-
53
- | Event | Fields | From |
54
- |---|---|---|
55
- | `text.delta` | `delta` | `node.output_text.delta` |
56
- | `message.done` | `text` (the message's whole text, possibly empty), `stop_reason` | `node.output_text.done`, once per assistant message |
57
- | `turn.completed` | `run_status`, `stop_reason`, `error?`, `message_ids` | `node.turn.completed`; always last |
58
- | `run.settled` | `outcome` | `node.settled`, when the run settles before the turn completes; always last |
59
-
60
- Every event also carries its `sequence_number`. The reply ends after `turn.completed` or `run.settled` and closes its event stream. A resident run never settles when it goes idle, so a plain `runs.events` stream stays open, but a reply does not. `.text()` consumes the reply and resolves to the text of its `message.done` events joined with a blank line, so a turn that speaks, calls a tool and speaks again returns both texts. `.collect({onDelta?})` consumes the reply too: it passes each streamed piece to `onDelta`, starting a later assistant message's first piece with a blank line, and resolves to `RunReplyResult` `{text, ended, error?, outcome?}`, where `text` is the trimmed messages joined with a blank line. `ended` is `completed` (text may be empty when the model wrote nothing), `error` (the turn's last message failed; `error` says why), `waiting_on_user` (the turn ended on a question to the person), or `settled` (the run settled first; `outcome` says how). `.text({onDelta?})` is `collect`'s `text`. A reply can be consumed once.
61
-
62
- Several messages delivered in one turn, including a `start_turn: false` message that joined it, all resolve to that turn. To have a reply to the run's first message, pass it as `runs.start({prompt, message})` and follow `runs.reply(run.run_id, run.message!)`: a message stored with the run always joins the first turn. A refused or failed model call ends the turn with an empty `message.done` (`stop_reason: 'error'`) and `turn.completed.error`; the run stays usable, and `runs.get` shows the same error as `last_turn_error` until a later turn completes without one. A provider failure the runtime retries on its own (connection, rate limit, overloaded) does not end the reply: the recovery attempt continues the same turn, so the reply yields its answer and one `turn.completed` without an error, and only a turn whose retries run out ends with `model_error`. An interrupt, `runs.cancel`, or a closed node ends the turn with `stop_reason: 'aborted'` and no error. An expired cursor throws `APIError` with code `stream_gap`; aborting `signal` rejects with `APIUserAbortError`.
63
-
64
- ## A quiet run: `event: idle`
65
-
66
- A runtime that sleeps when nothing holds a connection to it ends a run's SSE stream after 60 s in which the run has no running turn and no new event: it sends `event: idle` (`data: {}`) and closes. `RunStream` reports that and does not reconnect:
67
-
68
- ```ts
69
- const stream = client.runs.events(runId, {after: cursor});
70
- for await (const event of stream) handle(event);
71
- const end = await stream.whenEnded(); // rejects on a transport failure or abort()
72
- if (end.reason === 'idle') {
73
- // the run is quiet and nothing was missed; reopen later with client.runs.events(runId, {after: end.cursor})
74
- }
75
- ```
76
-
77
- `stream.idle` and `stream.position` read the same facts synchronously. A settled run ends with `reason: 'settled'`; a transport error rejects as before. `runs.reply` reopens through an idle close on its own.
78
-
79
- ## Typed run events
80
-
81
- `RunEvent`, exported by the SDK, is the union of every run event, discriminated on `type`. Its types are exactly the daemon's run event vocabulary, declared once. `RunEventOf<'node.turn.completed'>` picks one event, and `RunStream.on(type, listener)` passes the listener that event. An event type a newer daemon adds and this SDK does not declare is still delivered as-is.
12
+ Streaming a run of an app (forwarding it to a browser, following the reply to one message, `event: idle`, typed run events) is on the SDK docs site: [Stream a run](https://sdk.docs.northlight.ai/docs/guides/stream-a-run).
82
13
 
83
14
  ## The SDK surface
84
15
 
@@ -0,0 +1,15 @@
1
+ ---
2
+ kind: knowledge
3
+ description: Building a third-party app on Northlight with @north-light/sdk — register it, sign people in, start runs on their runtime, call providers
4
+ when-and-why-to-read: When you are building, debugging or shipping an app that signs people in with Northlight, starts runs on a person's Northlight runtime, or calls Northlight providers (sms, search, integrations, context, device) with @north-light/sdk, this reference should be read because the app runs on the developer's own server while its agent work runs on each person's runtime, and the sign-in, scope and connection rules differ from driving a local daemon with `new Crouter()`.
5
+ short-form: The app-builder guide from sdk.docs.northlight.ai as pages — quick setup, concepts (sign-in, scopes and grants, connections, runs, testing mode, providers), guides, provider pages, errors and the CLI.
6
+ surfaces:
7
+ - on: boot
8
+ at: name
9
+ ---
10
+
11
+ # `northlight-apps/` — build an app on Northlight
12
+
13
+ An app built on Northlight runs on the developer's own server. People sign in to it with Northlight, its agent work runs on each person's Northlight-hosted runtime, and it can call that person's providers. Northlight does not host the app. The app uses `OAuth2Client` and `oauth.client(userId)` from `@north-light/sdk`, never `new Crouter()`, which drives a daemon you run yourself (`crouter-sdk/`).
14
+
15
+ Start at `northlight-apps/quickstart` for the path from a new account to a running app, then `northlight-apps/how-it-fits` for where each part runs. The directory listing names every page. The same pages are published at https://sdk.docs.northlight.ai, where the generated API reference also lives.
@@ -0,0 +1,41 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need CLI reference, this knowledge should be read
4
+ because The npx @north-light/sdk commands for scaffolding an app, writing its
5
+ signing key and environment file, and installing the agent skill.
6
+ ---
7
+
8
+ # CLI reference
9
+
10
+ `npx @north-light/sdk <command>` runs the package's one command-line tool. Run it with no command, or with `--help`, to print the list below. An unknown command prints the list and exits 1.
11
+
12
+ | Command | What it does | Needs crtr |
13
+ |---|---|---|
14
+ | `npx @north-light/sdk init [dir] [--agent claude\|agents\|all] [--global-skill]` | Scaffold a Northlight app in an empty or new directory (server.ts, key, env file, skill), or add the SDK, a key and the skill to an existing project. Never overwrites a file. | No |
15
+ | `npx @north-light/sdk skill [--agent claude\|agents\|all] [--global] [--print]` | Write the packaged `northlight` skill into `.agents/skills` and `.claude/skills` (or `~/` with `--global`), replacing an older copy. `--print` writes it to stdout. | No |
16
+ | `npx @north-light/sdk keygen [--out <path>] [--app <client id>] [--yes]` | Write a private signing key to `local/app-key.json` (mode 0600) and print the public JWK to register. | Only with `--app` |
17
+ | `npx @north-light/sdk env --client-id <client id> [--origin <url>] [--issuer <url>] [--out <path>]`<br/>`npx @north-light/sdk env --app <client id> [--out <path>]` | Write `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env`, keeping every other line. `--client-id` makes no network call; `--app` reads them through crtr. | Only with `--app` |
18
+
19
+ `keygen` and `env` are the only commands that can call `crtr`, and only with `--app`. The documented path on this site never uses `--app`, so you do not need crtr.
20
+
21
+ ## Details
22
+
23
+ ### `init`
24
+
25
+ In an empty or new directory it writes `package.json` (with `@north-light/sdk` pinned to the CLI's own exact version, plus `tsx`), `server.ts` (the quickstart server), `.gitignore` (`local/`, `node_modules/`) and `local/app.env`, and runs `keygen` into `local/app-key.json`. It installs the skill. Then it prints the public JWK, the console URL `https://account.northlight.ai/console/new`, the values to type, and `npx @north-light/sdk env --client-id <client id>` as the next step.
26
+
27
+ In a directory that already has a `package.json` it writes no app code. It runs `npm i --save-exact @north-light/sdk@<its own version>`, runs `keygen` unless `local/app-key.json` exists, and installs the skill.
28
+
29
+ It never overwrites a file. On a refused step it exits non-zero and names the file it refused.
30
+
31
+ ### `skill`
32
+
33
+ Writes the `northlight` Agent Skill that ships in the package, replacing an older copy. `--agent` picks `.claude/skills`, `.agents/skills` or both. `--global` writes under your home directory instead of the project. The skill's `metadata.sdk-version` is the package version, so run `skill` again after you change the pinned SDK version.
34
+
35
+ ### `keygen`
36
+
37
+ Writes a private signing key to `local/app-key.json` with mode `0600`, refuses to overwrite an existing file (pass `--out` for another path), and prints the public JWK on stdout. Register the JWK in the console with **Add key** on the app's **Sign-in** tab, or paste it under **Register an app**. With `--app <client id>`, it offers to add the key to your app through your own `crtr` (`crtr cloud apps keys add`); `--yes` skips the question. This needs a joined crtr, which this site does not otherwise require.
38
+
39
+ ### `env`
40
+
41
+ `env --client-id <client id>` writes `CROUTER_ISSUER` (default `https://account.northlight.ai`), `CROUTER_CLIENT_ID` and `APP_ORIGIN` (default: the existing value in the file, else `http://localhost:3000`) into `local/app.env`. It keeps every other line and makes no network call. `--out <path>` writes another file. `env --app <client id>` reads the same values through crtr instead. Passing both `--client-id` and `--app` is a usage error.
@@ -0,0 +1,75 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Use your coding agent, this knowledge should
4
+ be read because What npx @north-light/sdk init and skill install for Claude
5
+ Code, Codex, Cursor and Copilot, where the skill lives, and how it stays in
6
+ step with your SDK version.
7
+ ---
8
+
9
+ # Use your coding agent
10
+
11
+ The `northlight` skill teaches a coding agent the whole path in the [[northlight-apps/quickstart]]: the commands, the console fields, the code patterns and the rules. `npx @north-light/sdk init` installs it into your project. The skill needs no account and no crtr.
12
+
13
+ ## Install it
14
+
15
+ For a new app, `init` installs it with everything else:
16
+
17
+ ```bash
18
+ npx @north-light/sdk@latest init my-app
19
+ ```
20
+
21
+ For a project that already has a `package.json`, run `npx @north-light/sdk init` in it. It adds `@north-light/sdk` at an exact version, makes a key if `local/app-key.json` does not exist, installs the skill, and writes no app code. It never overwrites a file: if one is in the way, it names the file and exits non-zero.
22
+
23
+ To install only the skill, or to replace an older copy:
24
+
25
+ ```bash
26
+ npx @north-light/sdk skill
27
+ ```
28
+
29
+ `skill` always replaces the `northlight` skill it finds. `--print` writes the skill to stdout instead of to files.
30
+
31
+ ## Where it goes
32
+
33
+ | Agent | In the project (default) | With `--global` |
34
+ |---|---|---|
35
+ | Codex, Cursor, GitHub Copilot, VS Code | `.agents/skills/northlight/SKILL.md` | `~/.agents/skills/northlight/SKILL.md` |
36
+ | Claude Code | `.claude/skills/northlight/SKILL.md` | `~/.claude/skills/northlight/SKILL.md` |
37
+
38
+ `--agent claude`, `--agent agents` or `--agent all` (the default) picks which. `all` writes both places. Cursor and Copilot also read `.claude/skills`, so they may list the skill twice. Use `--agent agents` or `--agent claude` to write one place. `init` takes `--global-skill` where `skill` takes `--global`.
39
+
40
+ The project copy is the default so it is committed with the project, and teammates get it from git. It matches the SDK version your project pins.
41
+
42
+ Checked: the files `init` writes, and that `skill` rewrites both, from the SDK source built on this branch. Which agent loads which directory is from each agent's documentation and is not yet checked on production for every agent.
43
+
44
+ ## Without a project: the hosted skill
45
+
46
+ An agent that has no project yet can read the hosted copy:
47
+
48
+ > Read https://sdk.docs.northlight.ai/SKILL.md and follow it.
49
+
50
+ The agent runs `npx @north-light/sdk@latest init` and then follows the quick setup. It asks you to do the browser steps: sign up, register the app at `https://account.northlight.ai/console/new`, and add permissions. The hosted copy matches the SDK version the docs site was last built from.
51
+
52
+ ## How it stays current
53
+
54
+ The skill's frontmatter carries `metadata.sdk-version`. Its first instruction tells the agent to compare that number with `node_modules/@north-light/sdk/package.json`. If they differ, the agent runs `npx @north-light/sdk skill`, which rewrites the skill from the installed package. A skill therefore always documents the SDK your project pins, and a newer SDK never gets documented against an older one.
55
+
56
+ ## Example prompts
57
+
58
+ After `init`:
59
+
60
+ > Add a "Summarize my day" button to the app. It starts a run on the signed-in person's runtime with `runs.start`, waits with `runs.wait`, and shows the report. Use the northlight skill.
61
+
62
+ Starting from nothing:
63
+
64
+ > Read https://sdk.docs.northlight.ai/SKILL.md and build me an app where people sign in with Northlight and the app texts them a daily summary. Tell me which console steps I need to do.
65
+
66
+ ## Rules the skill gives your agent
67
+
68
+ Your agent reads them from the skill. They are worth knowing as the person reviewing its work:
69
+
70
+ - Pin the exact SDK version.
71
+ - Take scope strings from the console's Add permissions list, never from memory.
72
+ - Never print, paste or commit `local/app-key.json`.
73
+ - Reach a person's runtime with `oauth.client(userId)`, not `new Crouter()`.
74
+ - Use a persistent `ConnectionStore` in production.
75
+ - Get data back with `runs.parse`, not by reading `runs.trace`.
@@ -0,0 +1,109 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Apps and sign-in, this knowledge should be
4
+ read because What registering an app creates, how an app proves who it is with
5
+ a key pair, and the authorization-code flow with PKCE that OAuth2Client runs.
6
+ ---
7
+
8
+ # Apps and sign-in
9
+
10
+ An app is a registration in the Northlight directory: a client ID, a public signing key, the redirect URIs people return to after sign-in, and the list of scopes the app may ask for. You create it in the console at `https://account.northlight.ai/console/new`. Your server proves it is that app by signing its requests with the matching private key.
11
+
12
+ ## What registration holds
13
+
14
+ | Part | What it is |
15
+ |---|---|
16
+ | **Client ID** | The app's id. The console shows it after you register. Your code passes it as `clientId`. |
17
+ | **Origin URL** | Where your app runs. People see its domain as your app's maker. `http://localhost:<port>` is accepted for development. |
18
+ | **Redirect URIs** | The only places sign-in may return to. A URI must match exactly: `localhost` and `127.0.0.1` are different, and the port counts. `http://localhost`, `http://127.0.0.1` and `http://[::1]` are accepted over plain http; every other URI is `https`. |
19
+ | **Public key** | The half of your key pair the directory keeps. |
20
+ | **Scopes** | What the app may ask for. See [[northlight-apps/concepts/scopes-and-grants]]. |
21
+
22
+ Name, logo and "Why your app needs access" appear on the consent page.
23
+
24
+ ## A key pair, not a client secret
25
+
26
+ An app has no client secret. It signs a short assertion with its private key (`private_key_jwt`, the only token authentication method the directory supports) each time it exchanges a code or refreshes a token. The directory checks the signature against the public keys it holds.
27
+
28
+ - `npx @north-light/sdk keygen` writes the private key to `local/app-key.json` (mode `0600`) and prints the public key as JSON. `init` runs it for you. Paste the public key into **Public key** when you register, or under **Add key** on the app's **Sign-in** tab.
29
+ - The private key stays on your server. Never paste it, log it or commit it. The console refuses a pasted private key.
30
+ - An app holds at most 2 active keys, so you can add a new one beside the old one, switch, and retire the old one without downtime.
31
+ - Instead of pasting keys you can give the directory a **JWKS URL** (**Use a JWKS URL instead** on the **Sign-in** tab).
32
+ - Until a key is added, `exchangeCode` throws an `AuthenticationError`.
33
+
34
+ ## The sign-in flow
35
+
36
+ `OAuth2Client` runs the OAuth 2.0 authorization-code flow with PKCE (`S256`) and an OpenID Connect nonce. Your two routes are short:
37
+
38
+ ```ts
39
+ if (url.pathname === '/auth/login') {
40
+ const {url: authorizeUrl, codeVerifier, state, nonce} = await oauth.authorizeUrl({
41
+ scopes: ['openid', 'profile', 'crtr:llm', 'crtr:act', 'sms:send'],
42
+ state: randomBytes(16).toString('base64url'),
43
+ });
44
+ pending.set(state, {codeVerifier, nonce});
45
+ return void res.writeHead(303, {location: authorizeUrl}).end();
46
+ }
47
+ ```
48
+
49
+ `authorizeUrl` returns the URL to redirect to, plus three values to keep for this sign-in: `codeVerifier`, `state` and `nonce`. The example keeps them in a `Map` keyed by `state`. Use your own session store.
50
+
51
+ ```ts
52
+ if (url.pathname === '/auth/callback') {
53
+ if (authorizationRefusalOf(url)?.code === 'app_in_testing') {
54
+ return page(res, 403, '<h1>This app is still in testing</h1><p>Ask its owner to add you on the Testers tab in the console.</p>');
55
+ }
56
+ const state = url.searchParams.get('state') ?? '';
57
+ const code = url.searchParams.get('code');
58
+ const saved = pending.get(state);
59
+ if (!saved || !code) return page(res, 400, '<p>Start sign-in again at <a href="/auth/login">/auth/login</a>.</p>');
60
+ pending.delete(state);
61
+ const connection = await oauth.exchangeCode({code, codeVerifier: saved.codeVerifier, state, expectedState: state, nonce: saved.nonce});
62
+ await store.save(connection);
63
+ const sid = randomBytes(16).toString('base64url');
64
+ sessions.set(sid, connection.userId);
65
+ return void res.writeHead(303, {location: '/', 'set-cookie': `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`}).end();
66
+ }
67
+ ```
68
+
69
+ On the callback:
70
+
71
+ 1. Read `state` and `code` from the query. Find what you saved for that `state`. If nothing is there, send the person back to the login route.
72
+ 2. Call `exchangeCode` with the code, the saved `codeVerifier` and `nonce`, the `state` from the query as `state`, and the value you saved as `expectedState`. Never pass the callback's `state` for both. A missing or different `expectedState` throws `CrouterError` with code `oauth_state_mismatch` before the code is sent anywhere.
73
+ 3. `exchangeCode` verifies the ID token (ES256 signature, issuer, audience and nonce) when it has one, and returns a `Connection`. Save it in your [[northlight-apps/concepts/connections]].
74
+
75
+ Then set your own session for the person. The SDK does not manage your sessions.
76
+
77
+ ### Scopes decide what comes back
78
+
79
+ A sign-in that asks only for `openid`, optionally with `email` and `profile`, creates no connection. Finish it with `oauth.exchangeIdentity(...)`, which returns the verified `Identity` (`sub`, and `email`, `name` and `picture` when approved and present). A sign-in that asks for runtime scopes such as `crtr:llm` finishes with `exchangeCode`. Each method refuses the other's answer with `oauth_exchange_mismatch`.
80
+
81
+ ### When the directory refuses to sign someone in
82
+
83
+ If the app is in testing and the person is not a tester, the directory redirects to your callback with `error=access_denied` and `error_description=app_in_testing` instead of a `code`. `authorizationRefusalOf(url)` reads it. See [[northlight-apps/concepts/testing-mode]].
84
+
85
+ ## Keep sign-in secrets out of HTTP logs
86
+
87
+ The callback URL carries a one-use `code` and your `state`, and the response that follows carries a session cookie. Request loggers such as `pino-http` log full URLs and response headers by default. Log the method, the path without the query string, an id and the response status, for every route, not only the callback. Redacting only the `authorization` and `cookie` request headers does not cover these values.
88
+
89
+ ```ts
90
+ LoggerModule.forRoot({
91
+ pinoHttp: {
92
+ serializers: {
93
+ req: (req: { method?: string; url?: string; id?: unknown }) => ({
94
+ method: req.method,
95
+ path: req.url?.split('?')[0],
96
+ id: req.id,
97
+ }),
98
+ res: (res: { statusCode?: number }) => ({ statusCode: res.statusCode }),
99
+ },
100
+ redact: ['req.headers.authorization', 'req.headers.cookie'],
101
+ },
102
+ });
103
+ ```
104
+
105
+ Test it: send a callback with a recognizable fake code and assert that the code, the state and the cookie never appear in the log output.
106
+
107
+ ## Checked and not checked
108
+
109
+ Checked on production: the discovery document lists `S256` and `private_key_jwt`, and the login route of the quickstart server redirects to `https://account.northlight.ai/authorize` with the PKCE challenge and nonce. The rest is from the SDK and directory source. A full sign-in with a real account is not yet checked on production.
@@ -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.