feat: add Data Extraction tools (parse_document, extract_fields) - #27
Conversation
Expose the build core (package-internal) so focused tools like data_extractor can compose instructions -> API call -> response routing without performBuildCall's required outputFilePath, enabling inline responses. Behavior-preserving; performBuildCall still consumes both. 🔮 View transcript: https://nutrient-agentlogs.dev/s/duk4x9tr3rmlnxta7c4bwk6o
Data Extraction is a separate DWS API (POST /extraction/parse with its own pdf_live_ key), not a json-content output of the Processor /build endpoint. data_extractor will not reuse the Build instruction machinery, so the processInstructions/makeApiBuildCall exports are unnecessary.
…rse) DWS Data Extraction is a separate API with its own pdf_live_ key, not a json-content output of Processor /build. Rework KTD1 (second DwsApiClient), add modes/formats + cost transparency, separate NUTRIENT_EXTRACTION_API_KEY, and ground all wiring in main's client.ts/index.ts. 🔮 View transcript: https://nutrient-agentlogs.dev/s/duk4x9tr3rmlnxta7c4bwk6o
Schemas for the Data Extraction API (/extraction/parse): mode (text/ structure/understand/agentic), output format (spatial/markdown), includeWords, language, pages, outputPath; plus query filters (pages/region/minConfidence/ elementTypes/limit). Cross-field rules enforced in handlers (Schema.shape requires a plain ZodObject). 🔮 View transcript: https://nutrient-agentlogs.dev/s/duk4x9tr3rmlnxta7c4bwk6o
Separate key from the Processor NUTRIENT_DWS_API_KEY; the Data Extraction client is constructed from it during tool registration (U5). 🔮 View transcript: https://nutrient-agentlogs.dev/s/duk4x9tr3rmlnxta7c4bwk6o
performExtractCall: POST /extraction/parse (multipart file + instructions), validates outputPath before the call, routes spatial->file+content-free summary and markdown->inline, parses streamed JSON, clear error when key unset. performQueryCall: reads a saved spatial file and returns elements filtered by page/region/minConfidence/elementTypes, capped by limit. Drop unsupported 'pages' request param from the extractor schema; language nests under options. 🔮 View transcript: https://nutrient-agentlogs.dev/s/duk4x9tr3rmlnxta7c4bwk6o
… extraction on document_processor Builds a Data Extraction DwsApiClient from NUTRIENT_EXTRACTION_API_KEY (undefined when unset; data_extractor then returns a clear setup error). Threads it through createMcpServer/addToolsToServer. Updates document_processor description to point extraction users to data_extractor, and the tool-list test for the two new tools. 🔮 View transcript: https://nutrient-agentlogs.dev/s/duk4x9tr3rmlnxta7c4bwk6o
12 tests against the documented response shape (mocked client): markdown inline, spatial->file with a content-free summary (asserts no PII leaks inline while the file retains it), spatial-requires-outputPath, text-mode rejects spatial, sandbox containment of absolute paths, missing-key error, and query filters (minConfidence/type/page/region/limit/malformed). 🔮 View transcript: https://nutrient-agentlogs.dev/s/duk4x9tr3rmlnxta7c4bwk6o
…ion key Add both tools to the Available Tools table, a Data Extraction section (modes, cost per page, spatial-vs-markdown, file+query workflow, transcript caveat), NUTRIENT_EXTRACTION_API_KEY to the env table and .env.example, and point the document_processor capability row at the dedicated tool. 🔮 View transcript: https://nutrient-agentlogs.dev/s/duk4x9tr3rmlnxta7c4bwk6o
examples/invoice-extraction-workflow.md demonstrates data_extractor (spatial to file) -> query_extraction (low-confidence + region slices) -> act via ai_redactor/document_signer, keeping the full payload out of context. 🔮 View transcript: https://nutrient-agentlogs.dev/s/duk4x9tr3rmlnxta7c4bwk6o
…e guard, leaner write - Honor outputPath for markdown output (large docs would overflow context inline) - Reject 2xx responses lacking output.elements before writing, so a non-extraction body can't overwrite the target file - Write the raw response body for spatial output instead of re-stringifying (drops a copy of large payloads, preserves all API fields) - Extract writeToResolvedPath helper (de-dupes mkdir-p), drop redundant includeWords coalesce and the resolvedOutputPath casts - Add tests for markdown-to-file and the non-spatial-body guard 🔮 View transcript: https://nutrient-agentlogs.dev/s/duk4x9tr3rmlnxta7c4bwk6o
U0 — live
|
Data Extraction is a separate product with its own tenant. A static Processor key is bound to the Processor tenant, so reusing it against /extraction/parse is rejected by the gateway — data_extractor returned an opaque 403 for every static-key user. Under OAuth nothing changes: one token already covers both products, so the Processor and Data Extraction clients stay the same instance. Under a static key, data_extractor now uses NUTRIENT_DWS_EXTRACT_API_KEY and fails fast with an actionable message when it is absent, rather than sending a request that cannot succeed. query_extraction reads a local file and still needs no credential. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
data_extractor implemented a subset of the documented parse contract. Adds the parts an agent workflow actually reaches for: - url input, so a document already hosted somewhere is parsed without a local copy. Exactly one of filePath/url is required. - formats, requesting spatial and markdown in a single call. The API bills a second format at no extra cost, so paying for two calls was pure waste. - the markdown rendering options (useHtmlTables, enableSemanticBlockFormatting, includeHeadersAndFooters, extractWordsFromPictures). These are omitted unless set: two of them default to true server-side, and sending an implicit false would silently change output. - maxLanguages/maxScripts for auto-detection, rejected when language is pinned rather than silently ignored. - storeRun, surfacing runId so a stored run can be retrieved later. Requests now pin x-nutrient-api-version. Without it the API selects a version based on when the API key was created, so two users of the same server could get different results. Success messages report extraction credit cost and remaining balance, named as a separate balance from the Processor credits check_credits reports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Data Extraction reports failures as errorMessage/errorDetails, where the Processor sends details. The shared handler recognizes only the Processor shape, so every extraction failure fell through to a generic "Error processing API response" wrapped around the raw body — wording that describes a parsing problem on our side rather than a rejection by the API. A 402 was the costly case: out of extraction credits read as an unexplained failure, inviting a retry that cannot succeed. It now says so, and points at the cheaper modes. The handler lives in the extraction module and consumes the error stream itself; the body can only be read once, so it cannot delegate to the shared handler after inspecting it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- maxLanguages/maxScripts were silently discarded in text mode: the language-conflict guard does not fire when language is unset, and the options block is skipped for text mode entirely, so the request shipped without them and said nothing. They are now rejected, since text mode does no OCR. - A whitespace-only NUTRIENT_DWS_API_KEY is truthy, so it selected static-key auth, suppressed the OAuth flow, and left extraction unconfigured. The user was then told to set NUTRIENT_DWS_EXTRACT_API_KEY when the real fault was a blank Processor key. Both keys are now trimmed, so blank reads as unset. - extraHeaders could displace Authorization on merge. Built headers now win; extra headers only add endpoint-specific keys. - includeWords carried a zod default, contradicting the rule applied to the other output options two lines away. It is omitted unless set, like them. Also drops an unreachable outputPath guard and a comment describing a precedence the mutual-exclusion check makes impossible. Tests cover the request shape rather than just the response: that a multi-format call sends output.formats and never output.format (and the converse), that includeWords is absent when unset and on markdown-only calls, that options is sent on a happy path and omitted when empty, and the text-mode and blank-key rejections. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
data_extractor parses a whole document; an agent that only wants an invoice number and a total had to parse everything and sift the elements itself. The Data Extraction API exposes /extraction/extract for exactly this: hand it a JSON schema, get back the matching values with a per-field citation tying each one to where it was found. schema_extractor takes that schema plus optional free-text guidance, and returns output.data inline — it is the answer, and is bounded by the caller's own schema. Citations and page geometry are large and only useful next to the document, so they go to outputPath when given, and are otherwise reported as omitted rather than silently dropped. Inline results carry a grounding summary: a tally of citation match quality and the field paths that came back not_found, so an agent can tell a confident extraction from a guess. The paths come from the caller's own schema, never from the document, so naming them leaks nothing. The endpoint has no text mode and bills a parse component plus a fixed extract component, so it does not reuse the parse tool's mode enum or its 402 advice — recommending text here would send the caller into a guaranteed rejection. Also lists data_extractor and query_extraction in manifest.json, which they were missing from, and adds a test asserting the manifest matches the registered tools so the two cannot drift apart again unnoticed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plan tracked this PR's own work while it was in progress. The tools it describes are built, documented in the README, and covered by tests, so the file now only restates them — and would go stale the moment either changed. docs/ keeps testing.md, which predates this branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 401 interceptor retries by replaying error.config verbatim, but a form-data payload is a stream that axios has already consumed by then, so the retried upload carried an empty body. The caller saw a confusing 400 or a hang from a request that would have succeeded with the fresh token. Serializing the form to a Buffer before handing it to axios makes the body inert and replayable. Every upload tool posts a form, so this covers them all, not just the extraction path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cached credentials recorded no scope, so a user who authenticated before product:all was added to the requested scopes kept a token minted under the narrower set: getToken returned it from cache while unexpired, and the refresh grant sends no scope parameter, so refreshes inherited the old set too. Data Extraction stayed rejected until the credentials file was deleted by hand, which nothing told the user to do. Credentials now record the scopes they were requested under, and a cached entry that does not cover the configured scopes falls through to a fresh authorization. Recording the requested set rather than the server's granted set is deliberate: checking against a granted set would re-trigger the browser flow on every call if the server ever narrowed a grant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ools Several arguments returned the inverse of what was asked, and several notes reported things that were not true: - Neither tool checked that outputPath differed from the input filePath, so passing the same path read the document and then overwrote it with the extraction result. ai_redactor has always guarded this; both extraction tools now do too, before the billable call. - Empty pages/elementTypes arrays coerced to "no filter" and returned every element inline — the opposite of the request, and the content leak the spatial-to-file design exists to prevent. Both now require a non-empty array at the schema. - A write failure after a successful, billed extraction was rendered through the API error handler as a bare filesystem error, so an agent read a generic failure and retried, paying twice. Only the billable call is guarded now; a write failure says the extraction was already billed. - The credit note, including the remaining balance, was dropped whenever cost was not a number — losing exactly the signal that pre-empts a 402. - output.metadata: null passed an object check, so schema_extractor claimed citations existed and told the caller to re-run with outputPath to keep them; a second billed call returned the same null. query_extraction gains maxConfidence. The low-confidence triage workflow the summary and README advertise was not expressible before: with only minConfidence, asking for weak fields meant minConfidence: 0, which returns everything. The low-confidence count is now inclusive so it matches exactly what the suggested maxConfidence query returns. Elements the API scored no confidence for stay excluded from a bounded query — an unscored element cannot be placed inside a bound either way — and the schema and tool description now say so instead of leaving it to be discovered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes the standalone worked example and the README link to it. The per-tool documentation in the README already covers the extract → query → act shape, so the walkthrough was a second place to keep in sync. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Data Extraction tool surface now maps 1:1 onto the REST API: data_extractor is POST /extraction/parse, schema_extractor is POST /extraction/extract. query_extraction mirrored nothing — it was a local file reader that sliced a saved spatial JSON by page, region, confidence, and element type. The job it served is better served by schema_extractor: an agent wants the values it cares about, typed and cited, not an element list it has to sift. Region queries were the weakest part — an agent cannot know a bounding box without having already read the elements it is trying to filter. It was also the least reliable part of the surface, because it was the only part implementing behavior of our own rather than passing a request through: empty filter arrays silently returned every element instead of none, and a confidence floor of zero excluded unscored elements. Deleting it removes that class of bug along with the code. Spatial output still goes to outputPath — /extraction/parse produces it, so the mirror keeps it — but the file is now a deliverable for the caller's own pipeline rather than something this server reads back. The inline summary still reports counts, element types, low-confidence totals, and page geometry, and still carries no document text. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… verbs data_extractor -> parse_document (POST /extraction/parse) schema_extractor -> extract_fields (POST /extraction/extract) schema_extractor was misleading: it read as "extract the schema from the document" when it does the opposite — extracts data according to a schema the caller supplies. data_extractor was merely vague; paired with it, two tools both named "_extractor" carried none of the routing decision an agent actually makes, which is scope: the whole document, or specific fields. The new names mirror the two endpoint verbs, matching how the surface is now shaped. Internal symbols follow the tools rather than drifting from them: ParseDocumentArgsSchema, ExtractFieldsArgsSchema, performParseDocumentCall, performExtractFieldsCall. Neither tool has shipped — origin/main and the published package have neither — so no aliases or deprecation path are needed. This is the last point at which the names are free to change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ndpoints parse.ts implements POST /extraction/parse and extract.ts implements POST /extraction/extract, so name the files (and their tests) for the endpoints they serve. Pure rename plus import-path updates; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The server exposes no way to retrieve a stored run by its runId (the /extraction/runs endpoints are not wired), so storeRun advertised a capability that isn't there. Remove it from both tool schemas, the request bodies, and the success-path runId reporting; the error-path runId tracing breadcrumb is kept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…behind a CredentialProvider Replace the two DwsApiClient instances (one per product) with a single client that resolves the right credential per request from the endpoint's product. A CredentialProvider hides the api-key-vs-OAuth choice and the per-product key selection, so index.ts no longer threads two clients. This also lets a Data Extraction key run the server on its own (extraction-only mode) and restores fail-fast on a rejected static key (canRefresh), since a static key cannot be refreshed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…XTRACTION_API_KEY Reconcile the naming: use the DWS-prefixed NUTRIENT_DWS_EXTRACTION_API_KEY (parallel to NUTRIENT_DWS_API_KEY) for the static Data Extraction key, and rename the internal Environment field to nutrientExtractionApiKey. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract stripLeadingSlash() shared by productFor() and buildUrl(). Rename formatRunMetadata/RunMetadataResponse (and the runMetadata locals) to formatCreditUsage/CreditUsageResponse — since storeRun and the success-path runId were dropped, these now carry only Data Extraction credit usage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 'spatial' | 'markdown' union in parse.ts duplicated ExtractionFormatSchema. Infer it via z.infer<typeof ExtractionFormatSchema>, exported from schemas.ts, matching the repo's convention of deriving request-side types from their schemas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Define the six response views (SpatialElement, ExtractionResponse, ExtractionErrorResponse, PriceComponent, CreditUsageResponse, ExtractFieldsResponse) as zod schemas in schemas.ts and infer their types, matching the repo's schema -> z.infer convention. Type-only: the handlers keep their explicit shape guards and JSON.parse(...) as X assertions, so responses are not runtime-validated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HungKNguyen
left a comment
There was a problem hiding this comment.
Approving. The per-request credential routing is the right design and it's well-tested (product-keyed 401 re-resolution, static-key fail-fast, extraction-only/processor-only gating). A few non-blocking follow-ups — none need to hold the merge:
1. product:all scope invalidates every existing cached OAuth credential (credential-provider.ts, nutrient-oauth.ts)
OAuth now requests ['mcp:invoke','product:all','offline_access'], and coversRequestedScopes nulls any cache lacking product:all — i.e. every currently-cached user. Next request forces a full browser re-auth, which is a silent break on headless/CI OAuth deployments until someone re-consents. Please add a release/upgrade note. (Secondary: exchangeCodeForToken records the requested scopes, not the server-granted ones — if the authz server ever narrows product:all, the cache would claim a scope it doesn't hold.)
2. formats not deduped/normalized (parse.ts)
["spatial","spatial"] is forwarded verbatim and only rejected by the API after local checks pass (wasted round trip). [...new Set(formats)] + a length check catches it locally.
3. Malformed-2xx messaging (low likelihood) (parse.ts)
When a "both" request returns 2xx with elements but no markdown, the handler returns a bare Error: … Nothing was written. I checked the DWS backend: "both" is one Maestro pass returning both artifacts, so this only happens on a partial/malformed 2xx — not reachable via valid input. But since the extraction was billed there and the client already models billed-write failures, consider routing missing-expected-field-on-2xx through that same billed messaging rather than a bare (retry-inviting) error.
Nit: writeToResolvedPath does fs.access then fs.mkdir(recursive) — the probe is redundant since mkdir is recursive; drop it.
writeToResolvedPath probed the directory with fs.access before calling
mkdir({ recursive: true }), but a recursive mkdir is already a no-op when
the directory exists — the probe was dead code. Collapse to a single
unconditional mkdir, matching writeCachedCredentials in nutrient-oauth.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A duplicated formats array (e.g. ["spatial","spatial"]) was forwarded to the Data Extraction API verbatim, and because the request builder switches on resolvedFormats.length, it was sent as a multi-format `formats` body instead of the cleaner singular `format`. Normalize in resolveFormats with a Set so duplicates collapse to the single-format path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tryable The three branches that reject a 2xx response missing an expected output field (spatial elements; markdown alongside spatial; markdown-only) returned a bare error, which reads as transient and invites a retry that pays for the same extraction again. Route them through a new billedMalformedResponse helper that mirrors billedWriteFailure — it surfaces the billed framing and the credit usage already computed for the call. This restores the invariant the file states for post-billing failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
exchangeCodeForToken and the refresh path recorded config.scopes / the cached scopes regardless of what the server actually granted. If the authorization server narrows the grant (RFC 6749 §5.1 returns `scope` when it differs from the request; §6 allows it on refresh), the cache would claim a scope it does not hold and skip a re-auth it needs. Read the response `scope` and fall back to the requested/cached set only when it is absent, on both paths. The now-redundant post-refresh scopes assignment moves into refreshAccessToken. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adding Data Extraction broadened the requested OAuth scope so one token covers both products. Credentials cached by an older version lack that scope and cannot be up-scoped by a refresh, so existing users are prompted to consent once on upgrade. Document the expected one-time re-auth and the headless/CI workaround (use static API keys) in the Troubleshooting FAQ. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Why
Agents need structured document data — typed elements with coordinates, tables,
key/value regions, confidence — not a wall of text. This exposes the Nutrient DWS
Data Extraction API as MCP tools, shaped so a large extraction never has to
land in the conversation.
What's added
Two tools, mapping 1:1 onto the two Data Extraction endpoints:
parse_document(POST /extraction/parse) — parse a whole document bymode(text/structure/understand/agentic). Spatial output (typedelements with bounding boxes, confidence, reading order) is written to
outputPathand summarized inline; markdown is returned inline, or to a file.Ask for both formats in one call — the second is billed at no extra cost.
extract_fields(POST /extraction/extract) — hand it a JSON schema, getback just those values, each with a per-field citation. A grounding summary
flags fields that came back
not_found, so a confident extraction isdistinguishable from a guess.
Authentication (the main thing to review)
Data Extraction is a separate product/tenant with its own credit balance, which
drives the credential model. A single
DwsApiClientresolves the right credentialper request — keyed by the endpoint's product — behind a
CredentialProvider:product:alltokencovers both products.
extraction, so extraction needs its own
NUTRIENT_DWS_EXTRACTION_API_KEY.Setting only that key runs the server in extraction-only mode (Processor
tools return a clear error). Any static key ⇒ static mode — the server never
silently falls back to the OAuth browser flow.
refreshes the token once and retries.
Design notes worth a look
summaries are content-free (counts, confidence, page geometry — no document
text). Markdown honors
outputPathfor the same reason.billable call (sandbox resolution; a same-path guard so the source is never
overwritten; format/mode compatibility). Nothing is written on a non-extraction
2xx, and a filesystem failure after a billed call is reported as "billed" —
not as a retryable error.
x-nutrient-api-version, so results don'tdrift with the age of the API key.
Data Extraction balance, distinct from the Processor credits
check_creditsreports), plus the parse/extract split.
401-retry replays the file rather than a consumed stream (affects every upload
tool).
Out of scope
GET/DELETE /extraction/runs) — a follow-up.storeRunis intentionally not exposed: there's no tool to retrieve a storedrun by
runId, so offering it would advertise a capability that isn't there.