Threads
A thread is one conversation with one agent. Each message starts a run that streams the agent's work back to you, using the agent's configuration and enabled capabilities.
Methods
| Method | Route |
|---|---|
| novo.threads.list({ agentId, limit?, cursor? }) | GET /v1/threads?agentId=… |
| novo.threads.create({...}) | POST /v1/threads |
| novo.threads.get(threadId) | GET /v1/threads/:id |
| novo.threads.update(threadId, {...}) | PATCH /v1/threads/:id |
| novo.threads.delete(threadId) | DELETE /v1/threads/:id |
| novo.threads.stream(threadId, {...}) | POST /v1/threads/:id/messages |
| novo.threads.cancel(threadId) | POST /v1/threads/:id/cancel |
| novo.threads.prewarm(threadId?) | GET /v1/threads/:id/messages?warm=1 |
Type signature
type ThreadsNamespace = {
list(input: { agentId: string; limit?: number; cursor?: string }): Promise<Page<Thread>>;
create(input: {
agentId: string;
context?: string | ContextBlock[];
band?: Band;
dataHandling?: 'strict' | null;
/** IANA timezone default for runs on this thread. Null/omitted uses UTC unless a run overrides it. */
timeZone?: string | null;
metadata?: Metadata;
idempotencyKey?: string;
}): Promise<ThreadCreateResponse>;
get(threadId: string): Promise<Thread>;
update(threadId: string, input: {
context?: string | ContextBlock[];
forceRefresh?: boolean;
band?: Band | null;
dataHandling?: 'strict' | null;
/** Set or clear the thread's default IANA timezone. */
timeZone?: string | null;
metadata?: MetadataPatch;
}): Promise<Thread>;
delete(threadId: string): Promise<DeleteThreadResponse>;
stream(threadId: string, input: {
input: AgentInput;
band?: Band;
/** 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 override for this run's built-in date/time context. */
timeZone?: string;
/** JSON Schema for this run's structured result; surfaced as `output` on the terminal chunk and on `RunFinalResult.output`. */
outputSchema?: Record<string, unknown>;
budgetCents?: number;
maxSteps?: number;
maxDurationMs?: number;
metadata?: Metadata;
idempotencyKey?: string;
}): Promise<RunStream>;
cancel(threadId: string): Promise<CancelThreadResponse>;
/** Pre-warm the run path to cut first-token latency after idle. See "Pre-warming". */
prewarm(threadId?: string): Promise<void>;
};
type ContextBlock = { id?: string; content: string };
// Stable named band. 'core' is the default when omitted.
type Band = 'lite' | 'core' | 'frontier';
type AgentInput =
| string
| { text: string; attachments?: Array<{ fileId: string }> }
| { text?: string; attachments: [{ fileId: string }, ...Array<{ fileId: string }>] };
type Thread = {
id: string;
agentId: string;
band: Band | null;
dataHandling: 'strict' | null;
timeZone: string | null;
status: 'idle' | 'running' | 'deleting';
workspace: null | {
status: 'uninitialized' | 'starting' | 'active' | 'hibernated' | 'restoring' | 'evicted' | 'failed' | 'unavailable' | 'archived';
previews?: Array<{ port: number; url: string; expiresAt: string }>;
updatedAt?: string;
};
};
type ThreadCreateResponse = Thread & {
/** True when this request created the thread; false on idempotency replay. */
created: boolean;
};
Object input must include either non-empty text or at least one attachment.
The thread carries its own durable context (per-user framing, a memory manifest), cached across the conversation. It's deferred: an edit folds into the cached prefix at the next rebuild rather than busting a warm cache. Pass forceRefresh: true on update to fold immediately at the cost of one cache write. There is no per-turn context; anything a single turn needs goes in the run input.
If the agent has a managed workspace, thread.workspace summarizes that thread's runtime state and signed preview URLs. Preview URLs expire; fetch the thread again after expiresAt for a fresh URL.
Create
const thread = await novo.threads.create({
agentId: agent.id,
context: 'Customer: Acme Inc (enterprise tier, EU region).',
});
Pass an idempotencyKey to make lazy-provisioning safe across retries for 24 hours. A fresh response has created: true; the second call with the same key returns the same thread with created: false. Both successful responses use HTTP 201. The flag applies only to successful creates; stored error replays keep their original error body. A retry while the original result is still pending returns 503 service_unavailable instead of creating another thread.
dataHandling is escalation-only: set 'strict' to require strict routes for this thread, or null to inherit. standard remains the default and does not promise ZDR.
timeZone is the thread-level default for the current date/time context Novo provides to the agent on each run. Use an IANA timezone such as America/New_York; omit it or set null to use UTC unless an individual run overrides it.
Stream
The everyday entry point. Starts a run on the thread and returns a stream you iterate.
const stream = await novo.threads.stream(thread.id, {
input: 'Refund order #ABC?',
});
const result = await stream.streamToCompletion();
process.stdout.write(result.text);
const { usage } = result;
threads.stream is sugar for runs.start + runs.stream in one call.
Raw HTTP clients that call POST /v1/threads/:id/messages with Accept: application/json receive the same RunReceipt shape as runs.start and can attach later with GET /v1/runs/:id/stream. SDK callers get a RunStream by default.
Per-turn overrides:
band: override the agent's default band for this run. Changing the band mid-thread keeps the conversation: the cached prefix is rebuilt for one turn, so expect a brief cost/latency bump in that run's usage.dataHandling: request'strict'data handling for this run. Strict requires verified ZDR/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 that requirement through a verified BYOK credential or a managed system-ZDR route; if neither is available, the request returnsstrict_data_handling_unavailablebefore inference. It does not claim regional processing or data residency. A strict agent or thread cannot be relaxed by passing'standard'.timeZone: override the thread timezone for this run's built-in date/time context. Use an IANA timezone such asAmerica/New_York.budgetCents: hard cap on this run's cost. HitsterminalReason: 'budget_exceeded'when reached.maxSteps,maxDurationMs: active run caps. Triggermax_steps_exceeded/max_duration_exceeded; abandoned human-in-the-loop pauses use the separateinteraction_timeoutmax-park window.
Durable framing does not go here; it lives on the agent or the thread as context. The run input is for what this turn is about.
Pre-warming
The hosted runtime scales to zero, so a thread idle for ~60 seconds cold-starts on its next message while the API and durable execution lambdas spin up, adding several seconds before the first token. When you can tell a user is about to send, call prewarm to hide that cold start:
// On input focus / thread open / first keystroke. Fire-and-forget, free no-op:
void novo.threads.prewarm(thread.id);
Aim for a 10–30 second lead before the message:
- Warming takes a few seconds, so a lead under ~10s gets only partial benefit.
- Warmth lapses after ~60s idle, so a lead over ~30s is wasted; fire again if the user keeps composing.
- It's a free no-op: no run is created and nothing is billed, so it's safe to call repeatedly (e.g. on focus and again on a throttled keystroke).
- After any message runs, the thread stays warm for ~60s, so follow-up turns in an active conversation need no pre-warm.
prewarm is best for interactive UIs, where a human's natural read/type time fills the lead. For programmatic or batch callers with no human pause there's no organic lead; keep a steady request rate instead. threadId is optional (warming targets shared infrastructure, not a specific thread), but passing the thread you're about to message is fine.
Raw HTTP clients: GET /v1/threads/:id/messages?warm=1 with your API key returns 204 and triggers the same warm. See Pre-warming for how it works and why.
Cancel
Cancel the active run on the thread:
await novo.threads.cancel(thread.id);
Equivalent to runs.cancel(activeRunId), but you don't need to know the run ID.
Errors
404 thread_not_found: bad thread ID.409 thread_busy: another run is active. Wait for it or cancel. The errordetails.active_run_idcarries the blocking run ID, so you canruns.cancel(...)it directly without a separate lookup. If your message was plain text, the same 409 can also carrydetails.queued: true+details.followup_id: the message was held and will be delivered to that run, at its next turn boundary while running or on resume from a pause, so don't re-send it. It is listed on the run'squeuedFollowupsand can be withdrawn withruns.followups.withdraw(runId, followupId). If it carriesdetails.resolved_interaction_id, the text matched a pending approval and was consumed as the answer; don't re-send it.409 idempotency_key_conflict: same key, different body. Use a fresh UUID per logical operation.