Background runs
Learn when a run your app started and did not wait on has ended, with RunWatcher or run notices.
A run your app starts and does not wait on (a draft, a scheduled job) ends while nobody is holding a request. Two tools tell your app. Use RunWatcher while your server is running. Add run notices when the person's runtime sleeps and your app must wake on output.
Follow runs with RunWatcher
oauth.watchRuns(...) returns a RunWatcher that follows those runs and hands each ending to onEnded, so you write no poll loop:
const watcher = oauth.watchRuns({
pending: () => db.drafts.findMany({where: {status: 'drafting'}, select: {userId: true, runId: true}}),
onEnded: async ({userId, runId, ended, run}) => {
// Idempotent: only a row still waiting on this run moves.
if (ended === 'gone') await db.drafts.updateMany({where: {runId, status: 'drafting'}, data: {status: 'failed'}});
else await db.drafts.updateMany({where: {runId, status: 'drafting'}, data: {status: 'ready', result: run.result}});
},
onError: (error, run) => log.warn({error, run}, 'run watcher'),
});
await watcher.start();
// After starting a run the app will watch:
watcher.nudge();
// On shutdown:
await watcher.stop();pending()returns{userId, runId}for every run the app still waits on, read from your own rows. The rows are the durable record: a restarted watcher re-derives everything from them. It is called atstart(), everysweepMs(default 30 000), after each delivery and onnudge(). Stop returning a run onceonEndedhas landed it.- Each run is followed with
runs.get(runId, {wait})onoauth.client(userId)until its status issettled,idleorwaiting_on_user. ThenonEnded({userId, runId, ended, run})receives that status asendedand theRunObject. At mostconcurrency(default 50) long-polls are open at once. - Delivery is at least once per
(runId, status, latest_sequence). The key is held in memory, so a restart may deliver an ending again: guard the write on the row still waiting on that run. gone(run: null) is delivered once when the runtime answers 404not_foundwithorigin: 'runtime'(the run was deleted). A 404 withorigin: 'router'is a transient failure, notgone.- If
onEndedthrows, the same ending is retried after 5 s, doubling to 5 min, and the error goes toonError. A missing connection (not_connected) or a refused one (whichonRefusalalready handled) drops that run's loop and reports toonError; the next sweep decides again frompending(). stop()aborts every open long-poll and resolves once every loop has ended.
Live progress
To show what a run is doing without a second runs.get per page, pass onActivity: async ({userId, runId, activity, run}) => …. It is called with each read of a still-running run whose activity differs from the last one handed over, and never for a run at rest. While it is set, running runs are read with wait: activityWait (integer 1–25 s, default 2) instead of 25 s. The hook is best effort and never awaited: a throw goes to onError and is not retried, and it never delays or changes onEnded. A call can finish after that run's onEnded, so guard the write on the row still waiting. The phases are listed in Typed results.
Run notices
A hosted runtime that sleeps cannot be polled, so it can tell your app when a run has output nobody is reading. If your app has a run notice URL, the runtime sends POST <run notice URL> with Authorization: Bearer <directory token> and body {run_id, latest_sequence}. It sends one when events are appended to one of your runs and no event 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 drops it; a lost notice delays output until your app next reads from its cursor, and never loses output.
Check each notice with verifyRunNotice:
import {RunNoticeError, verifyRunNotice} from '@north-light/sdk';
app.post('/run-notices', async (req, res) => {
try {
const notice = await verifyRunNotice(toFetchRequest(req), {
runNoticeUrl: 'https://app.example.com/run-notices', // exactly as registered
issuer: 'https://account.northlight.ai',
grantee: 'app:<your client id>',
});
await storeAndReadFrom(notice.userId, notice.runId, notice.latestSequence); // then read events from your cursor
res.sendStatus(204);
} catch (error) {
if (error instanceof RunNoticeError) return res.sendStatus(401); // store nothing
throw error;
}
});verifyRunNotice checks the token's signature, issuer, audience (your run notice URL), expiry, scope crtr:run_notice and grantee, and returns {userId, runtime, runId, latestSequence}. runtime comes from the token, never the body. Answer 2xx once the notice is stored. The same or a lower latest_sequence is a no-op. In tests, pass fetch: cloud.fetch from FakeCloud (Test your app).
Set the run notice URL
The console has no field for this yet. Until it does, set it with crtr on a machine joined to your Northlight account (crtr sys join; see joining a machine in the crouter docs):
crtr cloud apps set-run-notice-url --app-id <client id> --url https://app.example.com/run-notices
crtr cloud apps set-run-notice-url --app-id <client id> --clear # stop noticesThe URL must be https (plain http only on localhost in development) and at most 512 characters. crtr cloud apps get --app-id <client id> shows it as run_notice_url.