NorthlightSDK

Quick setup

From no account to a running app that signs a person in with Northlight, starts a run on their runtime and sends them a text.

npx @north-light/sdk@latest init my-app

This command creates a small Node server, a signing key and an env file. About ten minutes later you have an app on your laptop that signs you in with Northlight, starts an agent run on your own Northlight runtime, and texts your phone. The app runs on your machine and talks to Northlight's production directory at https://account.northlight.ai. There is no local Northlight stack to run, and you do not need crouter.

You need Node.js 22.19 or later and a phone that can receive iMessage.

Using a coding agent? Run the same init command, or tell your agent: Read https://sdk.docs.northlight.ai/SKILL.md and follow it. See Use your coding agent.

Each step below ends with what you should see when it worked. A step marked "(not yet checked on production)" is written from the source code and has not been run end to end against account.northlight.ai.

1. Create an account

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. 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.

You should see: your account's page after you enter the code. Opening https://account.northlight.ai/console while signed in shows the console, where your apps are listed. Checked on production: /signup redirects to the phone step at /signup/phone, and /console while signed out redirects to sign-in. Completing signup and the signed-in console are not yet checked on production.

2. Scaffold the app

npx @north-light/sdk@latest init my-app
cd my-app

init writes these files into my-app/:

FileWhat it is
package.jsonPins @north-light/sdk to the exact version of the CLI you ran, and adds tsx.
server.tsThe server shown at the end of this page.
local/app-key.jsonYour app's private signing key, mode 0600. Never commit it or paste it anywhere.
local/app.envThe settings the server reads: CROUTER_ISSUER, APP_ORIGIN, an empty CROUTER_CLIENT_ID= and CROUTER_KEY_FILE.
.gitignoreKeeps local/ and node_modules/ out of git.
.agents/skills/northlight/SKILL.md and .claude/skills/northlight/SKILL.mdThe Northlight skill for your coding agent.

You should see, on the terminal: Created a Northlight app in my-app: with the file names, the path of the private key, the next steps in order, and last, on its own line, the public key as one line of JSON:

{"kty":"EC","x":"…","y":"…","crv":"P-256","kid":"…","alg":"ES256","use":"sig"}

Copy that line. You paste it into the console in the next step. If a file already exists, init refuses, names it and exits non-zero without writing anything.

Checked: this output and these files, from the SDK source built on this branch in an empty directory with no crtr on PATH. npx @north-light/sdk@latest fetches the npm latest release, and that release does not include init until the site goes live (not yet checked on production).

3. Register the app in the console

Open https://account.northlight.ai/console/new. The page is titled Register an app. Fill in:

FieldValue
NameShown to people on the consent page and in their account.
Origin URLhttp://localhost:3000
Logo URLRequired. A square image shown beside your app's name.
Why your app needs accessOne sentence. It is quoted on the consent page as your words.
Redirect URIshttp://localhost:3000/auth/callback, one URL per line. Sign-in returns only to a URI listed here, matched exactly. localhost and 127.0.0.1 are different URIs, and the port counts.
Public key (optional)The JSON line init printed.

Choose Register app.

You should see: a page titled "your app's name is registered" with a Client ID and, under it, the key ID of the key you pasted. Copy the client ID. If it says "Next: add your app's public key on the Sign-in tab", the key was left out: open the app's Sign-in tab and choose Add key. The app starts asking for crtr:llm and crtr:act.

The field labels are from the console's source. Pasting a key at registration is not yet checked on production.

4. Add the permission to text

On the app's page, open the What it asks for tab and choose Add permissions. Add sms:send from the list.

You should see: sms:send under what your app asks for, next to crtr:llm and crtr:act. The server in this guide asks for all three, so without sms:send the consent page does not offer it and the text in step 6 is refused. Take scope strings from this list. Do not invent them. See Scopes and grants.

Not yet checked on production.

5. Write the client ID into the env file

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

You should see:

Set in local/app.env:
  CROUTER_ISSUER=https://account.northlight.ai
  CROUTER_CLIENT_ID=<client id>
  APP_ORIGIN=http://localhost:3000
Key file: local/app-key.json (set CROUTER_KEY_FILE to change it).

local/app.env now has all four settings. The command makes no network call and keeps every other line in the file. Add --origin <url> if your app does not run on http://localhost:3000.

Checked from the SDK source built on this branch.

6. Run it

npm i
node --env-file=local/app.env --import tsx server.ts

(npm start runs the same command.) You should see Listening on http://localhost:3000.

Open http://localhost:3000/auth/login. Your browser goes to account.northlight.ai. Sign in if asked, read the consent page, and choose Allow. You land back on http://localhost:3000/ with "Signed in" and your Northlight user id. Press Start a run and send me a text. The page answers "Done", shows the run's status idle, and your phone receives "Hello from my first Northlight app." from Northlight's number.

What was checked: the server starts, / shows the sign-in link, and /auth/login answers 303 to https://account.northlight.ai/authorize?client_id=…&scope=openid+profile+crtr%3Allm+crtr%3Aact+sms%3Asend…&code_challenge_method=S256…. The consent page, the callback, the run and the text are not yet checked on production.

If / shows "One step left", CROUTER_CLIENT_ID is empty: repeat step 5 and restart. If a call fails with 426 runtime_version_unsupported, your SDK is newer than the Northlight runtimes. Install the previous version with npm i --save-exact @north-light/sdk@<version> and tell support@northlight.ai.

7. What next

  • Only you can sign in until you add testers: see Testing mode.
  • Keep each person's sign-in across restarts and processes: see Connections.
  • See what server.ts does and where each piece runs: Where your app runs.

The server init writes

This is examples/apps/quickstart-server.ts. The SDK reads no environment: the server passes the values in.

// The server `npx @north-light/sdk init` writes as server.ts: sign a person in with Northlight, then start a run on
// their runtime and send them a text. It keeps sign-in state in memory, so it is a sketch for one process, not production.
import {createServer, type IncomingMessage, type ServerResponse} from 'node:http';
import {randomBytes} from 'node:crypto';
import {readFile} from 'node:fs/promises';
import {MemoryConnectionStore, OAuth2Client, authorizationRefusalOf} from '@north-light/sdk';

const origin = process.env.APP_ORIGIN ?? 'http://localhost:3000';

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 ?? 'https://account.northlight.ai',
  redirectUri: `${origin}/auth/callback`,
  privateKey: JSON.parse(await readFile(process.env.CROUTER_KEY_FILE ?? 'local/app-key.json', 'utf8')),
  store,
});

const pending = new Map<string, {codeVerifier: string; nonce: string}>(); // sign-ins in flight, keyed by state
const sessions = new Map<string, string>(); // session id -> userId; use a real session store in production

const escapeHtml = (text: string) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
const page = (res: ServerResponse, status: number, body: string) => {
  res.writeHead(status, {'content-type': 'text/html; charset=utf-8'}).end(`<!doctype html><meta charset="utf-8"><title>Northlight app</title><body style="font-family:system-ui;max-width:40rem;margin:3rem auto">${body}`);
};
const userOf = (req: IncomingMessage) => {
  const sid = /(?:^|; )sid=([\w-]+)/.exec(req.headers.cookie ?? '')?.[1];
  return sid ? sessions.get(sid) : undefined;
};

const server = createServer(async (req, res) => {
  const url = new URL(req.url ?? '/', origin);
  try {
    if (!process.env.CROUTER_CLIENT_ID) {
      return page(res, 200, '<h1>One step left</h1><p>Register the app in the console, then run <code>npx @north-light/sdk env --client-id &lt;client id&gt;</code> and restart this server.</p>');
    }

    if (url.pathname === '/auth/login') {
      const {url: authorizeUrl, 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});
      return void res.writeHead(303, {location: authorizeUrl}).end();
    }

    if (url.pathname === '/auth/callback') {
      if (authorizationRefusalOf(url)?.code === 'app_in_testing') {
        return page(res, 403, '<h1>This app is still in testing</h1><p>Ask its owner to add you on the Testers tab in the console.</p>');
      }
      const state = url.searchParams.get('state') ?? '';
      const code = url.searchParams.get('code');
      const saved = pending.get(state);
      if (!saved || !code) return page(res, 400, '<p>Start sign-in again at <a href="/auth/login">/auth/login</a>.</p>');
      pending.delete(state);
      const connection = await oauth.exchangeCode({code, codeVerifier: saved.codeVerifier, state, expectedState: state, nonce: saved.nonce});
      await store.save(connection);
      const sid = randomBytes(16).toString('base64url');
      sessions.set(sid, connection.userId);
      return void res.writeHead(303, {location: '/', 'set-cookie': `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`}).end();
    }

    const userId = userOf(req);
    if (!userId) return page(res, 200, '<h1>Northlight app</h1><p><a href="/auth/login">Sign in with Northlight</a></p>');

    if (url.pathname === '/run' && req.method === 'POST') {
      const runtime = await oauth.client(userId); // a client on this person's runtime, from their stored connection
      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 is 'idle' once the reply is done
      // Texts the signed-in person's own verified phone through the sms provider (50 per person per UTC day).
      const sent = await runtime.providers.call({provider: 'sms', tool: 'send', arguments: {body: 'Hello from my first Northlight app.'}});
      return page(res, 200, `<h1>Done</h1><p>The run finished with status <code>${escapeHtml(run.status)}</code>. A text is on its way to your phone.</p><pre>${escapeHtml(JSON.stringify({run: run.run_id, outcome: run.outcome, report: run.last_report, text: sent}, null, 2))}</pre><p><a href="/">Back</a></p>`);
    }
    return page(res, 200, `<h1>Signed in</h1><p>You are <code>${escapeHtml(userId)}</code>.</p><form method="post" action="/run"><button>Start a run and send me a text</button></form>`);
  } catch (error) {
    console.error(error);
    return page(res, 500, `<h1>Something failed</h1><pre>${escapeHtml(error instanceof Error ? error.message : String(error))}</pre>`);
  }
});

server.listen(Number(new URL(origin).port) || 3000, () => console.log(`Listening on ${origin}`));

On this page