Perception
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. A cancelled job is not charged unless results were already delivered before the cancel; a finished one keeps its charge. |
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.
document.content_url is set only for a very large file: when the Markdown would be over 1 MiB, document.markdown is a preview and content_url links the full text. Otherwise it's null and document.markdown is complete. See Very large results.
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;
}