@north-light/cli 0.3.472 → 0.3.474

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/api/dto/nodes.d.ts +1 -1
  2. package/dist/builtin-memory/crouter-sdk/README.md +4 -47
  3. package/dist/builtin-memory/crouter-sdk/client.md +2 -231
  4. package/dist/builtin-memory/crouter-sdk/errors.md +9 -50
  5. package/dist/builtin-memory/crouter-sdk/getting-started.md +3 -65
  6. package/dist/builtin-memory/crouter-sdk/guides/README.md +1 -3
  7. package/dist/builtin-memory/crouter-sdk/guides/typed-extraction.md +1 -1
  8. package/dist/builtin-memory/crouter-sdk/nodes.md +1 -1
  9. package/dist/builtin-memory/crouter-sdk/release-notes.md +20 -20
  10. package/dist/builtin-memory/crouter-sdk/streaming.md +1 -70
  11. package/dist/builtin-memory/northlight-apps/INDEX.md +15 -0
  12. package/dist/builtin-memory/northlight-apps/cli.md +41 -0
  13. package/dist/builtin-memory/northlight-apps/coding-agents.md +75 -0
  14. package/dist/builtin-memory/northlight-apps/concepts/apps-and-sign-in.md +109 -0
  15. package/dist/builtin-memory/northlight-apps/concepts/connections.md +109 -0
  16. package/dist/builtin-memory/northlight-apps/concepts/providers.md +76 -0
  17. package/dist/builtin-memory/northlight-apps/concepts/runs.md +101 -0
  18. package/dist/builtin-memory/northlight-apps/concepts/scopes-and-grants.md +64 -0
  19. package/dist/builtin-memory/northlight-apps/concepts/testing-mode.md +54 -0
  20. package/dist/builtin-memory/northlight-apps/errors.md +119 -0
  21. package/dist/builtin-memory/northlight-apps/guides/background-runs.md +80 -0
  22. package/dist/builtin-memory/northlight-apps/guides/files-and-documents.md +61 -0
  23. package/dist/builtin-memory/northlight-apps/guides/go-live.md +39 -0
  24. package/dist/builtin-memory/northlight-apps/guides/grant-changes.md +100 -0
  25. package/dist/builtin-memory/northlight-apps/guides/manage-your-app.md +77 -0
  26. package/dist/builtin-memory/northlight-apps/guides/many-users.md +63 -0
  27. package/dist/builtin-memory/northlight-apps/guides/stream-a-run.md +84 -0
  28. package/dist/builtin-memory/{crouter-sdk/testing.md → northlight-apps/guides/test-your-app.md} +6 -6
  29. package/dist/builtin-memory/northlight-apps/guides/typed-results.md +99 -0
  30. package/dist/builtin-memory/northlight-apps/how-it-fits.md +49 -0
  31. package/dist/builtin-memory/northlight-apps/providers/README.md +39 -0
  32. package/dist/builtin-memory/northlight-apps/providers/context.md +34 -0
  33. package/dist/builtin-memory/northlight-apps/providers/device.md +28 -0
  34. package/dist/builtin-memory/northlight-apps/providers/integrations.md +28 -0
  35. package/dist/builtin-memory/northlight-apps/providers/search.md +31 -0
  36. package/dist/builtin-memory/northlight-apps/providers/sms.md +39 -0
  37. package/dist/builtin-memory/northlight-apps/quickstart.md +210 -0
  38. package/dist/commands/node/create.js +2 -2
  39. package/docs/apps/cli.md +40 -0
  40. package/docs/apps/coding-agents.md +72 -0
  41. package/docs/apps/concepts/apps-and-sign-in.md +82 -0
  42. package/docs/apps/concepts/connections.md +106 -0
  43. package/docs/apps/concepts/providers.md +59 -0
  44. package/docs/apps/concepts/runs.md +94 -0
  45. package/docs/apps/concepts/scopes-and-grants.md +62 -0
  46. package/docs/apps/concepts/testing-mode.md +36 -0
  47. package/docs/apps/errors.md +117 -0
  48. package/docs/apps/guides/background-runs.md +78 -0
  49. package/docs/apps/guides/files-and-documents.md +59 -0
  50. package/docs/apps/guides/go-live.md +37 -0
  51. package/docs/apps/guides/grant-changes.md +98 -0
  52. package/docs/apps/guides/manage-your-app.md +75 -0
  53. package/docs/apps/guides/many-users.md +61 -0
  54. package/docs/apps/guides/stream-a-run.md +82 -0
  55. package/docs/{sdk/testing.md → apps/guides/test-your-app.md} +5 -5
  56. package/docs/apps/guides/typed-results.md +72 -0
  57. package/docs/apps/how-it-fits.md +46 -0
  58. package/docs/apps/providers/README.md +26 -0
  59. package/docs/apps/providers/context.md +32 -0
  60. package/docs/apps/providers/device.md +26 -0
  61. package/docs/apps/providers/integrations.md +26 -0
  62. package/docs/apps/providers/search.md +29 -0
  63. package/docs/apps/providers/sms.md +37 -0
  64. package/docs/apps/quickstart.md +120 -2
  65. package/docs/cli/README.md +1 -0
  66. package/docs/cli/join-northlight.md +58 -0
  67. package/docs/sdk/README.md +4 -47
  68. package/docs/sdk/client.md +2 -231
  69. package/docs/sdk/errors.md +8 -49
  70. package/docs/sdk/getting-started.md +3 -65
  71. package/docs/sdk/guides/README.md +1 -3
  72. package/docs/sdk/guides/typed-extraction.md +1 -1
  73. package/docs/sdk/nodes.md +1 -1
  74. package/docs/sdk/release-notes.md +20 -20
  75. package/docs/sdk/streaming.md +1 -70
  76. package/package.json +2 -2
  77. package/packages/crouter-identity/package.json +1 -1
  78. package/runtime.lock.json +12 -12
  79. package/dist/builtin-memory/crouter-sdk/error-envelope.md +0 -38
  80. package/dist/builtin-memory/crouter-sdk/guides/apps-on-northlight.md +0 -423
  81. package/dist/builtin-memory/crouter-sdk/guides/apps-with-many-users.md +0 -61
  82. package/dist/builtin-memory/crouter-sdk/memory.md +0 -49
  83. package/docs/sdk/error-envelope.md +0 -36
  84. package/docs/sdk/guides/apps-on-northlight.md +0 -419
  85. package/docs/sdk/guides/apps-with-many-users.md +0 -59
  86. package/docs/sdk/memory.md +0 -47
@@ -0,0 +1,72 @@
1
+ ---
2
+ title: Use your coding agent
3
+ description: What npx @north-light/sdk init and skill install for Claude Code, Codex, Cursor and Copilot, where the skill lives, and how it stays in step with your SDK version.
4
+ ---
5
+
6
+ # Use your coding agent
7
+
8
+ The `northlight` skill teaches a coding agent the whole path in the [quick setup](/docs/quickstart): the commands, the console fields, the code patterns and the rules. `npx @north-light/sdk init` installs it into your project. The skill needs no account and no crtr.
9
+
10
+ ## Install it
11
+
12
+ For a new app, `init` installs it with everything else:
13
+
14
+ ```bash
15
+ npx @north-light/sdk@latest init my-app
16
+ ```
17
+
18
+ For a project that already has a `package.json`, run `npx @north-light/sdk init` in it. It adds `@north-light/sdk` at an exact version, makes a key if `local/app-key.json` does not exist, installs the skill, and writes no app code. It never overwrites a file: if one is in the way, it names the file and exits non-zero.
19
+
20
+ To install only the skill, or to replace an older copy:
21
+
22
+ ```bash
23
+ npx @north-light/sdk skill
24
+ ```
25
+
26
+ `skill` always replaces the `northlight` skill it finds. `--print` writes the skill to stdout instead of to files.
27
+
28
+ ## Where it goes
29
+
30
+ | Agent | In the project (default) | With `--global` |
31
+ |---|---|---|
32
+ | Codex, Cursor, GitHub Copilot, VS Code | `.agents/skills/northlight/SKILL.md` | `~/.agents/skills/northlight/SKILL.md` |
33
+ | Claude Code | `.claude/skills/northlight/SKILL.md` | `~/.claude/skills/northlight/SKILL.md` |
34
+
35
+ `--agent claude`, `--agent agents` or `--agent all` (the default) picks which. `all` writes both places. Cursor and Copilot also read `.claude/skills`, so they may list the skill twice. Use `--agent agents` or `--agent claude` to write one place. `init` takes `--global-skill` where `skill` takes `--global`.
36
+
37
+ The project copy is the default so it is committed with the project, and teammates get it from git. It matches the SDK version your project pins.
38
+
39
+ Checked: the files `init` writes, and that `skill` rewrites both, from the SDK source built on this branch. Which agent loads which directory is from each agent's documentation and is not yet checked on production for every agent.
40
+
41
+ ## Without a project: the hosted skill
42
+
43
+ An agent that has no project yet can read the hosted copy:
44
+
45
+ > Read https://sdk.docs.northlight.ai/SKILL.md and follow it.
46
+
47
+ The agent runs `npx @north-light/sdk@latest init` and then follows the quick setup. It asks you to do the browser steps: sign up, register the app at `https://account.northlight.ai/console/new`, and add permissions. The hosted copy matches the SDK version the docs site was last built from.
48
+
49
+ ## How it stays current
50
+
51
+ The skill's frontmatter carries `metadata.sdk-version`. Its first instruction tells the agent to compare that number with `node_modules/@north-light/sdk/package.json`. If they differ, the agent runs `npx @north-light/sdk skill`, which rewrites the skill from the installed package. A skill therefore always documents the SDK your project pins, and a newer SDK never gets documented against an older one.
52
+
53
+ ## Example prompts
54
+
55
+ After `init`:
56
+
57
+ > Add a "Summarize my day" button to the app. It starts a run on the signed-in person's runtime with `runs.start`, waits with `runs.wait`, and shows the report. Use the northlight skill.
58
+
59
+ Starting from nothing:
60
+
61
+ > Read https://sdk.docs.northlight.ai/SKILL.md and build me an app where people sign in with Northlight and the app texts them a daily summary. Tell me which console steps I need to do.
62
+
63
+ ## Rules the skill gives your agent
64
+
65
+ Your agent reads them from the skill. They are worth knowing as the person reviewing its work:
66
+
67
+ - Pin the exact SDK version.
68
+ - Take scope strings from the console's Add permissions list, never from memory.
69
+ - Never print, paste or commit `local/app-key.json`.
70
+ - Reach a person's runtime with `oauth.client(userId)`, not `new Crouter()`.
71
+ - Use a persistent `ConnectionStore` in production.
72
+ - Get data back with `runs.parse`, not by reading `runs.trace`.
@@ -0,0 +1,82 @@
1
+ ---
2
+ title: Apps and sign-in
3
+ description: What registering an app creates, how an app proves who it is with a key pair, and the authorization-code flow with PKCE that OAuth2Client runs.
4
+ ---
5
+
6
+ # Apps and sign-in
7
+
8
+ An app is a registration in the Northlight directory: a client ID, a public signing key, the redirect URIs people return to after sign-in, and the list of scopes the app may ask for. You create it in the console at `https://account.northlight.ai/console/new`. Your server proves it is that app by signing its requests with the matching private key.
9
+
10
+ ## What registration holds
11
+
12
+ | Part | What it is |
13
+ |---|---|
14
+ | **Client ID** | The app's id. The console shows it after you register. Your code passes it as `clientId`. |
15
+ | **Origin URL** | Where your app runs. People see its domain as your app's maker. `http://localhost:<port>` is accepted for development. |
16
+ | **Redirect URIs** | The only places sign-in may return to. A URI must match exactly: `localhost` and `127.0.0.1` are different, and the port counts. `http://localhost`, `http://127.0.0.1` and `http://[::1]` are accepted over plain http; every other URI is `https`. |
17
+ | **Public key** | The half of your key pair the directory keeps. |
18
+ | **Scopes** | What the app may ask for. See [Scopes and grants](/docs/concepts/scopes-and-grants). |
19
+
20
+ Name, logo and "Why your app needs access" appear on the consent page.
21
+
22
+ ## A key pair, not a client secret
23
+
24
+ An app has no client secret. It signs a short assertion with its private key (`private_key_jwt`, the only token authentication method the directory supports) each time it exchanges a code or refreshes a token. The directory checks the signature against the public keys it holds.
25
+
26
+ - `npx @north-light/sdk keygen` writes the private key to `local/app-key.json` (mode `0600`) and prints the public key as JSON. `init` runs it for you. Paste the public key into **Public key** when you register, or under **Add key** on the app's **Sign-in** tab.
27
+ - The private key stays on your server. Never paste it, log it or commit it. The console refuses a pasted private key.
28
+ - An app holds at most 2 active keys, so you can add a new one beside the old one, switch, and retire the old one without downtime.
29
+ - Instead of pasting keys you can give the directory a **JWKS URL** (**Use a JWKS URL instead** on the **Sign-in** tab).
30
+ - Until a key is added, `exchangeCode` throws an `AuthenticationError`.
31
+
32
+ ## The sign-in flow
33
+
34
+ `OAuth2Client` runs the OAuth 2.0 authorization-code flow with PKCE (`S256`) and an OpenID Connect nonce. Your two routes are short:
35
+
36
+ <include lang="ts">../../../examples/apps/quickstart-server.ts#login</include>
37
+
38
+ `authorizeUrl` returns the URL to redirect to, plus three values to keep for this sign-in: `codeVerifier`, `state` and `nonce`. The example keeps them in a `Map` keyed by `state`. Use your own session store.
39
+
40
+ <include lang="ts">../../../examples/apps/quickstart-server.ts#callback</include>
41
+
42
+ On the callback:
43
+
44
+ 1. Read `state` and `code` from the query. Find what you saved for that `state`. If nothing is there, send the person back to the login route.
45
+ 2. Call `exchangeCode` with the code, the saved `codeVerifier` and `nonce`, the `state` from the query as `state`, and the value you saved as `expectedState`. Never pass the callback's `state` for both. A missing or different `expectedState` throws `CrouterError` with code `oauth_state_mismatch` before the code is sent anywhere.
46
+ 3. `exchangeCode` verifies the ID token (ES256 signature, issuer, audience and nonce) when it has one, and returns a `Connection`. Save it in your [`ConnectionStore`](/docs/concepts/connections).
47
+
48
+ Then set your own session for the person. The SDK does not manage your sessions.
49
+
50
+ ### Scopes decide what comes back
51
+
52
+ A sign-in that asks only for `openid`, optionally with `email` and `profile`, creates no connection. Finish it with `oauth.exchangeIdentity(...)`, which returns the verified `Identity` (`sub`, and `email`, `name` and `picture` when approved and present). A sign-in that asks for runtime scopes such as `crtr:llm` finishes with `exchangeCode`. Each method refuses the other's answer with `oauth_exchange_mismatch`.
53
+
54
+ ### When the directory refuses to sign someone in
55
+
56
+ If the app is in testing and the person is not a tester, the directory redirects to your callback with `error=access_denied` and `error_description=app_in_testing` instead of a `code`. `authorizationRefusalOf(url)` reads it. See [Testing mode](/docs/concepts/testing-mode).
57
+
58
+ ## Keep sign-in secrets out of HTTP logs
59
+
60
+ The callback URL carries a one-use `code` and your `state`, and the response that follows carries a session cookie. Request loggers such as `pino-http` log full URLs and response headers by default. Log the method, the path without the query string, an id and the response status, for every route, not only the callback. Redacting only the `authorization` and `cookie` request headers does not cover these values.
61
+
62
+ ```ts
63
+ LoggerModule.forRoot({
64
+ pinoHttp: {
65
+ serializers: {
66
+ req: (req: { method?: string; url?: string; id?: unknown }) => ({
67
+ method: req.method,
68
+ path: req.url?.split('?')[0],
69
+ id: req.id,
70
+ }),
71
+ res: (res: { statusCode?: number }) => ({ statusCode: res.statusCode }),
72
+ },
73
+ redact: ['req.headers.authorization', 'req.headers.cookie'],
74
+ },
75
+ });
76
+ ```
77
+
78
+ Test it: send a callback with a recognizable fake code and assert that the code, the state and the cookie never appear in the log output.
79
+
80
+ ## Checked and not checked
81
+
82
+ Checked on production: the discovery document lists `S256` and `private_key_jwt`, and the login route of the quickstart server redirects to `https://account.northlight.ai/authorize` with the PKCE challenge and nonce. The rest is from the SDK and directory source. A full sign-in with a real account is not yet checked on production.
@@ -0,0 +1,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.