NorthlightSDK

Release notes

Behavior changes in @north-light/sdk that an app must act on, newest first.

Changes to @north-light/sdk that can change what an existing app sees. Newest first.

0.3.472

npx @north-light/sdk init, skill and env --client-id (additive, no app change)

The crouter-sdk bin gains three commands that need neither crtr nor a joined machine:

  • init [dir] [--agent claude|agents|all] [--global-skill] scaffolds an app in an empty or new directory (package.json with @north-light/sdk pinned to the CLI's own version plus tsx, server.ts, .gitignore, local/app.env, a signing key in local/app-key.json) and installs the northlight Agent Skill into .agents/skills and .claude/skills. In a directory that already has a package.json it writes no app code: it runs npm i --save-exact @north-light/sdk@<this version>, makes a key if local/app-key.json is missing, and installs the skill. It never overwrites a file; it exits 1 and names the file it refused.
  • skill [--agent claude|agents|all] [--global] [--print] writes the skill that ships in the package, replacing an older copy. The skill's metadata.sdk-version is the package version; run skill again after changing the pinned SDK version.
  • env --client-id <id> [--origin <url>] [--issuer <url>] [--out <path>] writes CROUTER_ISSUER, CROUTER_CLIENT_ID and APP_ORIGIN into local/app.env and keeps every other line, with no network call. env --app <id> still reads them through crtr; passing both is a usage error.

keygen is unchanged. The package still has one bin, so npx @north-light/sdk <command> keeps working. A bare npx @north-light/sdk, --help and an unknown command print the command list (exit 1 for the unknown command).

0.3.462

ProfileDTO.memory_path is removed (profile objects carry only profile_path)

memory_path, the deprecated alias of profile_path that 0.3.459 kept, is no longer sent. Read profile_path, which holds the same value (<app space>/profiles/<id>). A runtime older than 0.3.459 sends only memory_path.

0.3.460

crouter-sdk keygen releases its key file when the add fails (no app change)

When keygen cannot add the public key to the app (the machine is not joined, or the directory refuses), it deletes the local/app-key.json it had reserved, so a rerun after fixing the cause starts clean. It keeps the file when the add timed out or returned no key, because the add may have landed.

0.3.459

ProfileDTO.profile_path replaces memory_path (additive, no app change)

A profile's folder holds no documents (those live in the canvas), so it is now <app space>/profiles/<id> rather than <app space>/memory/profiles/<id>, and the profile objects carry profile_path with that path. memory_path is still sent with the same value and is deprecated; it goes in the next breaking release, so read profile_path and fall back to memory_path against an older runtime. Nothing else changes: the runtime moves existing folders itself on startup.

0.3.455

provider.call can target a device (additive, no app change)

provider.call takes an optional machine (a machine id; the runtime: prefix is optional) for a provider served by one of the account's devices, such as crtr device. Without it, one device is used; with several the call is refused machine_required and details.candidates lists them. Five error codes are new and are raised only on that path: machine_required, device_offline, device_timeout, device_disconnected and invalid_argument (machine sent to a provider that is not served by a device). A call that sends no machine to an ordinary provider sees no change.

0.3.452

oauth.client(userId) no longer offers methods the runtime does not serve (type change)

ConnectedCrouter exposed every Crouter namespace, but a person's app listener serves only the app operations. client.crons.*, client.files.read/write/list, client.nodes.*, client.canvas.*, client.bash, client.human, client.models, client.customObjects, client.structuredRequests, client.system, client.auth and client.profiles.ensure/retrieve compiled and then failed with 404 unknown operation. They are now absent from the type, and reading one throws CrouterError. files.download and profiles.list stay. Nothing that worked changes. To schedule work, call runs.start from your own timer; a run's agent may use crtr cron add under crtr:schedule. new Crouter({baseURL, token}) on the owner's daemon is unchanged.

0.3.445

Peer requests between an account's machines (additive, no app change)

A runtime now serves requests from another of the account's machines (a machine token plus a Crtr-Envelope). Nothing an app sends or reads changes: an app grant token takes the same path as before. The router gains a forwarded caller class on the object, node-read, message and canvas read rows, four machine-only peer.* rows that answer 501 not_implemented until they ship, and two new refusal codes on peer requests only (app_not_granted, peer_version_too_old).

0.3.423

Compatibility is a contract level, not the package version (additive)

The SDK no longer refuses a runtime because its release number is lower than the SDK's. It sends Crouter-Required-Contract: <CONTRACT_LEVEL> and refuses a runtime that does not serve that level (MAJOR.MINOR, same major, minor at or above). The level changes only when the /v1 surface changes, so an ordinary release no longer makes production answer 426. RuntimeVersionError keeps status 426 and code runtime_version_unsupported, and gains requiredContract and runtimeContract; sdkVersion and runtimeVersion stay. Apps that catch the error need no change.

  • Every SDK release is published to the next dist-tag; latest moves only once production serves its level. @latest therefore always works against production.
  • The SDK now tolerates values it does not know: an unknown run event type is passed to your listener as-is, and an unknown run status ends runs.wait and the run watcher, reporting the raw status (RunEnded.ended can be 'unknown'). Handle a status or event type you do not recognize rather than assuming the union is closed.
  • FakeCloud takes contract and routedContract options and refuses an incompatible call at ingress, before any interceptor runs.

0.3.419

Testing mode: app_in_testing (additive)

Not breaking: no type or wire shape an app reads is removed or changed.

  • An app Northlight has not verified is in testing: only its admin and named testers can sign in. A refused sign-in redirects with error=access_denied, error_description=app_in_testing; a refused refresh answers invalid_grant app_in_testing. The refresh refusal is a new Refusal with code: 'app_in_testing' and kind: 'paused', like grant_suspended: keep the person's data. If you switch exhaustively on refusal.code, add the case. See When a refresh is refused.
  • New authorizationRefusalOf(callback) reads the sign-in refusal from the callback's query and returns AuthorizationRefusal ({code: 'app_in_testing', error: 'access_denied', state}), else null. See When sign-in is refused.
  • @north-light/sdk/testing: cloud.testing (enable, disable, active, addTester, removeTester, testers, allowed), cloud.authorize(userId, {redirectUri, state}), user.setAccount({email, emailVerified, phone}), and 'app_in_testing' in FakeRefusalCode. The fake directory now serves grant_type=authorization_code for codes cloud.authorize issued. See Testing mode.

0.3.415

Monthly spend cap enforced; usage.get (additive; one behavior change)

  • Behavior change: user-paid model calls (/v1/llm) and provider calls now count against the grant's spend_cap (from grant.get(); Northlight sets $50) for the current UTC month. Once the month reaches it, the next call answers 402 cap_exhausted (a RateLimitError, retryable: false, userAction: 'raise_cap', resetAt the first instant of next month) before any model or provider is called. A run whose model call is refused this way ends with outcome reason cap_exhausted. The check runs before each call, so the call that crosses the cap still completes. App-paid usage never counts. A grant with spend_cap: null is not capped.
  • New client.usage.get({month?}) (month is YYYY-MM, UTC; default the current month) answers SpendUsageDTO: usd_equivalent (user-paid spend, provider receipts included), by_model, app_paid, spend_cap and reset_at. See Show what a person's month has cost.
  • A completion's usage.usd_equivalent is now the served model's list-price cost; before it was always null. It stays null for a model with no list price, and that usage adds nothing to the month.

npx @north-light/sdk env (additive); keygen on an unjoined machine exits 2

  • New npx @north-light/sdk env --app <client id> [--out <path>] writes CROUTER_ISSUER, CROUTER_CLIENT_ID and APP_ORIGIN from crtr cloud apps get into local/app.env, keeping every other line. See Go live with an app key.
  • keygen --app on a machine whose crtr isn't joined to a Northlight account now prints the join step and the manual steps and exits 2. A missing crtr keeps its message and exit 0.

One deadline per SDK call, and a deadline on every OAuth/JWKS fetch (additive, with one behaviour change)

Not breaking: no type or wire shape an app reads is removed or changed. Apps pinned to the previous release keep working unchanged.

  • RequestOptions gains budgetMs and deadlineAt: one absolute deadline for the call covering every attempt, every retry wait (including a Retry-After) and the token fetch/refresh. A wait that would pass the deadline rejects at once with the new DeadlineExceededError (code: 'deadline_exceeded', reason: 'elapsed' | 'retry_after', deadlineAt, cause), which extends APIConnectionTimeoutError, so code catching that still catches it. A transport that ignores its AbortSignal can no longer outlive the deadline. Without budgetMs/deadlineAt nothing about retries changes. See One deadline per call.
  • An app that holds a lease or lock while it makes an SDK call should pass budgetMs and keep the lease longer than it, with margin, and state that in code.
  • OAuth2Options.timeoutMs (default 10 s): one deadline per directory operation (discovery, token exchange, refresh, revoke, runtime_provisioning retries and their retry_after_s). A refresh under the connection-store lock now holds the lock at most timeoutMs. JwksCache (and so verifyIdToken) takes {timeoutMs} (default 10 s) over its discovery and key-set reads together.
  • Behaviour change: these directory fetches used to have no deadline (only discovery had one). A directory that takes longer than 10 s now fails with DeadlineExceededError where it used to wait; set timeoutMs to change it.
  • OAuth2Client validates the discovery document: authorization_endpoint, token_endpoint, jwks_uri (and revocation_endpoint, when present) must be absolute https URLs (http: when the issuer is http:), and token_endpoint_auth_methods_supported an array of strings. A wrong type is a CrouterError naming the field instead of a later TypeError.

0.3.409

@north-light/sdk/testing: isolated runs start, FakeCall.runId, one runs shape (one breaking change)

  • runs.start({isolated: true}) now starts a run on FakeCloud, as it does on a Northlight guest; it used to answer 503 isolated_unavailable. run.params.isolated records it, and a cwd outside the run's root folder is 400 param: cwd, as the runtime checks it. The new option isolatedRuns: 'unavailable' restores the 503 for testing that path. If you worked around the refusal by deleting body.isolated in an intercept, remove the intercept.
  • FakeCall gains runId and questionId (the ids the path addresses, else null), so tests no longer split path.
  • FakeCall.userId is now the bearer's person whenever the cloud knows them, including a person with no refresh token; it is null only for a missing or unknown bearer.
  • Breaking: cloud.user(id).runs is now {all, respond}, the same shape as cloud.runs, instead of an array carrying respond. Replace user.runs[i], user.runs.find(...) and [...user.runs] with user.runs.all….
  • testing.md documents every SDK retry that absorbs a failNext answer (401: times: 2; 5xx or 429 on GET and DELETE: times: 3; a retryable answer on run.start or run.message: times: 3), and how to script a child turn that answers nothing.

0.3.408

@north-light/sdk/testing (additive)

New @north-light/sdk/testing: FakeCloud, a fake directory and runtime your real OAuth2Client and Crouter talk to through fetch. It covers runs (events, replies, long-poll, idempotency, endings), grant watch, refusals, directory failures, memory, providers and run notices, and is checked against the real runtime on every release. Additive: verifyRunNotice accepts fetch. No behaviour change. See Testing your app.

oauth.accountAppUrl() (additive), and a failed discovery read is retried (behaviour fix)

  • New OAuth2Client.accountAppUrl() returns the app's page on the signed-in person's Northlight account, as the directory's discovery document advertises it (account_app_uri_template). Use it instead of building <issuer>/account/apps/<client id> yourself. It needs no user or connection and throws CrouterError naming the fault when the directory advertises no template or an invalid one. See Link to the app's settings on Northlight.
  • Behaviour fix: OAuth2Client's discovery read now times out after 10 seconds, and a read that failed or timed out is retried by the next call that needs it. Before, one failed read was kept for the client's life, so every later authorizeUrl, exchangeCode, refresh and verifyIdToken failed until the process restarted, and a hung directory stalled them indefinitely. A successful read is still kept for the client's life.

0.3.405

Run notices, and forwardRunEvents stops on idle (additive; one behavior change)

A hosted runtime that sleeps cannot be polled, so it tells your app when a run has output nobody is reading.

  • An app whose directory registration has a run_notice_url receives POST <run_notice_url> with Authorization: Bearer <directory token> and body {run_id, latest_sequence}, sent when events are appended to one of its runs and no run.events stream is open (or a stream ended with events unread). A notice carries no content and never keeps the runtime awake; if it cannot be delivered after three quick attempts the runtime logs run_notice.failed and drops it. A lost notice delays output until your app next reads from its cursor; it never loses output.
  • New verifyRunNotice(request, {runNoticeUrl, issuer, grantee}) checks the token (signature, iss, aud = runNoticeUrl, exp, scope crtr:run_notice, grantee) and the body, and returns {userId, runtime, runId, latestSequence}. runtime comes from the token's act.sub, never the body. Any failure throws RunNoticeError (status 401): answer 401 and store nothing. Answer 2xx once the notice is stored; the same or a lower latest_sequence is a no-op.
  • Behavior change: when the runtime idle-closes the stream, forwardRunEvents now writes data: {"type":"stream.idle","cursor":N} and ends the response, instead of reconnecting. Browser code should close its EventSource on stream.idle and reopen with after=cursor on its own trigger (a send, or your notice handling).

0.3.404

crons.create refuses or defaults a cwd the run cannot start in (additive, not breaking)

In a hosted runtime every cron run is sandboxed and sees only the app's own folders, so a cron armed with any other cwd (the CLI's inherited /, for one) was accepted and then failed at every fire with cwd is not mounted.

  • cwd is now optional on crons.create. Omitted, it becomes the creating node's directory when the run can reach it, else the app's files directory.
  • New optional cwd_is_default: true marks a cwd you did not choose (a shell's current directory); an unreachable one is replaced by the same default instead of refused.
  • A cwd you chose that the run cannot reach is refused at create time with 400 invalid_request (param: "cwd"); nothing is stored. Pass a directory under the app's folders.
  • A runtime that does not sandbox cron runs (a local daemon on macOS) is unchanged. cron.cwd on the returned row is the directory the cron actually got.

0.3.402

RunStream surfaces the runtime's idle close (additive, not breaking)

A run.events SSE stream that ends with event: idle (0.3.401) used to look like any other close: RunStream dropped the frame and reconnected after 500 ms, which keeps a hosted runtime awake. Now the frame ends the stream and is reported, and the stream does not reconnect.

  • stream.idle is true once the runtime sent event: idle and closed. Iteration ends normally (no throw); finalOutcome() is not resolved, because the run did not settle.
  • stream.position is the sequence_number of the last event delivered (the after you passed until one arrives). Reopen with runs.events(runId, {after: stream.position}) when you want to read again.
  • stream.whenEnded() resolves {reason: 'settled' | 'idle', cursor} (new export RunStreamEnd) and rejects with the failure or APIUserAbortError, so "idle", "run ended" and "transport error" are three distinct outcomes.
  • runs.reply(...) keeps waiting for its turn: it reopens from the cursor after an idle close, so a reply still ends on turn.completed or run.settled.
  • Nothing else changes: any close without the idle frame reconnects as before, and a consumer that ignores idle sees the iteration end where it used to see a reconnect. The frame is not a run event and is not delivered to on(...) listeners. forwardRunEvents still holds the browser's response through an idle close, reconnecting as before; use runs.events directly to let a hosted runtime sleep.

0.3.401

Runtime sleep and wake support (additive, not breaking)

The runtime gains what a sandbox that sleeps needs; no app-facing type or route an app calls changes, and no SDK pin has to move.

  • GET /v1/due (owner API, unix socket) returns {busy, next_due_at}: whether any node is mid-turn, a cron or bash job is running or any timed lane has work due now, and the earliest time any timed lane owes work. Every lane the daemon tick runs registers a due source or is declared busy-only; a test fails when a new lane does neither.
  • A run.events SSE stream now ends with event: idle (data: {}) and closes after 60 s in which the run has no running turn and no new event. The SDK's RunStream at this release still reconnects from its cursor as after any close; the next release surfaces the frame (see above).
  • A request carrying Crtr-Defer-Head: 1 on the app listener whose response head is not ready 40 s after arrival gets 200 application/vnd.crtr.deferred-head, then crtr-deferred-head/1\n, a \n every 15 s, one JSON line {"status","headers"}, and the real body unchanged. Requests without the header behave as before.
  • The daemon pulls grants on every detected resume (wall clock jumping over 2 s beyond the monotonic clock).

0.3.400

Follow a person's grant with oauth.watchGrants (additive)

grant.get() answers a new field, version: opaque, it changes exactly when rev, resolved_scopes, spend_cap, payer or dependencies changes. grant.get({wait, since}) (wait an integer 1–25; anything else throws RangeError before sending) holds the read until the grant's version differs from since or wait seconds pass, and extends the request timeout by wait. A grant revoked or removed during the hold answers 401 grant_revoked. oauth.watchGrants({users, onChanged, onError?, sweepMs?, concurrency?, wait?}) returns a GrantWatcher that follows the grants of the people users() names and calls onChanged({userId, grant, previous}) level-triggered, at least once per version, so an app learns when a required provider's setup turns ready without a browser tab or a poll loop. New exports GrantWatcher, GrantWatchOptions, GrantChanged. Not breaking: nothing existing changes shape, and RunWatcher behaves exactly as before. The hold needs a runtime at this release and, for a hosted runtime, a hosting router that lets GET /v1/grant?wait=N through (before then a held read answers 504 after 10 s and the watcher backs off). See Learn when a person's grant changes.

0.3.399

onActivity no longer blocks the watcher

In 0.3.398 the watcher awaited onActivity before its next read, so a slow or hung hook delayed that run's onEnded, held its concurrency slot, and kept stop() from resolving. The watcher now never awaits it: at most one call per run runs at a time, a newer reading taken meanwhile replaces any still waiting, and stop() does not wait for a call already running. A call can now finish after that run's onEnded: guard the write on the row still waiting on the run. Runs that leave pending() without an ending no longer keep their last phase in memory. See Learn when a background run ends.

0.3.398

RunWatcher reports live progress with onActivity (additive)

oauth.watchRuns({..., onActivity, activityWait?}) calls onActivity({userId, runId, activity, run}) with each read of a still-running run whose activity differs from the last one handed over for that run (compared as JSON: phase, tool.name, tool.summary), so an app can show a progress badge from the watcher it already runs instead of a second runs.get per page read. It is never called for a run at rest. With onActivity set, running runs are read with wait: activityWait (integer 1–25, default 2) rather than 25, because the runtime's long-poll answers early only at rest. Best effort: a throwing onActivity goes to onError and is not retried, and onEnded delivery is unchanged. Not breaking: without onActivity the watcher behaves exactly as before. New exported type RunActivityEvent. See Learn when a background run ends.

0.3.397

A runtime older than the SDK is refused before it handles the request

The SDK now sends Crouter-Minimum-Runtime-Version: <SDK_VERSION> on every request except /healthz. The hosting router in front of a hosted runtime, and a runtime at this release or later, answer a request that names a newer release than the runtime's with RuntimeVersionError (status 426, code runtime_version_unsupported) before any handler runs: nothing is accepted, no Idempotency-Key is recorded, and a retry after the runtime is rolled forward is a first send. Previously an older runtime handled the request and the SDK raised afterwards. Not breaking: no app code changes. An app that worked around the late check, such as a runs.list read before every send, can delete it once its runtimes are at or after this release behind a hosting router that has the check. Back off on RuntimeVersionError and retry under the same key. A browser app's preflight now lists crouter-minimum-runtime-version in Access-Control-Allow-Headers. See Errors.

0.3.396

RunObject.activity has a submitting_result phase; output_schema runs need only crtr:llm (breaking)

  • New phase {phase: 'submitting_result'}. While the root agent writes or records its output_schema result, runs.get(runId).activity is {phase: 'submitting_result'}. It used to be {phase: 'tool', tool: {name: 'bash', summary: 'crtr push result …'}}. A switch on activity.phase that assumed the old set must handle the new value; an app that matched crtr push result in tool.summary should map this phase instead and delete that check.
  • An output_schema run no longer needs crtr:act. The agent submits through its submit_result tool, which needs no scope, so a run started with scopes: ['crtr:llm'] can finish. Drop crtr:act from runs that only granted it so the agent could submit.
  • A run without crtr:act is not offered bash. Its agent never sees a shell tool that the runtime would refuse.

0.3.395

OAuth2Client owns the connection store; one onRefusal for a dead connection (breaking)

OAuth2Client now takes the app's ConnectionStore once, as store (required), plus an optional onRefusal(refusal) handler, and every refused refresh goes through that one handler: a request on a connected client, refreshIdleConnections, and the new RunWatcher. Apps no longer keep their own refusal table or delete dead connections themselves. See When a refresh is refused.

  • oauth.client(connection, store) is now await oauth.client(userId). It loads the stored connection and returns a ConnectedCrouter (a Crouter with userId and runtimeUrl). Nothing stored throws CrouterError code not_connected. Save exchangeCode's connection with store.save(connection) first.
  • oauth.refreshIdleConnections(store, {olderThanMs}) is now oauth.refreshIdleConnections({olderThanMs}) and returns {refreshed, refused, failures}. RefreshFailure.reason is removed: a refused connection is handled by onRefusal, removed, and counted in refused; failures holds only connections still stored (any other error, or a refusal whose handler threw).
  • refreshRefusal(error) and the RefreshRefusal type are removed. Use onRefusal (refusal.kind is 'removed' | 'paused' | 'reconnect', type RefusalKind) to act on the connection, and refusalOf(error) to answer one request.
  • ConnectionStore.lock is now required, and remove(userId, refreshToken) is new and required. remove deletes the row only while it still holds that refresh token and answers whether it deleted one. MemoryConnectionStore and PostgresConnectionStore implement both; a custom store must add them. The SDK never runs app code under lock and never throws out of it, so a Postgres store's transaction commits.
  • onRefusal runs at least once per dead connection (make it idempotent): after the store lock is released, then the SDK removes the connection (only while it still holds the refused token), then the caller's call rejects with the directory's AuthenticationError, on which refusalOf(error) returns the Refusal. If the handler throws, the connection is kept, the caller gets the refusal error with cause set to what the handler threw, the client does not latch it (the next call refreshes and runs the handler again), and refreshIdleConnections reports it in failures. A handler that calls oauth.client or a refresh for the same user throws CrouterError code refusal_handler_reentry instead of waiting on itself.
  • refusalOf returns a Refusal only for an error the SDK raised while refreshing a stored connection, not for every directory-origin AuthenticationError.
  • Concurrent refreshes in one process are shared per user and refresh token, so a client holding a newer token never joins an older refusal.

Callers to update: Northlight's chat app (first-party-apps/chat/src/directory-client.ts, which builds oauth.client(connection, store)) and the context app (first-party-apps/context/req-run.mjs). Any app that called refreshIdleConnections(store, …), read RefreshFailure.reason, or imported refreshRefusal must move to the shapes above.

RunWatcher: learn when a background run ends (additive)

oauth.watchRuns({pending, onEnded, onError?, sweepMs?, concurrency?}) returns a RunWatcher (start(), nudge(), stop()) that follows the runs pending() reads from the app's own rows with runs.get(wait) and calls onEnded({userId, runId, ended, run}) when each comes to rest (settled, idle, waiting_on_user) or is deleted (gone, only for a 404 not_found from the runtime, never the router). Delivery is at least once per (runId, status, latest_sequence): a delivered run is re-read once per sweep and delivered again only when that key changes (an idle run later canceled is delivered as settled). A throwing onEnded is retried after 5 s, doubling to 5 min. Runs are opened with oauth.client(userId), so a refused connection reaches onRefusal. There is no runtime-to-app webhook: the runtime cannot mint an app-audience token, a directory relay would add a hop, and the app's rows already make a pull durable and work on localhost. See Learn when a background run ends.

0.3.394

grant.get dependencies are one { state, message?, next } entry per provider (breaking)

GrantGetDTO.dependencies[provider] was a bare SetupState string; it is now a ProviderDependency (exported from @north-light/sdk and @north-light/api): { state, message?, next }. state is the setup state the string used to be. message is the provider's own sentence for pending and failed (its message_pending / message_failed), so delete any copy of a provider's prose your app hard-codes and show message instead. next is the absolute URL of the directory's setup page for that provider and your app; link the person there (append return_to with your registered redirect origin) instead of building the URL yourself. Change every read of dependencies[p] to dependencies[p].state (or dependencies[p]?.state where the entry may be missing), and carry message and next into your provider_setup_pending details. The runtime reads only this shape from the directory's grant sync: a runtime on this release paired with a directory that still sends bare strings reports dependencies as {}, so roll the directory change with it. See Waiting on a provider your app requires.

0.3.393

The runtime orders grant state by revision; a failed grant check is service_unavailable (breaking)

A runtime on this release requires the rev claim in every app access token, so it needs a directory that issues it; a token without rev is refused 401 unauthorized, "access token has no rev claim: the directory is older than this runtime". A freshly consented or re-consented grant is now authorized on the first call, with no wait: the runtime pulls grant state once whenever a token is newer than its mirror. Delete any retry your app added for a 401 grant_revoked right after consent. When that pull fails, the call answers 503 service_unavailable (retryable: true) instead of 401 grant_revoked, so a directory outage no longer looks like a refused connection. service_unavailable was already in errorCodes; app calls can now receive it. See An app connection and refresh.

0.3.391

The SDK refuses a runtime older than itself (breaking)

@north-light/sdk now accepts only a runtime at or above its own version, and fails loudly otherwise instead of handing an app data its types do not describe. Every runtime answer carries Crouter-Runtime-Version; when that version is older than SDK_VERSION, or the header is absent (every runtime before this release), the call throws RuntimeVersionError (code: 'runtime_version_unsupported', status: 426, retryable: false) whose message and sdkVersion / runtimeVersion name both versions. A refusal without the header (a router in front of the runtime answering 401 or 503) is still raised as that refusal. There is no capability negotiation: RunObject.activity and every other field are typed for a runtime at this release. Upgrade every runtime the app talks to before upgrading the SDK, and pin the SDK to an exact version. A test double that fakes the runtime with fetch must send Crouter-Runtime-Version at the SDK's version on its 2xx answers. See Errors.

One release is one version set

@north-light/sdk@X now depends on exactly @north-light/api@X and @north-light/identity@X instead of ^X, so an exact SDK pin no longer floats its siblings to a later release.

0.3.390

runs.message is idempotent and retried

runs.message now sends an Idempotency-Key (one per call, reused on its retries; override with the new idempotencyKey option). The daemon records the key in the transaction that accepts the message and holds it for 24 hours: a replay returns the original result and delivers once, and the same key with a different body fails with 409 idempotency_conflict. Because it is now safe, runs.message retries connection failures and retryable refusals, as runs.start already did. The router path (/v1/runs/:id/messages through the run routes) previously dropped the header and now honors it.

0.3.383

Structured requests (additive)

client.structuredRequests creates, reads, and settles durable structured requests with deadlines and one settlement.

0.3.378

Identity.picture (additive)

Identity and profileFromClaims carry picture, the account photo as an https URL, when the sign-in granted profile and the account has one.

0.3.375

runs.get reports what a running run is doing (additive)

RunObject.activity is {phase: 'starting' | 'thinking' | 'writing' | 'between_turns'} or {phase: 'tool', tool: {name, summary}} while the run is running, and null otherwise, so a polling app can show progress from runs.get without reading runs.trace. runs.list entries carry it too. See Showing a background run's progress. A test double that builds a RunObject literal must add activity.

0.3.374

Identity carries the person's name (additive)

A sign-in that grants the profile scope returns name, givenName, and familyName on Identity when the person has a name.

0.3.373

CrouterError.code is typed SdkErrorCode

Every code the SDK raises itself is now listed in sdkErrorCodes (exported with SdkErrorCode and mapError from @north-light/sdk/errors), and CrouterError's code and constructor are typed with it. Runtime behavior is unchanged. An app that constructs new CrouterError(message, code) with a code of its own, for example in a test double, no longer typechecks: throw its own error class or an APIError instead. See Errors.

Run ergonomics (additive)

runs.start({prompt, message}) stores a first message that always joins the first turn, and returns it as run.message for runs.reply. RunReply.collect({onDelta}) streams a reply and says how its turn ended; text() takes the same onDelta. runs.listAll and memory.listAll iterate every page. refreshRefusal(error) classifies a refused refresh, and each refreshIdleConnections failure carries it as reason. authorizeUrl takes prompt: 'consent'. parsePrivateJwk and privateKeyFromFile load the app's signing key. No existing call changes.

0.3.371 (after 0.3.370)

A refused refresh says why: grant_removed, grant_suspended, refresh_token_expired

When the directory refuses a connection's refresh token, the SDK now raises the directory's reason as its own code instead of grant_revoked:

  • grant_removed: the person removed your app or deleted their account, and the runtime deleted your app's data for them. This is the only code on which you may delete what you keep for that person.
  • grant_suspended: your app is paused for that person. Nothing was deleted; keep their data.
  • refresh_token_expired: the refresh token went 90 days unused. The grant is still active; reconnecting skips the consent page.

Each is a 401 AuthenticationError with retryable: false, userAction: 'reconnect', and origin: 'directory', and the connected client from oauth.client(...) stops refreshing after it, as it already did for grant_revoked and refresh_token_reused. errorCodes and the ErrorCode type gain the three codes. The table of codes and app actions is in When a refresh is refused.

Behavior changes for existing apps:

  • If your app deletes a person's data on grant_revoked, switch it to grant_removed. A removal no longer arrives as grant_revoked. grant_revoked stays (it is not deprecated) and now means only that the directory or runtime refused without naming a reason; never delete data on it.
  • grant_revoked from the token endpoint is now a 401 AuthenticationError, not a 400 BadRequestError. It previously carried the directory's HTTP 400 status; it now has status 401 like the rest of contract 2's grant_revoked. Code that checks error instanceof BadRequestError or error.status === 400 for a refused refresh must check error.code, or error.userAction === 'reconnect', instead.
  • If your app reconnects on grant_revoked, switch on userAction === 'reconnect': every refused-refresh code carries it.

An SDK older than this release maps the three new reasons to grant_revoked, so an app keeps its current behavior until it upgrades.

On this page

0.3.472npx @north-light/sdk init, skill and env --client-id (additive, no app change)0.3.462ProfileDTO.memory_path is removed (profile objects carry only profile_path)0.3.460crouter-sdk keygen releases its key file when the add fails (no app change)0.3.459ProfileDTO.profile_path replaces memory_path (additive, no app change)0.3.455provider.call can target a device (additive, no app change)0.3.452oauth.client(userId) no longer offers methods the runtime does not serve (type change)0.3.445Peer requests between an account's machines (additive, no app change)0.3.423Compatibility is a contract level, not the package version (additive)0.3.419Testing mode: app_in_testing (additive)0.3.415Monthly spend cap enforced; usage.get (additive; one behavior change)npx @north-light/sdk env (additive); keygen on an unjoined machine exits 2One deadline per SDK call, and a deadline on every OAuth/JWKS fetch (additive, with one behaviour change)0.3.409@north-light/sdk/testing: isolated runs start, FakeCall.runId, one runs shape (one breaking change)0.3.408@north-light/sdk/testing (additive)oauth.accountAppUrl() (additive), and a failed discovery read is retried (behaviour fix)0.3.405Run notices, and forwardRunEvents stops on idle (additive; one behavior change)0.3.404crons.create refuses or defaults a cwd the run cannot start in (additive, not breaking)0.3.402RunStream surfaces the runtime's idle close (additive, not breaking)0.3.401Runtime sleep and wake support (additive, not breaking)0.3.400Follow a person's grant with oauth.watchGrants (additive)0.3.399onActivity no longer blocks the watcher0.3.398RunWatcher reports live progress with onActivity (additive)0.3.397A runtime older than the SDK is refused before it handles the request0.3.396RunObject.activity has a submitting_result phase; output_schema runs need only crtr:llm (breaking)0.3.395OAuth2Client owns the connection store; one onRefusal for a dead connection (breaking)RunWatcher: learn when a background run ends (additive)0.3.394grant.get dependencies are one { state, message?, next } entry per provider (breaking)0.3.393The runtime orders grant state by revision; a failed grant check is service_unavailable (breaking)0.3.391The SDK refuses a runtime older than itself (breaking)One release is one version set0.3.390runs.message is idempotent and retried0.3.383Structured requests (additive)0.3.378Identity.picture (additive)0.3.375runs.get reports what a running run is doing (additive)0.3.374Identity carries the person's name (additive)0.3.373CrouterError.code is typed SdkErrorCodeRun ergonomics (additive)0.3.371 (after 0.3.370)A refused refresh says why: grant_removed, grant_suspended, refresh_token_expired