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