Northlight
DocsSdkGuides

Apps on Northlight

When you are building and registering an app on Northlight, or an app starts runs on a person's runtime and needs data back, read this because it takes you from sign-up to a running app against production, and runs.parse returns a typed, validated result where the transcript is not a data channel.

This page first walks through building and registering an app against production with crtr cloud apps, then covers managing it, then runs.

An app holding a person's connection starts runs on their runtime with runs.start, follows them with runs.events or runs.reply, and reads them with runs.get. This page covers getting data back from a run. For connections and per-person access, see Apps with many users.

Build and register an app

This part takes you from nothing to an app running on your laptop against Northlight's production directory, https://account.northlight.ai. Your app server runs on localhost, you sign in to it through Northlight, and it calls your own runtime. There is no local Northlight stack to run.

You do all of it from the command line. Your laptop joins your Northlight account as an extra machine, and crtr cloud apps … registers and manages the app as you.

The example app texts you, so it asks for the sms provider's sms:send scope. You need Node.js 22.19 or later.

1. Account and join

Open https://account.northlight.ai/signup and create your account. Signup texts a confirmation code to your phone, and that text is sent over iMessage, so use a phone number that has iMessage. A number that cannot receive iMessage gets "We couldn't send the text", and retrying does not help. One phone number belongs to one account.

Install the CLI (npm i -g @north-light/cli). Joining needs crtr 0.3.436 or later (older versions need crtr sys daemon stop first and fail if a viewer is open); crtr --version shows yours.

Then join your laptop to the account:

  1. Open https://account.northlight.ai/account/machines and choose Create join token. A token needs a sign-in within the last 10 minutes, so the page asks you to sign in again if yours is older.

  2. The page shows the join command with the token filled in. On your laptop, run it from a plain terminal: crtr sys join --directory https://account.northlight.ai --token <token>. Leave your crtr windows open: join stops the daemon itself, holds it down while it writes your identity (so an open viewer cannot restart it early), and starts it again on the joined settings. Run it outside any crtr node, because a node's engine stops with the daemon; from inside one, join refuses with join_inside_node. Add --name <label> to name the machine on the page. The token works once and expires at the time the page shows; the page shows it only once.

    crtr sys join --directory https://account.northlight.ai --token <token> --name my-laptop

    It answers with registered: true, the machine id (runtime:<id>), your user id and daemon: restarted (daemon: started when crtr was not running: join starts the daemon, and its first grant sync installs crtr cloud … and your other provider plugins). Your open viewers and nodes reconnect on their own.

  3. Check it: crtr cloud apps mine lists the apps you manage. Before you have joined, it answers not_joined and prints these same steps.

Things to know before you join:

  • Joining rewrites your crtr home. Everything in your everyday crtr home that belonged to the local user (user:local) becomes your Northlight user, the next time the daemon starts. If you would rather not do that to your everyday home, join from a home you can throw away.
  • There is no unjoin. The only undo is removing the machine on /account/machines.
  • Removing the machine stops it. Once you choose Remove next to a machine, it gets no new tokens at once and the ones it holds expire within 5 minutes. After that its cloud commands answer machine_revoked: "This machine was removed from your Northlight account." It stays that way until you create a new join token and run crtr sys join again. Files on the computer stay on it.
  • Your hosted machine is unchanged. /account/machines lists it as "Northlight (hosted)", the default. Your chat agent at https://chat.northlight.ai still runs crtr cloud apps … as before.

2. Install the SDK

mkdir my-app && cd my-app
npm init -y
npm i --save-exact @north-light/sdk@0.3.451

0.3.451 is the version this guide was last checked against on production. To move on purpose, run npm view @north-light/sdk dist-tags, read the release notes for the versions in between, and install the new exact version the same way.

Pin an exact version of @north-light/sdk rather than a range or latest. A compatibility gate between the SDK and Northlight's runtimes protects the wire, so an SDK that is too new for the runtime is refused cleanly. It does not protect the TypeScript API: a later SDK release can change a type or a method your code uses, and an unpinned install picks that up on its next npm i. If a call fails with 426 runtime_version_unsupported, the SDK is newer than the Northlight runtimes. This can happen briefly right after a new SDK release, until production runtimes are upgraded. Install the previous version or wait, and tell Northlight at support@northlight.ai.

3. Register the app

crtr cloud apps register \
  --name "Texty" \
  --origin http://localhost:3000 \
  --logo https://example.com/logo.png \
  --justification "Texts you a summary of your day." \
  --redirect-uri http://localhost:3000/auth/callback
FlagValue
--nameShown to people on the consent page and in their account.
--originWhere your app runs. http://localhost:<port> and http://127.0.0.1:<port> are accepted for development.
--logoRequired. The URL of a square image.
--justificationOne sentence, quoted on the consent page as your words.
--redirect-uriRepeat the flag for each URI. Sign-in returns only to a URI listed here, matched exactly, so localhost and 127.0.0.1 are different URIs and the port counts.

The answer is the registration: the app id (app:<client id>), verified: false and testing: true (see Who can sign in: testing mode), key: {"source":"none"} and an env block with CROUTER_ISSUER, CROUTER_CLIENT_ID and APP_ORIGIN. The client id is the part of the app id after app:. crtr cloud apps get --app-id <client id> shows it again. Until a key is added, exchangeCode throws an AuthenticationError.

--app-id takes app:<client id> or the bare client id everywhere.

4. Generate the signing key and add it

An app signs in with a key, not a client secret. From your app's folder:

npx @north-light/sdk keygen --app <client id> --yes

This writes the private key to local/app-key.json (mode 0600; keep it out of git) and adds its public key to the app through your own crtr (crtr cloud apps keys add). Without --yes it asks first. The answer is "Added key to as an additional key. Your current key still works."; crtr cloud apps keys list --app-id <client id> then shows the key as active. Never paste the private key anywhere.

5. Say what the app asks for

An app can only ask for scopes it has been given. It starts with crtr:llm and crtr:act. See what else it may ask for, then set the list:

crtr cloud apps asks --app-id <client id> --source sms
crtr cloud apps set-scopes --app-id <client id> --add sms:send

asks lists, per source, what the app may ask for and whether it asks now; sms:send is under providers, with the consent sentence "Send you text messages from Northlight's number". set-scopes --add adds to the list the app has now and leaves the rest alone; --remove drops a scope that is in it (crtr:act needs crtr:llm, so drop both together). crtr cloud apps asks --app-id <client id> --requested shows exactly what the app asks for now. To replace the whole list instead, repeat --scope for every scope you want, or pass --clear. The console's What it asks for tab does the same with an Add permissions search. Without sms:send the consent page does not offer it and providers.call for sms is refused. Take scope strings from asks and do not invent them.

If your app does not work without a provider being set up first (Doppel needs Context), name it:

crtr cloud apps set-requires --app-id <client id> --provider context

The person is then taken through that provider's setup page the first time they sign in to your app. Context asks for a profile URL, and its state stays pending for a few minutes afterwards; see Waiting on a provider your app requires.

What each scope gates

A run you start can only do what the grant holds and the run's own scopes list allows. Narrowing a run's scopes removes abilities, so pick the smallest list that works.

ScopeA run (or your server) with it canWithout it
crtr:llmUse the person's AI subscription for the run's model calls. List it in every run's scopes.The run has no model access.
crtr:actStart 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. Ask for it in step 5 and list it in the run's scopes.
crtr:scheduleCreate, pause, resume and run schedules.Schedule calls are refused.
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.
sms:send (a provider scope)Call the sms provider's send tool, which texts the person's own verified phone.providers.call is refused.

Who can sign in: testing mode

A new app is in testing: it is registered as verified: false, 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. To let someone else in, add them by the email or phone number on their Northlight account: crtr cloud apps testers add --app-id <id> --email them@example.com (or --phone +15551234567). An email matches only while it is verified on that account. They can sign in right away; nobody gets a message, so tell them. (Specified behavior: adding a tester, and everything below about a second person signing in, being refused or being removed, has not been run on production yet.) crtr cloud apps testers list --app-id <id> shows who is on the list and testers remove takes someone off. A testing app is also left out of the marketplace listing, even once you set --listed true.

Per the testing-mode specification, anyone else who tries sees " is still in testing", and your app's callback receives error=access_denied with error_description=app_in_testing instead of a code. When sign-in is refused: apps in testing shows how to read it and what to show the person. Removing a tester stops their access at once, including their refresh tokens, and keeps their data; adding them back and signing in again restores it.

When you are ready for everyone, email support@northlight.ai to have the app verified. Only Northlight staff can verify an app; registering never sets verified: true. Apps that existed before testing mode are already verified.

6. Write the environment file

npx @north-light/sdk env --app <client id>

This reads the registration through crtr cloud apps get and writes CROUTER_ISSUER, CROUTER_CLIENT_ID and APP_ORIGIN into local/app.env, keeping every other line and comment in the file. --out <path> writes another file. It never writes CROUTER_KEY_FILE or anything secret. Add the key path yourself:

CROUTER_ISSUER=https://account.northlight.ai
CROUTER_CLIENT_ID=<client id>
APP_ORIGIN=http://localhost:3000
CROUTER_KEY_FILE=local/app-key.json
VariableValue
CROUTER_ISSUERhttps://account.northlight.ai
CROUTER_CLIENT_IDThe client id of your app
APP_ORIGINThe origin you registered. The redirect URI is ${APP_ORIGIN}/auth/callback by convention; it must match a registered redirect URI exactly.
CROUTER_KEY_FILEPath to the private key from step 4. Default local/app-key.json

The file holds no secret, but keep it out of git with the key.

7. Run it locally

Create the config from those variables. The SDK reads no environment; your app passes the values in.

import {readFile} from 'node:fs/promises';
import {OAuth2Client, MemoryConnectionStore} from '@north-light/sdk';

const store = new MemoryConnectionStore(); // one process only; use a persistent ConnectionStore in production
const oauth = new OAuth2Client({
  clientId: process.env.CROUTER_CLIENT_ID!,
  issuer: process.env.CROUTER_ISSUER!,
  redirectUri: `${process.env.APP_ORIGIN}/auth/callback`,
  privateKey: JSON.parse(await readFile(process.env.CROUTER_KEY_FILE ?? 'local/app-key.json', 'utf8')),
  store,
});

Start the server with the file loaded, for example node --env-file=local/app.env server.js.

Sign in. The login route redirects to Northlight; the callback exchanges the code on your server and stores the person's connection. This example uses Express and an in-memory session, which are only a sketch:

import express from 'express';
import {randomBytes} from 'node:crypto';

const app = express();
const pending = new Map<string, {codeVerifier: string; nonce: string}>(); // keyed by state; use a real session

app.get('/auth/login', async (_req, res) => {
  const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({
    scopes: ['openid', 'profile', 'crtr:llm', 'crtr:act', 'sms:send'],
    state: randomBytes(16).toString('base64url'),
  });
  pending.set(state, {codeVerifier, nonce});
  res.redirect(303, url);
});

app.get('/auth/callback', async (req, res) => {
  const state = String(req.query.state);
  const saved = pending.get(state);
  if (!saved || typeof req.query.code !== 'string') return void res.status(400).send('Start sign-in again.');
  pending.delete(state);
  const connection = await oauth.exchangeCode({
    code: req.query.code, codeVerifier: saved.codeVerifier,
    state, expectedState: state, nonce: saved.nonce,
  });
  await store.save(connection);
  res.redirect(303, `/?user=${encodeURIComponent(connection.userId)}`); // set your own session instead
});

app.listen(3000);

Open http://localhost:3000/auth/login. Northlight asks you to sign in and shows the consent page; choose Allow, and the browser returns to your callback with a code (and iss) in the query. Request only the scopes you ticked in step 5.

Start a run and call the sms provider. oauth.client(userId) opens a client on that person's runtime from their stored connection:

const runtime = await oauth.client(userId);

const started = await runtime.runs.start({prompt: 'Reply with exactly the word: pong', scopes: ['crtr:llm', 'crtr:act']});
const run = await runtime.runs.wait(started.run_id); // status 'idle' once the reply is done

// Text the signed-in person's own phone through the sms provider.
const sent = await runtime.providers.call({
  provider: 'sms',
  tool: 'send',
  arguments: {body: 'Hello from my first Northlight app.'},
});
// sent.message_id is the message's id

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, and allows at most 50 texts per person per UTC day. runtime.providers.tools('sms') lists the provider's tools. Getting a result back from a run covers runs.parse.

If a run or call throws because the person's connection ended, handle it in one place with onRefusal; see Apps with many users.

8. Test without a network

Test your app with FakeCloud from @north-light/sdk/testing. Your real OAuth2Client and Crouter talk to it through fetch, so tests never touch production or send a text. See Testing your app.

Manage an app with crtr cloud apps

Everything above is a crtr cloud apps command, and so is the rest of managing an app. They are leaves of Northlight's cloud provider, they run as you, and they need a machine joined to your account (step 1).

Your Northlight chat agent at https://chat.northlight.ai can run the same commands as you, on your hosted runtime. Ask it to run a command and show you the raw output. It can list and change an app (mine and set-scopes were checked on production). It cannot delete one: see Deleting an app. Generate keys on your laptop and give an agent only a public JWK, never the private key.

--app-id takes app:<client id> or the bare client id. The leaves:

CommandWhat it does
crtr cloud apps mineLists the apps you manage.
crtr cloud apps register --name … --origin … --logo … --justification … --redirect-uri …Registers an app and answers its registration, including env (the issuer, client id and origin for step 6). The --redirect-uri flag repeats.
crtr cloud apps get --app-id <id>Shows the registration.
crtr cloud apps update --app-id <id> --name … / --origin … / --logo … / --justification …Changes the profile fields you pass.
crtr cloud apps set-redirect-uris --app-id <id> --redirect-uri …Replaces the whole redirect URI list; repeat the flag for each.
crtr cloud apps asks --app-id <id>Lists what an app may ask for, with whether it asks now. Large sources such as integrations print as a count. Narrow it with --query sms:send, --source sms, --group providers|apps|agents|space, or --query <text>; --requested lists only what the app asks for now. For example crtr cloud apps asks --app-id <id> --source sms.
crtr cloud apps set-scopes --app-id <id> --add … --remove …Edits the scope list the app has now; repeat --add/--remove per scope. Removing a scope that is not in the list is refused. Or replace the whole list with repeated --scope; --clear empties it. crtr:act needs crtr:llm.
crtr cloud apps set-requires --app-id <id> --add … --remove …Edits the providers your app does not work without (for example context). Or replace the list with repeated --provider; --clear empties it.
crtr cloud apps keys add --app-id <id> --jwk '<public JWK>'Adds a signing key beside any existing one. Answers created: false if that key is already on the app. keygen --app calls it for you.
crtr cloud apps keys list --app-id <id>Lists the keys with their kid and active or retired status.
crtr cloud apps keys retire --app-id <id> --kid <kid>Retires a key so it no longer signs in.
crtr cloud apps set-listing --app-id <id> --login-start-url … --text-welcome … (also --listed, --price)Sets the listing fields; pass at least one. A loopback --login-start-url is accepted. --listed true --price free lists the app, so cloud apps install finds it; while the app is in testing it still stays out of the marketplace list.
crtr cloud apps install --app-id <id>Texts an install link to the signed-in person. If they already hold a grant it answers status: already_have and the install URL instead of texting. The app must be listed.
crtr cloud apps testers add --app-id <id> --email … (or --phone …)Lets that person sign in while the app is in testing; at most 100. Not yet run on production.
crtr cloud apps testers list --app-id <id>Lists the testers.
crtr cloud apps testers remove --app-id <id> --email … (or --phone …)Removes a tester and revokes their grants for the app. Not yet run on production.
crtr cloud apps delete --app-id <id> --confirm <client id>Deletes the app and removes every person's grant for it. Only you at a joined machine's terminal can run it: an agent, including your chat agent, is refused with provider_refused.

Deleting an app

crtr cloud apps delete --app-id <client id> --confirm <client id>

It answers deleted: true and removed_grants, the number of people's grants it removed. Each runtime deletes the app's runs, space and schedules, and the app's next refresh fails with invalid_grant. The app's key stops working, the app's page answers 404 and mine no longer lists it. It cannot be undone, and apps that hold the provider role cannot be deleted.

Only you can delete: run it yourself in the terminal of your joined machine. An agent, including your chat agent, is refused with provider_refused: "Only you can delete an app. Ask the person to run crtr cloud apps delete in their terminal." Reusing an app for tests is often simpler: change its redirect URIs and keys instead.

Let people find and install it

login_start_url is where Northlight starts sign-in for a person who installs your app from a texted link (specified behavior: pressing the texted link was not run on production, only the text send was). Your /auth/login route from step 7 is a valid start URL, and a loopback one is accepted:

crtr cloud apps set-listing --app-id <client id> --listed true --price free \
  --login-start-url http://localhost:3000/auth/login \
  --text-welcome "Texts you a summary of your day."

--text-welcome is one or two plain sentences, at most 120 characters, with no links. Agents can install an app for people by text only while it is listed (--listed true). crtr cloud apps install --app-id <client id> sends the signed-in person a text with the install link. Run it only for yourself or for a person who asked: it sends a real text.

Developing alongside your live app

Once your app is live, you can keep developing on your laptop against production while the live app keeps running. There are two ways to do it.

Add your localhost callback to the live app. This is the simplest, and it is fine for most apps. An app can hold several redirect URIs, and each sign-in returns to the one its request names, so your deployed server and your laptop can both sign people in at once:

crtr cloud apps set-redirect-uris --app-id <client id> \
  --redirect-uri https://app.example.com/auth/callback \
  --redirect-uri http://localhost:3000/auth/callback

set-redirect-uris replaces the whole list, so name the live URI too. Give your laptop its own signing key beside the deployed one (keygen --app adds it; an app holds at most 2 active keys), so you never copy the production private key to your laptop. Both servers share everything else: the same people, their grants, the app's space and the scopes the app asks for. A scope you add while developing changes what the live app asks of everyone. The origin, login_start_url and run notice URL each hold one value, so they keep pointing at the deployed app.

Register a separate development app. Do this when you are changing what the app asks for, or need data and grants of its own that live people never touch. Run crtr cloud apps register again with a name such as "Texty (dev)", a localhost origin and redirect URI, and its own key. It starts in testing and stays there, so only you and the testers you add can sign in, and it never appears in the marketplace. Point your laptop's environment file at its client id. This is how Google recommends separating tiers too: a separate project for development and for production.

Rotate keys

Add a new key beside the old one, switch, verify with a real sign-in, then retire the old key:

npx @north-light/sdk keygen --out local/app-key.next.json --app <client id> --yes
# point CROUTER_KEY_FILE at local/app-key.next.json, restart, and sign in once
crtr cloud apps keys list --app-id <client id>
crtr cloud apps keys retire --app-id <client id> --kid <old kid>

The old key keeps working until you retire it, and your app's refreshes keep working after. Rotating your key has the full sequence.

Getting a result back from a run

Pass an output_schema and call runs.parse. The runtime arms the schema on the run's root agent. The agent must submit a value that validates against it. The run settles with that value, and runs.parse returns it as output_parsed, typed from your schema:

import {z} from 'zod';

const Profile = z.object({
  display_name: z.string(),
  bio: z.string(),
  facts: z.array(z.string()),
});

const outcome = await crouter.runs.parse({
  prompt: 'Draft a public profile for me from my notes.',
  output_schema: Profile,
});

if (outcome.kind === 'result') {
  saveProfile(outcome.output_parsed); // {display_name, bio, facts}, typed from Profile
} else if (outcome.reason === 'declined') {
  // The agent could not honestly satisfy the schema.
  const {reason, code, retryable} = outcome.details ?? {reason: 'declined', code: 'declined', retryable: false};
  showDecline(reason, code, retryable);
} else {
  // canceled, deadline_exceeded, or runtime_error (details.crouter_reason)
  showFailure(outcome.reason);
}

The runtime validates the value before it records it. A submission that does not match is refused with its field errors and never appears in runs.get, an event, or a report, so the agent fixes it and resubmits. output_schema accepts a plain JSON Schema object or anything with toJSONSchema(), such as a Zod v4 object. The SDK sends it as a JSON object and validates nothing itself. A schema that is not an object, or is over 256 KB serialized, is refused with invalid_request and param: 'output_schema', and no run is created. A submitted result can be at most 1 MiB. The value is validated exactly as submitted, with no coercion: null is never turned into 0 or "", and "5" is not turned into 5, so a mismatch is refused and the agent resubmits. The schema is one JSON object, so a $ref can point only into itself (#/$defs/x, #anchor) or at another schema document bundled under $defs with its own $id ("$defs": { "common": { "$id": "common.schema.json", ... } } resolves common.schema.json#/$defs/x). Files are never fetched. A schema with a $ref that resolves nowhere inside it, or one that cannot be compiled (a bad pattern), is refused with invalid_request and param: 'output_schema' and no run is created. The agent submits with its submit_result tool, which needs no scope, so a run that only reasons and answers can be started with scopes: ['crtr:llm']. The same value is in runs.get(runId).result, which runs.wait returns unchanged, and on the run's node.settled event.

Do not parse assistant text out of runs.trace to get data back. runs.trace is for showing a person the transcript. Its text is whatever the model said, not a value anything checked.

Showing a background run's progress

An app that lets a run work in the background and polls it reads runs.get(runId).activity for a progress indicator. It is null unless status is running, and otherwise says what the run's root agent is doing now:

phaseThe root agent is
startinglaunching; its first turn has not begun
thinkingreasoning, or waiting on the model before any output
toolcalling a tool: tool.name, and tool.summary (the command's first line or the path it acts on, null while the call is still being written)
submitting_resultwriting or recording its output_schema result
writingstreaming its answer text
between_turnsbetween 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 output_schema result
const run = await crouter.runs.get(runId);
const badge = run.activity?.phase === 'tool' ? `Running ${run.activity.tool.name}` : run.activity?.phase ?? run.status;

activity covers the root only, and it is read each time you call runs.get, so a phase shorter than your poll interval can be missed. last_activity_at is when any agent of the run last did something. Do not read runs.trace for progress: it returns every conversation. To show the text as it streams, follow runs.events or runs.reply instead of polling.

Bounding a run and handling run_idle

No run is bounded in time until deadline lands. To bound one, pass a signal to runs.parse or runs.wait. When it aborts, the helper rejects with the signal's reason and leaves the run alone. Call runs.cancel if you want the run ended:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error('profile took too long')), 10 * 60_000);
let runId: string | undefined;
try {
  const started = await crouter.runs.start({prompt, output_schema: Profile});
  runId = started.run_id;
  const run = await crouter.runs.wait(runId, {signal: controller.signal});
  // …read run.status, run.outcome, run.result
} catch (error) {
  if (controller.signal.aborted && runId) await crouter.runs.cancel(runId);
  throw error;
} finally {
  clearTimeout(timer);
}

runs.parse rejects with APIError code run_idle when the agent stopped without submitting. The runtime reminds the agent of the schema up to three times in a row, then leaves the run idle with the schema still armed. The run is untouched: send runs.message to nudge the agent, which resets the reminders, or end the run with runs.cancel. A question from the run rejects with waiting_on_user unless you pass onQuestion. With onQuestion, parse answers each open question once and keeps waiting.

Waiting on a provider your app requires

An app whose directory registration requires a provider (Doppel requires Context) needs that provider set up for the person before the operations that depend on it work. The runtime does not check this for you. grant.get() returns dependencies: one ProviderDependency per required provider, keyed by provider id, or {} when the app requires none. Read it on each request that needs the provider; do not store it.

import type { ProviderDependency } from '@north-light/sdk';

// `ProviderDependency` is { state: SetupState; message?: string; next: string }.
const { dependencies } = await runtime.grant.get();
const context: ProviderDependency | undefined = dependencies['context'];
if (context?.state !== 'ready') {
  // Show the provider's own sentence and link its setup page; never hard-code either.
  const setup = context && new URL(context.next);
  setup?.searchParams.set('return_to', 'https://app.example.com'); // your registered redirect origin
  // AppError: your app's own typed error (see the error envelope).
  throw new AppError('provider_setup_pending', context?.message ?? 'Finish setting up Context, then try again.', {
    status: 403,
    retryable: false,
    details: { provider: 'context', state: context?.state ?? 'not_started', message: context?.message, next: setup?.href },
  });
}
  • state is not_started (the person has not done their part), pending (the provider is working), ready, or failed. To act when it turns ready (for example, start work the person was waiting on), follow it with oauth.watchGrants (Learn when a person's grant changes) instead of polling grant.get().
  • message is the provider's own sentence for pending and failed, the same words the directory's setup page shows. It is absent for not_started and ready, so keep a short fallback for not_started.
  • next is the absolute URL of the directory's setup page for this provider and your app, where the person does their part or retries a failure. Append return_to (your app's registered redirect origin) so Continue on that page brings them back to you.
  • An entry is missing when the runtime has not synced the grant since the app's registration started requiring the provider; treat it as not_started.

Refuse the dependent operation with provider_setup_pending (403, in errorCodes), carrying the entry's state, message and next in details so your front end can render them without another call.

On this page