@north-light/cli 0.3.473 → 0.3.474

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +80 -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 +77 -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 -1
  36. package/docs/apps/guides/manage-your-app.md +1 -1
  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,63 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Many users, this knowledge should be read
4
+ because Keep each person's runtime connection separate when people interact in
5
+ your app, and understand whose action a call records.
6
+ ---
7
+
8
+ # Many users
9
+
10
+ An app can bring people together without putting all of their agents or data on one runtime. **Each person's runtime holds that person's agents, memory, files, conversations, AI subscription, phone and device.** Your server holds its user table keyed by the Northlight `sub`, one stored `Connection` per person in a `ConnectionStore` (runtime URL, rotating refresh token, grant id), in-app roles and settings, and data shared between users. Northlight permissions decide what your app can do on each person's runtime. Your app decides what its users can do to each other.
11
+
12
+ Keep the connection and refresh token on the server, never in a visitor's browser. The server can call `providers.call` on a stored connection outside any run, even when the person is offline. Access tokens last 10 minutes; refresh tokens rotate on each use and expire after 90 days unused.
13
+
14
+ - Give `OAuth2Client` a persistent `ConnectionStore` with a cross-process lock as `store`, and reach each person's runtime with `await oauth.client(userId)`.
15
+ - Schedule `oauth.refreshIdleConnections({olderThanMs: 30 * 24 * 60 * 60 * 1000})` on a timer. It refreshes connections last saved before the cutoff, through the store's lock, and returns `{refreshed, refused, failures}`. The SDK starts no timer itself.
16
+ - Act on a dead connection in one place, `onRefusal`. Delete a person's data only on `grant_removed` (`kind: 'removed'`).
17
+ - Learn when a background run ends with `oauth.watchRuns(...)`: see [[northlight-apps/guides/background-runs]].
18
+
19
+ ## Signing in a visitor
20
+
21
+ A visitor who only needs to be recognized, not connected, signs in with identity only. Request `openid`, adding `email` for their email and `profile` for their name and photo, and no runtime scope. The directory shows one line ("Know who you are on Northlight"), writes no grant, and answers with an ID token and an access token usable only at `/userinfo`: no refresh token, grant or runtime URL. Finish that sign-in with `exchangeIdentity`, not `exchangeCode`:
22
+
23
+ ```ts
24
+ const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({scopes: ['openid', 'email', 'profile'], state: sessionState});
25
+ // Save codeVerifier, state and nonce in the visitor's session; redirect them to url.
26
+ // On the callback, read them back from the session and finish the sign-in:
27
+ const visitor = await oauth.exchangeIdentity({code: callbackCode, codeVerifier, state: callbackState, expectedState: state, nonce});
28
+ visitor.sub; // 'user:…', the same id a Connection.userId carries for this person
29
+ visitor.email; // present only when 'email' was requested and approved
30
+ visitor.emailVerified; // the directory's email_verified, when email was approved
31
+ visitor.name; // present only when 'profile' was approved and the person has a name (never a phone number)
32
+ visitor.picture; // an https URL, present only when 'profile' was approved and the account has a photo
33
+ ```
34
+
35
+ `exchangeIdentity` returns an `Identity`: `sub`, `email` and `emailVerified` when granted, `name`, `givenName` and `familyName` when `profile` was granted and the person has one, `picture` when `profile` was granted and the account has a photo (only an `https:` URL), and every verified claim in `claims`.
36
+
37
+ The SDK checks the ID token's ES256 signature against the directory's JWKS, its issuer, that `aud` is your client id, that it has not expired, and that its `nonce` equals the one `authorizeUrl` returned. `nonce` is required. Any failure throws `CrouterError` code `id_token_invalid`. If the directory's JWKS can't be fetched or is malformed, that error is thrown as is.
38
+
39
+ An `Identity` is not a connection. Nothing is stored in a `ConnectionStore` and nothing refreshes; your app keeps its own session for the visitor, and the visitor's runtime can't be called on their behalf. To act for the visitor later, send them through `authorizeUrl` again with the runtime scopes you need and finish with `exchangeCode`, which returns a `Connection`. Each call refuses the other's answer with code `oauth_exchange_mismatch`. If `exchangeIdentity` refuses an answer that carries a connection, the directory has already recorded that grant; it shows in the person's approved apps until they or your app revoke it.
40
+
41
+ ## Whose action a call records
42
+
43
+ When visitor A causes your server to call person B's runtime using **B's connection**, the runtime records the app as `grantee` and B as `sub`. It does not know or record that A caused the call. Even a request outside a run, with nobody present, has this attribution. Keep your own record of the app user and action when you need it. Do not send an app-user identity as a runtime principal.
44
+
45
+ A visitor chatting with B's clone starts an app run on **B's runtime and B's AI subscription**, using the app's grant from B. Your app rate-limits visitors; B's per-app spend cap is the platform backstop. Northlight's developer terms allow spending B's connection because of another user's action **only for what B turned on in your app, limited per person**. B can control the app's spend cap or remove the app.
46
+
47
+ ## The app's agent and documents
48
+
49
+ A run your app starts is **your app's agent**, not a continuation of another app's agent. It starts in `/apps/<app>/files`, uses the person's AI under `crtr:llm`, and reaches your app's own documents (owned by `app:<your app>`) without a `crtr:memory` scope. Other people's or apps' data is not shared merely because the app has many users. The grant's resolved permissions govern access to the person's files (`crtr:files:<access>:user:<dir>`), the person's documents (`crtr:memory:<access>:user`), another app's space (only where that app allows it), and another app's conversations (`crtr:conversations:read:app:<B>`).
50
+
51
+ The exception is the person's **public** documents (owned by the person, read through `memory`; see [[northlight-apps/guides/files-and-documents]]): an app with an active grant can read these even without a user-memory scope. A third-party app cannot see `friends` or `private` documents, even with `crtr:memory:read:user`. They look absent from lists, searches, reads and history. Only a holder of `crtr:memory:manage:user` (a first-party app), or the person, reads all three levels. A document without a privacy field reads as `private`. Public read access is not write access.
52
+
53
+ Every run is sandboxed: its shell runs as the app's OS user and sees only the app's space and the folders its permissions name. Normal runs have network access. Start a run with `isolated: true` when the agent should have **no network** and **no `/apps/<app>/files` mount**: its cwd is its own node folder, and all children inherit the restriction. The shell, stored conversation and model calls still work, and the run's `scopes` can narrow its commands. An isolated run cannot turn isolation off in a child or schedule, or use an outside cwd or worktree.
54
+
55
+ **An isolated run can't see, or be seen by, the app's other runs.** Starting one isolated run per visitor conversation keeps visitors apart. Another run's nodes, documents, conversation, trace, events and node folder answer `not_found` to it, and messaging, interrupting, cancelling or deleting another run does too. Each node of an isolated run gets its own home folder, `/apps/<app>/nodes/<node-id>/home`, in place of the shared `/apps/<app>/home`. An isolated run still reads your app's memory and the person's public memory. Your app's server is not confined: `runs.list` and `runs.trace` cover every run, so your server can show the person each visitor's conversation. When a call is refused by scope, a missing-scope error (`scope_missing` or `forbidden`) comes before any `not_found`, so the refusal reveals nothing about the target.
56
+
57
+ ## Messages to a running run
58
+
59
+ `runs.message(runId, message, {start_turn: false})` stores an app-attributed message on an existing run **without starting, reviving or reopening a turn**. The message is delivered at the start of the next turn, before that turn's input, and the trace marks it `custom` / `app_message`. The default `start_turn: true` sends an ordinary follow-up.
60
+
61
+ `runs.message` is safe to retry. The SDK sends an `Idempotency-Key` (one per call, reused across its own retries; pass `idempotencyKey` to choose it) and the runtime delivers a given key once for 24 hours. A replay returns the original result; the same key with different text, `start_turn` or run is refused with `409 idempotency_conflict`. An app that queues sends and may crash between the call and recording success should derive `idempotencyKey` from its own message id.
62
+
63
+ **A run's first prompt is always its first turn's input.** With the default `start_turn: true`, a message sent before the first turn has started queues behind it: the agent answers the `runs.start` prompt in turn one and your message in turn two. To answer a first message in the first turn, pass it with the prompt: `runs.start({prompt, message})` stores `message` with the run, so it joins the first turn as context delivered ahead of the prompt, and one reply covers both. Follow the reply with `runs.reply(runId, sent)`: see [[northlight-apps/guides/stream-a-run]].
@@ -0,0 +1,84 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Stream a run, this knowledge should be read
4
+ because Forward a run's events to a browser, follow the reply to one message,
5
+ and resume after a reload.
6
+ ---
7
+
8
+ # Stream a run
9
+
10
+ ## Forward a run to a browser
11
+
12
+ Your server keeps the runtime token and connection. The browser connects only to your server. Call `forwardRunEvents(runtime, runId, {after: cursorFromRequest(request), signal: request.signal})` and return the Fetch `Response`. After a host cuts the browser connection, `EventSource` sends its last SSE id as `Last-Event-ID`; `cursorFromRequest` prefers it over the URL's `after` query parameter.
13
+
14
+ Every forwarded run event is an unnamed SSE record with its `sequence_number` as `id` and the complete event object as `data`. One handler receives all of them:
15
+
16
+ ```ts
17
+ const source = new EventSource(`/api/runs/${runId}/events?after=${cursor}`);
18
+ source.onmessage = ({data}) => {
19
+ const event = JSON.parse(data);
20
+ if (event.type === 'stream.refused') { showRefusal(event.error); source.close(); return; }
21
+ if (event.type === 'stream.reconnecting') { showConnectionFailure(event.error); return; }
22
+ if (event.type === 'stream.reconnected') { clearConnectionFailure(); return; }
23
+ if (event.type === 'stream.idle') { source.close(); markIdle(event.cursor); return; } // reopen on your own trigger
24
+ showRunEvent(event);
25
+ };
26
+ ```
27
+
28
+ The four `stream.*` records are notices from the forwarder, not run events. They have no `sequence_number` or SSE id.
29
+
30
+ - **`stream.idle`** (`{type: 'stream.idle', cursor}`) means the runtime closed the stream after 60 s with no running turn and no new event. The helper writes it and ends the response. Close the `EventSource` on it and reopen with `after=cursor` on your own trigger: the person sends a message, or your server handles a run notice ([[northlight-apps/guides/background-runs]]). Without that, the browser would reconnect at once and keep a hosted runtime awake.
31
+ - **`stream.refused`** carries the full error envelope, including `stream_gap`'s `details.latest_sequence` and `details.earliest_sequence`. The helper ends the response after a refusal. The SDK does not recover a gap by skipping events.
32
+
33
+ ## Resume after a reload or a gap
34
+
35
+ When the page reloads mid-reply, or you get `stream_gap`, read `runs.trace(runId)` through your server and show each node's `in_progress_message` (`{thinking, text}` or `null`) as that node's open message. Then resume `runs.events(runId, {after: trace.latest_sequence})`, or forward with that cursor. Do not use a cursor from a separate `runs.get`.
36
+
37
+ Keep 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`. That shows the message from its beginning without duplicating content. `runs.get` and every `runs.list` entry carry `latest_sequence` and `last_activity_at`; a cursor equal to `latest_sequence` is not a gap even when older events have expired.
38
+
39
+ ## Follow the reply to one message
40
+
41
+ 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:
42
+
43
+ ```ts
44
+ const sent = await runtime.runs.message(runId, 'What did the report say?');
45
+ for await (const event of runtime.runs.reply(runId, sent)) {
46
+ if (event.type === 'text.delta') append(event.delta);
47
+ if (event.type === 'turn.completed' && event.error) showError(event.error); // {code: 'content_refused' | 'model_error', message}
48
+ }
49
+ // or collect it: text as it streams, then the whole text and how the turn ended
50
+ const {text, ended, error, outcome} = await runtime.runs.reply(runId, sent).collect({onDelta: (delta) => append(delta)});
51
+ // ended: 'completed' | 'error' (error set) | 'waiting_on_user' | 'settled' (outcome set)
52
+ ```
53
+
54
+ | Event | Fields | From |
55
+ |---|---|---|
56
+ | `text.delta` | `delta` | `node.output_text.delta` |
57
+ | `message.done` | `text`, `stop_reason` | `node.output_text.done`, once per assistant message |
58
+ | `turn.completed` | `run_status`, `stop_reason`, `error?`, `message_ids` | `node.turn.completed`; always last |
59
+ | `run.settled` | `outcome` | `node.settled`, when the run settles before the turn completes; always last |
60
+
61
+ Every event also carries its `sequence_number`. The reply ends after `turn.completed` or `run.settled` and closes its stream. `.text()` resolves to the text of the `message.done` events joined with a blank line. A reply can be consumed once.
62
+
63
+ To get 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.
64
+
65
+ 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. 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`.
66
+
67
+ ## A quiet run
68
+
69
+ A runtime that sleeps when nothing holds a connection to it ends a run's stream after 60 s with no running turn and no new event. `RunStream` reports that and does not reconnect:
70
+
71
+ ```ts
72
+ const stream = runtime.runs.events(runId, {after: cursor});
73
+ for await (const event of stream) handle(event);
74
+ const end = await stream.whenEnded(); // rejects on a transport failure or abort()
75
+ if (end.reason === 'idle') {
76
+ // the run is quiet and nothing was missed; reopen later with runtime.runs.events(runId, {after: end.cursor})
77
+ }
78
+ ```
79
+
80
+ A settled run ends with `reason: 'settled'`. `runs.reply` reopens through an idle close on its own.
81
+
82
+ ## Typed events
83
+
84
+ `RunEvent` is the union of every run event, discriminated on `type`. `RunEventOf<'node.turn.completed'>` picks one, and `RunStream.on(type, listener)` passes the listener that event. An event type a newer runtime adds and your SDK version does not declare is still delivered as-is.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  kind: knowledge
3
- when-and-why-to-read: When you need Testing your app, this knowledge should be
4
- read because FakeCloud from @north-light/sdk/testing — a fake directory and
5
- runtime your real OAuth2Client and Crouter talk to through fetch.
3
+ when-and-why-to-read: When you need Test your app, this knowledge should be read
4
+ because FakeCloud from @north-light/sdk/testing, a fake directory and runtime
5
+ your real OAuth2Client talks to through fetch.
6
6
  ---
7
7
 
8
- # Testing your app
8
+ # Test your app
9
9
 
10
10
  `@north-light/sdk/testing` exports `FakeCloud`, a fake Northlight: the directory (discovery, token refresh, JWKS) and each person's runtime (grant, runs, memory, providers, run notices). It answers through `fetch`. Your app's production path — `new OAuth2Client(...)`, `oauth.client(userId)`, `runs`, `memory`, `grant`, `providers`, `watchRuns`, `watchGrants`, `onRefusal`, `forwardRunEvents`, `verifyRunNotice` — runs against it unchanged. The only differences from production are the `fetch` and `issuer` you pass in.
11
11
 
@@ -44,7 +44,7 @@ Options: `new FakeCloud({clientId, issuer = 'https://directory.test', runtimeUrl
44
44
 
45
45
  The fake stands in for a Northlight guest, where the Linux root launcher always exists, so `runs.start({isolated: true})` starts a run by default and `run.params.isolated` is `true`. The checks `startRun` makes without the launcher are mirrored: `isolated` must be a boolean, `cwd` is accepted only with `isolated: true` and must be an absolute path inside the run's root folder, `/apps/<clientId>/nodes/<run id>` (else 400 `invalid_request`, `param: cwd`), and `worktree` is refused. Nothing in the fake enforces the sandbox itself (no network, no `files/` mount).
46
46
 
47
- `isolatedRuns: 'unavailable'` models a runtime without the launcher, such as a local daemon on a Mac: every isolated start answers 503 `isolated_unavailable` (`param: isolated`, retryable, so `runs.start` sends it three times before throwing). Use it to test your app's handling of that refusal.
47
+ `isolatedRuns: 'unavailable'` models a runtime without the launcher, such as a runtime on a machine that cannot launch isolated runs: every isolated start answers 503 `isolated_unavailable` (`param: isolated`, retryable, so `runs.start` sends it three times before throwing). Use it to test your app's handling of that refusal.
48
48
 
49
49
  ## Driving runs
50
50
 
@@ -190,4 +190,4 @@ Build two app instances (two `OAuth2Client`s) on the same `cloud`. They see the
190
190
 
191
191
  FakeCloud is checked against the real runtime on every crouter release: one set of conformance scenarios runs on the fake and on the real app listener (run start and idempotency, messages, reply streams, long-poll, every settle outcome, delete, the event stream's edges, grant holds, memory, providers, run notices). The keyed-write scenarios K1–K8 (equal-key replay, conflict, concurrent equal keys accepted once, the 24h window, replay after cancel, a key bound to its operation, keys scoped to the grantee, concurrent notes) are exported as `@north-light/sdk/testing/conformance` (`keyedWriteScenarios`, `IDEMPOTENCY_WINDOW_MS`) and run on FakeCloud and on the real daemon; an app test double for the runtime should run them against its own target. Two behaviours are fake-only because the runtime cannot be scripted that way: a successful provider call (the real side needs an installed provider), and `setActivity`. An isolated run that starts is checked only against `startRun`'s documented checks: the real listener in the suite runs without the Linux root launcher, so on it the `isolatedRuns: 'unavailable'` refusal is what is conformance-checked.
192
192
 
193
- Not faked: sign-in (`authorizeUrl`, `exchangeCode`, ID tokens — start from `cloud.connect`), token revocation, the owner and local-daemon surface (`nodes`, `canvas`, `crons`, `files`, …), and several apps on one cloud. `trace.get`, `run.rename`, `run.interrupt` and `profile.list` answer the right shape only.
193
+ Not faked: sign-in (`authorizeUrl`, `exchangeCode`, ID tokens — start from `cloud.connect`), token revocation, the owner surface (`nodes`, `canvas`, `crons`, `files`, …), and several apps on one cloud. `trace.get`, `run.rename`, `run.interrupt` and `profile.list` answer the right shape only.
@@ -0,0 +1,99 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Typed results, this knowledge should be read
4
+ because Start a run with a schema and get back a value that was checked
5
+ against it.
6
+ ---
7
+
8
+ # Typed results
9
+
10
+ Pass an `output_schema` and call `runs.parse`. The runtime arms the schema on the run's root agent, which must submit a value that validates against it. The run settles with that value, and `runs.parse` returns it as `output_parsed`, typed from your schema.
11
+
12
+ ```ts
13
+ import {z} from 'zod';
14
+ import type {ConnectedCrouter} from '@north-light/sdk';
15
+
16
+ const Profile = z.object({
17
+ display_name: z.string(),
18
+ bio: z.string(),
19
+ facts: z.array(z.string()),
20
+ });
21
+
22
+ /** Start a run on the person's runtime and get a value back that was checked against the schema. */
23
+ export async function draftProfile(runtime: ConnectedCrouter) {
24
+ const outcome = await runtime.runs.parse({
25
+ prompt: 'Draft a public profile for me from my notes.',
26
+ scopes: ['crtr:llm'], // submitting a result needs no other scope
27
+ output_schema: Profile,
28
+ });
29
+
30
+ if (outcome.kind === 'result') return outcome.output_parsed; // {display_name, bio, facts}, typed from Profile
31
+ if (outcome.reason === 'declined') {
32
+ // The agent could not honestly satisfy the schema; outcome.details says why.
33
+ throw new Error(`Declined: ${outcome.details?.reason ?? 'no reason given'}`);
34
+ }
35
+ throw new Error(`The run ended without a result: ${outcome.reason}`); // canceled, deadline_exceeded or runtime_error
36
+ }
37
+ ```
38
+
39
+ The runtime validates the value before it records it. A submission that does not match is refused with its field errors and never appears in `runs.get`, an event or a report, so the agent fixes it and resubmits.
40
+
41
+ - `output_schema` accepts a plain JSON Schema object or anything with `toJSONSchema()`, such as a Zod v4 object. The SDK sends it as JSON and validates nothing itself.
42
+ - A schema that is not an object, or is over 256 KB serialized, is refused with `invalid_request` and `param: 'output_schema'`, and no run is created. A submitted result can be at most 1 MiB.
43
+ - The value is validated exactly as submitted, with no coercion: `"5"` is not turned into `5`.
44
+ - A `$ref` can point only into the schema itself (`#/$defs/x`, `#anchor`) or at another schema document bundled under `$defs` with its own `$id`. Files are never fetched. A `$ref` that resolves nowhere, or a schema that cannot be compiled (a bad `pattern`), is refused with `invalid_request` and `param: 'output_schema'`.
45
+ - The agent submits with its `submit_result` tool, which needs no scope, so a run that only reasons and answers can start with `scopes: ['crtr:llm']`.
46
+ - The same value is in `runs.get(runId).result`, which `runs.wait` returns unchanged.
47
+
48
+ Do not parse assistant text out of `runs.trace` to get data back. `runs.trace` is for showing a person the transcript.
49
+
50
+ ## A run that declines
51
+
52
+ An agent that cannot honestly satisfy the schema declines. A decline, a cancel, a deadline and a runtime error all come back as an outcome. They do not throw. Narrow on `outcome.kind` and `outcome.reason`, as the example does.
53
+
54
+ ## Stopping without a result: `run_idle`
55
+
56
+ `runs.parse` rejects with an `APIError` with code `run_idle` when the agent stopped without submitting. The runtime reminds the agent of the schema up to three times in a row, then leaves the run `idle` with the schema still armed. The run is untouched: send `runs.message` to nudge the agent (this resets the reminders), or end it with `runs.cancel`.
57
+
58
+ A question from the run rejects with `waiting_on_user` unless you pass `onQuestion`. With `onQuestion`, `parse` answers each open question once and keeps waiting.
59
+
60
+ ## Bounding a run
61
+
62
+ No run is bounded in time by default. Pass a `signal` to `runs.parse` or `runs.wait`. 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:
63
+
64
+ ```ts
65
+ const controller = new AbortController();
66
+ const timer = setTimeout(() => controller.abort(new Error('took too long')), 10 * 60_000);
67
+ let runId: string | undefined;
68
+ try {
69
+ const started = await runtime.runs.start({prompt, output_schema: Profile});
70
+ runId = started.run_id;
71
+ const run = await runtime.runs.wait(runId, {signal: controller.signal});
72
+ // read run.status, run.outcome, run.result
73
+ } catch (error) {
74
+ if (controller.signal.aborted && runId) await runtime.runs.cancel(runId);
75
+ throw error;
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ ```
80
+
81
+ ## Showing progress
82
+
83
+ For a run you poll, `runs.get(runId).activity` is `null` unless `status` is `running`, and otherwise says what the root agent is doing:
84
+
85
+ | `phase` | The root agent is |
86
+ |---|---|
87
+ | `starting` | launching; its first turn has not begun |
88
+ | `thinking` | reasoning, or waiting on the model before any output |
89
+ | `tool` | calling a tool: `tool.name`, and `tool.summary` (`null` while the call is still being written) |
90
+ | `submitting_result` | writing or recording its `output_schema` result |
91
+ | `writing` | streaming its answer text |
92
+ | `between_turns` | between turns while the run still works |
93
+
94
+ ```ts
95
+ const run = await runtime.runs.get(runId);
96
+ const badge = run.activity?.phase === 'tool' ? `Running ${run.activity.tool.name}` : run.activity?.phase ?? run.status;
97
+ ```
98
+
99
+ `activity` covers the root agent only, and it 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 the events instead: see [[northlight-apps/guides/stream-a-run]]. To be told when a background run ends, see [[northlight-apps/guides/background-runs]].
@@ -0,0 +1,49 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Where your app runs, this knowledge should
4
+ be read because Your app runs on your server, people sign in at
5
+ account.northlight.ai, and the agent work runs on each person's own Northlight
6
+ runtime. Northlight does not host your app's code.
7
+ ---
8
+
9
+ # Where your app runs
10
+
11
+ Your app runs on a server you choose. Northlight hosts two other things: the directory at `account.northlight.ai`, and each person's agent runtime. Northlight does not host your app's code, and there is no flow for deploying it to Northlight.
12
+
13
+ | Piece | Runs where | What it does |
14
+ |---|---|---|
15
+ | **Your server** | Anywhere you choose. Your laptop while you develop. | Your pages and logic. It holds the app's private signing key and a stored connection for each person. |
16
+ | **The directory** | `https://account.northlight.ai`, hosted by Northlight | Accounts, app registration (the console at `/console`), consent, tokens and the marketplace. |
17
+ | **The person's runtime** | Hosted by Northlight, one per person | Their agents, runs, files, documents and providers (sms, search and others), using their own AI subscription. |
18
+
19
+ "Build on Northlight" therefore means three things. Your app's agent work runs on the person's Northlight-hosted runtime. People sign in to your app with their Northlight account. And your app can be listed in the Northlight marketplace.
20
+
21
+ ```mermaid
22
+ sequenceDiagram
23
+ participant P as Person's browser
24
+ participant A as Your server
25
+ participant D as account.northlight.ai
26
+ participant R as Person's runtime
27
+ P->>A: GET /auth/login
28
+ A->>P: redirect to the directory (client ID, scopes, PKCE challenge)
29
+ P->>D: sign in, consent
30
+ D->>P: redirect to your /auth/callback with a code
31
+ P->>A: GET /auth/callback?code=…
32
+ A->>D: exchange the code, signed with your private key
33
+ D->>A: access token, refresh token, runtime URL
34
+ Note over A: store the connection for this person
35
+ A->>R: oauth.client(userId).runs.start(…)
36
+ R->>A: the run, its status and its result
37
+ ```
38
+
39
+ 1. **Sign-in.** Your `/auth/login` route sends the person to the directory with your client ID and the scopes you ask for. The directory shows its consent page and returns them to your registered redirect URI with a one-use code.
40
+ 2. **Token.** Your server exchanges the code for tokens. It proves it is your app by signing the request with its private key. No client secret exists. The result is a connection: a short-lived access token, a rotating refresh token, and the URL of that person's runtime. See [[northlight-apps/concepts/apps-and-sign-in]].
41
+ 3. **Calls.** `oauth.client(userId)` loads the stored connection, refreshes the access token when it needs to, and returns a client on that person's runtime. You call `runs.*` and `providers.*` on it. See [[northlight-apps/concepts/connections]] and [[northlight-apps/concepts/runs]].
42
+
43
+ The person's AI subscription pays for model calls, and what your app may do on their runtime is limited by the scopes they approved. See [[northlight-apps/concepts/scopes-and-grants]].
44
+
45
+ ## What you operate
46
+
47
+ You operate your server, its sessions, its database and its `ConnectionStore`. You register the app in the console and keep its key pair. Northlight operates the directory and the runtimes; you do not run either, and there is no local Northlight to start. If you run crouter yourself and want a runtime of your own, that is a different path: see [crouter.docs.northlight.ai](https://crouter.docs.northlight.ai).
48
+
49
+ The directory's discovery document at `https://account.northlight.ai/.well-known/openid-configuration` lists the endpoints, the `S256` PKCE method and `private_key_jwt` as the only token authentication method (checked on production).
@@ -0,0 +1,39 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Providers, this knowledge should be read
4
+ because The first-party providers an app can call on a person's runtime, their
5
+ ids, how scope strings are named, and how to list a provider's tools.
6
+ ---
7
+
8
+ # Providers
9
+
10
+ A provider is a service the person's runtime calls for your app. Your app asks the runtime with `runtime.providers.call(...)`, and the runtime checks that the person approved the provider's scope for your app. See [[northlight-apps/concepts/providers]] for the model.
11
+
12
+ | Provider id | What it does | Scopes | Page |
13
+ |---|---|---|---|
14
+ | `sms` | Texts the signed-in person's own verified phone | `sms:send`, `sms:status` | [[northlight-apps/providers/sms]] |
15
+ | `search` | Web search, sourced answers, page contents and paid data endpoints | `search:<group>` | [[northlight-apps/providers/search]] |
16
+ | `integrations` | The person's own accounts at other services | `integrations:discover`, `integrations:<service>_read`, `integrations:<service>_write` | [[northlight-apps/providers/integrations]] |
17
+ | `context` | Ask about the person from their memory, and look up public profiles | `context:<group>` | [[northlight-apps/providers/context]] |
18
+ | `device` | The person's Mac: browser, files, Messages and shell | `device:<group>` | [[northlight-apps/providers/device]] |
19
+
20
+ ## Where names come from
21
+
22
+ - **Scope strings** come from the console's **What it asks for → Add permissions** list. A scope is `<provider id>:<group>`. Do not guess them.
23
+ - **Tool names and arguments** come from `runtime.providers.tools('<provider id>')`. It answers the provider's groups, and each group's tools with a description, parameters and output. It lists only the groups your app's grant holds, so ask for the scope first, then list.
24
+ - **Required providers.** If your app does not work without a provider being set up first, add it under **What your app requires** in the console. See [[northlight-apps/guides/grant-changes]].
25
+
26
+ ```ts
27
+ import type {ConnectedCrouter} from '@north-light/sdk';
28
+
29
+ /** List the tools a provider offers this person, then call one. Scope strings come from the console's
30
+ * "Add permissions" list; tool names and arguments come from `providers.tools`. Never invent either. */
31
+ export async function textMe(runtime: ConnectedCrouter, body: string) {
32
+ const sms = await runtime.providers.tools('sms');
33
+ console.log(JSON.stringify(sms.groups, null, 2)); // each leaf: tool, description, params
34
+
35
+ return runtime.providers.call({provider: 'sms', tool: 'send', arguments: {body}});
36
+ }
37
+ ```
38
+
39
+ Each provider page below shows the tools of a provider as captured on **2026-10-16**. The capture is not an app's view: it came from the runtime of a person's own Northlight account (`GET /v1/providers/<id>/tools` on `crtr` 0.3.462, joined to production), which shows the groups that person's own grant holds. What `providers.tools` answers for your app depends on the scopes your app was granted. Tools can change between releases, so treat a table as a starting point and list the tools at run time.
@@ -0,0 +1,34 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Context, this knowledge should be read
4
+ because Ask about the person from their memory, and look up public profiles,
5
+ through the context provider.
6
+ ---
7
+
8
+ # Context
9
+
10
+ The `context` provider answers questions about the person from what their agent knows, and looks up public profiles.
11
+
12
+ ## Scopes and setup
13
+
14
+ Scopes are `context:<group>`. The console's **Add permissions** list shows `context:ask_informed` and `context:ask_public` on 2026-10-16; the tool list also shows a `research` group (`context:research`) that was not in that console list when we checked (not yet checked on production).
15
+
16
+ Context needs setup before it works: the person gives a profile URL on the provider's setup page, and its state stays `pending` for a few minutes afterwards. If your app depends on it, add `context` under **What your app requires** and follow the state with `grant.get().dependencies['context']`. See [[northlight-apps/guides/grant-changes]].
17
+
18
+ ## Tools
19
+
20
+ Captured 2026-10-16 from `GET /v1/providers/context/tools` on a joined runtime (provider major version 1). `*` marks a required parameter. Tool names with a space are written as the provider lists them; check how to pass a multi-word `tool` to `providers.call` against your own `providers.tools` output before relying on it (not yet checked on production).
21
+
22
+ | Tool | Group | Parameters | What it does |
23
+ |---|---|---|---|
24
+ | `ask informed` | `ask_informed` | `request`\*, `question` (repeatable, up to 10) | Advice about the person shaped by everything their agent knows, private memories included, without being told private details |
25
+ | `ask public` | `ask_public` | `request`\*, `question` (repeatable, up to 10) | Answers about the person from public memories only |
26
+ | `linkedin profile` | `research` | `profile`\* | A person's LinkedIn profile |
27
+ | `linkedin posts` | `research` | `profile`\*, `limit` | Recent LinkedIn posts |
28
+ | `x profile` | `research` | `profile`\* | A person's X profile |
29
+ | `x posts` | `research` | `profile`\*, `limit` | Not available |
30
+ | `github profile` | `research` | `profile`\* | A person's GitHub profile |
31
+ | `github repos` | `research` | `profile`\*, `limit` | Public repositories |
32
+ | `search people` | `research` | `query`\* | Search LinkedIn people |
33
+
34
+ `request` is the task you are doing, 1 to 4000 characters. Each `question` is 1 to 500 characters. Run `runtime.providers.tools('context')` for the current list and each tool's output shape.
@@ -0,0 +1,28 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: "When you need Device, this knowledge should be read
4
+ because Reach the person's Mac from your app: browser, files, Messages and
5
+ shell, each behind its own scope."
6
+ ---
7
+
8
+ # Device
9
+
10
+ The `device` provider reaches the person's own Mac. Its tools run on the person's machine (the provider's transport is `machine`), and each group is its own scope, so ask only for the groups your app needs. What happens when the Mac is asleep or offline is not yet checked on production.
11
+
12
+ ## Scopes
13
+
14
+ `device:browser`, `device:files`, `device:messages_read`, `device:messages_send` and `device:shell`. Add them under **What it asks for → Add permissions** (provider `device`).
15
+
16
+ ## Tools
17
+
18
+ Captured 2026-10-16 from `GET /v1/providers/device/tools` on a joined runtime (provider major version 2, transport `machine`). `*` marks a required parameter.
19
+
20
+ | Group (scope) | Tools |
21
+ |---|---|
22
+ | `browser` | `browser profiles`, `browser executor create` (`url`, `mode`, `profile`, `target-pattern`), `browser executor list`, `browser executor navigate` (`executor`\*, `url`\*), `browser executor exec` (`executor`\*, `code`\*, `timeout-ms`, `short-description`), `browser executor result` (`run`\*), `browser executor close` (`executor`\*), `browser libs` (`query`, `limit`, `service`, `fn`), `browser cdp` (`method`\*, `params`, `tab`) |
23
+ | `files` | `files list` (`path`\*, `limit`), `files stat` (`path`\*), `files read` (`path`\*, `encoding`, `offset`, `length`), `files write` (`path`\*, `text`, `base64`, `from`, `append`, `parents`), `files delete` (`path`\*, `recursive`), `files move` (`from`\*, `to`\*, `overwrite`), `files mkdir` (`path`\*) |
24
+ | `messages_read` | `messages chats` (`limit`), `messages history` (`chat`, `participant`, `since`, `until`, `limit`, `attachments`), `messages search` (`text`\*, `limit`), `messages contacts` (`query`\*, `limit`), `messages permissions` (`open`) |
25
+ | `messages_send` | `messages send` (`recipient`\*, `text`\*, `file`, `service`) |
26
+ | `shell` | `shell run` (`command`\*, `cwd`, `timeout`, `stdin`) |
27
+
28
+ Tool names with spaces are written as the provider lists them; check how to pass them to `providers.call` against your own `providers.tools` output (not yet checked on production). The capture is of a person's own grant, not an app's. Run `runtime.providers.tools('device')` for the list your app can use.
@@ -0,0 +1,28 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Integrations, this knowledge should be read
4
+ because Call the person's own accounts at other services through the
5
+ integrations provider, and find the tool you need.
6
+ ---
7
+
8
+ # Integrations
9
+
10
+ The `integrations` provider lets your app act in the person's own accounts at other services, with the person's consent for each service.
11
+
12
+ ## Scopes
13
+
14
+ - `integrations:discover` allows `search` and `schema`, which find services and their tools, and the `link` group's `accounts`, which lists the person's linked accounts.
15
+ - `integrations:<service>_read` and `integrations:<service>_write` gate each service's tools. For example, a service named `gmail` has `integrations:gmail_read` and `integrations:gmail_write`.
16
+
17
+ Scope strings come from **What it asks for → Add permissions** (provider `integrations`). The catalog is large, so search it by service name.
18
+
19
+ ## Find the tool you need
20
+
21
+ The provider has thousands of tools: on 2026-10-16 the runtime we captured from listed 3,061 tools across 3,060 groups, one `_read` group and one `_write` group per service, plus `discover` and `link`. This page does not table them. List them from your app:
22
+
23
+ ```ts
24
+ const integrations = await runtime.providers.tools('integrations');
25
+ for (const group of integrations.groups) console.log(group.group, group.leaves.map((leaf) => leaf.tool));
26
+ ```
27
+
28
+ `providers.tools('integrations')` is large (several megabytes on that runtime), and it lists only groups your app's grant holds, so it stays small until you add service scopes. To find a service or a tool, call the provider's `search` and `schema` tools (group `discover`) instead of reading the whole list.
@@ -0,0 +1,31 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Search, this knowledge should be read
4
+ because Web search, sourced answers, page contents, and paid data endpoints
5
+ through the search provider.
6
+ ---
7
+
8
+ # Search
9
+
10
+ The `search` provider runs web search and returns ranked pages, sourced answers, page contents and deep research, and it finds and calls paid data endpoints.
11
+
12
+ ## Scopes
13
+
14
+ Each scope is `search:<group>`: `search:search`, `search:answer`, `search:contents`, `search:discover`, `search:research` and `search:run`. Add them under **What it asks for → Add permissions** (provider `search`). A tool is available when your app holds its group's scope.
15
+
16
+ ## Tools
17
+
18
+ Captured 2026-10-16 from `GET /v1/providers/search/tools` on a joined runtime (provider major version 1). `*` marks a required parameter.
19
+
20
+ | Tool | Group (scope) | Parameters | What it does |
21
+ |---|---|---|---|
22
+ | `search` | `search` | `query`\*, `type`, `category`, `num-results`, `start-published-date`, `end-published-date`, `include-domains`, `exclude-domains`, `no-text` | Ranked web pages |
23
+ | `answer` | `answer` | `query`\*, `structured` | A sourced answer |
24
+ | `contents` | `contents` | `urls`\*, `no-text`, `highlights`, `summary`, `livecrawl` | Contents of known pages |
25
+ | `research` | `research` | `query`\*, `depth` | Deep web research |
26
+ | `discover` | `discover` | `query`\*, `limit`, `min-score` | Matching data endpoints and prices |
27
+ | `inspect` | `discover` | `provider`\*, `endpoint`\* | Data endpoint details and price |
28
+ | `run` | `run` | `provider`\*, `endpoint`\*, `input`\* | A paid data result, or a pending run ID |
29
+ | `status` | `run` | `run-id`\* | State and result of a data run |
30
+
31
+ The capture is of a person's own grant, not an app's. Run `runtime.providers.tools('search')` for what your app's grant holds. A call is billed to the person under your app's spend cap ([[northlight-apps/guides/grant-changes]]).
@@ -0,0 +1,39 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need Texting (sms), this knowledge should be read
4
+ because Text the signed-in person's own verified phone from your app with the
5
+ sms provider.
6
+ ---
7
+
8
+ # Texting (sms)
9
+
10
+ The `sms` provider sends a text from Northlight's number to the verified phone of the person who signed in to your app. It has no recipient argument, so an app cannot text anyone else.
11
+
12
+ ## Scopes
13
+
14
+ | Scope | Consent sentence | Gates |
15
+ |---|---|---|
16
+ | `sms:send` | Send you text messages from Northlight's number | `send`, `react` |
17
+ | `sms:status` | Not captured | `status` |
18
+
19
+ Add them under **What it asks for → Add permissions** (provider `sms`). Without `sms:send` the consent page does not offer it and `providers.call` for `sms` is refused with `scope_missing`.
20
+
21
+ ## Send a text
22
+
23
+ ```ts
24
+ await runtime.providers.call({provider: 'sms', tool: 'send', arguments: {body: 'Your summary is ready.'}});
25
+ ```
26
+
27
+ `body` is required. The text goes from Northlight's number to the person's verified phone, and each person can receive at most 50 texts from your app per UTC day. These limits are the provider's documented behavior (the 50-per-day limit is not yet checked on production).
28
+
29
+ ## Tools
30
+
31
+ Captured 2026-10-16 from `GET /v1/providers/sms/tools` on a joined runtime (provider major version 1). `*` marks a required parameter.
32
+
33
+ | Tool | Group (scope) | Parameters | What it does |
34
+ |---|---|---|---|
35
+ | `send` | `send` (`sms:send`) | `body`\*, `file`, `reply-to` | Send a text to your verified phone. `file` is a signed URL of at most 5 MiB sent after the text. `reply-to` is the label of a message in the conversation, such as `m3`, to send a threaded reply. Answers `message_id`. |
36
+ | `react` | `send` (`sms:send`) | `label`\*, `emoji`\* | React to a text in the conversation with a tapback or any emoji. |
37
+ | `status` | `status` (`sms:status`) | `message-id`\* | Check the delivery state of a text. |
38
+
39
+ Parameter names are the provider's own. In a `providers.call`, `arguments` is an object keyed by those names, such as `{body, file}`. Run `runtime.providers.tools('sms')` for the current list.