@north-light/cli 0.3.473 → 0.3.474
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtin-memory/crouter-sdk/README.md +3 -46
- package/dist/builtin-memory/crouter-sdk/client.md +2 -231
- package/dist/builtin-memory/crouter-sdk/errors.md +9 -50
- package/dist/builtin-memory/crouter-sdk/getting-started.md +2 -64
- package/dist/builtin-memory/crouter-sdk/guides/README.md +1 -3
- package/dist/builtin-memory/crouter-sdk/guides/typed-extraction.md +1 -1
- package/dist/builtin-memory/crouter-sdk/streaming.md +1 -70
- package/dist/builtin-memory/northlight-apps/INDEX.md +15 -0
- package/dist/builtin-memory/northlight-apps/cli.md +41 -0
- package/dist/builtin-memory/northlight-apps/coding-agents.md +75 -0
- package/dist/builtin-memory/northlight-apps/concepts/apps-and-sign-in.md +109 -0
- package/dist/builtin-memory/northlight-apps/concepts/connections.md +109 -0
- package/dist/builtin-memory/northlight-apps/concepts/providers.md +76 -0
- package/dist/builtin-memory/northlight-apps/concepts/runs.md +101 -0
- package/dist/builtin-memory/northlight-apps/concepts/scopes-and-grants.md +64 -0
- package/dist/builtin-memory/northlight-apps/concepts/testing-mode.md +54 -0
- package/dist/builtin-memory/northlight-apps/errors.md +119 -0
- package/dist/builtin-memory/northlight-apps/guides/background-runs.md +80 -0
- package/dist/builtin-memory/northlight-apps/guides/files-and-documents.md +61 -0
- package/dist/builtin-memory/northlight-apps/guides/go-live.md +39 -0
- package/dist/builtin-memory/northlight-apps/guides/grant-changes.md +100 -0
- package/dist/builtin-memory/northlight-apps/guides/manage-your-app.md +77 -0
- package/dist/builtin-memory/northlight-apps/guides/many-users.md +63 -0
- package/dist/builtin-memory/northlight-apps/guides/stream-a-run.md +84 -0
- package/dist/builtin-memory/{crouter-sdk/testing.md → northlight-apps/guides/test-your-app.md} +6 -6
- package/dist/builtin-memory/northlight-apps/guides/typed-results.md +99 -0
- package/dist/builtin-memory/northlight-apps/how-it-fits.md +49 -0
- package/dist/builtin-memory/northlight-apps/providers/README.md +39 -0
- package/dist/builtin-memory/northlight-apps/providers/context.md +34 -0
- package/dist/builtin-memory/northlight-apps/providers/device.md +28 -0
- package/dist/builtin-memory/northlight-apps/providers/integrations.md +28 -0
- package/dist/builtin-memory/northlight-apps/providers/search.md +31 -0
- package/dist/builtin-memory/northlight-apps/providers/sms.md +39 -0
- package/dist/builtin-memory/northlight-apps/quickstart.md +210 -0
- package/docs/apps/guides/background-runs.md +1 -1
- package/docs/apps/guides/manage-your-app.md +1 -1
- package/docs/cli/README.md +1 -0
- package/docs/cli/join-northlight.md +58 -0
- package/docs/sdk/README.md +3 -46
- package/docs/sdk/client.md +2 -231
- package/docs/sdk/errors.md +8 -49
- package/docs/sdk/getting-started.md +2 -64
- package/docs/sdk/guides/README.md +1 -3
- package/docs/sdk/guides/typed-extraction.md +1 -1
- package/docs/sdk/streaming.md +1 -70
- package/package.json +2 -2
- package/packages/crouter-identity/package.json +1 -1
- package/runtime.lock.json +12 -12
- package/dist/builtin-memory/crouter-sdk/error-envelope.md +0 -38
- package/dist/builtin-memory/crouter-sdk/guides/apps-on-northlight.md +0 -423
- package/dist/builtin-memory/crouter-sdk/guides/apps-with-many-users.md +0 -61
- package/dist/builtin-memory/crouter-sdk/memory.md +0 -49
- package/docs/sdk/error-envelope.md +0 -36
- package/docs/sdk/guides/apps-on-northlight.md +0 -419
- package/docs/sdk/guides/apps-with-many-users.md +0 -59
- package/docs/sdk/memory.md +0 -47
- package/docs/sdk/testing.md +0 -191
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
kind: knowledge
|
|
3
|
-
when-and-why-to-read: When you need Memory, this knowledge should be read
|
|
4
|
-
because Read the person's public documents — knowledge and preferences about
|
|
5
|
-
you — from an app's server with client.memory.
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# `client.memory`
|
|
9
|
-
|
|
10
|
-
`client.memory` is the SDK's read-only view of the person's documents: the `knowledge` and `preference` documents owned by the person on the canvas (the same objects `crtr canvas read` shows them). The `memory` name and the `/v1/memory` routes are the API's names for this. An app reads the person's `public` documents with `memory.list` and `memory.get`, and needs no scope to do it. It never sees a `friends` or `private` memory, and cannot tell one from a memory that does not exist: both answer `404 not_found`. This list is what the app's agents can read in memory about you, so an app can show the person exactly which of their memories its agents draw on.
|
|
11
|
-
|
|
12
|
-
```ts
|
|
13
|
-
const page = await client.memory.list({ store: 'user' });
|
|
14
|
-
for (const item of page.items) console.log(item.name, item.preview);
|
|
15
|
-
|
|
16
|
-
const doc = await client.memory.get({ store: 'user', name: page.items[0].name });
|
|
17
|
-
console.log(doc.body);
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
`store` is `'user'` (memory about you) in this release; any other value answers `400 invalid_request` with `param: "store"`. An app whose grant holds `crtr:memory:manage:user` (a first-party app) sees every privacy level.
|
|
21
|
-
|
|
22
|
-
Reading records nothing: no watch, no `read` event, no audit row. Both methods are `GET` requests and follow the SDK's retry rule for safe requests.
|
|
23
|
-
|
|
24
|
-
## List
|
|
25
|
-
|
|
26
|
-
`list({store, path?, kind?, limit?, cursor?}, options?)` sends `GET /v1/memory` and returns `Promise<MemoryListPage>`, `{items, next_cursor}`.
|
|
27
|
-
|
|
28
|
-
| Field | Meaning |
|
|
29
|
-
|---|---|
|
|
30
|
-
| `path` | A folder: lists documents whose name starts with `<path>/`, at any depth |
|
|
31
|
-
| `kind` | `knowledge` or `preference`; both when omitted |
|
|
32
|
-
| `limit` | 1 to 100; default 50 |
|
|
33
|
-
| `cursor` | The previous page's `next_cursor`, with the same `store`, `path` and `kind` |
|
|
34
|
-
|
|
35
|
-
Items are ordered by `name`. Each `MemoryListItem` is `{name, folder, kind, preview, summary, privacy, revision, created_at, updated_at}`: `name` has no `user/` prefix, `folder` is the name up to its last `/` (`""` at the top level), and `updated_at` is the current revision's time. Folders are not items, and deleted and unlisted documents are left out. `next_cursor` is `null` on the last page.
|
|
36
|
-
|
|
37
|
-
`listAll(params, options?)` takes the same fields without `cursor` and yields every item across pages, fetching the next page only as you iterate; stop early with `break`:
|
|
38
|
-
|
|
39
|
-
```ts
|
|
40
|
-
for await (const item of client.memory.listAll({ store: 'user', path: 'work' })) render(item);
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## Get
|
|
44
|
-
|
|
45
|
-
`get({store, name}, options?)` sends `GET /v1/memory/document` and returns `Promise<MemoryDocument>`: a list item's fields plus `body`, `size`, `truncated` and `grantee`.
|
|
46
|
-
|
|
47
|
-
- `body` is the current markdown as stored; `[[links]]` are not resolved. A body over 1 MiB is cut to its longest whole-character prefix of at most 1 MiB, with `truncated: true`; `size` is always the full length in UTF-8 bytes.
|
|
48
|
-
- `grantee` is `app:<id>` of the app whose agent wrote the first revision, or `null` when the person wrote it.
|
|
49
|
-
- An absent, deleted, `friends` or `private` name rejects with `NotFoundError` (`code: 'not_found'`); a malformed field rejects with `BadRequestError`.
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: App-facing error envelope
|
|
3
|
-
description: Contract 2 codes and the HTTP refusal body for Northlight apps.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# App-facing error envelope
|
|
7
|
-
|
|
8
|
-
Northlight's app-facing HTTP endpoints use contract 2's error body. An app's **own** endpoints can use the same shape with `origin: 'app'`. The SDK publishes the fixed code tuples:
|
|
9
|
-
|
|
10
|
-
```ts
|
|
11
|
-
import { errorCodes, type ErrorEnvelope } from '@north-light/sdk/errors';
|
|
12
|
-
|
|
13
|
-
const row = errorCodes.invalid_request;
|
|
14
|
-
// { type: 'invalid_request_error', status: 400, retryable: false }
|
|
15
|
-
|
|
16
|
-
const body: ErrorEnvelope = {
|
|
17
|
-
error: {
|
|
18
|
-
code: 'invalid_request',
|
|
19
|
-
message: 'Missing prompt',
|
|
20
|
-
type: row.type,
|
|
21
|
-
origin: 'app',
|
|
22
|
-
retryable: row.retryable,
|
|
23
|
-
request_id: 'req-123',
|
|
24
|
-
param: 'prompt',
|
|
25
|
-
},
|
|
26
|
-
};
|
|
27
|
-
// Send body with HTTP status row.status.
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
The body is `{ error: { code, message, details?, type, param?, origin, retryable, retry_after_s?, reset_at?, request_id, run_id?, question_id?, user_action?, scope? } }`. `origin` is `directory`, `router`, `runtime`, `provider`, or `app`. `type` uses OpenAI's `invalid_request_error`, `authentication_error`, `permission_error`, `rate_limit_error`, or `server_error`. **Status belongs only in the HTTP status line**, not in the body. Send `Retry-After` when `retry_after_s` is set. Keep `request_id` in the body and echo it as a request-id response header. Do not expose stack traces in a 5xx message.
|
|
31
|
-
|
|
32
|
-
`errorCodes` is keyed by code. Each row gives that code's fixed contract-2 `{ type, status, retryable }` tuple; the daemon renderer uses this module for `type` via `@north-light/api`. An unsatisfiable byte range is `range_not_satisfiable` (416), an upstream model failure is `upstream_model_unavailable` (503, retryable), and an upstream vendor sign-in refusal is `upstream_refused` (502). The renderer preserves the thrown HTTP status and retry decision; throw sites must choose a code whose tuple matches those values. If no existing code fits an app-specific failure, declare an app code with a consistent type/status and document it in the app's OpenAPI spec.
|
|
33
|
-
|
|
34
|
-
Not every error has a fixed tuple. `provider_error` carries the provider's varying 4xx status and is not in `errorCodes`; `usage` is a legacy socket/loopback code that becomes `invalid_request` on the app-facing listener. `stream_gap`, `stream_dropped`, and `stream_error` are stream protocol codes with beta-defined behavior, not ordinary static HTTP refusal rows. SDK-generated connection failures (`daemon_unavailable`, `transport_error`, `daemon_request_interrupted`, `request_timeout`, `daemon_health_unavailable`) are not daemon refusals. `runtime_unreachable` applies to a hosted router, `runtime_provisioning`, `grant_removed`, `grant_suspended`, `refresh_token_expired`, and `refresh_token_reused` are raised by the SDK for directory token responses (see [When a refresh is refused](./client.md#when-a-refresh-is-refused)), and `wake_timeout` is reserved for the later hosted sleep/wake stage; their presence in the table does not mean the local daemon emits them today.
|
|
35
|
-
|
|
36
|
-
`ErrorEnvelope` models the **full app-facing response**, not the SDK's permissive `ErrorBody` used to parse older and socket responses. A socket or v0 loopback response can have only `code`, `message`, and optional `details` (with run-layer fields in some cases); never assume `type` or `request_id` exists on those transports. `APIError.status` is separate from the parsed JSON body. See [Errors](./errors.md) for error classes, transport failures, and retry rules.
|
|
@@ -1,419 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Apps on Northlight
|
|
3
|
-
description: When you are building and registering an app on Northlight, or an app starts runs on a person's runtime and needs data back, read this because it takes you from sign-up to a running app against production, and runs.parse returns a typed, validated result where the transcript is not a data channel.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Apps on Northlight
|
|
7
|
-
|
|
8
|
-
This page first walks through [building and registering an app](#build-and-register-an-app) against production with `crtr cloud apps`, then covers [managing it](#manage-an-app-with-crtr-cloud-apps), then runs.
|
|
9
|
-
|
|
10
|
-
An app holding a person's connection starts runs on their runtime with `runs.start`, follows them with `runs.events` or `runs.reply`, and reads them with `runs.get`. This page covers getting data back from a run. For connections and per-person access, see [Apps with many users](./apps-with-many-users.md).
|
|
11
|
-
|
|
12
|
-
## Build and register an app
|
|
13
|
-
|
|
14
|
-
This part takes you from nothing to an app running on your laptop against Northlight's production directory, `https://account.northlight.ai`. Your app server runs on `localhost`, you sign in to it through Northlight, and it calls your own runtime. There is no local Northlight stack to run.
|
|
15
|
-
|
|
16
|
-
You do all of it from the command line. Your laptop joins your Northlight account as an extra machine, and `crtr cloud apps …` registers and manages the app as you.
|
|
17
|
-
|
|
18
|
-
The example app texts you, so it asks for the `sms` provider's `sms:send` scope. You need Node.js 22.19 or later.
|
|
19
|
-
|
|
20
|
-
### 1. Account and join
|
|
21
|
-
|
|
22
|
-
Open `https://account.northlight.ai/signup` and create your account. Signup texts a confirmation code to your phone, and that text is sent over iMessage, so use a phone number that has iMessage. A number that cannot receive iMessage gets "We couldn't send the text", and retrying does not help. One phone number belongs to one account.
|
|
23
|
-
|
|
24
|
-
Install the CLI (`npm i -g @north-light/cli`). Joining needs `crtr` 0.3.436 or later (older versions need `crtr sys daemon stop` first and fail if a viewer is open); `crtr --version` shows yours.
|
|
25
|
-
|
|
26
|
-
Then join your laptop to the account:
|
|
27
|
-
|
|
28
|
-
1. Open `https://account.northlight.ai/account/machines` and choose **Create join token**. A token needs a sign-in within the last 10 minutes, so the page asks you to sign in again if yours is older.
|
|
29
|
-
2. The page shows the join command with the token filled in. On your laptop, run it from a plain terminal: `crtr sys join --directory https://account.northlight.ai --token <token>`. Leave your crtr windows open: join stops the daemon itself, holds it down while it writes your identity (so an open viewer cannot restart it early), and starts it again on the joined settings. Run it outside any crtr node, because a node's engine stops with the daemon; from inside one, join refuses with `join_inside_node`. Add `--name <label>` to name the machine on the page. The token works once and expires at the time the page shows; the page shows it only once.
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
crtr sys join --directory https://account.northlight.ai --token <token> --name my-laptop
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
It answers with `registered: true`, the machine id (`runtime:<id>`), your user id and `daemon: restarted` (`daemon: started` when crtr was not running: join starts the daemon, and its first grant sync installs `crtr cloud …` and your other provider plugins). Your open viewers and nodes reconnect on their own.
|
|
36
|
-
3. Check it: `crtr cloud apps mine` lists the apps you manage. Before you have joined, it answers `not_joined` and prints these same steps.
|
|
37
|
-
|
|
38
|
-
Things to know before you join:
|
|
39
|
-
|
|
40
|
-
- **Joining rewrites your crtr home.** Everything in your everyday crtr home that belonged to the local user (`user:local`) becomes your Northlight user, the next time the daemon starts. If you would rather not do that to your everyday home, join from a home you can throw away.
|
|
41
|
-
- **There is no unjoin.** The only undo is removing the machine on `/account/machines`.
|
|
42
|
-
- **Removing the machine stops it.** Once you choose **Remove** next to a machine, it gets no new tokens at once and the ones it holds expire within 5 minutes. After that its `cloud` commands answer `machine_revoked`: "This machine was removed from your Northlight account." It stays that way until you create a new join token and run `crtr sys join` again. Files on the computer stay on it.
|
|
43
|
-
- **Your hosted machine is unchanged.** `/account/machines` lists it as "Northlight (hosted)", the default. Your chat agent at `https://chat.northlight.ai` still runs `crtr cloud apps …` as before.
|
|
44
|
-
|
|
45
|
-
### 2. Install the SDK
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
mkdir my-app && cd my-app
|
|
49
|
-
npm init -y
|
|
50
|
-
npm i --save-exact @north-light/sdk@0.3.451
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
`0.3.451` is the version this guide was last checked against on production. To move on purpose, run `npm view @north-light/sdk dist-tags`, read the [release notes](https://sdk.docs.northlight.ai/docs/release-notes) for the versions in between, and install the new exact version the same way.
|
|
54
|
-
|
|
55
|
-
Pin an exact version of `@north-light/sdk` rather than a range or `latest`. A compatibility gate between the SDK and Northlight's runtimes protects the wire, so an SDK that is too new for the runtime is refused cleanly. It does not protect the TypeScript API: a later SDK release can change a type or a method your code uses, and an unpinned install picks that up on its next `npm i`.
|
|
56
|
-
If a call fails with `426 runtime_version_unsupported`, the SDK is newer than the Northlight runtimes. This can happen briefly right after a new SDK release, until production runtimes are upgraded. Install the previous version or wait, and tell Northlight at support@northlight.ai.
|
|
57
|
-
|
|
58
|
-
### 3. Register the app
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
crtr cloud apps register \
|
|
62
|
-
--name "Texty" \
|
|
63
|
-
--origin http://localhost:3000 \
|
|
64
|
-
--logo https://example.com/logo.png \
|
|
65
|
-
--justification "Texts you a summary of your day." \
|
|
66
|
-
--redirect-uri http://localhost:3000/auth/callback
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
| Flag | Value |
|
|
70
|
-
|---|---|
|
|
71
|
-
| `--name` | Shown to people on the consent page and in their account. |
|
|
72
|
-
| `--origin` | Where your app runs. `http://localhost:<port>` and `http://127.0.0.1:<port>` are accepted for development. |
|
|
73
|
-
| `--logo` | Required. The URL of a square image. |
|
|
74
|
-
| `--justification` | One sentence, quoted on the consent page as your words. |
|
|
75
|
-
| `--redirect-uri` | Repeat the flag for each URI. Sign-in returns only to a URI listed here, matched exactly, so `localhost` and `127.0.0.1` are different URIs and the port counts. |
|
|
76
|
-
|
|
77
|
-
The answer is the registration: the app id (`app:<client id>`), `verified: false` and `testing: true` (see [Who can sign in: testing mode](#who-can-sign-in-testing-mode)), `key: {"source":"none"}` and an `env` block with `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN`. The client id is the part of the app id after `app:`. `crtr cloud apps get --app-id <client id>` shows it again. Until a key is added, `exchangeCode` throws an `AuthenticationError`.
|
|
78
|
-
|
|
79
|
-
`--app-id` takes `app:<client id>` or the bare client id everywhere.
|
|
80
|
-
|
|
81
|
-
### 4. Generate the signing key and add it
|
|
82
|
-
|
|
83
|
-
An app signs in with a key, not a client secret. From your app's folder:
|
|
84
|
-
|
|
85
|
-
```bash
|
|
86
|
-
npx @north-light/sdk keygen --app <client id> --yes
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
This writes the private key to `local/app-key.json` (mode `0600`; keep it out of git) and adds its public key to the app through your own `crtr` (`crtr cloud apps keys add`). Without `--yes` it asks first. The answer is "Added key <kid> to <client id> as an additional key. Your current key still works."; `crtr cloud apps keys list --app-id <client id>` then shows the key as `active`. Never paste the private key anywhere.
|
|
90
|
-
|
|
91
|
-
### 5. Say what the app asks for
|
|
92
|
-
|
|
93
|
-
An app can only ask for scopes it has been given. It starts with `crtr:llm` and `crtr:act`. See what else it may ask for, then set the list:
|
|
94
|
-
|
|
95
|
-
```bash
|
|
96
|
-
crtr cloud apps asks --app-id <client id> --source sms
|
|
97
|
-
crtr cloud apps set-scopes --app-id <client id> --add sms:send
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
`asks` lists, per source, what the app may ask for and whether it asks now; `sms:send` is under providers, with the consent sentence "Send you text messages from Northlight's number". `set-scopes --add` adds to the list the app has now and leaves the rest alone; `--remove` drops a scope that is in it (`crtr:act` needs `crtr:llm`, so drop both together). `crtr cloud apps asks --app-id <client id> --requested` shows exactly what the app asks for now. To replace the whole list instead, repeat `--scope` for every scope you want, or pass `--clear`. The console's **What it asks for** tab does the same with an **Add permissions** search. Without `sms:send` the consent page does not offer it and `providers.call` for `sms` is refused. Take scope strings from `asks` and do not invent them.
|
|
101
|
-
|
|
102
|
-
If your app does not work without a provider being set up first (Doppel needs Context), name it:
|
|
103
|
-
|
|
104
|
-
```bash
|
|
105
|
-
crtr cloud apps set-requires --app-id <client id> --provider context
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
The person is then taken through that provider's setup page the first time they sign in to your app. Context asks for a profile URL, and its state stays `pending` for a few minutes afterwards; see [Waiting on a provider your app requires](#waiting-on-a-provider-your-app-requires).
|
|
109
|
-
|
|
110
|
-
#### What each scope gates
|
|
111
|
-
|
|
112
|
-
A run you start can only do what the grant holds and the run's own `scopes` list allows. Narrowing a run's `scopes` removes abilities, so pick the smallest list that works.
|
|
113
|
-
|
|
114
|
-
| Scope | A run (or your server) with it can | Without it |
|
|
115
|
-
|---|---|---|
|
|
116
|
-
| `crtr:llm` | Use the person's AI subscription for the run's model calls. List it in every run's `scopes`. | The run has no model access. |
|
|
117
|
-
| `crtr:act` | Start runs (`runs.start`) and use a shell: every bash call on the hosted (Linux) runtime needs it. | `runs.start` is refused with `403 scope_missing`. Ask for it in step 5 and list it in the run's `scopes`. |
|
|
118
|
-
| `crtr:schedule` | Create, pause, resume and run schedules. | Schedule calls are refused. |
|
|
119
|
-
| `crtr:memory:read:user`, `crtr:files:read:user:.`, `crtr:files:rw:user:.` | Read the person's documents beyond their public ones; read or write the person's files. | The run sees only the person's `public` documents and its own app space. |
|
|
120
|
-
| `sms:send` (a provider scope) | Call the `sms` provider's `send` tool, which texts the person's own verified phone. | `providers.call` is refused. |
|
|
121
|
-
|
|
122
|
-
#### Who can sign in: testing mode
|
|
123
|
-
|
|
124
|
-
A new app is in testing: it is registered as `verified: false`, only you and up to 100 testers can sign in to it, and it is not shown in the marketplace. You can sign in as yourself with no setup. To let someone else in, add them by the email or phone number on their Northlight account: `crtr cloud apps testers add --app-id <id> --email them@example.com` (or `--phone +15551234567`). An email matches only while it is verified on that account. They can sign in right away; nobody gets a message, so tell them. (Specified behavior: adding a tester, and everything below about a second person signing in, being refused or being removed, has not been run on production yet.) `crtr cloud apps testers list --app-id <id>` shows who is on the list and `testers remove` takes someone off. A testing app is also left out of the marketplace listing, even once you set `--listed true`.
|
|
125
|
-
|
|
126
|
-
Per the testing-mode specification, anyone else who tries sees "<app> is still in testing", and your app's callback receives `error=access_denied` with `error_description=app_in_testing` instead of a `code`. [When sign-in is refused: apps in testing](../client.md#when-sign-in-is-refused-apps-in-testing) shows how to read it and what to show the person. Removing a tester stops their access at once, including their refresh tokens, and keeps their data; adding them back and signing in again restores it.
|
|
127
|
-
|
|
128
|
-
When you are ready for everyone, email `support@northlight.ai` to have the app verified. Only Northlight staff can verify an app; registering never sets `verified: true`. Apps that existed before testing mode are already verified.
|
|
129
|
-
|
|
130
|
-
### 6. Write the environment file
|
|
131
|
-
|
|
132
|
-
```bash
|
|
133
|
-
npx @north-light/sdk env --app <client id>
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
This reads the registration through `crtr cloud apps get` and writes `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env`, keeping every other line and comment in the file. `--out <path>` writes another file. It never writes `CROUTER_KEY_FILE` or anything secret. Add the key path yourself:
|
|
137
|
-
|
|
138
|
-
```bash
|
|
139
|
-
CROUTER_ISSUER=https://account.northlight.ai
|
|
140
|
-
CROUTER_CLIENT_ID=<client id>
|
|
141
|
-
APP_ORIGIN=http://localhost:3000
|
|
142
|
-
CROUTER_KEY_FILE=local/app-key.json
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
| Variable | Value |
|
|
146
|
-
|---|---|
|
|
147
|
-
| `CROUTER_ISSUER` | `https://account.northlight.ai` |
|
|
148
|
-
| `CROUTER_CLIENT_ID` | The client id of your app |
|
|
149
|
-
| `APP_ORIGIN` | The origin you registered. The redirect URI is `${APP_ORIGIN}/auth/callback` by convention; it must match a registered redirect URI exactly. |
|
|
150
|
-
| `CROUTER_KEY_FILE` | Path to the private key from step 4. Default `local/app-key.json` |
|
|
151
|
-
|
|
152
|
-
The file holds no secret, but keep it out of git with the key.
|
|
153
|
-
|
|
154
|
-
### 7. Run it locally
|
|
155
|
-
|
|
156
|
-
Create the config from those variables. The SDK reads no environment; your app passes the values in.
|
|
157
|
-
|
|
158
|
-
```ts
|
|
159
|
-
import {readFile} from 'node:fs/promises';
|
|
160
|
-
import {OAuth2Client, MemoryConnectionStore} from '@north-light/sdk';
|
|
161
|
-
|
|
162
|
-
const store = new MemoryConnectionStore(); // one process only; use a persistent ConnectionStore in production
|
|
163
|
-
const oauth = new OAuth2Client({
|
|
164
|
-
clientId: process.env.CROUTER_CLIENT_ID!,
|
|
165
|
-
issuer: process.env.CROUTER_ISSUER!,
|
|
166
|
-
redirectUri: `${process.env.APP_ORIGIN}/auth/callback`,
|
|
167
|
-
privateKey: JSON.parse(await readFile(process.env.CROUTER_KEY_FILE ?? 'local/app-key.json', 'utf8')),
|
|
168
|
-
store,
|
|
169
|
-
});
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
Start the server with the file loaded, for example `node --env-file=local/app.env server.js`.
|
|
173
|
-
|
|
174
|
-
**Sign in.** The login route redirects to Northlight; the callback exchanges the code on your server and stores the person's connection. This example uses Express and an in-memory session, which are only a sketch:
|
|
175
|
-
|
|
176
|
-
```ts
|
|
177
|
-
import express from 'express';
|
|
178
|
-
import {randomBytes} from 'node:crypto';
|
|
179
|
-
|
|
180
|
-
const app = express();
|
|
181
|
-
const pending = new Map<string, {codeVerifier: string; nonce: string}>(); // keyed by state; use a real session
|
|
182
|
-
|
|
183
|
-
app.get('/auth/login', async (_req, res) => {
|
|
184
|
-
const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({
|
|
185
|
-
scopes: ['openid', 'profile', 'crtr:llm', 'crtr:act', 'sms:send'],
|
|
186
|
-
state: randomBytes(16).toString('base64url'),
|
|
187
|
-
});
|
|
188
|
-
pending.set(state, {codeVerifier, nonce});
|
|
189
|
-
res.redirect(303, url);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
app.get('/auth/callback', async (req, res) => {
|
|
193
|
-
const state = String(req.query.state);
|
|
194
|
-
const saved = pending.get(state);
|
|
195
|
-
if (!saved || typeof req.query.code !== 'string') return void res.status(400).send('Start sign-in again.');
|
|
196
|
-
pending.delete(state);
|
|
197
|
-
const connection = await oauth.exchangeCode({
|
|
198
|
-
code: req.query.code, codeVerifier: saved.codeVerifier,
|
|
199
|
-
state, expectedState: state, nonce: saved.nonce,
|
|
200
|
-
});
|
|
201
|
-
await store.save(connection);
|
|
202
|
-
res.redirect(303, `/?user=${encodeURIComponent(connection.userId)}`); // set your own session instead
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
app.listen(3000);
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
Open `http://localhost:3000/auth/login`. Northlight asks you to sign in and shows the consent page; choose Allow, and the browser returns to your callback with a `code` (and `iss`) in the query. Request only the scopes you ticked in step 5.
|
|
209
|
-
|
|
210
|
-
**Start a run and call the `sms` provider.** `oauth.client(userId)` opens a client on that person's runtime from their stored connection:
|
|
211
|
-
|
|
212
|
-
```ts
|
|
213
|
-
const runtime = await oauth.client(userId);
|
|
214
|
-
|
|
215
|
-
const started = await runtime.runs.start({prompt: 'Reply with exactly the word: pong', scopes: ['crtr:llm', 'crtr:act']});
|
|
216
|
-
const run = await runtime.runs.wait(started.run_id); // status 'idle' once the reply is done
|
|
217
|
-
|
|
218
|
-
// Text the signed-in person's own phone through the sms provider.
|
|
219
|
-
const sent = await runtime.providers.call({
|
|
220
|
-
provider: 'sms',
|
|
221
|
-
tool: 'send',
|
|
222
|
-
arguments: {body: 'Hello from my first Northlight app.'},
|
|
223
|
-
});
|
|
224
|
-
// sent.message_id is the message's id
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
`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, and allows at most 50 texts per person per UTC day. `runtime.providers.tools('sms')` lists the provider's tools. [Getting a result back from a run](#getting-a-result-back-from-a-run) covers `runs.parse`.
|
|
228
|
-
|
|
229
|
-
If a run or call throws because the person's connection ended, handle it in one place with `onRefusal`; see [Apps with many users](./apps-with-many-users.md).
|
|
230
|
-
|
|
231
|
-
### 8. Test without a network
|
|
232
|
-
|
|
233
|
-
Test your app with `FakeCloud` from `@north-light/sdk/testing`. Your real `OAuth2Client` and `Crouter` talk to it through `fetch`, so tests never touch production or send a text. See [Testing your app](../testing.md).
|
|
234
|
-
|
|
235
|
-
## Manage an app with `crtr cloud apps`
|
|
236
|
-
|
|
237
|
-
Everything above is a `crtr cloud apps` command, and so is the rest of managing an app. They are leaves of Northlight's `cloud` provider, they run as you, and they need a machine joined to your account ([step 1](#1-account-and-join)).
|
|
238
|
-
|
|
239
|
-
Your Northlight chat agent at `https://chat.northlight.ai` can run the same commands as you, on your hosted runtime. Ask it to run a command and show you the raw output. It can list and change an app (`mine` and `set-scopes` were checked on production). It cannot delete one: see [Deleting an app](#deleting-an-app). Generate keys on your laptop and give an agent only a public JWK, never the private key.
|
|
240
|
-
|
|
241
|
-
`--app-id` takes `app:<client id>` or the bare client id. The leaves:
|
|
242
|
-
|
|
243
|
-
| Command | What it does |
|
|
244
|
-
|---|---|
|
|
245
|
-
| `crtr cloud apps mine` | Lists the apps you manage. |
|
|
246
|
-
| `crtr cloud apps register --name … --origin … --logo … --justification … --redirect-uri …` | Registers an app and answers its registration, including `env` (the issuer, client id and origin for step 6). The `--redirect-uri` flag repeats. |
|
|
247
|
-
| `crtr cloud apps get --app-id <id>` | Shows the registration. |
|
|
248
|
-
| `crtr cloud apps update --app-id <id> --name … / --origin … / --logo … / --justification …` | Changes the profile fields you pass. |
|
|
249
|
-
| `crtr cloud apps set-redirect-uris --app-id <id> --redirect-uri …` | Replaces the whole redirect URI list; repeat the flag for each. |
|
|
250
|
-
| `crtr cloud apps asks --app-id <id>` | Lists what an app may ask for, with whether it asks now. Large sources such as `integrations` print as a count. Narrow it with `--query sms:send`, `--source sms`, `--group providers\|apps\|agents\|space`, or `--query <text>`; `--requested` lists only what the app asks for now. For example `crtr cloud apps asks --app-id <id> --source sms`. |
|
|
251
|
-
| `crtr cloud apps set-scopes --app-id <id> --add … --remove …` | Edits the scope list the app has now; repeat `--add`/`--remove` per scope. Removing a scope that is not in the list is refused. Or replace the whole list with repeated `--scope`; `--clear` empties it. `crtr:act` needs `crtr:llm`. |
|
|
252
|
-
| `crtr cloud apps set-requires --app-id <id> --add … --remove …` | Edits the providers your app does not work without (for example `context`). Or replace the list with repeated `--provider`; `--clear` empties it. |
|
|
253
|
-
| `crtr cloud apps keys add --app-id <id> --jwk '<public JWK>'` | Adds a signing key beside any existing one. Answers `created: false` if that key is already on the app. `keygen --app` calls it for you. |
|
|
254
|
-
| `crtr cloud apps keys list --app-id <id>` | Lists the keys with their `kid` and `active` or `retired` status. |
|
|
255
|
-
| `crtr cloud apps keys retire --app-id <id> --kid <kid>` | Retires a key so it no longer signs in. |
|
|
256
|
-
| `crtr cloud apps set-listing --app-id <id> --login-start-url … --text-welcome …` (also `--listed`, `--price`) | Sets the listing fields; pass at least one. A loopback `--login-start-url` is accepted. `--listed true --price free` lists the app, so `cloud apps install` finds it; while the app is in testing it still stays out of the marketplace list. |
|
|
257
|
-
| `crtr cloud apps install --app-id <id>` | Texts an install link to the signed-in person. If they already hold a grant it answers `status: already_have` and the install URL instead of texting. The app must be listed. |
|
|
258
|
-
| `crtr cloud apps testers add --app-id <id> --email …` (or `--phone …`) | Lets that person sign in while the app is in testing; at most 100. Not yet run on production. |
|
|
259
|
-
| `crtr cloud apps testers list --app-id <id>` | Lists the testers. |
|
|
260
|
-
| `crtr cloud apps testers remove --app-id <id> --email …` (or `--phone …`) | Removes a tester and revokes their grants for the app. Not yet run on production. |
|
|
261
|
-
| `crtr cloud apps delete --app-id <id> --confirm <client id>` | Deletes the app and removes every person's grant for it. Only you at a joined machine's terminal can run it: an agent, including your chat agent, is refused with `provider_refused`. |
|
|
262
|
-
|
|
263
|
-
### Deleting an app
|
|
264
|
-
|
|
265
|
-
```bash
|
|
266
|
-
crtr cloud apps delete --app-id <client id> --confirm <client id>
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
It answers `deleted: true` and `removed_grants`, the number of people's grants it removed. Each runtime deletes the app's runs, space and schedules, and the app's next refresh fails with `invalid_grant`. The app's key stops working, the app's page answers 404 and `mine` no longer lists it. It cannot be undone, and apps that hold the provider role cannot be deleted.
|
|
270
|
-
|
|
271
|
-
Only you can delete: run it yourself in the terminal of your joined machine. An agent, including your chat agent, is refused with `provider_refused`: "Only you can delete an app. Ask the person to run `crtr cloud apps delete` in their terminal." Reusing an app for tests is often simpler: change its redirect URIs and keys instead.
|
|
272
|
-
|
|
273
|
-
### Let people find and install it
|
|
274
|
-
|
|
275
|
-
`login_start_url` is where Northlight starts sign-in for a person who installs your app from a texted link (specified behavior: pressing the texted link was not run on production, only the text send was). Your `/auth/login` route from step 7 is a valid start URL, and a loopback one is accepted:
|
|
276
|
-
|
|
277
|
-
```bash
|
|
278
|
-
crtr cloud apps set-listing --app-id <client id> --listed true --price free \
|
|
279
|
-
--login-start-url http://localhost:3000/auth/login \
|
|
280
|
-
--text-welcome "Texts you a summary of your day."
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
`--text-welcome` is one or two plain sentences, at most 120 characters, with no links. Agents can install an app for people by text only while it is listed (`--listed true`). `crtr cloud apps install --app-id <client id>` sends the signed-in person a text with the install link. Run it only for yourself or for a person who asked: it sends a real text.
|
|
284
|
-
|
|
285
|
-
### Developing alongside your live app
|
|
286
|
-
|
|
287
|
-
Once your app is live, you can keep developing on your laptop against production while the live app keeps running. There are two ways to do it.
|
|
288
|
-
|
|
289
|
-
**Add your localhost callback to the live app.** This is the simplest, and it is fine for most apps. An app can hold several redirect URIs, and each sign-in returns to the one its request names, so your deployed server and your laptop can both sign people in at once:
|
|
290
|
-
|
|
291
|
-
```bash
|
|
292
|
-
crtr cloud apps set-redirect-uris --app-id <client id> \
|
|
293
|
-
--redirect-uri https://app.example.com/auth/callback \
|
|
294
|
-
--redirect-uri http://localhost:3000/auth/callback
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
`set-redirect-uris` replaces the whole list, so name the live URI too. Give your laptop its own signing key beside the deployed one (`keygen --app` adds it; an app holds at most 2 active keys), so you never copy the production private key to your laptop. Both servers share everything else: the same people, their grants, the app's space and the scopes the app asks for. A scope you add while developing changes what the live app asks of everyone. The origin, `login_start_url` and run notice URL each hold one value, so they keep pointing at the deployed app.
|
|
298
|
-
|
|
299
|
-
**Register a separate development app.** Do this when you are changing what the app asks for, or need data and grants of its own that live people never touch. Run `crtr cloud apps register` again with a name such as "Texty (dev)", a `localhost` origin and redirect URI, and its own key. It starts in testing and stays there, so only you and the testers you add can sign in, and it never appears in the marketplace. Point your laptop's environment file at its client id. This is how Google recommends separating tiers too: a separate project for development and for production.
|
|
300
|
-
|
|
301
|
-
### Rotate keys
|
|
302
|
-
|
|
303
|
-
Add a new key beside the old one, switch, verify with a real sign-in, then retire the old key:
|
|
304
|
-
|
|
305
|
-
```bash
|
|
306
|
-
npx @north-light/sdk keygen --out local/app-key.next.json --app <client id> --yes
|
|
307
|
-
# point CROUTER_KEY_FILE at local/app-key.next.json, restart, and sign in once
|
|
308
|
-
crtr cloud apps keys list --app-id <client id>
|
|
309
|
-
crtr cloud apps keys retire --app-id <client id> --kid <old kid>
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
The old key keeps working until you retire it, and your app's refreshes keep working after. [Rotating your key](../getting-started.md#rotating-your-key) has the full sequence.
|
|
313
|
-
|
|
314
|
-
## Getting a result back from a run
|
|
315
|
-
|
|
316
|
-
Pass an `output_schema` and call `runs.parse`. The runtime arms the schema on the run's root agent. The agent 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:
|
|
317
|
-
|
|
318
|
-
```ts
|
|
319
|
-
import {z} from 'zod';
|
|
320
|
-
|
|
321
|
-
const Profile = z.object({
|
|
322
|
-
display_name: z.string(),
|
|
323
|
-
bio: z.string(),
|
|
324
|
-
facts: z.array(z.string()),
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
const outcome = await crouter.runs.parse({
|
|
328
|
-
prompt: 'Draft a public profile for me from my notes.',
|
|
329
|
-
output_schema: Profile,
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
if (outcome.kind === 'result') {
|
|
333
|
-
saveProfile(outcome.output_parsed); // {display_name, bio, facts}, typed from Profile
|
|
334
|
-
} else if (outcome.reason === 'declined') {
|
|
335
|
-
// The agent could not honestly satisfy the schema.
|
|
336
|
-
const {reason, code, retryable} = outcome.details ?? {reason: 'declined', code: 'declined', retryable: false};
|
|
337
|
-
showDecline(reason, code, retryable);
|
|
338
|
-
} else {
|
|
339
|
-
// canceled, deadline_exceeded, or runtime_error (details.crouter_reason)
|
|
340
|
-
showFailure(outcome.reason);
|
|
341
|
-
}
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
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. `output_schema` accepts a plain JSON Schema object or anything with `toJSONSchema()`, such as a Zod v4 object. The SDK sends it as a JSON object and validates nothing itself. 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. The value is validated exactly as submitted, with no coercion: `null` is never turned into `0` or `""`, and `"5"` is not turned into `5`, so a mismatch is refused and the agent resubmits. The schema is one JSON object, so a `$ref` can point only into itself (`#/$defs/x`, `#anchor`) or at another schema document bundled under `$defs` with its own `$id` (`"$defs": { "common": { "$id": "common.schema.json", ... } }` resolves `common.schema.json#/$defs/x`). Files are never fetched. A schema with a `$ref` that resolves nowhere inside it, or one that cannot be compiled (a bad `pattern`), is refused with `invalid_request` and `param: 'output_schema'` and no run is created. The agent submits with its `submit_result` tool, which needs no scope, so a run that only reasons and answers can be started with `scopes: ['crtr:llm']`. The same value is in `runs.get(runId).result`, which `runs.wait` returns unchanged, and on the run's `node.settled` event.
|
|
345
|
-
|
|
346
|
-
Do not parse assistant text out of `runs.trace` to get data back. `runs.trace` is for showing a person the transcript. Its text is whatever the model said, not a value anything checked.
|
|
347
|
-
|
|
348
|
-
## Showing a background run's progress
|
|
349
|
-
|
|
350
|
-
An app that lets a run work in the background and polls it reads `runs.get(runId).activity` for a progress indicator. It is `null` unless `status` is `running`, and otherwise says what the run's root agent is doing now:
|
|
351
|
-
|
|
352
|
-
| `phase` | The root agent is |
|
|
353
|
-
| --- | --- |
|
|
354
|
-
| `starting` | launching; its first turn has not begun |
|
|
355
|
-
| `thinking` | reasoning, or waiting on the model before any output |
|
|
356
|
-
| `tool` | calling a tool: `tool.name`, and `tool.summary` (the command's first line or the path it acts on, `null` while the call is still being written) |
|
|
357
|
-
| `submitting_result` | writing or recording its `output_schema` result |
|
|
358
|
-
| `writing` | streaming its answer text |
|
|
359
|
-
| `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 `output_schema` result |
|
|
360
|
-
|
|
361
|
-
```ts
|
|
362
|
-
const run = await crouter.runs.get(runId);
|
|
363
|
-
const badge = run.activity?.phase === 'tool' ? `Running ${run.activity.tool.name}` : run.activity?.phase ?? run.status;
|
|
364
|
-
```
|
|
365
|
-
|
|
366
|
-
`activity` covers the root only, and it is read each time you call `runs.get`, so a phase shorter than your poll interval can be missed. `last_activity_at` is when any agent of the run last did something. Do not read `runs.trace` for progress: it returns every conversation. To show the text as it streams, follow `runs.events` or `runs.reply` instead of polling.
|
|
367
|
-
|
|
368
|
-
## Bounding a run and handling `run_idle`
|
|
369
|
-
|
|
370
|
-
No run is bounded in time until `deadline` lands. To bound one, 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:
|
|
371
|
-
|
|
372
|
-
```ts
|
|
373
|
-
const controller = new AbortController();
|
|
374
|
-
const timer = setTimeout(() => controller.abort(new Error('profile took too long')), 10 * 60_000);
|
|
375
|
-
let runId: string | undefined;
|
|
376
|
-
try {
|
|
377
|
-
const started = await crouter.runs.start({prompt, output_schema: Profile});
|
|
378
|
-
runId = started.run_id;
|
|
379
|
-
const run = await crouter.runs.wait(runId, {signal: controller.signal});
|
|
380
|
-
// …read run.status, run.outcome, run.result
|
|
381
|
-
} catch (error) {
|
|
382
|
-
if (controller.signal.aborted && runId) await crouter.runs.cancel(runId);
|
|
383
|
-
throw error;
|
|
384
|
-
} finally {
|
|
385
|
-
clearTimeout(timer);
|
|
386
|
-
}
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
`runs.parse` rejects with `APIError` 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, which resets the reminders, or end the run with `runs.cancel`. 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.
|
|
390
|
-
|
|
391
|
-
## Waiting on a provider your app requires
|
|
392
|
-
|
|
393
|
-
An app whose directory registration `requires` a provider (Doppel requires Context) needs that provider set up for the person before the operations that depend on it work. The runtime does not check this for you. `grant.get()` returns `dependencies`: one `ProviderDependency` per required provider, keyed by provider id, or `{}` when the app requires none. Read it on each request that needs the provider; do not store it.
|
|
394
|
-
|
|
395
|
-
```ts
|
|
396
|
-
import type { ProviderDependency } from '@north-light/sdk';
|
|
397
|
-
|
|
398
|
-
// `ProviderDependency` is { state: SetupState; message?: string; next: string }.
|
|
399
|
-
const { dependencies } = await runtime.grant.get();
|
|
400
|
-
const context: ProviderDependency | undefined = dependencies['context'];
|
|
401
|
-
if (context?.state !== 'ready') {
|
|
402
|
-
// Show the provider's own sentence and link its setup page; never hard-code either.
|
|
403
|
-
const setup = context && new URL(context.next);
|
|
404
|
-
setup?.searchParams.set('return_to', 'https://app.example.com'); // your registered redirect origin
|
|
405
|
-
// AppError: your app's own typed error (see the error envelope).
|
|
406
|
-
throw new AppError('provider_setup_pending', context?.message ?? 'Finish setting up Context, then try again.', {
|
|
407
|
-
status: 403,
|
|
408
|
-
retryable: false,
|
|
409
|
-
details: { provider: 'context', state: context?.state ?? 'not_started', message: context?.message, next: setup?.href },
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
- `state` is `not_started` (the person has not done their part), `pending` (the provider is working), `ready`, or `failed`. To act when it turns `ready` (for example, start work the person was waiting on), follow it with `oauth.watchGrants` ([Learn when a person's grant changes](../client.md#learn-when-a-persons-grant-changes)) instead of polling `grant.get()`.
|
|
415
|
-
- `message` is the provider's own sentence for `pending` and `failed`, the same words the directory's setup page shows. It is absent for `not_started` and `ready`, so keep a short fallback for `not_started`.
|
|
416
|
-
- `next` is the absolute URL of the directory's setup page for this provider and your app, where the person does their part or retries a failure. Append `return_to` (your app's registered redirect origin) so Continue on that page brings them back to you.
|
|
417
|
-
- An entry is missing when the runtime has not synced the grant since the app's registration started requiring the provider; treat it as `not_started`.
|
|
418
|
-
|
|
419
|
-
Refuse the dependent operation with `provider_setup_pending` (403, in `errorCodes`), carrying the entry's `state`, `message` and `next` in `details` so your front end can render them without another call.
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Apps with many users
|
|
3
|
-
description: Keep each person's runtime connection separate while building an app where people interact.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Apps with many users
|
|
7
|
-
|
|
8
|
-
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 app's 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. Sign in with Northlight is the initial sign-in for every app user, including a visitor. Request the app's grants during sign-in from a person whose runtime your app will use. A visitor who only needs to be recognized signs in with identity only (see [Signing in a visitor](#signing-in-a-visitor)). Northlight permissions decide what your app can do on each person's runtime. Your app decides what its users can do to each other.
|
|
9
|
-
|
|
10
|
-
Keep the connection and refresh token on the server, not in a visitor's browser. The server can call `providers.call` on a stored connection outside any run, even when nobody is present and the person is offline. Access tokens last 10 minutes; refresh tokens rotate on each use and expire after 90 days unused. Give `OAuth2Client` a persistent `ConnectionStore` with a cross-process lock as `store`, reach each person's runtime with `await oauth.client(userId)`, and schedule `oauth.refreshIdleConnections({ olderThanMs: 30 * 24 * 60 * 60 * 1000 })` on a timer. Act on a dead connection in one place, `onRefusal`, as [When a refresh is refused](../client.md#when-a-refresh-is-refused) describes, and delete a person's data only on `grant_removed` (`kind: 'removed'`). Learn when a background run ends with `oauth.watchRuns(...)` instead of a poll loop: see [Learn when a background run ends](../client.md#learn-when-a-background-run-ends). See [Client construction](../client.md#an-app-connection-and-refresh).
|
|
11
|
-
|
|
12
|
-
## Signing in a visitor
|
|
13
|
-
|
|
14
|
-
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`:
|
|
15
|
-
|
|
16
|
-
```ts
|
|
17
|
-
const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({scopes: ['openid', 'email', 'profile'], state: sessionState});
|
|
18
|
-
// Save codeVerifier, state, and nonce in the visitor's session; redirect them to url.
|
|
19
|
-
// On the callback, read them back from the session and finish the sign-in:
|
|
20
|
-
const visitor = await oauth.exchangeIdentity({code: callbackCode, codeVerifier, state: callbackState, expectedState: state, nonce});
|
|
21
|
-
visitor.sub; // 'user:…', the same id a Connection.userId carries for this person
|
|
22
|
-
visitor.email; // present only when 'email' was requested and approved
|
|
23
|
-
visitor.emailVerified; // the directory's email_verified, when email was approved
|
|
24
|
-
visitor.name; // 'Sam Rivera', present only when 'profile' was approved and the person has a name (never a phone number); also givenName, familyName
|
|
25
|
-
visitor.picture; // 'https://lh3.googleusercontent.com/…', present only when 'profile' was approved and the account has a Google photo; show initials otherwise
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
`exchangeIdentity` returns an `Identity`: `sub`, `email` and `emailVerified` when granted, `name`, `givenName`, `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; any other value is dropped, though it stays in `claims`), and every verified claim in `claims`. 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, including a response with no ID token, throws `CrouterError` code `id_token_invalid`; if the directory's JWKS can't be fetched or is malformed, that error is thrown as is, not `id_token_invalid`.
|
|
29
|
-
|
|
30
|
-
An `Identity` is not a connection. It has no runtime URL, grant, or refresh token, nothing is stored in a `ConnectionStore`, and nothing refreshes; your app keeps its own session for the visitor. 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`: `exchangeCode` on a sign-in-only answer, and `exchangeIdentity` on an answer that carries a connection. A visitor is never mistaken for a connected user. When `exchangeIdentity` refuses an answer that carries a connection, the directory has already recorded that grant; the SDK leaves it in place, and it shows in the person's approved apps until they or your app revoke it.
|
|
31
|
-
|
|
32
|
-
## Whose action a call records
|
|
33
|
-
|
|
34
|
-
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.
|
|
35
|
-
|
|
36
|
-
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. The 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**. Northlight records nothing about the other user. B can control the app's spend cap or remove the app.
|
|
37
|
-
|
|
38
|
-
## The app's agent and documents
|
|
39
|
-
|
|
40
|
-
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. Its 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>`).
|
|
41
|
-
|
|
42
|
-
The exception for the person's documents is their **public** ones (owned by the person, read through [`client.memory`](../memory.md)): 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, individual reads, history, and automatic loads. 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. Your app's own documents are separate and is not filtered by these levels.
|
|
43
|
-
|
|
44
|
-
In v1, every run is sandboxed: its shell runs as the app's OS user and sees only the app's space and folders its permissions name. Memory and conversations are accessed through the daemon, not by opening their storage directly. 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, `crtr` commands, stored conversation, and daemon-mediated model calls still work; 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.
|
|
45
|
-
|
|
46
|
-
**An isolated run can't see, or be seen by, the app's other runs**, so starting one isolated run per visitor conversation keeps visitors apart. A node of an isolated run reaches only its own run's nodes: the root and the children it spawns. Another run's nodes, documents, conversation, trace, events and node folder answer `not_found`, and they are left out of `runs.list`, `canvas list` and history search. Messaging, interrupting, cancelling or deleting another run also answers `not_found`. The app's other, non-isolated runs can't see into an isolated run either, though they still see each other. 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`. No run's sandbox mounts every node folder in `/apps/<app>/nodes`, so a non-isolated run's shell can't read a visitor's folder either. Custom objects a node of an isolated run registers stay inside its run, and it can't spawn a child under, or fork, another run's node. 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` still cover every run, so your server can show the person each visitor's conversation. A scheduled job an isolated run creates starts a new isolated run, and that run can't see the run that created the schedule. 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.
|
|
47
|
-
|
|
48
|
-
On v0 (the Mac local runtime), there is no sandbox: a run's shell has the Mac user's access; scopes gate only daemon and CLI calls.
|
|
49
|
-
|
|
50
|
-
`runs.message(runId, message, { start_turn: false })` stores an app-attributed message on an existing run **without starting, reviving, or reopening a turn**. The run's status does not change; the message is delivered at the start of the next turn, before that turn's input, and the trace marks it as `custom` / `app_message`. This lets an app add context for the agent to see on its next turn without waking it now. The default `start_turn: true` sends an ordinary follow-up.
|
|
51
|
-
|
|
52
|
-
`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 daemon 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.
|
|
53
|
-
|
|
54
|
-
**A run's first prompt is always its first turn's input**, however soon after `runs.start` a message arrives:
|
|
55
|
-
|
|
56
|
-
- 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, in the order you sent them. So an app can start a run with standing instructions and send the user's first message at once, without waiting for the first turn to begin.
|
|
57
|
-
- 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 always joins the first turn as context delivered ahead of the prompt (a `custom` / `app_message` entry), and the prompt is still that turn's input, so one reply covers both. The result's `message` is what `runs.reply` takes: `runs.reply(run.run_id, run.message!)`. A `runs.message(runId, message, {start_turn: false})` sent separately after `runs.start` joins the first turn only if it is stored before the run claims that turn's messages; sent later, it waits for the next turn.
|
|
58
|
-
|
|
59
|
-
Follow the reply to your message with `runs.reply(runId, sent)`, where `sent` is what `runs.message` (or `runs.start` with `message`) returned; `.text()` gives its text, and `.collect({onDelta})` streams it and says how the turn ended. It matches the turn that lists the message's `message_id` in `node.turn.started.input.message_ids` and ends when that turn completes. See [Streaming](../streaming.md#following-the-reply-to-one-message).
|