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_schemaaccepts a plain JSON Schema object or anything withtoJSONSchema(), 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_requestandparam: '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 into5. - A
$refcan point only into the schema itself (#/$defs/x,#anchor) or at another schema document bundled under$defswith its own$id. Files are never fetched. A$refthat resolves nowhere, or a schema that cannot be compiled (a badpattern), is refused withinvalid_requestandparam: 'output_schema'. - The agent submits with its
submit_resulttool, which needs no scope, so a run that only reasons and answers can start withscopes: ['crtr:llm']. - The same value is in
runs.get(runId).result, whichruns.waitreturns 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:
phase | The root agent is |
|---|---|
starting | launching; its first turn has not begun |
thinking | reasoning, or waiting on the model before any output |
tool | calling a tool: tool.name, and tool.summary (null while the call is still being written) |
submitting_result | writing or recording its output_schema result |
writing | streaming its answer text |
between_turns | between 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.