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.jsonwith@north-light/sdkpinned to the CLI's own version plustsx,server.ts,.gitignore,local/app.env, a signing key inlocal/app-key.json) and installs thenorthlightAgent Skill into.agents/skillsand.claude/skills. In a directory that already has apackage.jsonit writes no app code: it runsnpm i --save-exact @north-light/sdk@<this version>, makes a key iflocal/app-key.jsonis 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'smetadata.sdk-versionis the package version; runskillagain after changing the pinned SDK version.env --client-id <id> [--origin <url>] [--issuer <url>] [--out <path>]writesCROUTER_ISSUER,CROUTER_CLIENT_IDandAPP_ORIGINintolocal/app.envand 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
nextdist-tag;latestmoves only once production serves its level.@latesttherefore always works against production. - The SDK now tolerates values it does not know: an unknown run event
typeis passed to your listener as-is, and an unknown runstatusendsruns.waitand the run watcher, reporting the raw status (RunEnded.endedcan be'unknown'). Handle a status or event type you do not recognize rather than assuming the union is closed. FakeCloudtakescontractandroutedContractoptions 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 answersinvalid_grantapp_in_testing. The refresh refusal is a newRefusalwithcode: 'app_in_testing'andkind: 'paused', likegrant_suspended: keep the person's data. If youswitchexhaustively onrefusal.code, add the case. See When a refresh is refused. - New
authorizationRefusalOf(callback)reads the sign-in refusal from the callback's query and returnsAuthorizationRefusal({code: 'app_in_testing', error: 'access_denied', state}), elsenull. 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'inFakeRefusalCode. The fake directory now servesgrant_type=authorization_codefor codescloud.authorizeissued. 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'sspend_cap(fromgrant.get(); Northlight sets $50) for the current UTC month. Once the month reaches it, the next call answers 402cap_exhausted(aRateLimitError,retryable: false,userAction: 'raise_cap',resetAtthe first instant of next month) before any model or provider is called. A run whose model call is refused this way ends with outcome reasoncap_exhausted. The check runs before each call, so the call that crosses the cap still completes. App-paid usage never counts. A grant withspend_cap: nullis not capped. - New
client.usage.get({month?})(monthisYYYY-MM, UTC; default the current month) answersSpendUsageDTO:usd_equivalent(user-paid spend, provider receipts included),by_model,app_paid,spend_capandreset_at. See Show what a person's month has cost. - A completion's
usage.usd_equivalentis now the served model's list-price cost; before it was alwaysnull. It staysnullfor 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>]writesCROUTER_ISSUER,CROUTER_CLIENT_IDandAPP_ORIGINfromcrtr cloud apps getintolocal/app.env, keeping every other line. See Go live with an app key. keygen --appon a machine whosecrtrisn't joined to a Northlight account now prints the join step and the manual steps and exits 2. A missingcrtrkeeps 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.
RequestOptionsgainsbudgetMsanddeadlineAt: one absolute deadline for the call covering every attempt, every retry wait (including aRetry-After) and the token fetch/refresh. A wait that would pass the deadline rejects at once with the newDeadlineExceededError(code: 'deadline_exceeded',reason: 'elapsed' | 'retry_after',deadlineAt,cause), which extendsAPIConnectionTimeoutError, so code catching that still catches it. A transport that ignores itsAbortSignalcan no longer outlive the deadline. WithoutbudgetMs/deadlineAtnothing about retries changes. See One deadline per call.- An app that holds a lease or lock while it makes an SDK call should pass
budgetMsand 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_provisioningretries and theirretry_after_s). A refresh under the connection-store lock now holds the lock at mosttimeoutMs.JwksCache(and soverifyIdToken) 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
DeadlineExceededErrorwhere it used to wait; settimeoutMsto change it. OAuth2Clientvalidates the discovery document:authorization_endpoint,token_endpoint,jwks_uri(andrevocation_endpoint, when present) must be absolute https URLs (http:when the issuer ishttp:), andtoken_endpoint_auth_methods_supportedan array of strings. A wrong type is aCrouterErrornaming the field instead of a laterTypeError.
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 onFakeCloud, as it does on a Northlight guest; it used to answer 503isolated_unavailable.run.params.isolatedrecords it, and acwdoutside the run's root folder is 400param: cwd, as the runtime checks it. The new optionisolatedRuns: 'unavailable'restores the 503 for testing that path. If you worked around the refusal by deletingbody.isolatedin an intercept, remove the intercept.FakeCallgainsrunIdandquestionId(the ids the path addresses, elsenull), so tests no longer splitpath.FakeCall.userIdis now the bearer's person whenever the cloud knows them, including a person with no refresh token; it isnullonly for a missing or unknown bearer.- Breaking:
cloud.user(id).runsis now{all, respond}, the same shape ascloud.runs, instead of an array carryingrespond. Replaceuser.runs[i],user.runs.find(...)and[...user.runs]withuser.runs.all…. testing.mddocuments every SDK retry that absorbs afailNextanswer (401:times: 2; 5xx or 429 on GET and DELETE:times: 3; a retryable answer onrun.startorrun.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 throwsCrouterErrornaming 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 laterauthorizeUrl,exchangeCode, refresh andverifyIdTokenfailed 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_urlreceivesPOST <run_notice_url>withAuthorization: Bearer <directory token>and body{run_id, latest_sequence}, sent when events are appended to one of its runs and norun.eventsstream 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 logsrun_notice.failedand 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, scopecrtr:run_notice,grantee) and the body, and returns{userId, runtime, runId, latestSequence}.runtimecomes from the token'sact.sub, never the body. Any failure throwsRunNoticeError(status401): answer 401 and store nothing. Answer 2xx once the notice is stored; the same or a lowerlatest_sequenceis a no-op. - Behavior change: when the runtime idle-closes the stream,
forwardRunEventsnow writesdata: {"type":"stream.idle","cursor":N}and ends the response, instead of reconnecting. Browser code should close itsEventSourceonstream.idleand reopen withafter=cursoron 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.
cwdis now optional oncrons.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: truemarks acwdyou did not choose (a shell's current directory); an unreachable one is replaced by the same default instead of refused. - A
cwdyou chose that the run cannot reach is refused at create time with 400invalid_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.cwdon 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.idleistrueonce the runtime sentevent: idleand closed. Iteration ends normally (no throw);finalOutcome()is not resolved, because the run did not settle.stream.positionis thesequence_numberof the last event delivered (theafteryou passed until one arrives). Reopen withruns.events(runId, {after: stream.position})when you want to read again.stream.whenEnded()resolves{reason: 'settled' | 'idle', cursor}(new exportRunStreamEnd) and rejects with the failure orAPIUserAbortError, 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 onturn.completedorrun.settled.- Nothing else changes: any close without the idle frame reconnects as before, and a consumer that ignores
idlesees the iteration end where it used to see a reconnect. The frame is not a run event and is not delivered toon(...)listeners.forwardRunEventsstill holds the browser's response through an idle close, reconnecting as before; useruns.eventsdirectly 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.eventsSSE stream now ends withevent: idle(data: {}) and closes after 60 s in which the run has no running turn and no new event. The SDK'sRunStreamat 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: 1on the app listener whose response head is not ready 40 s after arrival gets200 application/vnd.crtr.deferred-head, thencrtr-deferred-head/1\n, a\nevery 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 itsoutput_schemaresult,runs.get(runId).activityis{phase: 'submitting_result'}. It used to be{phase: 'tool', tool: {name: 'bash', summary: 'crtr push result …'}}. Aswitchonactivity.phasethat assumed the old set must handle the new value; an app that matchedcrtr push resultintool.summaryshould map this phase instead and delete that check. - An
output_schemarun no longer needscrtr:act. The agent submits through itssubmit_resulttool, which needs no scope, so a run started withscopes: ['crtr:llm']can finish. Dropcrtr:actfrom runs that only granted it so the agent could submit. - A run without
crtr:actis not offeredbash. 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 nowawait oauth.client(userId). It loads the stored connection and returns aConnectedCrouter(aCrouterwithuserIdandruntimeUrl). Nothing stored throwsCrouterErrorcodenot_connected. SaveexchangeCode's connection withstore.save(connection)first.oauth.refreshIdleConnections(store, {olderThanMs})is nowoauth.refreshIdleConnections({olderThanMs})and returns{refreshed, refused, failures}.RefreshFailure.reasonis removed: a refused connection is handled byonRefusal, removed, and counted inrefused;failuresholds only connections still stored (any other error, or a refusal whose handler threw).refreshRefusal(error)and theRefreshRefusaltype are removed. UseonRefusal(refusal.kindis'removed' | 'paused' | 'reconnect', typeRefusalKind) to act on the connection, andrefusalOf(error)to answer one request.ConnectionStore.lockis now required, andremove(userId, refreshToken)is new and required.removedeletes the row only while it still holds that refresh token and answers whether it deleted one.MemoryConnectionStoreandPostgresConnectionStoreimplement both; a custom store must add them. The SDK never runs app code underlockand never throws out of it, so a Postgres store's transaction commits.onRefusalruns 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'sAuthenticationError, on whichrefusalOf(error)returns theRefusal. If the handler throws, the connection is kept, the caller gets the refusal error withcauseset to what the handler threw, the client does not latch it (the next call refreshes and runs the handler again), andrefreshIdleConnectionsreports it infailures. A handler that callsoauth.clientor a refresh for the same user throwsCrouterErrorcoderefusal_handler_reentryinstead of waiting on itself.refusalOfreturns aRefusalonly for an error the SDK raised while refreshing a stored connection, not for every directory-originAuthenticationError.- 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 togrant_removed. A removal no longer arrives asgrant_revoked.grant_revokedstays (it is not deprecated) and now means only that the directory or runtime refused without naming a reason; never delete data on it. grant_revokedfrom the token endpoint is now a 401AuthenticationError, not a 400BadRequestError. It previously carried the directory's HTTP 400 status; it now has status 401 like the rest of contract 2'sgrant_revoked. Code that checkserror instanceof BadRequestErrororerror.status === 400for a refused refresh must checkerror.code, orerror.userAction === 'reconnect', instead.- If your app reconnects on
grant_revoked, switch onuserAction === '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.