@north-light/cli 0.3.471 → 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 +32 -20
- package/dist/commands/node/create.js +2 -2
- package/dist/commands/sys/panels/provider-panel.d.ts +2 -0
- package/dist/commands/sys/panels/provider-panel.js +1 -1
- package/dist/commands/sys/setup-wizard.js +1 -1
- 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 +126 -0
- 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 +32 -20
- package/package.json +3 -3
- package/packages/crouter-identity/package.json +1 -1
- package/runtime.lock.json +32 -12
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Test your app
|
|
3
|
+
description: FakeCloud from @north-light/sdk/testing, a fake directory and runtime your real OAuth2Client talks to through fetch.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Test your app
|
|
7
|
+
|
|
8
|
+
`@north-light/sdk/testing` exports `FakeCloud`, a fake Northlight: the directory (discovery, token refresh, JWKS) and each person's runtime (grant, runs, memory, providers, run notices). It answers through `fetch`. Your app's production path — `new OAuth2Client(...)`, `oauth.client(userId)`, `runs`, `memory`, `grant`, `providers`, `watchRuns`, `watchGrants`, `onRefusal`, `forwardRunEvents`, `verifyRunNotice` — runs against it unchanged. The only differences from production are the `fetch` and `issuer` you pass in.
|
|
9
|
+
|
|
10
|
+
There is no mock `Crouter`. An object typed as `Crouter` would re-implement `RunReply`, `RunStream`, the watchers, retries and error mapping, so your tests would check a copy of the SDK instead of the SDK. FakeCloud fakes only the wire, so every SDK behaviour under test is the one you ship.
|
|
11
|
+
|
|
12
|
+
The subpath is Node-only (it uses `node:crypto`), adds no dependency, and imports no test framework: it works with `node:test`, vitest, jest or anything else. Nothing listens on a port.
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { OAuth2Client, MemoryConnectionStore } from '@north-light/sdk';
|
|
18
|
+
import { FakeCloud } from '@north-light/sdk/testing';
|
|
19
|
+
|
|
20
|
+
const cloud = new FakeCloud({ clientId: 'doppel' });
|
|
21
|
+
const store = new MemoryConnectionStore(); // or your own ConnectionStore
|
|
22
|
+
await cloud.connect(store, 'user:o1', { scopes: ['crtr:llm', 'crtr:act', 'sms:send'] });
|
|
23
|
+
|
|
24
|
+
const oauth = new OAuth2Client({
|
|
25
|
+
clientId: 'doppel', issuer: cloud.issuer, fetch: cloud.fetch,
|
|
26
|
+
privateKey, redirectUri: 'https://app.test/callback', store,
|
|
27
|
+
});
|
|
28
|
+
const client = await oauth.client('user:o1'); // first call refreshes through the fake directory
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`cloud.connect(store, userId, {scopes?, dependencies?})` makes the person's grant active (default scopes `crtr:llm`, `crtr:act`) and saves a `Connection` in your store with its access token already expired, so the first call refreshes through the directory exactly as in production.
|
|
32
|
+
|
|
33
|
+
`cloud.crouter(userId)` returns a real `Crouter` on that person's runtime with a fresh token and no refresh. Refusals and refreshes never reach it. Use it only for code whose production signature takes a `Crouter`. Code that goes through `OAuth2Client` in production should go through `oauth.client` in tests too.
|
|
34
|
+
|
|
35
|
+
Options: `new FakeCloud({clientId, issuer = 'https://directory.test', runtimeUrl = 'https://runtime.test' (or (userId) => url), runtimeId = 'runtime:test', runtimeClientId = 'runtime-client', runtimeVersion?, streamIdleMs = 60000, accountAppUriTemplate = '<issuer>/account/apps/{client_id}' (null omits it), runNotice?, isolatedRuns = 'available', clock = Date.now})`.
|
|
36
|
+
|
|
37
|
+
`clock` is `{now(): number}`; every timestamp the fake writes reads it (run `created_at` and `settled_at`, grant `exp`, memory `created_at`/`updated_at`, access-token `iat`/`exp`, `calls[].at`, `directoryCalls[].at`) and the idempotency window runs on it. Pass `manualClock()` (from `@north-light/sdk/testing`; `now()`, `advance(ms)`) to step time by hand. Stream idle, long-poll waits and run-notice retries stay on real timers.
|
|
38
|
+
|
|
39
|
+
`cloud.activate(userId, {scopes?, dependencies?})` makes the grant active with no stored connection, for an app that mints a per-user runtime token itself. The fake directory serves that mint at `POST <issuer>/token` with `grant_type=client_credentials&sub=<userId>` (the client assertion is not verified): 200 `{access_token, token_type: 'Bearer', expires_in: 600, runtimeUrl}` (the token's `exp` claim is 600 s after `iat`; the fake runtime does not enforce token expiry); a missing `sub` is 400 `unauthorized_client`; an unknown or revoked user is 400 `invalid_grant` (`no active grant`); `user.refuse('runtime_provisioning')` is 400 `invalid_grant` with `error_description: runtime_provisioning`. A mint is a `directory.token` call (`userId` is the `sub`), so `failNext('directory.token', …, {user})` applies to it.
|
|
40
|
+
|
|
41
|
+
### Isolated runs
|
|
42
|
+
|
|
43
|
+
The fake stands in for a Northlight guest, where the Linux root launcher always exists, so `runs.start({isolated: true})` starts a run by default and `run.params.isolated` is `true`. The checks `startRun` makes without the launcher are mirrored: `isolated` must be a boolean, `cwd` is accepted only with `isolated: true` and must be an absolute path inside the run's root folder, `/apps/<clientId>/nodes/<run id>` (else 400 `invalid_request`, `param: cwd`), and `worktree` is refused. Nothing in the fake enforces the sandbox itself (no network, no `files/` mount).
|
|
44
|
+
|
|
45
|
+
`isolatedRuns: 'unavailable'` models a runtime without the launcher, such as a runtime on a machine that cannot launch isolated runs: every isolated start answers 503 `isolated_unavailable` (`param: isolated`, retryable, so `runs.start` sends it three times before throwing). Use it to test your app's handling of that refusal.
|
|
46
|
+
|
|
47
|
+
## Driving runs
|
|
48
|
+
|
|
49
|
+
Nothing in the fake runs a model. A run stays `running` until your test drives it, either directly or through a responder:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
cloud.runs.respond(({ run }) => run.reply(`echo: ${run.pending.at(-1)?.text ?? run.params.prompt}`)); // every user
|
|
53
|
+
cloud.user('user:o1').runs.respond(null); // per-user override
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A responder gets `{run, kind: 'start' | 'message', messages}`. It is called after the fake answers a `run.start`, or a `run.message` with `start_turn` true, and never for an idempotent replay. A responder that throws is recorded in `cloud.responderErrors` and fails nothing else. An `async` responder can await your app's state mid-reply.
|
|
57
|
+
|
|
58
|
+
`cloud.runs` and `cloud.user(id).runs` have one shape, `{all: FakeRun[], respond(fn)}`: `all` lists the runs in start order (a deleted run stays, with `deleted` set), so `expect(user.runs.all).toEqual([])` works. Each entry is a `FakeRun`:
|
|
59
|
+
|
|
60
|
+
| Method | Effect |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `reply(text, {chunks = 2, messageIds?})` | One turn: `node.turn.started`, text deltas, `node.output_text.done`, `node.turn.completed`. Delivers `messageIds` (default: every pending message). The run goes `idle` (`waiting_on_user` with an open question). |
|
|
63
|
+
| `failTurn({code: 'model_error' \| 'content_refused', message})` | The same turn ending `stop_reason: 'error'`; `runs.get` shows `last_turn_error`; `runs.reply(...).collect()` ends `error`. |
|
|
64
|
+
| `setActivity(activity, {at?})` | Fake-only: status `running` with this `activity` until the next event. The only way to reach `writing` or a streaming tool call. |
|
|
65
|
+
| `settle(ending)` | `{result}`, `{declined: {reason, code, retryable}}`, `{failure: 'canceled' \| 'deadline_exceeded'}`, or `{runtimeError: reason}`. Emits `node.settled` with the outcome the runtime writes. |
|
|
66
|
+
| `idle()` | Goes idle without a new turn (stopped without submitting). |
|
|
67
|
+
| `ask({title, slots?})` | Opens a question (`waiting_on_user`); returns its id. Your app's `questions.answer` records the answer. |
|
|
68
|
+
| `delete()` | As if the runtime deleted it: reads 404, streams close, its idempotency keys are dropped. |
|
|
69
|
+
| `emit(type, fields, {nodeId?})` | Appends any run event; another `nodeId` is a child node's event. Status and activity re-derive from the log. |
|
|
70
|
+
| `closeIdle()`, `expireEvents(upTo)` | Idle-close open streams now; drop old events so `stream_gap` is reachable. |
|
|
71
|
+
|
|
72
|
+
Read-only members: `id`, `userId`, `params` (the `run.start` body, `isolated` and `cwd` included), `idempotencyKey`, `messages`, `pending`, `status`, `activity`, `outcome`, `result`, `lastTurnError`, `events`.
|
|
73
|
+
|
|
74
|
+
What a turn's input is: `reply` and `failTurn` deliver `messageIds` (default: every pending message), and the turn's `node.turn.started` input text is those messages' text. A turn that delivers none (`messageIds: []`, or nothing pending) uses the run's `prompt` as its input on the run's first turn only, and an empty input after that; it still emits a full root turn with the text you pass. So `reply('', {messageIds: []})` is not a turn that answers nothing. To script a child node that works and answers nothing, or any event outside a root turn, use `emit(type, fields, {nodeId: 'child-1'})`: for example `run.emit('node.turn.started', {input: {text: 'x', message_ids: []}, entry_id: 'c1'}, {nodeId: 'child-1'})` followed by `node.turn.completed` with the same `nodeId`. Events with another `nodeId` never complete the app's `runs.reply`, which waits for the root's turn.
|
|
75
|
+
|
|
76
|
+
Messages follow the runtime: `start_turn` true makes the run `running` at once and calls the responder; `start_turn` false only records it. A message to a run settled `canceled` or `deadline_exceeded` is refused 409 `run_settled`; to a run settled any other way it reopens the run. A deleted run answers 404.
|
|
77
|
+
|
|
78
|
+
Idempotency follows the runtime too: the same `Idempotency-Key` with the same body replays the first answer (no new run, no responder call); the same key with a different body, or reused between `run.start` and `run.message`, is 409 `idempotency_conflict`. After a run is deleted, its start key creates a new run. A key is live for 24 hours (`IDEMPOTENCY_WINDOW_MS`): at exactly the window it reads as absent, and the next accept replaces the record. Step the window with `clock`.
|
|
79
|
+
|
|
80
|
+
`run.openStreams` counts the run's open event streams. It rises when the SDK starts reading the response body, which is after `cloud.calls` records `run.events`, so poll `openStreams`, not `calls`, to know a stream is attached. It falls on idle, error, abort or revoke. Run notices are owed only while no stream is open.
|
|
81
|
+
|
|
82
|
+
## Grants and setup state
|
|
83
|
+
|
|
84
|
+
`cloud.user(id).grant`: `state` (the `GrantGetDTO`, or null), `set({dependencies?, resolved_scopes?, spend_cap?})` (a new `rev`), `setup(provider, state, {message?})` (a dependency change without a `rev` bump), `sync()` (unchanged content: no version change, no wake), `revoke(code = 'grant_revoked')`, `restore(patch?)`.
|
|
85
|
+
|
|
86
|
+
`grant.get` with `wait` and `since` is held until the version changes, exactly as the runtime holds it, so `oauth.watchGrants` works unmodified. `user.reads` lists the person's `grant.get` calls and `user.holding` counts reads held now.
|
|
87
|
+
|
|
88
|
+
## Refusals
|
|
89
|
+
|
|
90
|
+
- `user.refuse(code)` leaves the grant alone: the directory refuses each refresh with `code` (`grant_removed`, `grant_suspended`, `app_in_testing`, `refresh_token_expired`, `refresh_token_reused`, `grant_revoked`), and tokens issued before the call get 401, so the next call refreshes and is refused. `refuse('ok')` clears it.
|
|
91
|
+
- `grant.revoke('grant_removed')` deactivates the grant: calls answer 401 `grant_revoked`, held reads wake to it, open streams get an error frame, and the next refresh is refused with that code.
|
|
92
|
+
- `user.expireTokens()` forces a refresh without a refusal.
|
|
93
|
+
|
|
94
|
+
The real `OAuth2Client` handles all of it: your `onRefusal` runs with the refusal, the stored connection is removed, and the thrown error has `refusalOf(e)` set. `await cloud.refusalError(code)` returns that exact error for unit tests that need the value.
|
|
95
|
+
|
|
96
|
+
## Testing mode
|
|
97
|
+
|
|
98
|
+
An unverified third-party app is *in testing* on Northlight: only its admin and up to 100 named testers can sign in, and anyone else is refused. `cloud.testing` puts the fake's app in that state so your app can test the refusal.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
cloud.user('user:tess').setAccount({ email: 'tess@example.com' }); // the identifiers the account holds
|
|
102
|
+
cloud.testing.addTester({ email: 'Tess@Example.com' }); // or {phone: '+15551234567'}; false if already present
|
|
103
|
+
cloud.testing.enable({ admin: 'user:me' }); // in testing; revokes active grants of people not allowed
|
|
104
|
+
|
|
105
|
+
const back = cloud.authorize('user:stranger', { redirectUri, state }); // the redirect the browser follows back to your app
|
|
106
|
+
authorizationRefusalOf(back); // {code: 'app_in_testing', error: 'access_denied', state}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- `cloud.testing.enable({admin?})` / `disable()` / `active`. `enable` returns the user ids whose active grants it revoked.
|
|
110
|
+
- `addTester({email} | {phone})` stores the entry (email trimmed and lowercased, phone E.164). The 101st entry throws. `removeTester(ref)` returns `{removed, revoked}`: a removed tester's active grant is revoked, not deleted, and the fake keeps their runs.
|
|
111
|
+
- `cloud.testing.allowed(userId)`: not in testing, the admin, or a tester entry matches the account: an `email` entry needs `emailVerified` (default true when `email` is set), a `phone` entry matches `setAccount({phone})`.
|
|
112
|
+
- A refresh for an active grant whose person is not allowed answers `invalid_grant` `app_in_testing`, and so does a refresh after the grant was revoked this way. `oauth.client(id)` rejects with an `AuthenticationError` (`code: 'app_in_testing'`, `refusalOf(e).kind === 'paused'`) and `onRefusal` runs. Keep the person's data.
|
|
113
|
+
- `cloud.authorize(userId, {redirectUri, state, scopes?})` stands in for the browser round trip. An allowed person gets an active grant and a redirect with `code` and `state`; give `code` to `oauth.exchangeCode`. A person who is not allowed gets `error=access_denied`, `error_description=app_in_testing` and `state`, and nothing is written. A person revoked by `removeTester` who is allowed again gets access back by `authorize`.
|
|
114
|
+
- When a person's email or phone stops matching (`setAccount`), nothing is revoked; their next refresh and `authorize` are refused.
|
|
115
|
+
|
|
116
|
+
In your callback handler, check `authorizationRefusalOf(url)` before reading `code`.
|
|
117
|
+
|
|
118
|
+
## Memory and providers
|
|
119
|
+
|
|
120
|
+
`user.memory.put({name, body, privacy = 'public', kind = 'knowledge', ...})` and `remove(name)`. Without `crtr:memory:manage:user` in the grant, only `public` documents are listed and others read as 404, as on the runtime. List items carry the full `MemoryListItem` fields.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
cloud.providers.define('sms', { tools: { send: { group: 'send', handler: ({ userId, arguments: args }) => ({ sent: true }) } } });
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A call is checked against the grant's `<provider>:<group>` scope (403 `scope_missing`). A handler that throws `FakeCloud.error({...})` answers that envelope; any other throw is 502 `provider_error`. `status: 'install_failed'` lists the provider with its `error` and answers calls 503 `provider_unavailable`.
|
|
127
|
+
|
|
128
|
+
## Failures, gates and the directory
|
|
129
|
+
|
|
130
|
+
`intercept(op, fn, {user?, times?})` runs `fn` before an operation; it may await, act, or return an answer. `failNext(op, answer, {user?, times = 1})` is the shorthand. `gate(op)` holds every matching request until you call the `release()` it returns. `op` is a runtime operation name (`run.start`, `grant.get`, `memory.list`, …), `'*'` for every runtime operation, or `directory.discovery`, `directory.token`, `directory.jwks`.
|
|
131
|
+
|
|
132
|
+
| Answer | Runtime operation | `directory.discovery` | `directory.token` | `directory.jwks` |
|
|
133
|
+
|---|---|---|---|---|
|
|
134
|
+
| `'unreachable'` | fetch rejects; the SDK throws `APIConnectionError` | fetch rejects | fetch rejects; no `onRefusal` | fetch rejects; `verifyRunNotice` throws `RunNoticeError` |
|
|
135
|
+
| a status number | that status with a runtime envelope | that status; discovery fails | that status; an `InternalServerError` for 5xx coded `invalid_request`, origin `directory`; connection kept | that status |
|
|
136
|
+
| `FakeCloud.error({status, code, message?, origin?, retryable?, details?, param?})` | that envelope | not allowed | not allowed (use `user.refuse`) | not allowed |
|
|
137
|
+
|
|
138
|
+
Retries are the SDK's, so a scripted failure the SDK retries is absorbed unless `times` covers every attempt. One SDK call makes these attempts (`maxRetries` defaults to 2; a call's `maxRetries` option changes it):
|
|
139
|
+
|
|
140
|
+
| What the fake answers | Operations | SDK behaviour | `times` to surface it |
|
|
141
|
+
|---|---|---|---|
|
|
142
|
+
| 401 (any code) | every runtime operation called through `oauth.client` | refreshes the token once, then repeats the call once | 2 |
|
|
143
|
+
| a status number ≥ 500, or 429 | GET and DELETE operations: `grant.get`, `run.get`, `run.list`, `run.delete`, `trace.get`, `question.get`, `memory.list`, `memory.get`, `provider.tools`, `profile.list` | retried `maxRetries` times with backoff (250 ms, 500 ms) | 3 |
|
|
144
|
+
| `'unreachable'` | the same GET and DELETE operations | retried `maxRetries` times | 3 |
|
|
145
|
+
| `FakeCloud.error({..., retryable: true})` (the default for a 503), or `'unreachable'` | `run.start`, `run.message` (keyed sends) | resent with the same `Idempotency-Key` `maxRetries` times, waiting 500 ms, 1 s (or the envelope's `retry_after_s`) | 3 |
|
|
146
|
+
| a status number, or an envelope with `retryable: false` | `run.start`, `run.message` | not retried (a status number answers `retryable: false`) | 1 |
|
|
147
|
+
| anything else | the other POSTs: `run.interrupt`, `run.cancel`, `run.rename`, `question.answer`, `provider.call` | never retried | 1 |
|
|
148
|
+
| anything | `run.events` (the SDK's `RunStream`) | a retryable error reconnects from the cursor with backoff; a non-retryable one, `stream_error` or `stream_dropped`, ends the stream | `Infinity` for a retryable one |
|
|
149
|
+
|
|
150
|
+
The rows compose: one 401 followed by three 500s on `grant.get` surfaces the 500 (the call repeated after the refresh has its own two retries), so a 401 surfaces only when the repeat gets one too. A test that wants an operation to fail no matter what scripts `times: Infinity` and removes it afterwards with `intercept`'s returned function, or calls `cloud.reset()`.
|
|
151
|
+
|
|
152
|
+
Discovery is memoized per `OAuth2Client`. A test that fails discovery should use a fresh `FakeCloud` and `OAuth2Client`.
|
|
153
|
+
|
|
154
|
+
A runtime outage on an ordinary call: `user.expireTokens(); cloud.failNext('directory.token', 503, {user})`. An unreachable runtime: `cloud.failNext('*', 'unreachable', {user, times: Infinity})`. With `runtimeVersion` older than the SDK's minimum, calls fail with `RuntimeVersionError` (426).
|
|
155
|
+
|
|
156
|
+
## Run notices and forwarding
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const cloud = new FakeCloud({ clientId: 'chat', runNotice: { url: 'https://app.test/run-notices', deliver: (request) => app.handleNotice(request) } });
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The fake sends notices by the runtime's rules: one is owed when an event is appended with no `run.events` stream open, or when the last stream closes with events unread; a burst coalesces into one; up to three attempts; a 401 re-mints the token once; a 5xx, a thrown `deliver`, or no answer within 10 s is retried; any other status ends the send. A run whose person has no active grant is owed nothing. The waits default to 0 so tests stay fast. Pass the runtime's values (`retryWaitsMs: [3000, 5000]`, `ackGapMs: 5000`, `failGapMs: 60000`) to test timing. Every attempt is recorded in `cloud.notices`.
|
|
163
|
+
|
|
164
|
+
In your route, pass the cloud's `fetch` to `verifyRunNotice`:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
await verifyRunNotice(request, { runNoticeUrl, issuer: cloud.issuer, grantee: 'app:chat', fetch: cloud.fetch });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Keys are cached per `(issuer, fetch)`, and each cloud has its own signing key, so several clouds on one issuer verify independently. `forwardRunEvents` needs nothing special: it reads the fake's event stream, idle close included (`streamIdleMs`).
|
|
171
|
+
|
|
172
|
+
## Two app processes
|
|
173
|
+
|
|
174
|
+
Build two app instances (two `OAuth2Client`s) on the same `cloud`. They see the same runs, grants and held reads. If they keep separate stores, the second refresh with a superseded token is refused `refresh_token_reused`, as it would be in production; a shared store with `lock` prevents it.
|
|
175
|
+
|
|
176
|
+
## Asserting
|
|
177
|
+
|
|
178
|
+
- `cloud.calls`: every runtime request as `{userId, op, runId, questionId, method, path, query, body, idempotencyKey, status, at}`.
|
|
179
|
+
- `userId` is the bearer token's `sub` whenever it names a person this cloud knows (created by `connect` or `user(id)`), whatever their grant or refresh-token state, so a call refused 401 still carries it. It is `null` only for a missing or undecodable bearer, or a `sub` the cloud has never seen.
|
|
180
|
+
- `runId` is the run the call addresses: the path's id for `run.get`, `run.message`, `run.events`, `run.interrupt`, `run.cancel`, `run.delete`, `run.rename` and `trace.get` (as sent, even when no such run exists), and for `question.get` and `question.answer` the run holding the question (null when none of the caller's runs does). `questionId` is the path's id on the two question operations. Both are `null` on every other operation.
|
|
181
|
+
- `body` is a parsed copy for your assertions; mutating it from an intercept is not a supported way to change what the fake handles.
|
|
182
|
+
- `cloud.directoryCalls` and `cloud.refreshes`: directory requests, with `answer` (`ok`, a refusal code, a status or `unreachable`).
|
|
183
|
+
- `run.params`, `run.messages`, `run.idempotencyKey`: what your app sent.
|
|
184
|
+
- `cloud.notices`: every notice attempt, with its decoded token claims and your route's status.
|
|
185
|
+
- `cloud.reset()` clears runs, calls, intercepts, responders, memory, idempotency keys and notices, and keeps grants and refresh tokens so stored connections stay valid.
|
|
186
|
+
|
|
187
|
+
## Fidelity
|
|
188
|
+
|
|
189
|
+
FakeCloud is checked against the real runtime on every crouter release: one set of conformance scenarios runs on the fake and on the real app listener (run start and idempotency, messages, reply streams, long-poll, every settle outcome, delete, the event stream's edges, grant holds, memory, providers, run notices). The keyed-write scenarios K1–K8 (equal-key replay, conflict, concurrent equal keys accepted once, the 24h window, replay after cancel, a key bound to its operation, keys scoped to the grantee, concurrent notes) are exported as `@north-light/sdk/testing/conformance` (`keyedWriteScenarios`, `IDEMPOTENCY_WINDOW_MS`) and run on FakeCloud and on the real daemon; an app test double for the runtime should run them against its own target. Two behaviours are fake-only because the runtime cannot be scripted that way: a successful provider call (the real side needs an installed provider), and `setActivity`. An isolated run that starts is checked only against `startRun`'s documented checks: the real listener in the suite runs without the Linux root launcher, so on it the `isolatedRuns: 'unavailable'` refusal is what is conformance-checked.
|
|
190
|
+
|
|
191
|
+
Not faked: sign-in (`authorizeUrl`, `exchangeCode`, ID tokens — start from `cloud.connect`), token revocation, the owner surface (`nodes`, `canvas`, `crons`, `files`, …), and several apps on one cloud. `trace.get`, `run.rename`, `run.interrupt` and `profile.list` answer the right shape only.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Typed results
|
|
3
|
+
description: Start a run with a schema and get back a value that was checked against it.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Typed results
|
|
7
|
+
|
|
8
|
+
Pass an `output_schema` and call `runs.parse`. The runtime arms the schema on the run's root agent, which must submit a value that validates against it. The run settles with that value, and `runs.parse` returns it as `output_parsed`, typed from your schema.
|
|
9
|
+
|
|
10
|
+
<include lang="ts">../../../examples/apps/runs-parse.ts</include>
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
- `output_schema` accepts a plain JSON Schema object or anything with `toJSONSchema()`, such as a Zod v4 object. The SDK sends it as JSON and validates nothing itself.
|
|
15
|
+
- 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.
|
|
16
|
+
- The value is validated exactly as submitted, with no coercion: `"5"` is not turned into `5`.
|
|
17
|
+
- A `$ref` can point only into the schema itself (`#/$defs/x`, `#anchor`) or at another schema document bundled under `$defs` with its own `$id`. Files are never fetched. A `$ref` that resolves nowhere, or a schema that cannot be compiled (a bad `pattern`), is refused with `invalid_request` and `param: 'output_schema'`.
|
|
18
|
+
- The agent submits with its `submit_result` tool, which needs no scope, so a run that only reasons and answers can start with `scopes: ['crtr:llm']`.
|
|
19
|
+
- The same value is in `runs.get(runId).result`, which `runs.wait` returns unchanged.
|
|
20
|
+
|
|
21
|
+
Do not parse assistant text out of `runs.trace` to get data back. `runs.trace` is for showing a person the transcript.
|
|
22
|
+
|
|
23
|
+
## A run that declines
|
|
24
|
+
|
|
25
|
+
An agent that cannot honestly satisfy the schema declines. A decline, a cancel, a deadline and a runtime error all come back as an outcome. They do not throw. Narrow on `outcome.kind` and `outcome.reason`, as the example does.
|
|
26
|
+
|
|
27
|
+
## Stopping without a result: `run_idle`
|
|
28
|
+
|
|
29
|
+
`runs.parse` rejects with an `APIError` with code `run_idle` when the agent stopped without submitting. The runtime reminds the agent of the schema up to three times in a row, then leaves the run `idle` with the schema still armed. The run is untouched: send `runs.message` to nudge the agent (this resets the reminders), or end it with `runs.cancel`.
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
## Bounding a run
|
|
34
|
+
|
|
35
|
+
No run is bounded in time by default. Pass a `signal` to `runs.parse` or `runs.wait`. When it aborts, the helper rejects with the signal's reason and leaves the run alone. Call `runs.cancel` if you want the run ended:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => controller.abort(new Error('took too long')), 10 * 60_000);
|
|
40
|
+
let runId: string | undefined;
|
|
41
|
+
try {
|
|
42
|
+
const started = await runtime.runs.start({prompt, output_schema: Profile});
|
|
43
|
+
runId = started.run_id;
|
|
44
|
+
const run = await runtime.runs.wait(runId, {signal: controller.signal});
|
|
45
|
+
// read run.status, run.outcome, run.result
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (controller.signal.aborted && runId) await runtime.runs.cancel(runId);
|
|
48
|
+
throw error;
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Showing progress
|
|
55
|
+
|
|
56
|
+
For a run you poll, `runs.get(runId).activity` is `null` unless `status` is `running`, and otherwise says what the root agent is doing:
|
|
57
|
+
|
|
58
|
+
| `phase` | The root agent is |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `starting` | launching; its first turn has not begun |
|
|
61
|
+
| `thinking` | reasoning, or waiting on the model before any output |
|
|
62
|
+
| `tool` | calling a tool: `tool.name`, and `tool.summary` (`null` while the call is still being written) |
|
|
63
|
+
| `submitting_result` | writing or recording its `output_schema` result |
|
|
64
|
+
| `writing` | streaming its answer text |
|
|
65
|
+
| `between_turns` | between turns while the run still works |
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const run = await runtime.runs.get(runId);
|
|
69
|
+
const badge = run.activity?.phase === 'tool' ? `Running ${run.activity.tool.name}` : run.activity?.phase ?? run.status;
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`activity` covers the root agent only, and it is read each time you call `runs.get`, so a phase shorter than your poll interval can be missed. To show text as it streams, follow the events instead: see [Stream a run](/docs/guides/stream-a-run). To be told when a background run ends, see [Background runs](/docs/guides/background-runs).
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Where your app runs
|
|
3
|
+
description: Your app runs on your server, people sign in at account.northlight.ai, and the agent work runs on each person's own Northlight runtime. Northlight does not host your app's code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Where your app runs
|
|
7
|
+
|
|
8
|
+
Your app runs on a server you choose. Northlight hosts two other things: the directory at `account.northlight.ai`, and each person's agent runtime. Northlight does not host your app's code, and there is no flow for deploying it to Northlight.
|
|
9
|
+
|
|
10
|
+
| Piece | Runs where | What it does |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| **Your server** | Anywhere you choose. Your laptop while you develop. | Your pages and logic. It holds the app's private signing key and a stored connection for each person. |
|
|
13
|
+
| **The directory** | `https://account.northlight.ai`, hosted by Northlight | Accounts, app registration (the console at `/console`), consent, tokens and the marketplace. |
|
|
14
|
+
| **The person's runtime** | Hosted by Northlight, one per person | Their agents, runs, files, documents and providers (sms, search and others), using their own AI subscription. |
|
|
15
|
+
|
|
16
|
+
"Build on Northlight" therefore means three things. Your app's agent work runs on the person's Northlight-hosted runtime. People sign in to your app with their Northlight account. And your app can be listed in the Northlight marketplace.
|
|
17
|
+
|
|
18
|
+
```mermaid
|
|
19
|
+
sequenceDiagram
|
|
20
|
+
participant P as Person's browser
|
|
21
|
+
participant A as Your server
|
|
22
|
+
participant D as account.northlight.ai
|
|
23
|
+
participant R as Person's runtime
|
|
24
|
+
P->>A: GET /auth/login
|
|
25
|
+
A->>P: redirect to the directory (client ID, scopes, PKCE challenge)
|
|
26
|
+
P->>D: sign in, consent
|
|
27
|
+
D->>P: redirect to your /auth/callback with a code
|
|
28
|
+
P->>A: GET /auth/callback?code=…
|
|
29
|
+
A->>D: exchange the code, signed with your private key
|
|
30
|
+
D->>A: access token, refresh token, runtime URL
|
|
31
|
+
Note over A: store the connection for this person
|
|
32
|
+
A->>R: oauth.client(userId).runs.start(…)
|
|
33
|
+
R->>A: the run, its status and its result
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
1. **Sign-in.** Your `/auth/login` route sends the person to the directory with your client ID and the scopes you ask for. The directory shows its consent page and returns them to your registered redirect URI with a one-use code.
|
|
37
|
+
2. **Token.** Your server exchanges the code for tokens. It proves it is your app by signing the request with its private key. No client secret exists. The result is a connection: a short-lived access token, a rotating refresh token, and the URL of that person's runtime. See [Apps and sign-in](/docs/concepts/apps-and-sign-in).
|
|
38
|
+
3. **Calls.** `oauth.client(userId)` loads the stored connection, refreshes the access token when it needs to, and returns a client on that person's runtime. You call `runs.*` and `providers.*` on it. See [Connections](/docs/concepts/connections) and [Runs](/docs/concepts/runs).
|
|
39
|
+
|
|
40
|
+
The person's AI subscription pays for model calls, and what your app may do on their runtime is limited by the scopes they approved. See [Scopes and grants](/docs/concepts/scopes-and-grants).
|
|
41
|
+
|
|
42
|
+
## What you operate
|
|
43
|
+
|
|
44
|
+
You operate your server, its sessions, its database and its `ConnectionStore`. You register the app in the console and keep its key pair. Northlight operates the directory and the runtimes; you do not run either, and there is no local Northlight to start. If you run crouter yourself and want a runtime of your own, that is a different path: see [crouter.docs.northlight.ai](https://crouter.docs.northlight.ai).
|
|
45
|
+
|
|
46
|
+
The directory's discovery document at `https://account.northlight.ai/.well-known/openid-configuration` lists the endpoints, the `S256` PKCE method and `private_key_jwt` as the only token authentication method (checked on production).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Providers
|
|
3
|
+
description: The first-party providers an app can call on a person's runtime, their ids, how scope strings are named, and how to list a provider's tools.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Providers
|
|
7
|
+
|
|
8
|
+
A provider is a service the person's runtime calls for your app. Your app asks the runtime with `runtime.providers.call(...)`, and the runtime checks that the person approved the provider's scope for your app. See [Providers](/docs/concepts/providers) for the model.
|
|
9
|
+
|
|
10
|
+
| Provider id | What it does | Scopes | Page |
|
|
11
|
+
|---|---|---|---|
|
|
12
|
+
| `sms` | Texts the signed-in person's own verified phone | `sms:send`, `sms:status` | [Texting (sms)](/docs/providers/sms) |
|
|
13
|
+
| `search` | Web search, sourced answers, page contents and paid data endpoints | `search:<group>` | [Search](/docs/providers/search) |
|
|
14
|
+
| `integrations` | The person's own accounts at other services | `integrations:discover`, `integrations:<service>_read`, `integrations:<service>_write` | [Integrations](/docs/providers/integrations) |
|
|
15
|
+
| `context` | Ask about the person from their memory, and look up public profiles | `context:<group>` | [Context](/docs/providers/context) |
|
|
16
|
+
| `device` | The person's Mac: browser, files, Messages and shell | `device:<group>` | [Device](/docs/providers/device) |
|
|
17
|
+
|
|
18
|
+
## Where names come from
|
|
19
|
+
|
|
20
|
+
- **Scope strings** come from the console's **What it asks for → Add permissions** list. A scope is `<provider id>:<group>`. Do not guess them.
|
|
21
|
+
- **Tool names and arguments** come from `runtime.providers.tools('<provider id>')`. It answers the provider's groups, and each group's tools with a description, parameters and output. It lists only the groups your app's grant holds, so ask for the scope first, then list.
|
|
22
|
+
- **Required providers.** If your app does not work without a provider being set up first, add it under **What your app requires** in the console. See [Grant changes](/docs/guides/grant-changes#wait-on-a-provider-your-app-requires).
|
|
23
|
+
|
|
24
|
+
<include lang="ts">../../../examples/apps/provider-tools.ts</include>
|
|
25
|
+
|
|
26
|
+
Each provider page below shows the tools of a provider as captured on **2026-10-16**. The capture is not an app's view: it came from the runtime of a person's own Northlight account (`GET /v1/providers/<id>/tools` on `crtr` 0.3.462, joined to production), which shows the groups that person's own grant holds. What `providers.tools` answers for your app depends on the scopes your app was granted. Tools can change between releases, so treat a table as a starting point and list the tools at run time.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Context
|
|
3
|
+
description: Ask about the person from their memory, and look up public profiles, through the context provider.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Context
|
|
7
|
+
|
|
8
|
+
The `context` provider answers questions about the person from what their agent knows, and looks up public profiles.
|
|
9
|
+
|
|
10
|
+
## Scopes and setup
|
|
11
|
+
|
|
12
|
+
Scopes are `context:<group>`. The console's **Add permissions** list shows `context:ask_informed` and `context:ask_public` on 2026-10-16; the tool list also shows a `research` group (`context:research`) that was not in that console list when we checked (not yet checked on production).
|
|
13
|
+
|
|
14
|
+
Context needs setup before it works: the person gives a profile URL on the provider's setup page, and its state stays `pending` for a few minutes afterwards. If your app depends on it, add `context` under **What your app requires** and follow the state with `grant.get().dependencies['context']`. See [Grant changes](/docs/guides/grant-changes#wait-on-a-provider-your-app-requires).
|
|
15
|
+
|
|
16
|
+
## Tools
|
|
17
|
+
|
|
18
|
+
Captured 2026-10-16 from `GET /v1/providers/context/tools` on a joined runtime (provider major version 1). `*` marks a required parameter. Tool names with a space are written as the provider lists them; check how to pass a multi-word `tool` to `providers.call` against your own `providers.tools` output before relying on it (not yet checked on production).
|
|
19
|
+
|
|
20
|
+
| Tool | Group | Parameters | What it does |
|
|
21
|
+
|---|---|---|---|
|
|
22
|
+
| `ask informed` | `ask_informed` | `request`\*, `question` (repeatable, up to 10) | Advice about the person shaped by everything their agent knows, private memories included, without being told private details |
|
|
23
|
+
| `ask public` | `ask_public` | `request`\*, `question` (repeatable, up to 10) | Answers about the person from public memories only |
|
|
24
|
+
| `linkedin profile` | `research` | `profile`\* | A person's LinkedIn profile |
|
|
25
|
+
| `linkedin posts` | `research` | `profile`\*, `limit` | Recent LinkedIn posts |
|
|
26
|
+
| `x profile` | `research` | `profile`\* | A person's X profile |
|
|
27
|
+
| `x posts` | `research` | `profile`\*, `limit` | Not available |
|
|
28
|
+
| `github profile` | `research` | `profile`\* | A person's GitHub profile |
|
|
29
|
+
| `github repos` | `research` | `profile`\*, `limit` | Public repositories |
|
|
30
|
+
| `search people` | `research` | `query`\* | Search LinkedIn people |
|
|
31
|
+
|
|
32
|
+
`request` is the task you are doing, 1 to 4000 characters. Each `question` is 1 to 500 characters. Run `runtime.providers.tools('context')` for the current list and each tool's output shape.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Device
|
|
3
|
+
description: "Reach the person's Mac from your app: browser, files, Messages and shell, each behind its own scope."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Device
|
|
7
|
+
|
|
8
|
+
The `device` provider reaches the person's own Mac. Its tools run on the person's machine (the provider's transport is `machine`), and each group is its own scope, so ask only for the groups your app needs. What happens when the Mac is asleep or offline is not yet checked on production.
|
|
9
|
+
|
|
10
|
+
## Scopes
|
|
11
|
+
|
|
12
|
+
`device:browser`, `device:files`, `device:messages_read`, `device:messages_send` and `device:shell`. Add them under **What it asks for → Add permissions** (provider `device`).
|
|
13
|
+
|
|
14
|
+
## Tools
|
|
15
|
+
|
|
16
|
+
Captured 2026-10-16 from `GET /v1/providers/device/tools` on a joined runtime (provider major version 2, transport `machine`). `*` marks a required parameter.
|
|
17
|
+
|
|
18
|
+
| Group (scope) | Tools |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `browser` | `browser profiles`, `browser executor create` (`url`, `mode`, `profile`, `target-pattern`), `browser executor list`, `browser executor navigate` (`executor`\*, `url`\*), `browser executor exec` (`executor`\*, `code`\*, `timeout-ms`, `short-description`), `browser executor result` (`run`\*), `browser executor close` (`executor`\*), `browser libs` (`query`, `limit`, `service`, `fn`), `browser cdp` (`method`\*, `params`, `tab`) |
|
|
21
|
+
| `files` | `files list` (`path`\*, `limit`), `files stat` (`path`\*), `files read` (`path`\*, `encoding`, `offset`, `length`), `files write` (`path`\*, `text`, `base64`, `from`, `append`, `parents`), `files delete` (`path`\*, `recursive`), `files move` (`from`\*, `to`\*, `overwrite`), `files mkdir` (`path`\*) |
|
|
22
|
+
| `messages_read` | `messages chats` (`limit`), `messages history` (`chat`, `participant`, `since`, `until`, `limit`, `attachments`), `messages search` (`text`\*, `limit`), `messages contacts` (`query`\*, `limit`), `messages permissions` (`open`) |
|
|
23
|
+
| `messages_send` | `messages send` (`recipient`\*, `text`\*, `file`, `service`) |
|
|
24
|
+
| `shell` | `shell run` (`command`\*, `cwd`, `timeout`, `stdin`) |
|
|
25
|
+
|
|
26
|
+
Tool names with spaces are written as the provider lists them; check how to pass them to `providers.call` against your own `providers.tools` output (not yet checked on production). The capture is of a person's own grant, not an app's. Run `runtime.providers.tools('device')` for the list your app can use.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Integrations
|
|
3
|
+
description: Call the person's own accounts at other services through the integrations provider, and find the tool you need.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Integrations
|
|
7
|
+
|
|
8
|
+
The `integrations` provider lets your app act in the person's own accounts at other services, with the person's consent for each service.
|
|
9
|
+
|
|
10
|
+
## Scopes
|
|
11
|
+
|
|
12
|
+
- `integrations:discover` allows `search` and `schema`, which find services and their tools, and the `link` group's `accounts`, which lists the person's linked accounts.
|
|
13
|
+
- `integrations:<service>_read` and `integrations:<service>_write` gate each service's tools. For example, a service named `gmail` has `integrations:gmail_read` and `integrations:gmail_write`.
|
|
14
|
+
|
|
15
|
+
Scope strings come from **What it asks for → Add permissions** (provider `integrations`). The catalog is large, so search it by service name.
|
|
16
|
+
|
|
17
|
+
## Find the tool you need
|
|
18
|
+
|
|
19
|
+
The provider has thousands of tools: on 2026-10-16 the runtime we captured from listed 3,061 tools across 3,060 groups, one `_read` group and one `_write` group per service, plus `discover` and `link`. This page does not table them. List them from your app:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const integrations = await runtime.providers.tools('integrations');
|
|
23
|
+
for (const group of integrations.groups) console.log(group.group, group.leaves.map((leaf) => leaf.tool));
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`providers.tools('integrations')` is large (several megabytes on that runtime), and it lists only groups your app's grant holds, so it stays small until you add service scopes. To find a service or a tool, call the provider's `search` and `schema` tools (group `discover`) instead of reading the whole list.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Search
|
|
3
|
+
description: Web search, sourced answers, page contents, and paid data endpoints through the search provider.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Search
|
|
7
|
+
|
|
8
|
+
The `search` provider runs web search and returns ranked pages, sourced answers, page contents and deep research, and it finds and calls paid data endpoints.
|
|
9
|
+
|
|
10
|
+
## Scopes
|
|
11
|
+
|
|
12
|
+
Each scope is `search:<group>`: `search:search`, `search:answer`, `search:contents`, `search:discover`, `search:research` and `search:run`. Add them under **What it asks for → Add permissions** (provider `search`). A tool is available when your app holds its group's scope.
|
|
13
|
+
|
|
14
|
+
## Tools
|
|
15
|
+
|
|
16
|
+
Captured 2026-10-16 from `GET /v1/providers/search/tools` on a joined runtime (provider major version 1). `*` marks a required parameter.
|
|
17
|
+
|
|
18
|
+
| Tool | Group (scope) | Parameters | What it does |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| `search` | `search` | `query`\*, `type`, `category`, `num-results`, `start-published-date`, `end-published-date`, `include-domains`, `exclude-domains`, `no-text` | Ranked web pages |
|
|
21
|
+
| `answer` | `answer` | `query`\*, `structured` | A sourced answer |
|
|
22
|
+
| `contents` | `contents` | `urls`\*, `no-text`, `highlights`, `summary`, `livecrawl` | Contents of known pages |
|
|
23
|
+
| `research` | `research` | `query`\*, `depth` | Deep web research |
|
|
24
|
+
| `discover` | `discover` | `query`\*, `limit`, `min-score` | Matching data endpoints and prices |
|
|
25
|
+
| `inspect` | `discover` | `provider`\*, `endpoint`\* | Data endpoint details and price |
|
|
26
|
+
| `run` | `run` | `provider`\*, `endpoint`\*, `input`\* | A paid data result, or a pending run ID |
|
|
27
|
+
| `status` | `run` | `run-id`\* | State and result of a data run |
|
|
28
|
+
|
|
29
|
+
The capture is of a person's own grant, not an app's. Run `runtime.providers.tools('search')` for what your app's grant holds. A call is billed to the person under your app's spend cap ([Grant changes](/docs/guides/grant-changes#show-what-a-persons-month-has-cost)).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Texting (sms)
|
|
3
|
+
description: Text the signed-in person's own verified phone from your app with the sms provider.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Texting (sms)
|
|
7
|
+
|
|
8
|
+
The `sms` provider sends a text from Northlight's number to the verified phone of the person who signed in to your app. It has no recipient argument, so an app cannot text anyone else.
|
|
9
|
+
|
|
10
|
+
## Scopes
|
|
11
|
+
|
|
12
|
+
| Scope | Consent sentence | Gates |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `sms:send` | Send you text messages from Northlight's number | `send`, `react` |
|
|
15
|
+
| `sms:status` | Not captured | `status` |
|
|
16
|
+
|
|
17
|
+
Add them under **What it asks for → Add permissions** (provider `sms`). Without `sms:send` the consent page does not offer it and `providers.call` for `sms` is refused with `scope_missing`.
|
|
18
|
+
|
|
19
|
+
## Send a text
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
await runtime.providers.call({provider: 'sms', tool: 'send', arguments: {body: 'Your summary is ready.'}});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`body` is required. The text goes from Northlight's number to the person's verified phone, and each person can receive at most 50 texts from your app per UTC day. These limits are the provider's documented behavior (the 50-per-day limit is not yet checked on production).
|
|
26
|
+
|
|
27
|
+
## Tools
|
|
28
|
+
|
|
29
|
+
Captured 2026-10-16 from `GET /v1/providers/sms/tools` on a joined runtime (provider major version 1). `*` marks a required parameter.
|
|
30
|
+
|
|
31
|
+
| Tool | Group (scope) | Parameters | What it does |
|
|
32
|
+
|---|---|---|---|
|
|
33
|
+
| `send` | `send` (`sms:send`) | `body`\*, `file`, `reply-to` | Send a text to your verified phone. `file` is a signed URL of at most 5 MiB sent after the text. `reply-to` is the label of a message in the conversation, such as `m3`, to send a threaded reply. Answers `message_id`. |
|
|
34
|
+
| `react` | `send` (`sms:send`) | `label`\*, `emoji`\* | React to a text in the conversation with a tapback or any emoji. |
|
|
35
|
+
| `status` | `status` (`sms:status`) | `message-id`\* | Check the delivery state of a text. |
|
|
36
|
+
|
|
37
|
+
Parameter names are the provider's own. In a `providers.call`, `arguments` is an object keyed by those names, such as `{body, file}`. Run `runtime.providers.tools('sms')` for the current list.
|