Skip to content

feat: add Data Extraction tools (parse_document, extract_fields) - #27

Merged
nickwinder merged 38 commits into
mainfrom
feat/dws-data-extraction-workflow
Jul 21, 2026
Merged

feat: add Data Extraction tools (parse_document, extract_fields)#27
nickwinder merged 38 commits into
mainfrom
feat/dws-data-extraction-workflow

Conversation

@jdrhyne

@jdrhyne jdrhyne commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

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 by
    mode (text / structure / understand / agentic). Spatial output (typed
    elements with bounding boxes, confidence, reading order) is written to
    outputPath and 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, get
    back just those values, each with a per-field citation. A grounding summary
    flags fields that came back not_found, so a confident extraction is
    distinguishable 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 DwsApiClient resolves the right credential
per request — keyed by the endpoint's product — behind a CredentialProvider:

  • OAuth (recommended, nothing extra to configure): one product:all token
    covers both products.
  • Static keys: a Processor key is tenant-bound and can't authenticate
    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.
  • A rejected static key fails fast (it can't be refreshed); a 401 under OAuth
    refreshes the token once and retries.

Design notes worth a look

  • Context safety: large spatial output and citations go to a file; inline
    summaries are content-free (counts, confidence, page geometry — no document
    text). Markdown honors outputPath for the same reason.
  • Billing-safe by construction: everything cheap is checked before the
    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.
  • Version-pinned: requests send x-nutrient-api-version, so results don't
    drift with the age of the API key.
  • Costs surfaced: each result reports credits used + remaining (named as the
    Data Extraction balance, distinct from the Processor credits check_credits
    reports), plus the parse/extract split.
  • Uploads survive a token refresh: multipart bodies are buffered so the
    401-retry replays the file rather than a consumed stream (affects every upload
    tool).

Out of scope

  • Run-history / share endpoints (GET/DELETE /extraction/runs) — a follow-up.
  • storeRun is intentionally not exposed: there's no tool to retrieve a stored
    run by runId, so offering it would advertise a capability that isn't there.

jdrhyne added 12 commits June 7, 2026 11:27
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
@jdrhyne

jdrhyne commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

U0 — live /extraction/parse verification (do before merge)

The handlers are built against Nutrient's published Data Extraction schema, but no live call has run yet (no key was available during development). One real request confirms the response shape and gives us a recorded fixture. Costs ~2–3 credits total.

Steps

# 1. Set the Data Extraction key (SEPARATE from NUTRIENT_DWS_API_KEY; starts with pdf_live_)
export NUTRIENT_EXTRACTION_API_KEY=pdf_live_...   # or add to .env (gitignored)

# 2a. Spatial (structure mode, ~1.5 credits) — confirms the element schema
curl -sS -X POST https://api.nutrient.io/extraction/parse \
  -H "Authorization: Bearer $NUTRIENT_EXTRACTION_API_KEY" \
  -F "file=@tests/assets/example.pdf" \
  -F 'instructions={"mode":"structure","output":{"format":"spatial"}}' \
  | tee tests/fixtures/extraction-spatial-sample.json | jq '.output.elements[0], .metrics'

# 2b. Markdown (text mode, ~1 credit)
curl -sS -X POST https://api.nutrient.io/extraction/parse \
  -H "Authorization: Bearer $NUTRIENT_EXTRACTION_API_KEY" \
  -F "file=@tests/assets/example.pdf" \
  -F 'instructions={"mode":"text","output":{"format":"markdown"}}' \
  | jq '.output | keys'

Acceptance criteria — the code reads exactly these paths (src/dws/extract.ts)

  • Spatial: output.elements is an array; each element has type, confidence, bounds.{x,y,width,height}, page.pageIndex. metrics.pagesProcessed is present.
    • summarizeSpatial uses element.type / element.confidence / element.page.pageIndex / metrics.pagesProcessed
    • query_extraction filters on element.bounds / confidence / page.pageIndex / type
  • Markdown: response has output.markdown (string).

If the field names match → no code change needed; just drop the captured response into tests/fixtures/ and (optionally) swap the inline fixture in tests/extract.test.ts for it, then this is release-ready.

If anything differs → adjust the SpatialElement / ExtractionResponse types and the filter/summary field access in src/dws/extract.ts (and the instructions shape if options.language / output.includeWords placement differs from the docs).

Tracked as U0 in docs/plans/2026-06-07-001-feat-dws-data-extraction-workflow-plan.md.

nickwinder and others added 12 commits July 17, 2026 12:07
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>
@nickwinder nickwinder changed the title feat: add data_extractor and query_extraction tools (DWS Data Extraction API) feat: add Data Extraction tools (data_extractor, query_extraction, schema_extractor) Jul 17, 2026
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>
@nickwinder nickwinder changed the title feat: add Data Extraction tools (data_extractor, query_extraction, schema_extractor) feat: add Data Extraction tools (data_extractor, schema_extractor) Jul 17, 2026
… 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>
@nickwinder nickwinder changed the title feat: add Data Extraction tools (data_extractor, schema_extractor) feat: add Data Extraction tools (parse_document, extract_fields) Jul 17, 2026
nickwinder and others added 7 commits July 20, 2026 11:57
…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>
@nickwinder
nickwinder requested a review from HungKNguyen July 20, 2026 02:29

@HungKNguyen HungKNguyen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

nickwinder and others added 5 commits July 21, 2026 13:37
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>
@nickwinder
nickwinder merged commit f2e8c49 into main Jul 21, 2026
2 checks passed
@nickwinder
nickwinder deleted the feat/dws-data-extraction-workflow branch July 21, 2026 03:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants