Errors
Every Novo API error follows the same envelope. The SDK turns that envelope into a typed subclass you can instanceof on.
The envelope
{
"error": {
"type": "authentication_error",
"code": "invalid_api_key",
"message": "Invalid API key.",
"request_id": "req_…",
"details": { "optional": "resource-specific fields" }
}
}
type is the broad class. code is the specific reason. request_id matches the X-Request-Id response header; log both. details is optional and contains machine-readable resource-specific fields, for example inUseByAgentIds on credential delete conflicts.
SDK subclasses
NovoAPIError is the base class. Catch it for "anything API-shaped":
import {
NovoAuthenticationError,
NovoPermissionError,
NovoNotFoundError,
NovoInvalidRequestError,
NovoConflictError,
NovoRateLimitError,
NovoInsufficientQuotaError,
NovoTimeoutError,
NovoServerError,
NovoAPIError,
} from 'novoagents';
try {
await novo.threads.stream(threadId, { input });
} catch (err) {
if (err instanceof NovoInsufficientQuotaError) return topUpAndRetry();
if (err instanceof NovoRateLimitError) return backoffRetry();
if (err instanceof NovoAPIError) console.error('novo', err.code, err.requestId);
throw err;
}
NovoConfigurationError is a separate base for SDK misconfiguration (missing key, browser runtime).
Catalog
| Status | Type | SDK subclass | Retry? |
|---|---|---|---|
| 400 / 422 | invalid_request_error | NovoInvalidRequestError | No. Fix the input. |
| 401 | authentication_error | NovoAuthenticationError | No. Rotate / replace key. |
| 403 | permission_error | NovoPermissionError | No. Scope or workspace mismatch. |
| 404 | not_found | NovoNotFoundError | No. Id is wrong or deleted. |
| 408 / 504 | api_error | NovoTimeoutError | Yes, with exponential backoff. |
| 409 | api_error | NovoConflictError | Sometimes. See code below. |
| 429 (rate_limit_error) | rate_limit_error | NovoRateLimitError | Yes. Honor Retry-After. |
| 429 (insufficient_quota) | insufficient_quota | NovoInsufficientQuotaError | After top-up. |
| 5xx | api_error | NovoServerError | Yes, with exponential backoff. |
Codes worth knowing
| Code | Notes |
|---|---|
| invalid_api_key | Bad or revoked key. |
| permission_denied | Key valid; resource scoped to another workspace. |
| agent_not_found, thread_not_found, run_not_found, … | Id doesn't exist. |
| thread_busy | Another run is active on the thread. details.active_run_id names it. If your message was a plain text send, details.queued: true + details.followup_id mean it was held and will be delivered to that run: at its next turn boundary while running, or on resume from a pause. Don't re-send it. details.resolved_interaction_id means the text matched a pending approval and was consumed as the answer; don't re-send it. Without queued or resolved_interaction_id, nothing was held: wait for the run to terminate, then retry. |
| followup_not_found | The held follow-up is already gone: withdrawn earlier, claimed for delivery by the run, or left on a terminal run as a read-only undelivered record that is visible in queuedFollowups but no longer withdrawable. |
| 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. Stream replay can still attach to terminal runs. |
| idempotency_key_conflict | Same Idempotency-Key reused with a different request body. |
| url_not_allowed | 400 invalid_request_error. A connection URL (baseUrl, spec URL, or MCP server url) was rejected at register/update: remote connection URLs must be https and resolve to a public host. details.field names the offending field; the message carries the reason. |
| name_conflict | A create or rename tried to reuse an org-scoped resource name. |
| resource_in_use | A delete was blocked by active references. Check error.details. |
| quota_exceeded, rate_limited | Throttled; back off. |
| insufficient_quota | Wallet balance below floor; top up. |
| budget_exceeded, per_run_cap_exceeded | A caller-supplied or platform cost cap stopped the run. |
| strict_data_handling_unavailable | 400 invalid_request_error. Strict data handling was requested or inherited, but the resolved text route or an enabled managed capability has no verified strict path. Query GET /v1/bands for strict text-route availability. |
| pricing_unavailable | Engine couldn't price the run (missing rate). Run is failed; nothing billed. |
| upstream_unavailable | The model provider was temporarily unavailable and every retry/fallback was exhausted. The run is failed with terminalReason: null; retry the request. |
| workspace_unavailable | Managed workspace creation, resume, or keep-alive failed. Retry, then delete and recreate the thread if the workspace remains unusable. |
| web_runtime_unconfigured, artifact_storage_unconfigured, …_unconfigured | A server-side prerequisite is missing. Open a support ticket; this is on Novo to fix. |
| managed_browser_unconfigured | 400 invalid_request_error. An agent enabled the browser capability on a workspace not provisioned for the managed browser. This is a provisioning request, not a server fault: unlike the server-side *_unconfigured 5xx codes above, fix it by enabling the managed browser for the workspace (contact us to provision). |
| managed_media_editing_unconfigured | 400 invalid_request_error. An agent set workspace.mediaEditing, but Novo's pinned media-editing binary source is not configured for this deployment. Contact support; the run is rejected before the agent reaches a broken first edit. |
| tools_changed | The agent's tool list changed mid-run; the run cannot resume. Start a new one. |
Terminal reasons (in-stream)
Errors above are call failures. Mid-stream the run can terminate by Novo policy, or by an internal engine fault, without the call itself failing:
const { status, terminalReason } = await stream.finalResult;
terminalReason is one of:
budget_exceeded: the caller-suppliedbudgetCentscap hit.insufficient_quota: wallet went to zero mid-run.per_run_cap_exceeded: Novo's platform per-run blast-radius cap hit.pricing_unavailable: rate missing; nothing billed.max_steps_exceeded: the agent'smaxStepscap hit.max_duration_exceeded: the agent'smaxDurationMscap hit.step_duration_exceeded: a single agent step ran past its per-step execution budget (distinct from the whole-runmaxDurationMscap). The run is finalized cleanly with the usage consumed so far.context_overflow: the conversation grew past the model's context window before the run could finish. The run is finalizedfailedwith the usage consumed so far billed; shorten the task, reduce attached context, or split it across runs. Not an engine fault, so noerror.referenceis minted.interaction_timeout: a human-in-the-loop pause (approval or question) went unresolved past the platform max-park window (~14 days, or a longermaxDurationMsif set). The run iscancelled, its pending interactions are cancelled, and the thread is freed. Distinct frommax_duration_exceeded, which is the run's compute budget; this is the human-response window.workspace_unavailable: managed workspace creation or resume failed before the run could continue.data_handling_unavailable: strict data handling was required but the resolved band or managed capability had no verified strict route.stream_replay_unavailable: a durable stream replay could not safely reconstruct already-emitted bytes; the run is finalized and the accompanying error stays content-free.engine_fault: an internal engine failure terminated the run. The accompanyingerrorcarries a structuredreference(see Support reference) you can quote in a ticket. An engine fault is a platform failure, not a fault of your request, so when it terminates a delegated subagent run that run is not billed: its inference is credited to$0and never draws down your wallet (it still appears as a$0subagent_runrow in usage). Novo re-dispatches the work; you pay only for attempts that ran cleanly. A root run that faults after streaming partial output is still billed for what it delivered.snapshot_unavailable: durable encrypted runtime state could not be read or written.run_recovery_reaped: an out-of-band recovery pass finalized a run that wedged without writing its own terminal.
Or null on normal completion.
Idempotency-Key
These methods accept an idempotencyKey:
threads.create({ idempotencyKey })threads.stream(threadId, { idempotencyKey })runs.start({ idempotencyKey })
The SDK sends it as the Idempotency-Key HTTP header (stripped from the JSON body). Cloud retains (method, path, key, request-body-hash) for 24 hours:
- Replay: same method + path + key + body → returns the cached status and body. A successful
threads.createreplay setscreated: false(the fresh response sets it totrue). - Conflict: same key, different body →
409 idempotency_key_conflict. - Pending: same key before the first call has stored its result → returns retryable
503 service_unavailable. Retry the same request and key later. Novo keeps the key reserved for the full 24-hour window rather than guessing that an older request failed and risking a duplicate thread, run, or message.
Use a stable UUID per logical operation (per Stripe event, per inbound message id), not a fresh one per retry. After 24 hours, the key may be reused as a new operation.
Support reference
When a run terminates on an internal engine fault or an upstream-provider failure, the terminal error carries a structured reference field:
{
"type": "api_error",
"code": "internal_error",
"message": "The model step failed.",
"reference": "3f2a9c4e-1b7d-4e21-9a6c-8d5f0b2e1a44"
}
Quote that id in a support ticket; it maps the failure to exactly one internal incident across our logs and monitoring. It is a per-occurrence id (distinct from X-Request-Id, which is per HTTP response), and appears only on failures we record as incidents: content-safe agent outcomes (for example a repeat_loop_halted stop, surfaced as agent_loop_halted) carry no reference because there is nothing to investigate. Read error.reference directly: it is a dedicated field, not embedded in message; error.code and error.type are unchanged. Engine-fault terminals also carry a stable terminalReason (engine_fault, snapshot_unavailable, or run_recovery_reaped) you can map to friendly copy.
X-Request-Id
Every response carries X-Request-Id. Log it. Pass it in any support ticket. The SDK exposes it on every error:
catch (err) {
if (err instanceof NovoAPIError) {
log.error({ requestId: err.requestId, code: err.code });
}
}
Stream errors
Inside for await (const event of stream), two chunk types signal trouble:
event.type === 'error': the model or a tool errored. The run keeps running unless the model decides to stop.event.type === 'abort': the stream is being torn down. Treat as terminal.
Expected tool failures may also appear as tool-output-available with
event.output.type === 'tool-output-error'. Novo keeps that payload visible to
the model. In the SDK, readNovoToolError(event) normalizes both the standard
tool-output-error chunk and that embedded-error form into
{ code, message?, retryable, action? }. Retryable preconditions use the same
tool-output surface; correlate toolCallId with the preceding tool-input-*
chunk when you need the public tool name. A terminal command returning exit code
1 is still a successful tool execution with domain output, not a tool lifecycle
error.
For the run's classification (was this a Novo policy stop or a normal completion?), read terminalReason on data-novo-terminal (or on the RunFinalResult from await stream.finalResult).