Runs
A run is one turn of the agent loop on one thread. When subagents are enabled, that turn can fan out into delegated sub-streams under the same root run.
Methods
| Method | Route |
|---|---|
| novo.runs.list({...}) | GET /v1/runs |
| novo.runs.start({...}) | POST /v1/runs |
| novo.runs.stream(runId) | GET /v1/runs/:id/stream |
| novo.runs.workers.stream(rootRunId, workerRef) | GET /v1/runs/:id/workers/:workerRef/stream |
| novo.runs.get(runId) | GET /v1/runs/:id |
| novo.runs.cancel(runId) | POST /v1/runs/:id/cancel |
| novo.runs.followups.withdraw(runId, followupId) | DELETE /v1/runs/:id/followups/:followupId |
Type signature
type RunsListParams = {
agentId?: string;
threadId?: string;
status?: RunStatus;
/** ISO datetime. Returns runs whose createdAt >= createdSince. */
createdSince?: string;
cursor?: string;
/** Default 20, max 100. */
limit?: number;
};
type RunsNamespace = {
list(input?: RunsListParams): Promise<Page<Run>>;
start(input: RunStartInput): Promise<RunReceipt>;
stream(runId: string): Promise<RunStream>;
workers: {
stream(rootRunId: string, workerRef: string): Promise<WorkerStream>;
};
get(runId: string): Promise<Run>;
cancel(runId: string): Promise<Run>;
followups: {
withdraw(
runId: string,
followupId: string,
): Promise<{ id: string; object: 'run.followup'; deleted: true }>;
};
};
type RunStartInput = {
threadId: string;
input: AgentInput;
band?: Band; // 'core' is the default when omitted; see the Bands API for offered bands
/** Emit thinking/activity progress, including safe reasoning-derived summaries when available; never raw reasoning. Default false. */
streamProgress?: boolean;
/** Emit raw provider reasoning when the active profile exposes it. Default false. */
streamReasoning?: boolean;
dataHandling?: 'standard' | 'strict';
/** IANA timezone for this run's built-in date/time context. Defaults to the thread default, then UTC. */
timeZone?: string;
/** JSON Schema for this run's structured result; see Structured output below. */
outputSchema?: Record<string, unknown>;
budgetCents?: number;
maxSteps?: number;
maxDurationMs?: number;
metadata?: Metadata;
idempotencyKey?: string;
};
RunStartInput.input accepts either a string or text plus staged file attachments:
type AgentInput =
| string
| { text: string; attachments?: Array<{ fileId: string }> }
| { text?: string; attachments: [{ fileId: string }, ...Array<{ fileId: string }>] };
Create files first with novo.files. The run stores each staged file as a run-scoped managed artifact and gives the agent a compact manifest with existing tool hints such as view_media, document_read, or workspace_import_artifact.
Object input must include either non-empty text or at least one attachment.
Thinking and activity progress
Pass streamProgress: true to receive a provider-neutral data-novo-progress
snapshot across thinking, public tool work, and waiting. When safe readable
reasoning is available, Novo may emit a short reasoning-derived summary; this
surface never emits raw reasoning. Render the latest event in place by its
stable id; the browser reducer exposes the same value as state.progress.
Reasoning-capable steps may set kind: 'summary' and
summaryAvailable: true. Tool and waiting phases use deterministic runtime
activity with kind: 'activity'. If a step has no safe readable reasoning,
Novo keeps the activity generic and leaves summaryAvailable: false.
Progress is intentionally separate from streamReasoning: enabling progress
does not expose raw reasoning, enable reasoning, or change the model's reasoning
effort or budget. The first answer text, a model-step restart, a pause, and a
terminal event clear state.progress; there is no separate clear event.
List
Page through recent runs with filters:
const page = await novo.runs.list({
agentId: 'agent_…',
status: 'failed',
createdSince: '2026-05-01T00:00:00Z',
limit: 50,
});
for (const run of page.data) console.log(run.id, run.status);
if (page.hasMore && page.nextCursor) {
const next = await novo.runs.list({ cursor: page.nextCursor });
}
Returns a Page<Run>:
{
object: 'list',
data: Run[],
hasMore: boolean,
nextCursor: string | null,
limit: number
}
No total. Sort: descending by createdAt.
Start + stream: the split pair
When the process that starts a run is not the process that consumes its stream (webhook handler kicks the run, worker tails the stream), split the call into runs.start and runs.stream:
// In the webhook handler:
const { runId } = await novo.runs.start({
threadId,
input: 'Refund order #ABC?',
idempotencyKey: webhookEventId, // safe replay
});
await db.runs.insert({ runId, ticketId });
// In a worker, later:
const stream = await novo.runs.stream(runId);
for await (const event of stream) { … }
The Idempotency-Key makes runs.start safe to retry for 24 hours, so Stripe webhook redeliveries within that window won't kick a duplicate run. If the original request is still pending, retries return 503 service_unavailable until its result is stored; Novo does not infer failure from the request's age and start another run.
Data handling
Pass dataHandling: 'strict' on runs.start or threads.stream to require verified zero-data-retention, no-training, and providers based in the US or Europe (EU and UK) for AI inference routes, with matching strict gates for managed capabilities that receive customer data. Novo may satisfy the requirement through verified BYOK ZDR or a managed system-ZDR route; if the resolved band or enabled managed capability has neither, the call is rejected with strict_data_handling_unavailable before inference. It does not claim regional processing or data residency. standard remains the default and does not promise ZDR. Strict inherited from an agent, thread, or parent run cannot be relaxed by passing 'standard'.
Timezone
Novo gives the agent the current date and time for each run. By default that context uses the thread's timeZone, or UTC when the thread has no default. Pass timeZone on runs.start to override it for one run:
await novo.runs.start({
threadId,
input: 'Schedule the follow-up for tomorrow morning.',
timeZone: 'America/New_York',
});
Use an IANA timezone string such as America/New_York, Europe/London, or Asia/Tokyo.
Structured output
Pass an outputSchema (a JSON Schema object) on runs.start or threads.stream to have the run deliver a structured result. The agent returns a value matching your schema, so the value you get back is structured rather than free-form prose.
const stream = await novo.threads.stream(threadId, {
input: 'Classify the sentiment of: "shipping was late but support was great".',
outputSchema: {
type: 'object',
additionalProperties: false,
required: ['sentiment', 'confidence'],
properties: {
sentiment: { type: 'string', enum: ['positive', 'neutral', 'negative'] },
confidence: { type: 'number' },
},
},
});
const result = await stream.streamToCompletion();
console.log(result.output); // { sentiment: 'negative', confidence: 0.62 }
The structured value is surfaced on the data-novo-terminal stream chunk as output, and on the resolved RunFinalResult.output (returned by stream.streamToCompletion() and stream.finalResult). It is undefined when no outputSchema was declared, or when the run produced no structured value.
The structured result is stream-surfaced only for now; it is not yet a persisted field on the Run resource returned by novo.runs.get(runId). Capture output from the stream while you have it.
Worker streams
When an agent has capabilities.subagents, the root run stream emits data-novo-task chunks for each delegated task. Render label as the primary task title. Use workerRef from that chunk to follow the task's live transcript. role tells you whether the task is a worker or advisor. The sub-stream's opening data-novo-worker-meta carries the full dispatch prompt (the instructions the subagent ran on); the internal routing that selects the model/band for the task is never exposed:
const worker = await novo.runs.workers.stream(rootRunId, workerRef);
for await (const event of worker) {
if (event.type === 'data-novo-worker-meta') {
console.log(event.data.role, event.data.label ?? 'Subagent', event.data.status);
if (event.data.prompt) console.log('task prompt:', event.data.prompt);
}
}
Worker streams are scoped under the root run and are not standalone public run resources.
Get
Read the current stored run state by ID:
const run = await novo.runs.get(runId);
// run.status, run.usage, run.terminalReason, …
For a run that's still executing, usage reflects what's been metered so far across the whole run tree (root run + any subagents) as of its last completed step. Poll it to watch spend accrue before the run terminates, rather than seeing 0 until the end. In-flight totals exclude reasoning tokens (metered at finalize) and advance per step, so they trail the live moment slightly. Once the run reaches a terminal status, usage is the exact billed tree total and matches the stream's terminal data-novo-usage receipt.
Cancel
await novo.runs.cancel(runId);
Cancellation is idempotent. If the run already terminated, returns the final state.
Withdraw a held follow-up
await novo.runs.followups.withdraw(runId, followupId);
// -> { id, object: 'run.followup', deleted: true }
A message sent while a run is active (running, or parked on an approval/question) is held and delivered at the run's next turn boundary; the 409 thread_busy response carries details.queued: true and details.followup_id, and the run resource lists it in queuedFollowups. If the text matched a pending approval, the same 409 carries details.resolved_interaction_id instead: the message was consumed as the approval answer, not held. Withdraw removes a held message before it is delivered. A 404 followup_not_found means it is already gone: withdrawn earlier, already claimed for delivery (you'll see its data-novo-followup-replayed chunk instead), or left on a terminal run as a visible but read-only undelivered record.
The Run resource
| Field | Type | Notes |
|---|---|---|
| id | string | run_… |
| object | 'run' | |
| threadId, agentId | string | |
| status | RunStatus | queued \| preparing \| running \| waiting_for_approval \| waiting_for_input \| completed \| failed \| cancelled |
| terminalReason | NovoTerminalReason \| null | null on normal completion; see terminal reasons, including interaction_timeout for abandoned human-in-the-loop pauses. |
| usage | UsageTotal? | Token + cost totals. |
| error | RunError? | Set when status === 'failed'. |
| queuedFollowups | QueuedFollowup[]? | Messages held for delivery at the run's next turn boundary ([{ id, text, queuedAt }], oldest-first), sent while the run was executing (mid-run steering) or parked on a pause. Present whenever the run is parked (possibly empty) and whenever messages remain held, including on a terminal run, where it lists accepted-but-undelivered messages to re-send. |
| config | { band, mediaFidelity, dataHandling, timeZone } | The agent config captured at run start. Durable context is frozen into the run server-side; only the band, media fidelity, effective data-handling posture, and effective timezone surface here. |
| createdAt, startedAt, completedAt | string | ISO timestamps. |
RunReceipt (the return of runs.start) is a separate, smaller shape: it carries runId, threadId, agentId, organizationId, status, optional idempotencyKey, and createdAt. Persist the runId to reattach later via runs.stream(runId).
Errors
404 run_not_found: bad ID.404 followup_not_found: the follow-up is already gone (withdrawn, claimed for delivery by the run, or visible on a terminal run as a read-only undelivered record).404 thread_not_found:runs.startagainst a missing thread.409 run_not_active: the run is no longer active on its thread but has not reached a terminal state that can be returned as a final run.runs.stream(runId)can replay terminal run streams.409 idempotency_key_conflict: same key, different body.400 strict_data_handling_unavailable: strict data handling was requested or inherited, but the resolved band, explicit route, or managed capability has no verified strict route.