TypeScript SDK
Call Drex's decision model and run Perception's document tools from Node with nace-sdk — client options, calibrated questions, typed answers, parse, split, classify, extract, ground, uploads, jobs, saved schemas, errors, retries and logging.
The nace-sdk npm package is the official TypeScript client for https://console.nace.ai. One client covers Drex's decision model, systemOne and models.list, and Perception's document tools: the five document jobs, uploads, jobs, job files and saved extraction schemas. It needs Node 20 or later and ships as both ESM and CommonJS.
The client is built on the TypeSafe SDK, so systemOne, noul, choice, score, the inferred answer types, RetryPolicy and the error classes keep the names they have in @typesafe-ai/sdk. Drex takes instructions and criteria as text only, and it has its own classes for 402 and 529; Migrate from TypeSafe lists every difference.
npm install nace-sdk
export NACE_API_KEY="nace_sk_..."Create a key as shown in Authentication, and keep it on a server: it spends your account's credit, and a browser can't call the /v1 routes from another origin (see Browsers). The samples on this page use a client made with new NaceClient(), which reads NACE_API_KEY, NACE_BASE_URL, NACE_DEFAULT_DECISION_MODEL and NACE_LOG_LEVEL.
Configure the client
new NaceClient(config?)
| Parameter | Type | Required | Description |
|---|---|---|---|
apiKey | string | Yes, or NACE_API_KEY | Your nace_sk_ key. |
baseURL | string | No | API root, with trailing slashes removed. Default: NACE_BASE_URL, then https://console.nace.ai. |
defaultModel | string | No | The model for calls that don't name one. Default: NACE_DEFAULT_DECISION_MODEL, then drex-latest. |
timeout | number | No | Milliseconds per attempt, more than 0. Default: 60000, because Drex waits up to 55 s for the model before it answers 529. A document create adds its wait_seconds to it. |
retry | Partial<RetryPolicy> | No | Retry overrides. Fields you leave out keep the defaults in Retries. |
logLevel | LogLevel | No | "debug", "info", "warn", "error" or "off". Default: NACE_LOG_LEVEL, then "warn". See Logging. |
logger | Logger | No | Where log lines go. Default: console, with a [nace-sdk] prefix. |
defaultHeaders | Record<string, string> | No | Headers sent with every request to Drex. They aren't sent to the document service's upload URLs or to signed links. |
fetch | Fetch | No | The fetch to send requests with, for a proxy agent or a test double. Default: the global fetch. |
dangerouslyAllowBrowser | boolean | No | Let the constructor run where browser globals exist. Default: false. See Browsers. |
config is a NaceClientConfig. Fetch is (input: string, init?: RequestInit) => Promise<Response>, the shape of the global fetch.
An option you pass wins over its environment variable, and the variable wins over the default. Blank or whitespace-only environment values are ignored; an option you pass is used as it is, even when empty. The variables are read from process.env, so in a runtime without it, pass the options. The client doesn't read the CLI's ~/.nace/config.toml: pass apiKey, or set NACE_API_KEY.
| Variable | Option | ENV key |
|---|---|---|
NACE_API_KEY | apiKey | ENV.apiKey |
NACE_BASE_URL | baseURL | ENV.baseURL |
NACE_DEFAULT_DECISION_MODEL | defaultModel | ENV.defaultModel |
NACE_LOG_LEVEL | logLevel | ENV.logLevel |
Each variable's name from before the package was renamed from drex-sdk still works when the new one is unset or blank: DREX_API_KEY, DREX_BASE_URL, DREX_DEFAULT_MODEL and DREX_LOG_LEVEL (LEGACY_ENV). The new name wins when both are set.
The constructor throws a NaceError when neither apiKey nor NACE_API_KEY is set, when timeout or a retry field is out of range, when the log level is unknown (from the option or from NACE_LOG_LEVEL), when the runtime has no global fetch and you passed none, and in a browser without dangerouslyAllowBrowser.
import { NaceClient } from "nace-sdk";
const client = new NaceClient({
timeout: 90_000,
retry: { maxRetries: 4, timeoutMs: 500_000 }, // room for 5 attempts of 90 s and the waits between them
defaultHeaders: { "X-Team": "support" },
logLevel: "info",
});Client properties
Every option resolves to a read-only property. The API key is never exposed.
| Property | Type | Description |
|---|---|---|
baseURL | string | The API root in use. |
defaultModel | string | The model for calls that don't name one. |
timeout | number | Milliseconds per attempt. |
retry | RetryPolicy | The full retry policy, with your overrides applied. |
logLevel | LogLevel | The resolved log level. |
logger | Logger | Your logger, or the console one, filtered to logLevel. |
defaultHeaders | Readonly<Record<string, string>> | A copy of defaultHeaders. |
fetch | Fetch | The fetch in use. |
models | Models | List models. |
documents | Documents | Document jobs and uploads. See Create a job and Uploads. |
jobs | Jobs | Reading, waiting for and deleting document jobs, and their files. See Jobs and Job files. |
extractionSchemas | ExtractionSchemas | Saved extraction schemas. See Saved schemas. |
Browsers
new NaceClient() throws in a browser, because the page would hand your API key to anyone who opens it. dangerouslyAllowBrowser: true only turns that check off. It doesn't make Drex reachable: the /v1 routes send no Access-Control-Allow-Origin header, so the browser blocks a page on another origin from reading their answers, and every call fails with APIConnectionError. The option is for code that has browser globals but no cross-origin checks, such as tests under jsdom.
Call Drex from your server. To move files in a browser, mint an upload grant on the server and let the browser send the file to the document service, and hand the browser signed links from jobs.fileLink; see Upload grants.
Per-call options
Every method takes a RequestOptions as its last argument. A few document methods take more, or less; their sections say so.
| Parameter | Type | Required | Description |
|---|---|---|---|
signal | AbortSignal | No | Cancels the request and any wait before a retry. Throws APIUserAbortError. |
timeout | number | No | Milliseconds per attempt, in place of the client's timeout. The retry budget decides whether another attempt starts; it doesn't shorten an attempt. |
retry | Partial<RetryPolicy> | No | Retry overrides for this call. Fields you leave out keep the client's policy. { maxRetries: 0 } turns retries off. |
headers | Record<string, string> | No | Extra headers, merged over defaultHeaders. Names compare case-insensitively. |
The SDK sets Authorization, Accept, User-Agent, X-Nace-SDK, X-Nace-Runtime, Content-Type, Idempotency-Key and X-Nace-Retry-Count itself, so a value you pass for one of them is replaced or dropped.
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);
await client.systemOne(
{ state: "Where is my parcel?", questions: { shipping: noul("Is it about shipping?") } },
{ signal: controller.signal, retry: { maxRetries: 0 }, headers: { "X-Trace-Id": "t-123" } },
);Ask questions
client.systemOne(request, options?) returns APIPromise<SystemOneResult<Q>>, where Q is the type of your questions.
| Parameter | Type | Required | Description |
|---|---|---|---|
request.state | EntryType | Yes | Any JSON value to evaluate: text, a number, a boolean, an object, an array or null. See State. |
request.questions | Questions | Yes | 1 to 512 questions, each keyed by the name its answer comes back under. Build them with noul(), choice() and score(), or write plain objects. See Questions. |
request.model | string | No | The model for this call. Default: the client's defaultModel. |
options | RequestOptions | No | The second argument, not a field of request: signal, timeout, retry and headers for this call. See Per-call options. |
request is a SystemOneRequest<Q>. Any other field on it is sent in the body as it is.
import { choice, NaceClient, noul, score } from "nace-sdk";
const client = new NaceClient();
const { answers, usage, request_id } = await client.systemOne({
state: "I was charged twice for my March invoice.",
questions: {
wants_refund: noul("Is the customer asking for a refund?"),
topic: choice("Which topic is it?", { billing: null, shipping: null, other: null }),
urgency: score("How urgent is it?", ["low", "medium", "high"]),
},
});
answers.wants_refund.noul; // number
answers.topic.choice; // "billing" | "shipping" | "other"
answers.urgency.probabilities["2"]; // probability of "high"
console.log(usage.input_tokens, request_id);The answer types come from your questions, so a misspelled question name or label is a compile error. A decision is billed per input token, and only when it returns 200, so the client's retries never bill twice.
systemOne checks the questions before it sends anything, and throws a NaceError for:
- no questions, or more than 512;
- a noul with neither
instructionsnor a non-emptytrueorfalsecriterion; - a choice whose criteria aren't a non-empty map of labels;
- a score whose criteria aren't a non-empty list.
An instructions or criteria left null is dropped from the body, since Drex refuses null there.
Question helpers
| Helper | Returns | Description |
|---|---|---|
noul(instructions?, criteria?) | NoulQuestion | A yes/no question. instructions defaults to null. criteria is { true?, false? }, a description of each outcome. |
choice(instructions, criteria) | ChoiceQuestion<T> | Picks one label. criteria maps each label to a description, or to null. Throws a NaceError when criteria is a list. |
score(instructions, criteria) | ScoreQuestion<T> | Rates on an ordered rubric: criteria is a list of descriptions, one per score from zero. Throws a NaceError when criteria is a map. |
instructions is a string or null. Plain objects work too, such as { type: "noul", instructions: "Is it urgent?" }.
Answer types
SystemOneResult<Q> is what systemOne resolves to:
| Field | Type | Description |
|---|---|---|
model | string | The model version that answered. |
answers | { [K in keyof Q]: ResultFor<Q[K]> } | One answer per question, under the question's name. |
usage | Usage | input_tokens, which Drex bills, and output_tokens. |
evaluation_time_ms | number | How long the model took. |
request_id | string | Drex's request id. Quote it in support requests. |
| Type | What it holds |
|---|---|
NoulResponse | type: "noul" and noul, the probability of yes, from 0 to 1. |
ChoiceResponse<T> | type: "choice", choice (one of your labels), confidence and probabilities, keyed by label. |
ScoreResponse<T> | type: "score", score (the expected score, which can fall between levels), confidence, legend (your rubric, as a list) and probabilities, keyed by score. |
ResultFor<T> | The answer type for question type T: NoulResponse, ChoiceResponse or ScoreResponse, keeping your labels. |
ScoreOf<T> | The score keys of rubric T: "0" | "1" | "2" for a three-entry rubric written in the call, number for one whose length isn't known. |
Usage | input_tokens and output_tokens. |
See Reading answers for what each number means.
Request types
| Type | What it is |
|---|---|
JsonValue | Any JSON value. |
EntryType | The type of state: a JsonValue. |
Description | A criterion's description: string | null. |
NoulQuestion | { type: "noul", instructions?, criteria?: { true?, false? } | null }. |
ChoiceCriteria | Labels mapped to a Description. |
ChoiceQuestion<T> | { type: "choice", instructions?, criteria: T }. |
ScoreCriteria | readonly [string, ...string[]]: at least one description. |
ScoreQuestion<T> | { type: "score", instructions?, criteria: T }. |
Question | NoulQuestion | ChoiceQuestion | ScoreQuestion. |
Questions | Questions keyed by name. |
SystemOneRequest<Q> | systemOne's first argument: state, questions and an optional model. |
SystemOneRequestPayload | The body sent to POST /v1/systemone: a SystemOneRequest with model set. |
List models
client.models.list(options?) returns APIPromise<ModelCard[]>, the models your key can use. It's free and works at any balance, so it doubles as a key check.
| Field | Type | Description |
|---|---|---|
name | string | The value to pass as model. |
description | string | What the model is. |
release_date | string | When the version was released. |
alias_for | string | null | The version an alias such as drex-latest points to, or the version that now serves a retired one. null for a pinned version. |
for (const model of await client.models.list()) {
console.log(model.name, model.alias_for);
}See Models.
Sources
Every tool takes a source, typed SourceInput: a Source object, or a plain string.
source | Type | What it is |
|---|---|---|
"https://..." | string | A public HTTPS link, sent as a url source with a file_name taken from the URL. |
{ type: "url", url, file_name? } | UrlSource | A public HTTPS link the document service downloads. file_name is taken from the URL when you leave it out. |
{ type: "workspace_file", workspace_id, file_id } | WorkspaceFileSource | A file you uploaded. workspaceFile(file) builds it from what documents.upload returns. |
{ type: "parse_result", job_id } | ParseResultSource | One of your finished parse jobs, so Parse, Split, Extract and Ground reuse it instead of parsing the document again. Classify doesn't take it. |
Source is the union of the three objects. workspaceFile(file) returns the WorkspaceFileSource for an UploadedFile, which documents.upload and completeUploadSession return.
A URL source needs a file_name: the document service picks the parser from its extension, and refuses a type the tool can't read (see Format support). When you leave it out, the SDK takes the URL's last path segment, without its query or fragment and percent-decoded, as long as that segment has an extension: https://example.com/files/Q3%20report.pdf?dl=1 sends Q3 report.pdf. Otherwise it throws a NaceError before sending, and you pass the name yourself. A file_name you pass is sent as it is.
A string that isn't an https:// URL throws a NaceError; http:// links aren't accepted.
// The last segment here isn't a file name: the SDK would send "1706.03762", which the service refuses.
await client.documents.parse({
source: { type: "url", url: "https://arxiv.org/pdf/1706.03762", file_name: "attention.pdf" },
});Create a job
The five tools each take one params object and return APIPromise<DocumentJob>. These parameters are shared:
| Parameter | Type | Required | Description |
|---|---|---|---|
source | SourceInput | Yes | The document. See Sources. |
wait_seconds | number | No | Hold the request open until the job finishes, 0 to 60 (MAX_WAIT_SECONDS). The attempt's timeout grows by as much. Out of range throws a NaceError. |
idempotency_key | string | No | Up to 200 characters. The same key and body return the same job, billed once. Minted per call when omitted. |
name | string | No | A display name for the job. |
project_id | string | No | A UUID you mint to group jobs you submit together. |
Every other field goes to the document service as it is, and a field it doesn't know, a misspelled one included, is refused with 422. CreateParams is the shared type; ClassesParams, ExtractParams and GroundParams add each tool's own fields.
The job comes back queued or running, or finished when wait_seconds was long enough. Wait for the rest with jobs.wait:
const created = await client.documents.parse({
source: "https://example.com/report.pdf",
wait_seconds: 60,
});
const job = created.status === "succeeded" ? created : await client.jobs.wait(created.job_id);
console.log(job.result);Retries. The minted key stays the same across the SDK's own retries, so they never start or bill a second job. To retry a create yourself, after the SDK's retries ran out, after a 503 job_pending or after a restart, pass your own idempotency_key and send it again. See Retry safely.
A create that holds the request open is retried like any other call, but the default retry budget is 30 seconds in all, so an attempt that fails after waiting longer than that isn't retried. Raise retry.timeoutMs to retry those; see Retries.
The charge's id. A create's x-drex-document-job header carries Drex's own id for the charge. Quote it with the request id when you contact support:
const { data: job, response, requestId } = await client.documents
.parse({ source: "https://example.com/report.pdf" })
.withResponse();
console.log(job.job_id, response.headers.get("x-drex-document-job"), requestId);Parse, Split, Classify and Extract take page_ranges as Array<{ start: number; end: number }>: one-based and inclusive, at most 200 ranges and 500 pages in all.
Parse
client.documents.parse(params, options?) converts a document to Markdown, text, layout blocks or chunks. It takes the shared parameters and:
| Parameter | Type | Required | Description |
|---|---|---|---|
page_ranges | Array<{ start, end }> | No | Pages to parse. Not with a parse_result source. Default: all pages. |
parse_mode | "low" | "medium" | "high" | No | Effort. low uses native OCR, medium routes to the best parser, high adds page verification and correction. Only low with a parse_result source. Default: "low". |
output | { formats?, table_format?, include_images?, include_page_markers?, return_ocr_data? } | No | formats (default ["markdown", "blocks"]; also "text"), table_format ("html" or "markdown", default "html"), include_images (default false; not with a parse_result source), include_page_markers (default true), return_ocr_data (word and line OCR geometry, default false; PDF sources only). |
figures | { mode?: "include" | "omit" | "describe" } | No | Default "include". "describe" captions each figure with one vision call. |
diagrams | { mode?: "omit" | "mermaid" } | No | Default "omit". "mermaid" rewrites flowchart-like figures. |
chunking | { strategy?: "none" | "page" | "section" } | No | Default "none". "page" and "section" produce pieces sized for retrieval. |
spreadsheet | { sheets?, include_hidden_sheets?, include_hidden_rows?, include_hidden_columns?, include_formulas? } | No | Workbook filters. include_formulas needs output.table_format: "html" and markdown or blocks output. Not with a parse_result source. Default: every sheet, row and column, without formula source text. |
password | string | No | Password for an encrypted PDF. Never stored or returned. Not with a parse_result source. |
A parse_result source with an option marked "not with a parse_result source" set to something other than its default (for spreadsheet: sheets, an include_hidden_* of false, or include_formulas: true), or with a parse_mode other than low, is refused with 422 before a job starts. OCR runs when the file needs it: the document service refuses any ocr setting other than the default with 422 invalid_request.
const job = await client.documents.parse({
source: { type: "url", url: "https://example.com/report.pdf", file_name: "report.pdf" },
page_ranges: [{ start: 1, end: 3 }],
output: { formats: ["markdown", "blocks"] },
chunking: { strategy: "page" },
});
const done = await client.jobs.wait(job.job_id);See Parse for the result shape, and Job files for full content too large to inline.
Split
client.documents.split(params, options?) cuts a packet into documents, each with a class. It takes the shared parameters and:
| Parameter | Type | Required | Description |
|---|---|---|---|
classes | Array<{ id, label, description?, subclasses?, instance_key? }> | Yes | The document classes to cut on. subclasses are { id, label, description? }. instance_key is { name, description, required? }: a value that tells one document of the class from the next, such as an invoice number. See Split's options. |
unknown_policy | "include" | "force" | "error" | No | "include" (default) keeps unmatched pages as unclassified, "force" gives them the best class, "error" fails the job. |
overlap_policy | "exclusive" | "shared_boundary_page" | No | "exclusive" (default) keeps segments apart; "shared_boundary_page" puts a boundary page in both neighbors. |
split_rules | string | No | Free-text guidance on how to cut the packet. |
output | { include_content?, materialize_files? } | No | include_content (default false) puts each segment's Markdown in content; materialize_files (default false) gives each segment a downloadable file. |
page_ranges | Array<{ start, end }> | No | Pages to consider. Not for workbooks. Default: all pages. |
parse_mode | "low" | "medium" | "high" | No | How hard the parse behind the split works. Only low with a parse_result source. Default: "low". |
const job = await client.documents.split({
source: { type: "url", url: "https://example.com/packet.pdf", file_name: "packet.pdf" },
classes: [
{ id: "invoice", label: "Invoice", description: "A supplier invoice" },
{ id: "receipt", label: "Receipt", description: "A payment receipt" },
],
});
const done = await client.jobs.wait(job.job_id);Class ids must be unique and can't contain /, and blank_page, unreadable_content and __ndi_unclassified are reserved. See Split.
Classify
client.documents.classify(params, options?) labels a document, or each page, with your classes. It takes the shared parameters, except that source can't be a parse_result, and:
| Parameter | Type | Required | Description |
|---|---|---|---|
classes | Array<{ id, label, description?, criteria?, subclasses? }> | Yes | Your labels. criteria lists extra rules for the class; subclasses are { id, label, description?, criteria? }. |
granularity | "document" | "page" | No | Score the whole file once, or once per page. Default: "document". |
page_ranges | Array<{ start, end }> | No | Pages to read and classify. Not for workbooks. Default: all pages. |
unknown_policy | "allow" | "force_best" | No | "allow" (default) may mark a unit unknown; "force_best" commits an ambiguous unit to one of your classes. |
output | { max_alternatives?, include_reason? } | No | max_alternatives (1 to 10, default 3) ranked labels per unit; include_reason (default true) adds a short reason per label. |
parse_mode | "low" | "medium" | "high" | No | How hard the parse behind the classification works. Default: "low". |
const job = await client.documents.classify({
source: { type: "url", url: "https://example.com/invoice.pdf", file_name: "invoice.pdf" },
classes: [
{ id: "invoice", label: "Invoice", description: "A supplier invoice" },
{ id: "contract", label: "Contract", description: "A signed agreement between two parties" },
],
granularity: "page",
});
const done = await client.jobs.wait(job.job_id);Class ids must be unique and can't contain /, and other, blank_page, unreadable_content and __ndi_unclassified are reserved. See Classify.
Extract
client.documents.extract(params, options?) fills a JSON Schema from a document. params is an ExtractParams: the shared parameters, and either an inline schema or a saved schema_id, never both.
| Parameter | Type | Required | Description |
|---|---|---|---|
schema | Record<string, unknown> | Yes, or schema_id | The fields to extract, as a JSON Schema. |
schema_id | string | Yes, or schema | A saved schema's id (sch_...). |
schema_version | number | No | The saved version to use, with schema_id only. Default: the latest. |
instructions | string | No | Extra guidance for filling the declared fields. Can't add fields the schema doesn't list. |
page_ranges | Array<{ start, end }> | No | Pages to read. Not with a parse_result source. Default: all pages. |
citations | { enabled?, include_source_text? } | No | enabled (default true) says where each field was read; include_source_text (default true) adds a short quote. |
parse_mode | "low" | "medium" | "high" | No | How hard the parse behind the extraction works. Only low with a parse_result source. Default: "low". |
Both schema and schema_id, neither of them, or a schema_version without schema_id fail to compile, and throw a NaceError before sending if the types are bypassed.
const parsed = await client.jobs.wait(
(await client.documents.parse({ source: "https://example.com/invoice.pdf" })).job_id,
);
const job = await client.documents.extract({
source: { type: "parse_result", job_id: parsed.job_id },
schema: {
type: "object",
properties: { invoice_number: { type: "string" }, total: { type: "number" } },
required: ["invoice_number", "total"],
},
});
const done = await client.jobs.wait(job.job_id);
console.log((done.result as { data: unknown }).data); // { invoice_number: 'INV-1042', total: 1200.5 }See Extract for the result: data, shaped by your schema, and fields, one entry per schema field with its path, value, status (found, not_found or ambiguous), confidence and citations.
Ground
client.documents.ground(params, options?) finds where each quote appears. It takes the shared parameters and:
| Parameter | Type | Required | Description |
|---|---|---|---|
targets | Array<{ id, text, hint?, sheet?, semantic?, page_hints? }> | Yes | 1 to 30 quotes, each with your own id. Per target: hint is nearby text for a repeated quote, sheet a workbook tab, semantic (default false; deprecated: true fails that target with semantic_mode_deprecated on PDF, Word and PowerPoint sources), and page_hints are one-based pages to search first. |
options | { max_matches?, include_previews?, minimum_semantic_score? } | No | max_matches (1 to 50, default 10) locations per target; include_previews (default false) links a cropped image per visual hit; minimum_semantic_score (0 to 1) drops weaker semantic matches, and does nothing on PDF, Word and PowerPoint. |
Ground takes no page_ranges and no parse_mode, and semantic belongs on each target: a top-level semantic is refused with 422.
const job = await client.documents.ground({
source: { type: "url", url: "https://example.com/report.pdf", file_name: "report.pdf" },
targets: [
{ id: "total", text: "1,200.50" },
{ id: "signer", text: "Jane Smith", hint: "Signed on behalf of the supplier" },
{ id: "late_fee", text: "Late payment fee" },
],
});
const done = await client.jobs.wait(job.job_id);job.result is a plain object (unknown in the types). Each target has id, status and matches; each match has rank, matched_text, match_method, confidence, cropped_image_url and location, whose kind decides its other keys: page_region (page, polygons), text_span (page, char_start, char_end), sheet_range (sheet, a1_range), row_range (start_row, end_row, column), json_pointer (pointer), jsonl_record (row_offset, column) or audio_range (start_ms, end_ms). See Matches.
Uploads
Uploads put a file in your account's workspace, to use as a workspace_file source. They're free and work at any balance. The bytes go straight to the document service with a one-use token, never with your API key, your defaultHeaders or the SDK's X-Drex-* headers: they carry only X-Upload-Token, plus an Idempotency-Key on a one-request upload and on completing a session.
Upload a file
client.documents.upload(content, params?, options?) returns Promise<UploadedFile>.
| Parameter | Type | Required | Description |
|---|---|---|---|
content | FileContent | Yes | The bytes: a Blob (a File included), a Uint8Array or an ArrayBuffer. |
params.file_name | string | Yes, unless content is a File | The file's name, and the default path. Default: the File's own name. |
params.path | string | No | Where the file goes in the workspace, relative. Keep the file's extension: the document service types an upload from its first bytes, and from this extension when those don't settle it. Default: file_name. |
params.on_conflict | OnConflict | No | "reject" (default) refuses a different file at path; "new_version" replaces it with a new version. |
params is an UploadParams. A file under 32 MiB goes up in one request through an upload grant. From 32 MiB (UPLOAD_SESSION_THRESHOLD_BYTES) it goes up in parts through an upload session, each part read only as it is sent. options apply in full to the call to Drex; the calls to the document service take its signal, timeout and retry, not headers.
import { openAsBlob } from "node:fs";
import { workspaceFile } from "nace-sdk";
// A Blob backed by the file on disk: the SDK reads it part by part, not all at once.
const blob = await openAsBlob("invoices/invoice.pdf");
const file = await client.documents.upload(blob, {
file_name: "invoice.pdf",
path: "invoices/invoice.pdf",
on_conflict: "new_version",
});
const job = await client.documents.extract({
source: workspaceFile(file),
schema: { type: "object", properties: { total: { type: "number" } } },
});Uploading the same bytes to the same path again returns the file that is there. A different file at a taken path, with on_conflict: "reject", fails with code path_conflict: a ConflictError (409) for a file under 32 MiB, and a NaceError naming the failed upload job and path_conflict for a larger one. upload without a file_name for a non-File throws a NaceError. A file sent in parts that is still assembling after about ten minutes throws a NaceError; the document service keeps assembling it, so upload the same file to the same path again later to get it.
Upload grants
client.documents.createUploadGrant(params?, options?) mints a one-use token for one upload into your workspace, so another client, such as a browser, can upload without your API key. It returns APIPromise<UploadGrant>. params is an UploadGrantParams:
| Parameter | Type | Required | Description |
|---|---|---|---|
path | string | No | Pin the upload to this workspace path. |
max_bytes | number | No | Refuse a larger file. Default: the document service's limit for the file type. |
total_size_bytes | number | No | The exact size the file must have. |
ttl_seconds | number | No | How long the token works, 1 to 3600. Default: 3600. |
UploadGrant field | Type | Description |
|---|---|---|
workspace_id | string | Your workspace. |
upload_url | string | The document service's upload URL, not Drex's. |
token | string | Sent as X-Upload-Token. Good for one upload. |
expires_at | string | When the token stops working. |
max_bytes | number | null | The cap you set, or null. |
Mint the grant on your server, then let the browser send the file to the grant's upload_url itself:
const grant = await client.documents.createUploadGrant({
path: "invoices/invoice.pdf",
total_size_bytes: 48_213,
ttl_seconds: 600,
});
// Hand grant.upload_url and grant.token to the browser. The token works for one upload.declare const grant: { upload_url: string; token: string };
declare const file: File; // from an <input type="file">
const form = new FormData();
form.append("file", file);
form.append("metadata", JSON.stringify({ path: "invoices/invoice.pdf", total_size_bytes: file.size }));
const response = await fetch(grant.upload_url, {
method: "POST",
body: form,
headers: { "X-Upload-Token": grant.token },
credentials: "omit",
});
const upload = await response.json(); // upload.result.file has workspace_id and file_idSend only the X-Upload-Token header, with credentials: "omit", so the request works from any origin. The metadata part also takes on_conflict. Send the file's workspace_id and file_id back to your server and use them as a workspace_file source: the /v1 routes send no Access-Control-Allow-Origin header, so keep every Drex call on your server. See Uploads.
Upload sessions
An upload session takes a large file in parts and can resume after a drop. upload runs one for you from 32 MiB; run one yourself to resume, or to send parts from somewhere else.
client.documents.createUploadSession(params, options?) returns APIPromise<UploadSession>. params is an UploadSessionParams:
| Parameter | Type | Required | Description |
|---|---|---|---|
path | string | Yes | Where the file goes in the workspace. |
total_size_bytes | number | Yes | The file's exact size across every part, at least 1. |
ttl_seconds | number | No | How long the session takes parts, 1 to 5400. Default: 5400. |
on_conflict | OnConflict | No | "reject" (default) fails the assembly with code path_conflict when a different file has the path; "new_version" replaces it. |
idempotency_key | string | No | Sent as Idempotency-Key: the same key returns the same session. Minted per call when omitted, so a retry never opens a second session. |
UploadSession has workspace_id, session_id, session_token, chunk_size (every part's size, except a shorter last part), total_parts, expires_at and upload_session_url, the document service's URL for the session.
| Method | Returns | Description |
|---|---|---|
documents.uploadPart(session, number, data, options?) | Promise<void> | Sends part number, from 0: chunk_size bytes of data (a FileContent), the last part shorter. Sending a part again replaces it. |
documents.completeUploadSession(session, params?, options?) | Promise<UploadedFile> | Assembles the parts into one file. Waits up to 10 minutes under one params.idempotency_key (minted when omitted), then throws a NaceError naming the key: call again with it to keep waiting. A failed assembly, such as path_conflict, throws a NaceError with the job's error code. |
documents.getUploadSession(session, options?) | APIPromise<UploadSessionStatus> | Which parts have landed, to resume after a drop. |
documents.abortUploadSession(session, options?) | Promise<void> | Drops the session and its parts. Does nothing to a finished session. A session still assembling (completing) is refused with ConflictError, code session_busy: wait for completeUploadSession instead. |
These four send X-Upload-Token: session.session_token to session.upload_session_url, never your API key, so they need only those two fields: save them to resume from another process. Their options take signal, timeout and retry, not headers.
A session refuses calls that don't fit its state. None of these is retried:
session_busy(ConflictError): sending a part or aborting while the session iscompleting, or completing it with a different key while a completion runs. CallcompleteUploadSessionagain with the same key to resume that completion.upload_expired(ConflictError): sending a part to a session that completed or was aborted, or completing an aborted one. Completing a completed session again returns its file.- After
expires_atthe session token stops working, and every call on the session throwsAuthenticationError(401). Open a new session.
UploadSessionStatus field | Type | Description |
|---|---|---|
session_id | string | The session. |
status | "open" | "completing" | "completed" | "aborted" | Where it stands, or another string a newer service sends. |
chunk_size, total_parts | number | As on UploadSession. |
parts | Array<{ part_number, size_bytes }> | Parts landed so far, in no order. Send only the missing ones. |
received_bytes | number | The sum of parts[].size_bytes. |
declared_size_bytes | number | total_size_bytes from the create. |
expires_at | string | When the session stops taking parts. |
import { openAsBlob } from "node:fs";
const blob = await openAsBlob("media/interview.mp4");
const session = await client.documents.createUploadSession({
path: "media/interview.mp4",
total_size_bytes: blob.size,
});
// After a drop, ask which parts landed and send only the rest.
const status = await client.documents.getUploadSession(session);
const landed = new Set(status.parts.map((part) => part.part_number));
for (let number = 0; number < session.total_parts; number++) {
if (landed.has(number)) continue;
const start = number * session.chunk_size;
await client.documents.uploadPart(session, number, blob.slice(start, start + session.chunk_size));
}
const file = await client.documents.completeUploadSession(session);UploadedFile has the file's workspace_id, file_id and path, and the document service's other file fields, such as file_name, size_bytes and version.
Jobs
| Method | Returns | Description |
|---|---|---|
jobs.get(jobId, options?) | APIPromise<DocumentJob> | One job, with its result once it has succeeded, until retention drops it. Classify may include a partial result while still running. |
jobs.list(params?, options?) | APIPromise<DocumentJobList> | One page of jobs, newest first, without results. |
jobs.iter(params?, options?) | AsyncGenerator<DocumentJob> | Every job across every page, 50 a page. params takes operation and status. |
jobs.wait(jobId, opts?) | Promise<DocumentJob> | Polls until the job finishes. See Wait for a job. |
jobs.events(jobId, opts?) | AsyncGenerator<JobEvent> | The job's progress as it happens. See Progress events. |
jobs.delete(jobId, options?) | Promise<void> | Cancels a running job and drops it from the list. A finished job is just dropped. It stays readable by id, and work done is still charged. |
A jobId that isn't a UUID throws a NaceError before sending. Another account's job answers 404 (NotFoundError), like one that doesn't exist. A job whose result has expired still reads: jobs.get and jobs.wait return it succeeded, with result set to null and result_state set to "expired" or "not_retained". See Jobs and results. A job created before 3 October 2026 that the document service no longer has answers 410 (NotFoundError, code job_unavailable).
jobs.list takes a ListParams:
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | number | No | 1 to 50. Default: 20. Out of range throws a NaceError. |
operation | string | No | Only one tool's jobs: one of DOCUMENT_OPERATIONS. |
status | string | No | Only jobs in one status: one of JOB_STATUSES. A job whose status changed since Drex recorded it is left off, so a page can be short, or empty, while next_cursor is set. |
cursor | string | null | No | The previous page's next_cursor. Keep going until it's null. |
const page = await client.jobs.list({ limit: 20, operation: "parse", status: "succeeded" });
console.log(page.items.length, page.next_cursor);
for await (const job of client.jobs.iter({ operation: "extract" })) {
console.log(job.job_id, job.status);
}Wait for a job
client.jobs.wait(jobId, opts?) polls the job, 1 second apart at first and doubling to 10 seconds, until it's succeeded, failed or cancelled. Each poll is a jobs.get, with the client's retries.
| Parameter | Type | Required | Description |
|---|---|---|---|
opts.timeout | number | No | How long to keep polling, in milliseconds: the whole wait, not one request. Default: 600000 (10 minutes). |
opts.raiseOnFailure | boolean | No | When true (default), a failed or cancelled job throws JobFailedError. When false, it's returned. |
opts.signal | AbortSignal | No | Stops waiting and throws APIUserAbortError. |
When the time runs out it throws JobTimeoutError. The job keeps running, so you can wait again:
import { JobFailedError, JobTimeoutError } from "nace-sdk";
try {
const done = await client.jobs.wait(jobId, { timeout: 120_000 });
console.log(done.result);
} catch (err) {
if (err instanceof JobTimeoutError) {
console.log(`still ${err.job.status}; wait again with ${err.job.job_id}`);
} else if (err instanceof JobFailedError) {
console.log(err.job.error);
} else {
throw err;
}
}Progress events
client.jobs.events(jobId, opts?) yields a JobEvent each time the job's status or progress changes, for up to five minutes. The first one is the current state, and the stream ends once the job finishes. It's a latency convenience, never retried, and it counts against your per-minute limit: read the outcome with jobs.get or jobs.wait.
| Parameter | Type | Required | Description |
|---|---|---|---|
opts.lastEventId | string | No | The last event's String(event.sequence), to keep the numbering after a drop. |
opts.signal | AbortSignal | No | Aborts the stream. Before the response arrives it throws APIUserAbortError; while events are being read it throws the runtime's AbortError DOMException, or the signal's own reason. |
let lastEventId: string | undefined;
for await (const event of client.jobs.events(jobId, { lastEventId })) {
lastEventId = String(event.sequence);
console.log(event.at, event.status, event.progress);
}
const done = await client.jobs.wait(jobId);JobEvent has sequence, at (ISO 8601), status, progress (null until the job reports any) and message (usually null).
Job files
Links in a result to a job's own files point at https://console.nace.ai/v1/documents/jobs/{id}/...: figure crops, ground crops, transcripts, converted PDFs, cell maps, full content and spreadsheet rows. Each method below takes that link as it is, or the part after the job id, such as document-content/<ref>. A query or fragment on it is dropped. A full link from another job's result throws NaceError before any request: pass that job's id instead.
| Method | Returns | Description |
|---|---|---|
jobs.fetchFile(jobId, path, options?) | Promise<Response> | Any job file, as a streaming Response. |
jobs.download(jobId, path, options?) | Promise<Uint8Array> | Any job file, read into memory. |
jobs.fileLink(jobId, path, options?) | APIPromise<FileLink> | A signed link to a stored file, good for 5 minutes. |
jobs.getRequest(jobId, options?) | APIPromise<JobRequest> | The request the job ran under. |
jobs.rows(jobId, path, params?, options?) | APIPromise<RowsPage> | One page of a parsed sheet's rows. |
Downloads of job files don't count against your per-minute limit; the request echo and the event stream do. Results and their files expire on the document service's retention schedule. After that, job files and jobs.getRequest answer 404 or 410 (NotFoundError, code result_expired), while jobs.get still returns the job with result: null.
Download a file
jobs.fetchFile and jobs.download read every kind of job file. A stored file (figure crops, transcripts, ground crops, converted PDFs, cell maps) is fetched through its signed link, without your API key, or streams from Drex where the service can't sign links. Anything else, such as document-content, spreadsheet-content, spreadsheet-rows and request, streams from Drex.
Full content can still be in preparation: the SDK waits what Retry-After says (else the body's retry_after_seconds, else 3 seconds) and asks again. When waitTimeout runs out, it throws a NaceError; preparation keeps going, so ask again later. An export that failed throws ConflictError (409).
options is a FetchFileOptions: a RequestOptions plus waitTimeout, how long to wait for content in preparation, in milliseconds (default 600000). The signed-link fetch takes its signal, timeout and retry, not headers.
import { createWriteStream } from "node:fs";
import { writeFile } from "node:fs/promises";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { ReadableStream } from "node:stream/web";
const job = await client.jobs.wait(jobId);
const { document } = job.result as { document: { content_url: string | null } };
if (document.content_url) {
// Stream the complete Markdown to disk.
const response = await client.jobs.fetchFile(jobId, document.content_url);
await pipeline(Readable.fromWeb(response.body as ReadableStream), createWriteStream("report.md"));
// Or read it into memory.
await writeFile("report.md", await client.jobs.download(jobId, document.content_url));
}Signed links
jobs.fileLink returns a FileLink: url, a signed link that opens without your API key, and expires_at, in Unix seconds, 5 minutes away. Hand it to a browser to open or download. Only stored files have one: split-artifacts, ground-crops, ground-transcript, parse-images, parse-transcript, converted-pdf and spreadsheet-cell-maps. Any other path throws a NaceError saying to use fetchFile or download, before a request goes out. A stored-file path this job doesn't have rejects with NotFoundError (404). Where the deployment's document service can't sign links yet, stored files stream from Drex too and fileLink throws that error for them as well; fetchFile and download work either way.
const { url, expires_at } = await client.jobs.fileLink(jobId, "parse-images/<ref>");Job request
jobs.getRequest returns a JobRequest: the request the job ran under, as the document service stored it, with request_type set to the job's kind, and its other fields, such as source, options, schema, classes or targets. A parse password is never stored. It expires with the job's result.
const request = await client.jobs.getRequest(jobId);
console.log(request.request_type, request.schema);Spreadsheet rows
client.jobs.rows(jobId, path, params?, options?) reads one page of a parsed sheet's rows, in file order. path is the sheet's rows_url from the parse result, which only Parquet sources carry. params is a RowsParams:
| Parameter | Type | Required | Description |
|---|---|---|---|
start_row | number | No | The zero-based first row. Default: 0. |
limit | number | No | Rows per page, 1 to 100 (MAX_ROWS_PAGE_SIZE). Default: 50. |
Out of range throws a NaceError. A RowsPage has path, start_row, limit, total_rows, columns (each a RowsColumn: name and data_type) and rows, each row's cells as text keyed by column name.
let start = 0;
while (true) {
const page = await client.jobs.rows(jobId, rowsUrl, { start_row: start, limit: 100 });
for (const row of page.rows) console.log(row);
start += page.rows.length;
if (page.rows.length === 0 || start >= page.total_rows) break;
}Saved schemas
Save a JSON Schema once, then name it with schema_id on an extract instead of sending it each time. Schemas can't be edited or deleted; add a version instead. The schema routes are free and work at any balance.
| Method | Returns | Description |
|---|---|---|
extractionSchemas.create(params, options?) | APIPromise<ExtractionSchema> | Saves version 1 of a new schema. |
extractionSchemas.list(params?, options?) | APIPromise<ExtractionSchemaList> | One page of schemas at their latest version, sorted by schema_id (case-insensitively, with - and _ ignored at first; not byte order), without schema. |
extractionSchemas.iter(params?, options?) | AsyncGenerator<ExtractionSchema> | Every schema, in list's order, 200 a page. params takes no filters. |
extractionSchemas.get(schemaId, options?) | APIPromise<ExtractionSchema> | A schema's latest version. |
extractionSchemas.createVersion(schemaId, params, options?) | APIPromise<ExtractionSchema> | Adds the next version, which becomes the latest. |
extractionSchemas.listVersions(schemaId, params?, options?) | APIPromise<ExtractionSchemaList> | One page of a schema's versions, oldest first. |
extractionSchemas.iterVersions(schemaId, params?, options?) | AsyncGenerator<ExtractionSchema> | Every version, oldest first, 200 a page. |
extractionSchemas.getVersion(schemaId, version, options?) | APIPromise<ExtractionSchema> | One version. |
| Parameter type | Fields |
|---|---|
CreateExtractionSchemaParams | name (1 to 128 characters), schema (the JSON Schema), description? (up to 2000 characters). |
CreateExtractionSchemaVersionParams | schema, and name? and description?, which keep their current values when left out. |
ExtractionSchemaListParams | limit? (1 to 200, default 50; out of range throws a NaceError) and cursor?. |
- Retries:
createandcreateVersionaren't idempotent, so they aren't retried unless you passretryinoptions: a retried create can save a second schema. - Nace's schemas: the list can include schemas Nace provides, with
owner: "platform". You can extract with them, but they take no new versions (404). - Ids: another account's
schema_idanswers404. An empty id, or one with/,?or#, throws aNaceErrorbefore sending.
An ExtractionSchema has schema_id, name, description, version, owner ("account" or "platform"), created_at, updated_at and schema, which list and iter leave out; version pages include it. An ExtractionSchemaList has items, next_cursor and, when the service counts them, total_count.
const saved = await client.extractionSchemas.create({
name: "invoice",
description: "Invoice header fields",
schema: {
type: "object",
properties: { invoice_number: { type: "string" }, total: { type: "number" } },
},
});
const job = await client.documents.extract({
source: "https://example.com/invoice.pdf",
schema_id: saved.schema_id,
});
const v2 = await client.extractionSchemas.createVersion(saved.schema_id, {
schema: {
type: "object",
properties: { invoice_number: { type: "string" }, total: { type: "number" }, due_date: { type: "string" } },
},
});
await client.documents.extract({
source: "https://example.com/invoice.pdf",
schema_id: saved.schema_id,
schema_version: v2.version,
});
for await (const version of client.extractionSchemas.iterVersions(saved.schema_id)) {
console.log(version.version, version.name);
}Read the raw response
Most methods return an APIPromise<T>. Await it for the parsed body, or call one of its methods:
| Method | Returns | Description |
|---|---|---|
withResponse() | Promise<WithResponse<T>> | { data, response, requestId }: the parsed body, the Response (its body already read) and the x-request-id header. |
asResponse() | Promise<Response> | The Response with its body unread. Read the body yourself, and don't also await the parsed result. A non-2xx answer still rejects with an APIError. |
map(fn) | APIPromise<U> | A new APIPromise of fn(data), sharing the response and its single parse. |
const { data, response, requestId } = await client
.systemOne({ state: "Refund my order", questions: { refund: noul("Is it a refund request?") } })
.withResponse();
console.log(response.status, requestId, data.answers.refund.noul);systemOne, models.list, the document creates, createUploadGrant, createUploadSession, getUploadSession, jobs.get, jobs.list, jobs.getRequest, jobs.rows, jobs.fileLink and the extractionSchemas reads and creates return an APIPromise. documents.upload, uploadPart, completeUploadSession, abortUploadSession, jobs.wait, jobs.delete, jobs.fetchFile and jobs.download return a plain Promise, and the iter, iterVersions and events methods are async generators.
Types and constants
client.models is a Models, client.documents a Documents, client.jobs a Jobs and client.extractionSchemas an ExtractionSchemas. The decision types are in Answer types and Request types, and the parameter types in each method's section above. The document types:
| Type | What it is |
|---|---|
DocumentJob | A job as the document service sends it: job_id, kind (a DocumentOperation), status (a JobStatus), result, usually once it has succeeded (null again once retention drops it; Classify may include a partial one while still running, with reason_status), error, credits (final once usage_final is true), usage_final, created_at, and every other field the service sends, such as result_state: "available", or why a finished job's result is null ("expired" or "not_retained"). |
DocumentJobList | items (DocumentJob[]) and next_cursor, null on the last page. |
DocumentOperation | "parse" | "split" | "classify" | "extract" | "ground", or another string a newer service sends. |
JobStatus | "queued" | "running" | "succeeded" | "failed" | "cancelled", or another string a newer service sends. |
JobEvent | One event from jobs.events: sequence, at, status, progress, message. |
JobRequest | The request echo from jobs.getRequest: request_type and the request's fields. |
FileLink | url and expires_at, in Unix seconds. |
RowsPage, RowsColumn | A page from jobs.rows, and one of its columns. |
UploadedFile | workspace_id, file_id, path and the service's other file fields. |
UploadGrant, UploadSession, UploadSessionStatus | See Uploads. |
ExtractionSchema, ExtractionSchemaList | See Saved schemas. |
OnConflict | "reject" | "new_version". |
FileContent | Blob | Uint8Array | ArrayBuffer. |
SourceInput, Source, UrlSource, WorkspaceFileSource, ParseResultSource | See Sources. |
| Constant | Value | Description |
|---|---|---|
DEFAULT_BASE_URL | "https://console.nace.ai" | The API root when neither baseURL nor NACE_BASE_URL is set. |
DEFAULT_MODEL | "drex-latest" | The model when neither defaultModel nor NACE_DEFAULT_DECISION_MODEL is set. |
DEFAULT_TIMEOUT_MS | 60000 | The default timeout. |
ENV | { apiKey, baseURL, defaultModel, logLevel } | The environment variable for each option, from "NACE_API_KEY" to "NACE_LOG_LEVEL". EnvVar is the union of the four names. |
LEGACY_ENV | { NACE_API_KEY, NACE_BASE_URL, NACE_DEFAULT_DECISION_MODEL, NACE_LOG_LEVEL } | Each variable's name before the rename, "DREX_API_KEY" to "DREX_LOG_LEVEL", read when the new one is unset or blank. |
LOG_LEVELS | ["debug", "info", "warn", "error", "off"] | Every LogLevel. |
VERSION | "0.1.0" | The package version, sent as User-Agent and X-Nace-SDK: nace-sdk/<VERSION>. |
DOCUMENT_OPERATIONS | ["parse", "split", "classify", "extract", "ground"] | The five tools. |
JOB_STATUSES | ["queued", "running", "succeeded", "failed", "cancelled"] | Every job status. |
TERMINAL_STATUSES | ReadonlySet of "succeeded", "failed", "cancelled" | The statuses a job finishes in. |
MAX_WAIT_SECONDS | 60 | The longest wait_seconds. |
MAX_ROWS_PAGE_SIZE | 100 | The largest jobs.rows limit. |
UPLOAD_SESSION_THRESHOLD_BYTES | 33554432 (32 MiB) | The size from which upload sends a file in parts. |
import { TERMINAL_STATUSES } from "nace-sdk";
const job = await client.jobs.get(jobId);
if (!TERMINAL_STATUSES.has(job.status)) console.log("still running");Handle errors
Every error the SDK throws is a NaceError, except a failure while you read a streamed body (see below). An HTTP failure throws an APIError subclass:
| Status | Class |
|---|---|
| 400 | BadRequestError |
| 401 | AuthenticationError |
| 402 | InsufficientCreditError (error.type is insufficient_credit or payment_required) |
| 403 | PermissionDeniedError |
| 404, 410 | NotFoundError |
| 409 | ConflictError |
| 422 | UnprocessableEntityError |
| 429 | RateLimitError |
| 5xx | InternalServerError (529 is OverloadedError, a subclass) |
| Any other, such as 413 | APIError |
Every APIError has these fields:
| Field | Type | Description |
|---|---|---|
status | number | The HTTP status. |
headers | Headers | The response headers. |
body | unknown | The parsed JSON body, the text, or undefined when empty. |
requestId | string | undefined | x-request-id, else the body's request_id. Quote it in support requests. |
type | string | undefined | error.type, the class of failure, such as rate_limit_error. |
code | string | undefined | error.code, the exact cause, sent by the document routes. |
issues | Issue[] | error.issues: each place a /v1/systemone request failed validation, as { path, message }. Empty when there are none. |
detail | unknown | error.detail: the document service's detail behind a document-route error, such as the validation problems under detail.errors. |
serverRetryable | boolean | undefined | error.retryable, the server's verdict on retrying. |
The other classes:
| Class | Thrown when | Extra fields |
|---|---|---|
RateLimitError | 429. | retryAfterMs: the server's delay from retry-after-ms or retry-after, or undefined. |
APIConnectionError | No response arrived: DNS, TLS, a dropped connection or a cut-off body. | |
APITimeoutError | An attempt ran past its timeout. A kind of APIConnectionError. | timeoutMs: the timeout that ran out. |
APIUserAbortError | Your signal aborted a request, a wait before a retry, or a wait between polls. | |
JobFailedError | jobs.wait found the job failed or cancelled. | job: the DocumentJob, with its error. |
JobTimeoutError | jobs.wait ran out of time. The job keeps running. | job, and timeoutMs: the wait budget. |
NaceError | The SDK refused your input before sending, the client configuration is invalid, or a call ended without the result it needs: a job file with no signed link (jobs.fileLink), content still in preparation after waitTimeout (jobs.fetchFile, jobs.download), an upload job that failed or is still assembling (documents.upload, completeUploadSession), or a response of an unexpected shape. |
jobs.events, the Response from jobs.fetchFile and jobs.download read their body after the headers arrive. If the connection drops or your signal aborts while that body is read, you get the runtime's own error, a TypeError or an AbortError DOMException, rather than a NaceError.
import { APIError, NaceClient, InsufficientCreditError, noul, RateLimitError } from "nace-sdk";
const client = new NaceClient();
try {
await client.systemOne({ state: "Cancel my plan", questions: { churn: noul("Does the customer want to cancel?") } });
} catch (err) {
if (err instanceof InsufficientCreditError) {
// insufficient_credit: top up or add a card; payment_required: pay the open invoice
console.log(err.type, "- see Billing in the dashboard");
} else if (err instanceof RateLimitError) {
console.log("Retry in", err.retryAfterMs, "ms");
} else if (err instanceof APIError) {
console.log(err.status, err.type, err.issues, err.requestId);
} else {
throw err;
}
}See Errors and retries for what each error means, and the error reference for every status.
On the document routes, code names the exact cause, and detail holds the document service's detail when it refused the request, such as the validation problems under detail.errors. What each failure there throws:
| What happened | What you get |
|---|---|
| A request the document service refused, such as an unsupported file type or an unknown field | UnprocessableEntityError (422), with code (invalid_request, unsupported_file_type, invalid_source and so on), and detail when the document service sent one (undefined otherwise, as for unsupported_file_type). |
The same idempotency_key with a different body | ConflictError (409), code idempotency_conflict. |
| A create while the balance isn't positive and no card pays for usage beyond it, or while the account has an unpaid invoice | InsufficientCreditError (402), with type insufficient_credit or payment_required. payment_required isn't retryable until the invoice is paid on the Billing page. |
| No such job or saved schema for this account | NotFoundError (404), code schema_not_found for a schema. |
| An upload session call that doesn't fit the session's state | ConflictError (409), code session_busy or upload_expired. See Upload sessions. |
| Document prices aren't configured, or a retried key's job isn't confirmed yet | InternalServerError (503), code document_billing_not_configured or job_pending. |
| A different file at a taken upload path | ConflictError (409), code path_conflict, under 32 MiB; a NaceError naming the failed upload job and path_conflict from 32 MiB. |
| Full content whose export failed | ConflictError (409), from fetchFile or download. |
| An expired job file or request echo, or a job created before 3 October 2026 whose results are gone | NotFoundError (410), code result_expired or job_unavailable. An expired job itself still reads, with result: null. |
| A job that failed, was cancelled, or outlasted the wait | JobFailedError or JobTimeoutError, from jobs.wait. |
| Input the SDK checks before sending | NaceError: a string source that isn't https://, a URL without a usable file name, wait_seconds outside 0 to 60, an idempotency_key that is empty or over 200 characters, both or neither of schema and schema_id, schema_version without schema_id, a job id that isn't a UUID, a bad schema id, a limit or start_row out of range, a negative part number, or upload without a file_name. |
The document routes have their own per-minute rate limit, separate from systemOne's. A create refused with 429 starts no job: retry it with the same idempotency_key. See Errors.
import { ConflictError, UnprocessableEntityError } from "nace-sdk";
try {
await client.documents.upload(new Uint8Array([37, 80, 68, 70]), { file_name: "invoice.pdf" });
} catch (err) {
if (err instanceof ConflictError && err.code === "path_conflict") {
console.log("A different invoice.pdf is already there: pass on_conflict: \"new_version\"");
} else if (err instanceof UnprocessableEntityError) {
console.log(err.code, err.detail);
} else {
throw err;
}
}Retries
The client retries a failed attempt while its RetryPolicy allows:
| Field | Type | Default | Description |
|---|---|---|---|
maxRetries | number | 2 | Retries after the first attempt. 0 turns retries off. |
backoffInitialMs | number | 500 | The first wait, doubled for each retry up to backoffMaxMs. |
backoffMaxMs | number | 5000 | The longest backoff wait. |
backoffJitter | number | 0.25 | The fraction of each backoff wait taken off at random, from 0 to 1. |
httpStatuses | ReadonlySet<number> | 408, 429 and 500 to 599 | Statuses to retry. A set you pass replaces this one. An error whose body says "retryable": false is never retried. |
respectRetryAfter | boolean | true | Wait what retry-after-ms or retry-after asks, instead of the backoff. |
maxRetryAfterMs | number | 60000 | The longest server delay to honor. A longer one falls back to the backoff. |
apiConnectionError | boolean | true | Retry an APIConnectionError. |
apiTimeoutError | boolean | true | Retry an APITimeoutError. |
timeoutMs | number | null | 30000 | The retry budget: no retry starts once the time since the call began, plus the next wait, would reach it. It doesn't cut a running attempt short. null removes it. |
A value out of range throws a NaceError. A retry you pass to the client or to one call is merged over the policy beneath it, so name only the fields you change.
The budget. Before each retry, the client adds the time since the call started to the next wait. If that reaches timeoutMs, it stops and throws the last error. With the defaults, a 60-second attempt timeout and a 30-second budget:
- An attempt that fails after 30 seconds or more is never retried. That covers every
APITimeoutErrorand a529that Drex sends after waiting 55 seconds for the model. - A
retry-afterthat doesn't fit in what is left of the 30 seconds isn't waited out, such as the 30 seconds a paused API asks for.
To retry those, give the budget room for every attempt and wait, or remove it:
// Three 60 s attempts and two waits of up to 60 s.
const client = new NaceClient({ retry: { timeoutMs: 300_000 } });
// Or no budget at all: maxRetries alone limits the call.
const patient = new NaceClient({ retry: { timeoutMs: null } });A retry of a request to Drex carries X-Nace-Retry-Count. Document creates carry an Idempotency-Key, minted once per call, so their retries never start or bill a second job. extractionSchemas.create and createVersion aren't retried unless you pass retry, and jobs.events is never retried.
Logging
| Level | What the SDK logs |
|---|---|
"debug" | Everything info logs, plus each request's URL, headers and JSON body, and each response's body. |
"info" | One line per attempt, with its status, time and request id, plus each retry, timeout, connection error and abort. |
"warn", "error" | Nothing yet: the SDK logs only at info and debug. |
"off" | Nothing. |
LOG_LEVELS lists the levels from most to least verbose, ["debug", "info", "warn", "error", "off"], and LogLevel is their union. A Logger has debug, info, warn and error methods that take a message and any values, so console fits.
Logs are redacted: Authorization, Proxy-Authorization and X-API-Key keep only their last four characters, Cookie, Set-Cookie and X-Upload-Token are hidden, and so is a token or session_token field in a response body. Everything else is logged as it is at debug, including state, a parse password you send and signed link URLs, so keep debug out of shared logs.
const client = new NaceClient({ logLevel: "info", logger: console });Python SDK
Call Drex's decision model and run Perception's document tools from Python with nace-sdk — client options, calibrated questions, typed answers, sources, parse, split, classify, extract, ground, uploads, jobs, saved schemas, errors, retries and logging.
CLI
Install the drex command, sign in, ask Drex's decision model calibrated questions, and run Perception's document tools, uploads, jobs and saved schemas from the shell.