NorthlightSDK
Guides

Typed results

Start a run with a schema and get back a value that was checked against it.

Pass an output_schema and call runs.parse. The runtime arms the schema on the run's root agent, which 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';
import type {ConnectedCrouter} from '@north-light/sdk';

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

/** Start a run on the person's runtime and get a value back that was checked against the schema. */
export async function draftProfile(runtime: ConnectedCrouter) {
  const outcome = await runtime.runs.parse({
    prompt: 'Draft a public profile for me from my notes.',
    scopes: ['crtr:llm'], // submitting a result needs no other scope
    output_schema: Profile,
  });

  if (outcome.kind === 'result') return outcome.output_parsed; // {display_name, bio, facts}, typed from Profile
  if (outcome.reason === 'declined') {
    // The agent could not honestly satisfy the schema; outcome.details says why.
    throw new Error(`Declined: ${outcome.details?.reason ?? 'no reason given'}`);
  }
  throw new Error(`The run ended without a result: ${outcome.reason}`); // canceled, deadline_exceeded or runtime_error
}

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 JSON 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: "5" is not turned into 5.
  • A $ref can point only into the schema itself (#/$defs/x, #anchor) or at another schema document bundled under $defs with its own $id. Files are never fetched. A $ref that resolves nowhere, or a schema that cannot be compiled (a bad pattern), is refused with invalid_request and param: 'output_schema'.
  • The agent submits with its submit_result tool, which needs no scope, so a run that only reasons and answers can start with scopes: ['crtr:llm'].
  • The same value is in runs.get(runId).result, which runs.wait returns unchanged.

Do not parse assistant text out of runs.trace to get data back. runs.trace is for showing a person the transcript.

A run that declines

An agent that cannot honestly satisfy the schema declines. A decline, a cancel, a deadline and a runtime error all come back as an outcome. They do not throw. Narrow on outcome.kind and outcome.reason, as the example does.

Stopping without a result: run_idle

runs.parse rejects with an APIError with 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 (this resets the reminders), or end it 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.

Bounding a run

No run is bounded in time by default. 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('took too long')), 10 * 60_000);
let runId: string | undefined;
try {
  const started = await runtime.runs.start({prompt, output_schema: Profile});
  runId = started.run_id;
  const run = await runtime.runs.wait(runId, {signal: controller.signal});
  // read run.status, run.outcome, run.result
} catch (error) {
  if (controller.signal.aborted && runId) await runtime.runs.cancel(runId);
  throw error;
} finally {
  clearTimeout(timer);
}

Showing progress

For a run you poll, runs.get(runId).activity is null unless status is running, and otherwise says what the root agent is doing:

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 (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
const run = await runtime.runs.get(runId);
const badge = run.activity?.phase === 'tool' ? `Running ${run.activity.tool.name}` : run.activity?.phase ?? run.status;

activity covers the root agent only, and it is read each time you call runs.get, so a phase shorter than your poll interval can be missed. To show text as it streams, follow the events instead: see Stream a run. To be told when a background run ends, see Background runs.

On this page