# Command line (afx) Source: https://docs.anyformat.ai/api-reference-v3/cli Parse a file, extract fields, run and manage workflows from the terminal with afx, the command the Python package installs. `afx` is the command line that ships with the [Python SDK](/api-reference-v3/sdks/python). It covers the one-off jobs you do not want to write code for: parse a file to markdown, extract a few fields, run an existing workflow, and list or delete workflows. ## Install `afx` is the `anyformat` package's console script. ```bash theme={null} pip install anyformat afx --version ``` Run `afx` with no arguments for the banner, or `afx --help` for the command list. ## Auth Every command reads the API key from `ANYFORMAT_API_KEY`, or from `--api-key`. A missing key exits with `Error: API key is required. Set --api-key or ANYFORMAT_API_KEY.` ```bash theme={null} export ANYFORMAT_API_KEY=af_... ``` Every command also takes `--base-url` (default `https://api.anyformat.ai`) for a non-production deployment. ## Commands Commands in the order `afx --help` lists them. Progress lines go to stderr; results go to stdout, so you can pipe them. ### extract Creates a workflow with one [Parse](/guides/nodes/parse) node and one [Extract](/guides/nodes/extract) node, uploads the file, runs it, and prints the fields. Give the fields inline with `--field`, or as a JSON file with `--schema`. At least one is required. | Option | Default | What it does | | -------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------- | | `FILE` (argument) | required | Path to the file to process. | | `--field name:description` | | A string field. Repeatable. | | `--schema PATH` | | JSON file with the full schema. Supports every scalar type and one level of `object` nesting. | | `--name` | `anyformat-cli/extract ` | Workflow name. | | `--parse-mode` | `standard` | Parse tier: `standard`, `agentic`, `lite` or `flash`. | | `--on-conflict` | server default (`error`) | `error` rejects a filename that already exists in the workflow with `409`; `rename` auto-renames it. | | `--timeout` | `300` | Seconds to wait for the run. | | `--json` / `--table` | `--table` | Output format. | | `--output PATH`, `-o` | stdout | Write the results to a file. | ```bash theme={null} afx extract invoice.pdf \ --field "invoice_number:The unique invoice identifier" \ --field "total_amount:Total invoice amount" \ --parse-mode agentic --json ``` The `--schema` file is a JSON array. Each entry has `name`, `type` and `description`; an `object` entry adds `fields`. Supported types: `string`, `integer`, `float`, `boolean`, `date`, `datetime`, `object`. `enum` and `multi_select` are not supported on the command line. ```json theme={null} [ { "name": "vendor", "type": "string", "description": "Vendor name on the invoice." }, { "name": "total", "type": "float", "description": "Grand total." }, { "name": "line_items", "type": "object", "description": "One row per line item.", "fields": [ { "name": "sku", "type": "string", "description": "Stock keeping unit." }, { "name": "amount", "type": "float", "description": "Line amount." } ]} ] ``` The table view prints scalar fields with their confidence and summarises object fields as row counts; use `--json` to see the rows. ### run Runs an existing workflow, built in Studio or with an SDK, against a file and prints the fields. | Option | Default | What it does | | --------------------- | ------------------------ | -------------------------------------- | | `FILE` (argument) | required | Path to the file to process. | | `--workflow-id`, `-w` | required | The workflow to run. | | `--on-conflict` | server default (`error`) | `error` or `rename`, as for `extract`. | | `--timeout` | `300` | Seconds to wait for the run. | | `--json` / `--table` | `--json` | Output format. | | `--output PATH`, `-o` | stdout | Write the results to a file. | ```bash theme={null} afx run invoice.pdf -w 0686bb97-8c30-70f0-8000-97669e000eb8 > result.json ``` ### parse Parses a file with the atomic parse operation (a fast, lite parse) and prints the markdown. No workflow is created. | Option | Default | What it does | | --------------------- | --------- | ---------------------------------------------------------------------------------------------- | | `FILE` (argument) | required | Path to the file to parse. | | `--timeout` | `300` | Seconds to wait. | | `--poll-interval` | `3.0` | Seconds between status polls. | | `--json` / `--table` | `--table` | `--table` prints the markdown (rendered on a terminal); `--json` prints `{ "markdown": ... }`. | | `--output PATH`, `-o` | stdout | Write the result to a file. | ```bash theme={null} afx parse contract.pdf -o contract.md ``` ### update-workflow Replaces an existing workflow's graph with a new Parse + Extract pair built from `--field` and `--schema`, the same way `extract` builds one. A field keeps its identity only when the schema file carries its `persistent_id`. Nothing matches by name. A field without one is new, and loses the quality metrics, ground truth and analytics attached to the old field. Fetch the current ids with `afx get ` before updating. | Option | Default | What it does | | -------------------------- | ------------------------------------------- | ------------------------------------------------------------------------ | | `--workflow-id` | required | The workflow to update. | | `--field name:description` | | A string field. Repeatable. | | `--schema PATH` | | JSON schema file, as for `extract`. May carry `persistent_id` per field. | | `--name` | `anyformat-cli/update-workflow ` | New workflow name. | | `--parse-mode` | `standard` | Parse tier. | ```bash theme={null} afx update-workflow --workflow-id 0686bb97-8c30-70f0-8000-97669e000eb8 --schema fields.json ``` ### list Lists your organization's workflows, newest first, as a table of id, name and creation time. | Option | Default | What it does | | --------- | ------- | -------------------------------------------------------------- | | `--limit` | `20` | Rows to show, 1 to 100. A note on stderr says when more exist. | ```bash theme={null} afx list --limit 50 ``` ### get Prints a workflow's typed definition as JSON: the same `{ name, description, nodes, edges }` shape the [create endpoint](/api-reference-v3/workflows/create) accepts. ```bash theme={null} afx get 0686bb97-8c30-70f0-8000-97669e000eb8 > workflow.json ``` ### delete Deletes a workflow, with its document packets and runs. Asks for confirmation unless `--yes` is given. | Option | Default | What it does | | ------------- | ------- | ----------------------------- | | `--yes`, `-y` | off | Skip the confirmation prompt. | ```bash theme={null} afx delete 0686bb97-8c30-70f0-8000-97669e000eb8 -y ``` ### runs Lists a workflow's runs, newest first: run id, document packet id, status and creation time. | Option | Default | What it does | | --------- | ------- | ----------------------- | | `--limit` | `20` | Rows to show, 1 to 100. | ```bash theme={null} afx runs 0686bb97-8c30-70f0-8000-97669e000eb8 ``` ## What a CLI workflow is `afx extract` and `afx update-workflow` build the same typed graph the SDKs and Studio use: a `parse` node with the chosen `mode`, one `extract` node with your fields, and one edge between them. `afx get` shows it. Open a CLI-created workflow in Studio, run it with `afx run` or either SDK, and edit it with `afx update-workflow` or `PATCH /v3/workflows/{id}/`. The field ids stay stable across those edits as long as the schema file keeps `persistent_id`. ## Exit status `afx` exits `1` and prints an `Error:` line on stderr in five cases: the key is missing, the input is invalid, the API rejects the request, the run ends in `error` or `cancelled`, or the wait times out. A rejected request also prints the status and the `error_code`. # Upload to Dataset Source: https://docs.anyformat.ai/api-reference-v3/datasets/upload POST /v3/workflows/{workflow_id}/dataset/upload/ Upload one document (+ optional ground truth) into a workflow dataset *Rate limit tier: **submission**, 60 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Uploads 1–100 files as a single [document packet](/concepts/document-packets) in the workflow's dataset, with an optional `ground_truth` for that packet. Creation is all-or-nothing. One rejected file or a ground-truth failure fails the whole request and stores nothing. An unsupported type and disguised bytes both cause a rejection. Send the files as multipart form data under the `files` field, repeating the field for a multi-file document. The response returns a stable `document_packet_id`. The presigned upload-slot ids stay hidden. The client orchestrates bulk ingestion: loop this endpoint, **one call per document**. The API has no batch endpoint, so every call stays bounded, atomic and independently retryable. The SDKs provide the per-document loop. **Filenames are unique within a workflow.** Under the default, `on_conflict=error`, a document whose name collides with a live dataset member fails with `409`. The response lists each conflict and the name it would take. No rename is ever silent. Pass `on_conflict=rename` to rename the collision instead. The returned file's `name` is then the name it landed under, and `original_name` holds the name you sent. `original_name` is `null` when no rename happened. **Ground truth arrives in the optional `ground_truth` field.** It holds the expected document for this document packet, as a JSON-encoded object sent as a string, because multipart carries no nested objects. The keys are the workflow schema's field identifiers. Use `persistent_id`; the API also accepts `sanitized_name`. A scalar field maps to `string | null`, and a table or object field maps to an array of row objects. Ground truth attaches to the workflow's current version. On success, `ground_truth_saved` is `true`. **Retries are safe with `Idempotency-Key`.** Pass any unique string. Retrying the request with the same key replays the original document packet, so the API registers no duplicate document. See [Idempotency](/api-reference-v3/introduction#idempotency). ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/dataset/upload/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Idempotency-Key: 5f2b6c3e-8a1d-4f7e-9c0b-1a2b3c4d5e6f' \ -F 'files=@/path/to/invoice_001.pdf' \ -F 'ground_truth={"invoice_no": "INV-1", "total": "90.00"}' ``` ```python Python (SDK) theme={null} from anyformat.sdk import Client, DatasetDocument client = Client(api_key="YOUR_API_KEY") workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" # One document with ground truth. upload = client.upload_to_dataset( workflow_id, "invoice_001.pdf", ground_truth={"invoice_no": "INV-1", "total": "90.00"}, ) print(upload.document_packet_id, upload.ground_truth_saved) # Bulk: one atomic call per document. uploads = client.upload_documents_to_dataset( workflow_id, [ DatasetDocument(file="invoice_001.pdf", ground_truth={"invoice_no": "INV-1"}), DatasetDocument(file="invoice_002.pdf", ground_truth={"invoice_no": "INV-2"}), ], ) ``` ```typescript TypeScript theme={null} interface DatasetUpload { document_packet_id: string; files: { id: string; name: string; original_name: string | null }[]; ground_truth_saved: boolean; } const workflowId = '0686bb97-8c30-70f0-8000-97669e000eb8'; const formData = new FormData(); formData.append('files', fileInput.files[0]); formData.append('ground_truth', JSON.stringify({ invoice_no: 'INV-1', total: '90.00' })); const response = await fetch( `https://api.anyformat.ai/v3/workflows/${workflowId}/dataset/upload/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Idempotency-Key': crypto.randomUUID(), }, body: formData, } ); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const upload: DatasetUpload = await response.json(); console.log(upload.document_packet_id, upload.ground_truth_saved); ``` ```json Response (201 Created) theme={null} { "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "files": [ { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e2", "name": "invoice_001.pdf", "original_name": null } ], "ground_truth_saved": true } ``` # Delete Document Packet Source: https://docs.anyformat.ai/api-reference-v3/document-packets/delete DELETE /v3/document-packets/{document_packet_id}/ Delete a document packet, its files, and its extraction results *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Deletes a [document packet](/concepts/document-packets) and every file in it, along with any extraction results. The packet leaves the API at once, and its content stays in storage for a grace window of 14 days, or 24 hours for an organization with zero data retention. Only after that window does the content become eligible for a permanent purge. The API offers no call that undoes a delete. Returns `204 No Content` on success. An unknown id returns `404`, as does a packet belonging to another organization. ```bash curl theme={null} curl -X DELETE 'https://api.anyformat.ai/v3/document-packets/069dcc2c-e14c-7606-8000-2ee4fb17b4e1/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests document_packet_id = "069dcc2c-e14c-7606-8000-2ee4fb17b4e1" url = f"https://api.anyformat.ai/v3/document-packets/{document_packet_id}/" headers = {"Authorization": "Bearer YOUR_API_KEY"} response = requests.delete(url, headers=headers) assert response.status_code == 204 ``` # Get Document Packet Source: https://docs.anyformat.ai/api-reference-v3/document-packets/get GET /v3/document-packets/{document_packet_id}/ Retrieve one document packet: status, files, and its latest run id *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Returns one [document packet](/concepts/document-packets) flat: extraction status, timestamps, the files it groups, `latest_run_id`, and any `metadata` supplied at creation. `latest_run_id` names the packet's most recent [run](/concepts/runs-and-results), and is `null` until the packet has run. It is the hop to results: follow it with [`GET /v3/runs/{run_id}/`](/api-reference-v3/runs/get). The response embeds no run history. List a workflow's runs with [`GET /v3/workflows/{workflow_id}/runs/`](/api-reference-v3/runs/list). `metadata` is the free-form JSON object the caller attached on create, and is `null` when the caller attached none. See [Attaching metadata](/concepts/document-packets#attaching-metadata) for how it flows through extraction. An unknown id returns `404`, as does a packet belonging to another organization. ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/document-packets/069dcc2c-e14c-7606-8000-2ee4fb17b4e1/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests document_packet_id = "069dcc2c-e14c-7606-8000-2ee4fb17b4e1" url = f"https://api.anyformat.ai/v3/document-packets/{document_packet_id}/" headers = {"Authorization": "Bearer YOUR_API_KEY"} packet = requests.get(url, headers=headers).json() print(packet["status"], packet["latest_run_id"]) ``` ```json Response (200 OK) theme={null} { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "name": "contract-bundle", "status": "processed", "created_at": "2026-07-01T12:00:00.000Z", "updated_at": "2026-07-01T12:05:00.000Z", "files": [ { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e2", "name": "contract.pdf" }, { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e3", "name": "annex-a.pdf" } ], "latest_run_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "metadata": { "customer_reference": "CR-2026-99001-ZTX" } } ``` # List Document Packets Source: https://docs.anyformat.ai/api-reference-v3/document-packets/list GET /v3/workflows/{workflow_id}/document-packets/ List a workflow's document packets, newest first *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Returns one keyset page of a workflow's [document packets](/concepts/document-packets), newest first. Follow `next_cursor` until it is `null`. See [Pagination](/api-reference-v3/introduction#pagination). Each item is a slim summary: id, name, `status` and timestamps. For the per-file breakdown and `latest_run_id`, fetch [`GET /v3/document-packets/{document_packet_id}/`](/api-reference-v3/document-packets/get). Packet `status` reflects the packet's most recent extraction. It is one of `not_started`, `queued`, `in_progress`, `processed`, `error` or `cancelled`. A packet that has never run is `not_started`. ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/document-packets/?limit=20' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" url = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/document-packets/" headers = {"Authorization": "Bearer YOUR_API_KEY"} page = requests.get(url, headers=headers, params={"limit": 20}).json() for packet in page["items"]: print(packet["id"], packet["status"]) ``` ```json Response (200 OK) theme={null} { "items": [ { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "name": "invoice-1043.pdf", "status": "processed", "created_at": "2026-07-01T12:00:00.000Z", "updated_at": "2026-07-01T12:05:00.000Z" }, { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e4", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "name": "invoice-1044.pdf", "status": "not_started", "created_at": "2026-07-01T11:00:00.000Z", "updated_at": "2026-07-01T11:00:00.000Z" } ], "next_cursor": null } ``` # Run Document Packet Source: https://docs.anyformat.ai/api-reference-v3/document-packets/run POST /v3/document-packets/{document_packet_id}/run/ Run (or re-run) a document packet on the latest version of its workflow *Rate limit tier: **submission**, 60 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Runs, or **re-runs**, a [document packet](/concepts/document-packets) on the latest version of its workflow. The path is flat: the packet already knows its workflow, so no `workflow_id` appears in the URL. **Every call creates a new run.** Re-running the same document after editing the workflow is the intended flow. Earlier runs stay readable at [`GET /v3/runs/{run_id}/`](/api-reference-v3/runs/get) with their own results. The platform meters a re-run like a fresh run. See [How credits work](/concepts/how-credits-work). An `Idempotency-Key` replay is the one exception. Retrying with the same key returns the **original** run, and triggers and bills no second extraction. See [Idempotency](/api-reference-v3/introduction#idempotency). An unknown id returns `404`, as does a packet belonging to another organization. An organization without extraction credit receives `402 PAYMENT_REQUIRED`. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v3/document-packets/069dcc2c-e14c-7606-8000-2ee4fb17b4e1/run/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Idempotency-Key: 9e8d7c6b-5a4f-3e2d-1c0b-a9b8c7d6e5f4' ``` ```python Python (requests) theme={null} import requests document_packet_id = "069dcc2c-e14c-7606-8000-2ee4fb17b4e1" url = f"https://api.anyformat.ai/v3/document-packets/{document_packet_id}/run/" headers = { "Authorization": "Bearer YOUR_API_KEY", "Idempotency-Key": "9e8d7c6b-5a4f-3e2d-1c0b-a9b8c7d6e5f4", } triggered = requests.post(url, headers=headers).json() print(triggered["run_id"]) ``` ```typescript TypeScript theme={null} interface RunTriggered { run_id: string; document_packet_id: string; workflow_id: string; status: 'queued' | 'in_progress' | 'processed' | 'error' | 'cancelled'; } const packetId = '069dcc2c-e14c-7606-8000-2ee4fb17b4e1'; const response = await fetch( `https://api.anyformat.ai/v3/document-packets/${packetId}/run/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Idempotency-Key': crypto.randomUUID(), }, } ); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const triggered: RunTriggered = await response.json(); console.log(triggered.run_id); ``` ```json Response (202 Accepted) theme={null} { "run_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4fa", "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "status": "queued" } ``` # Get Eval Source: https://docs.anyformat.ai/api-reference-v3/evals/get GET /v3/workflows/{workflow_id}/evals/{eval_id}/ Fetch one eval's frozen headline: grading status plus accuracy *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Fetches one [eval](/concepts/evals) by its `eval_id`. It is the poll target for a run launched with [`POST /v3/workflows/{workflow_id}/evals/`](/api-reference-v3/evals/launch). The `id` in the response is the identifier that launch returned as `eval_id`. The response is the eval's **frozen headline**: grading `status`, the `accuracy` fraction, the `matched`, `mismatched` and `ungraded` field tallies, the cohort's `file_count`, and `failed_count`. `failed_count` counts the documents that failed to enqueue. The headline carries **counts, never internal ids**. ## Status model The endpoint **always returns `200` while the eval exists**. Grading in flight raises no precondition error. | `status` | Meaning | Headline (`accuracy`, `matched`, `mismatched`, `ungraded`) | | ------------- | ------------------------------------------------ | ---------------------------------------------------------- | | `in_progress` | Extractions are running, or grading is not final | `null` | | `processed` | Terminal. The eval is graded | populated | | `error` | Terminal. The run failed | `null` | Poll until `status` is terminal. On a `processed` eval, `accuracy` is the `matched / (matched + mismatched)` fraction. It stays `null` when the graded denominator is 0, which happens when nothing is gradable. Every field being `ungraded` for lack of ground truth produces that case. An unknown id returns `404`, and so does an eval on another workflow or organization: existence never leaks. A caller outside the workflow's organization gets `403`. ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/evals/069dcc2c-e14c-7606-8000-2ee4fb17b4f9/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (SDK) theme={null} import time from anyformat.sdk import Client client = Client(api_key="YOUR_API_KEY") workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" eval_id = "069dcc2c-e14c-7606-8000-2ee4fb17b4f9" for _ in range(100): an_eval = client.get_eval(workflow_id, eval_id) if an_eval.status == "processed": print(f"accuracy={an_eval.accuracy} " f"({an_eval.matched}/{an_eval.mismatched}/{an_eval.ungraded})") break if an_eval.status == "error": print("Eval failed") break time.sleep(5) ``` ```typescript TypeScript theme={null} interface EvalDetail { id: string; run_number: number; version_id: string; status: 'in_progress' | 'processed' | 'error'; accuracy: number | null; matched: number | null; mismatched: number | null; ungraded: number | null; file_count: number; failed_count: number; created_at: string | null; } const workflowId = '0686bb97-8c30-70f0-8000-97669e000eb8'; const evalId = '069dcc2c-e14c-7606-8000-2ee4fb17b4f9'; const response = await fetch( `https://api.anyformat.ai/v3/workflows/${workflowId}/evals/${evalId}/`, { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } } ); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const an_eval: EvalDetail = await response.json(); if (an_eval.status === 'processed') { console.log(an_eval.accuracy); } ``` ```json Response (200 OK, processed) theme={null} { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "run_number": 3, "version_id": "abcdef1234", "status": "processed", "accuracy": 0.94, "matched": 47, "mismatched": 3, "ungraded": 1, "file_count": 42, "failed_count": 0, "created_at": "2026-07-05T10:00:00.000Z" } ``` ```json Response (200 OK, in progress) theme={null} { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "run_number": 3, "version_id": "abcdef1234", "status": "in_progress", "accuracy": null, "matched": null, "mismatched": null, "ungraded": null, "file_count": 42, "failed_count": 0, "created_at": "2026-07-05T10:00:00.000Z" } ``` ```json Response (200 OK, error) theme={null} { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "run_number": 3, "version_id": "abcdef1234", "status": "error", "accuracy": null, "matched": null, "mismatched": null, "ungraded": null, "file_count": 42, "failed_count": 0, "created_at": "2026-07-05T10:00:00.000Z" } ``` # Launch Eval Source: https://docs.anyformat.ai/api-reference-v3/evals/launch POST /v3/workflows/{workflow_id}/evals/ Launch a graded eval over a workflow's whole dataset on a target version *Rate limit tier: **submission**, 60 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Launches an [eval](/concepts/evals): one graded run of a workflow's **whole dataset** against a target version. The call is **async and fire-and-forget**. It enqueues a fresh extraction for every dataset document, pins the realized cohort into a new eval, and returns `202` at once with an `eval_id` and a `run_number`. Grading finalizes on a worker, so poll the eval by its `eval_id` until `status` leaves `in_progress`. The response carries **counts, never internal ids**. `enqueued_count` counts the documents whose extraction was queued, and `failed_count` counts those that failed to enqueue. For the per-document breakdown, read the eval by `eval_id`. ## Target version Omit `version_id` to evaluate the workflow's **current version**; pass one to override. A workflow with no version yet returns `404 NOT_FOUND`. ## Idempotency Without an `Idempotency-Key`, **every launch creates a new eval**. Re-launching runs a fresh cohort, and the platform meters it accordingly. See [How credits work](/concepts/how-credits-work). Supplying the header **replays** instead: a retry with the same key returns the **original** eval, and launches no second run. See [Idempotency](/api-reference-v3/introduction#idempotency). When you omit `version_id`, a replayed key sent **after a new version was published** resolves to a different version than the first call, so the request no longer matches and returns `422`. Pass an explicit `version_id` when you need a stable replay across version changes. ## Errors | Status | `error_code` | When | | ------ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | `422` | `EMPTY_EVAL` | No dataset document was enqueued, because the dataset is empty or every document failed to enqueue. The API creates no eval. | | `400` | `OPERATOR_DEPRECATED` | The target version contains a deprecated operator and cannot run. | | `404` | `NOT_FOUND` | Unknown workflow, including one in another organization. Also raised when the workflow has no version to evaluate. | | `422` | `IDEMPOTENCY_KEY_REUSED` | An `Idempotency-Key` was reused with a different request. | ```bash curl theme={null} # Current version: no body needed curl -X POST 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/evals/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Idempotency-Key: 9e8d7c6b-5a4f-3e2d-1c0b-a9b8c7d6e5f4' ``` ```python Python (SDK) theme={null} from anyformat.sdk import Client client = Client(api_key="YOUR_API_KEY") launched = client.launch_eval( "0686bb97-8c30-70f0-8000-97669e000eb8", idempotency_key="9e8d7c6b-5a4f-3e2d-1c0b-a9b8c7d6e5f4", ) print(launched.eval_id, launched.run_number) ``` ```typescript TypeScript theme={null} interface EvalLaunched { eval_id: string; run_number: number; enqueued_count: number; failed_count: number; } const workflowId = '0686bb97-8c30-70f0-8000-97669e000eb8'; const response = await fetch( `https://api.anyformat.ai/v3/workflows/${workflowId}/evals/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, // Optional: pin a version. Omit the body to use the current version. body: JSON.stringify({ version_id: 'abcdef1234' }), } ); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const launched: EvalLaunched = await response.json(); console.log(launched.eval_id, launched.run_number); ``` ```json Response (202 Accepted) theme={null} { "eval_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "run_number": 3, "enqueued_count": 42, "failed_count": 0 } ``` # List Evals Source: https://docs.anyformat.ai/api-reference-v3/evals/list GET /v3/workflows/{workflow_id}/evals/ List a workflow's evals, newest first *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Returns one keyset page of a workflow's [evals](/concepts/evals), newest first. Follow `next_cursor` until it is `null`. See [Pagination](/api-reference-v3/introduction#pagination). Each item is the **same headline** [`GET /v3/workflows/{workflow_id}/evals/{eval_id}/`](/api-reference-v3/evals/get) returns: `run_number`, grading `status`, `accuracy` with its `matched`, `mismatched` and `ungraded` tallies, `file_count`, and `failed_count`. The headline fields are `null` on any item still `in_progress`, so one page can mix graded and running evals. See [its status model](/api-reference-v3/evals/get#status-model). An unknown workflow returns `404`, as does one in another organization. ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/evals/?limit=20' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (SDK) theme={null} from anyformat.sdk import Client client = Client(api_key="YOUR_API_KEY") workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" # iter_evals follows next_cursor across pages for you. for an_eval in client.iter_evals(workflow_id): print(an_eval.run_number, an_eval.status, an_eval.accuracy) ``` ```typescript TypeScript theme={null} interface EvalSummary { id: string; run_number: number; version_id: string; status: 'in_progress' | 'processed' | 'error'; accuracy: number | null; matched: number | null; mismatched: number | null; ungraded: number | null; file_count: number; failed_count: number; created_at: string | null; } interface EvalPage { items: EvalSummary[]; next_cursor: string | null; } const workflowId = '0686bb97-8c30-70f0-8000-97669e000eb8'; const base = `https://api.anyformat.ai/v3/workflows/${workflowId}/evals/`; const headers = { 'Authorization': 'Bearer YOUR_API_KEY' }; let cursor: string | null = null; do { const url = cursor ? `${base}?cursor=${cursor}` : base; const response = await fetch(url, { headers }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const page: EvalPage = await response.json(); for (const e of page.items) { console.log(e.run_number, e.status, e.accuracy); } cursor = page.next_cursor; } while (cursor); ``` ```json Response (200 OK) theme={null} { "items": [ { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4fa", "run_number": 4, "version_id": "abcdef1234", "status": "in_progress", "accuracy": null, "matched": null, "mismatched": null, "ungraded": null, "file_count": 42, "failed_count": 0, "created_at": "2026-07-06T09:00:00.000Z" }, { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "run_number": 3, "version_id": "abcdef1234", "status": "processed", "accuracy": 0.94, "matched": 47, "mismatched": 3, "ungraded": 1, "file_count": 42, "failed_count": 0, "created_at": "2026-07-05T10:00:00.000Z" } ], "next_cursor": null } ``` # Introduction Source: https://docs.anyformat.ai/api-reference-v3/introduction API v3 overview: base URL, resource model, keyset pagination, idempotency, response headers, and the error envelope. anyformat API **v3** is the current stable major. It runs the same extraction engine as v2 behind a smaller, flatter surface. Three resources cover it: [workflows](/concepts/workflows), [document packets](/concepts/document-packets), and [runs](/concepts/runs-and-results). Coming from v2? The [migration guide](/api-reference-v3/migrating-from-v2) maps every v2 path to its v3 successor. v2 keeps serving traffic unchanged through its announced deprecation window. *** ## Base URL ``` https://api.anyformat.ai/v3/ ``` All endpoint paths require a trailing slash. Requests without one receive a `307 Temporary Redirect`, which preserves the request method and body. *** ## Authentication All endpoints require an API key, passed as a Bearer token: ```bash theme={null} curl -H "Authorization: Bearer YOUR_API_KEY" https://api.anyformat.ai/v3/workflows/ ``` A key works identically across v2 and v3. See [Authentication](/api-reference/authentication) to create and manage keys. *** ## Resource model | Resource | What it is | Identifier | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | | **Workflow** | The extraction template: a typed graph of nodes. The node types are parse, extract, classify, splitter, validate, if\_else, slack\_alert, edit and knowledge. See [Nodes](/guides/nodes/overview) and [Node schemas](/api-reference-v3/node-schemas). | `workflow_id` | | **Document packet** | The unit a workflow runs on: one or more files treated as a single document. Uploading creates one. | `document_packet_id` | | **Run** | One execution attempt of a workflow against a packet. One packet can have many runs. | `run_id` | All identifiers are hyphenated UUIDs, such as `069dcc2c-e14c-7606-8000-2ee4fb17b4e1`. Path parameters are always snake\_case: `workflow_id`, `document_packet_id`, `run_id`. The typical flow: 1. [Create a workflow](/api-reference-v3/workflows/create) (or build it visually in the [dashboard](https://app.anyformat.ai)). 2. [Upload and run](/api-reference-v3/workflows/upload-and-run) a document in one call. The response carries a `run_id`. 3. [Get the run](/api-reference-v3/runs/get) until `status` is `processed`; the results envelope is inline on the same response. *** ## Pagination Every list endpoint is **keyset-paginated** and returns the same page shape: ```json theme={null} { "items": [ ... ], "next_cursor": "eyJjcmVhdGVkX2F0Ijoi..." } ``` * `?limit=` sets the page size. The default is `20` and the maximum is `100`. A larger value returns `400`; the API does not clamp it. * `?cursor=` takes the opaque `next_cursor` token from the previous page. * Iterate by following `next_cursor` until it is `null`. * Sort order is fixed at newest-first (`-created_at, -id`). v3 returns **no totals, counts, or page numbers**. A count over a growing collection is stale the moment it is computed. List endpoints also reject an unknown query parameter with `400` rather than ignoring it, so a typo like `?pagesize=` fails loudly. *** ## Idempotency The POSTs that create packets or trigger runs accept an optional `Idempotency-Key` header. The value is any unique string you choose, such as a UUID. * [`POST /v3/workflows/{workflow_id}/upload/`](/api-reference-v3/workflows/upload) * [`POST /v3/workflows/{workflow_id}/upload/run/`](/api-reference-v3/workflows/upload-and-run) * [`POST /v3/document-packets/{document_packet_id}/run/`](/api-reference-v3/document-packets/run) * [`POST /v3/workflows/{workflow_id}/evals/`](/api-reference-v3/evals/launch) Retrying a request with the same key **replays the original response**. The API creates no duplicate packet, and triggers and bills no second extraction or eval. Use it to make network-timeout retries safe. Reusing a key with a **different** request body returns `422` `IDEMPOTENCY_KEY_REUSED`. A key binds to the first request it saw; send a fresh key for a genuinely new request. ```bash theme={null} curl -X POST 'https://api.anyformat.ai/v3/document-packets/069dcc2c-e14c-7606-8000-2ee4fb17b4e1/run/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Idempotency-Key: 5f2b6c3e-8a1d-4f7e-9c0b-1a2b3c4d5e6f' ``` *** ## Rate limits v3 uses the same two-tier model as v2. Submission endpoints have a stricter limit. Everything else shares a higher general limit. | Tier | v3 endpoints | Limit | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | | **Submission** | Every endpoint that starts work or asks a question: `POST /v3/workflows/{id}/upload/`, `POST /v3/workflows/{id}/upload/run/`, `POST /v3/workflows/{id}/upload/from-url/`, `POST /v3/workflows/{id}/dataset/upload/`, `POST /v3/workflows/{id}/evals/`, `POST /v3/workflows/{id}/knowledge/ask`, `POST /v3/document-packets/{id}/run/`, `POST /v3/parse/`, `POST /v3/parse/from-url/` | 60 requests/min | | **General** | Reading and editing: workflows, runs, document packets, versions, evals results | 600 requests/min | Exceeding a limit returns `429 Too Many Requests`; wait for the number of seconds in the `Retry-After` header. Every response carries `x-ratelimit-limit`, `x-ratelimit-remaining`, and `x-ratelimit-reset` for the tier that applies. *** ## Response headers Every `/v3/` response is stamped with the API version: ```http theme={null} X-API-Version: 3.0.0 ``` v3 responses carry **no** `Deprecation` or `Sunset` headers. Those appear only on a version that is on the retirement path, as `/v2/` responses are today. See [Versioning & deprecation](/api-reference/introduction#versioning--deprecation) for the full header reference and how to alert on sunset signals. *** ## Errors Error responses use the same structured envelope as v2: ```json theme={null} { "error": "Brief, human-readable error description", "detail": "Detailed explanation of what went wrong", "error_code": "MACHINE_READABLE_ERROR_CODE", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` `retryable: true` means the same request may succeed on retry, with backoff. `retryable: false` means you must change the request first. See [Errors](/api-reference/errors) for the complete code reference. Two codes are worth knowing up front: * `402 PAYMENT_REQUIRED`: the organization is out of extraction credit, so the API rejected the run trigger. * `400 TOPOLOGY_INVALID`: a workflow create or update body failed graph validation. `detail.violations` lists each broken rule with the offending node ids. v3 never returns `412 Precondition Failed`. Where v2 used 412 to signal "results not ready yet", v3 returns `200` with the run's `status` field. See [Get run](/api-reference-v3/runs/get). *** ## Endpoints at a glance ### Workflows | Method | Endpoint | Description | | ------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------- | | POST | `/v3/workflows/` | [Create a workflow](/api-reference-v3/workflows/create) | | GET | `/v3/workflows/` | [List workflows](/api-reference-v3/workflows/list) | | GET | `/v3/workflows/{workflow_id}/` | [Get a workflow](/api-reference-v3/workflows/get), with the full typed graph inline | | GET | `/v3/workflows/{workflow_id}/versions/` | [List a workflow's versions](/api-reference-v3/workflows/versions). Read-only, for comparison and audit | | PATCH | `/v3/workflows/{workflow_id}/` | [Update a workflow](/api-reference-v3/workflows/update) | | DELETE | `/v3/workflows/{workflow_id}/` | [Delete a workflow](/api-reference-v3/workflows/delete) | ### Upload | Method | Endpoint | Description | | ------ | ---------------------------------------------- | ------------------------------------------------------------------------------ | | POST | `/v3/workflows/{workflow_id}/upload/` | [Upload a document packet](/api-reference-v3/workflows/upload) (no processing) | | POST | `/v3/workflows/{workflow_id}/upload/run/` | [Upload and run](/api-reference-v3/workflows/upload-and-run) in one call | | POST | `/v3/workflows/{workflow_id}/upload/from-url/` | [Create a packet from URLs](/api-reference-v3/workflows/upload-from-url) | ### Document packets | Method | Endpoint | Description | | ------ | ------------------------------------------------ | ------------------------------------------------------------------------------------------ | | GET | `/v3/workflows/{workflow_id}/document-packets/` | [List a workflow's packets](/api-reference-v3/document-packets/list) | | GET | `/v3/document-packets/{document_packet_id}/` | [Get a packet](/api-reference-v3/document-packets/get), with its files and `latest_run_id` | | POST | `/v3/document-packets/{document_packet_id}/run/` | [Run a packet](/api-reference-v3/document-packets/run). Returns a new `run_id` | | DELETE | `/v3/document-packets/{document_packet_id}/` | [Delete a packet](/api-reference-v3/document-packets/delete) | ### Runs | Method | Endpoint | Description | | ------ | ----------------------------------- | ----------------------------------------------------------------------- | | GET | `/v3/workflows/{workflow_id}/runs/` | [List a workflow's runs](/api-reference-v3/runs/list) | | GET | `/v3/runs/{run_id}/` | [Get a run](/api-reference-v3/runs/get), with status and results inline | *** ## OpenAPI schema The full OpenAPI specification (v2 + v3) is available at: * **JSON**: [https://api.anyformat.ai/schema/?format=json](https://api.anyformat.ai/schema/?format=json) * **Swagger UI**: [https://api.anyformat.ai/docs/](https://api.anyformat.ai/docs/) # Check API Key Source: https://docs.anyformat.ai/api-reference-v3/key-check GET /key-check/ Verify an API key without triggering a billable operation Confirms the caller's API key is active and names its owning organization. The endpoint is unversioned, so it serves v2 and v3 keys alike. * **200**: the key is valid. The body carries `organization_id`. It also carries `organization_name` and `scopes` when they are available. `scopes` lists the granted scope names, and is `null` when the key does not report them. * **401**: the standard [error envelope](/api-reference/errors). `error_code` is `MISSING_API_KEY` when you send no key, and `INVALID_API_KEY` when the key is not recognised. The endpoint bills nothing and creates no run, so use it as a cheap probe before the first real request. It makes the same `/me/organization/` round-trip as every authenticated request, so a successful check also warms the org cache. ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/key-check/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests response = requests.get( "https://api.anyformat.ai/key-check/", headers={"Authorization": "Bearer YOUR_API_KEY"}, ) response.raise_for_status() print(response.json()["organization_id"]) ``` ```typescript TypeScript theme={null} const response = await fetch('https://api.anyformat.ai/key-check/', { headers: { 'Authorization': 'Bearer YOUR_API_KEY' }, }); if (!response.ok) throw new Error(`Invalid API key: ${response.status}`); const { organization_id, organization_name } = await response.json(); ``` ```json Response (200 OK) theme={null} { "valid": true, "organization_id": "7f4a1c2e-0000-4000-8000-000000000001", "organization_name": "Acme Corp", "scopes": ["read", "write"] } ``` ```json Response (401 Unauthorized) theme={null} { "error": "Invalid API key", "detail": "The provided API key is not recognised.", "error_code": "INVALID_API_KEY", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` # Ask Source: https://docs.anyformat.ai/api-reference-v3/knowledge/ask POST /v3/workflows/{workflow_id}/knowledge/ask Answer a question from the content of a workflow's documents, with citations *Rate limit tier: **submission**, 60 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Answers a question about what a workflow's documents **say**: contract terms, invoice details, anything that needs reading rather than a field lookup. Extraction answers "what is in this document". This endpoint answers "what do these documents say about X", across the workflow's current corpus. Every answer carries the exact quotes it rests on. Each quote resolves to a page and a region in the source PDF, so you can show a customer where a number came from. ## Requires a knowledge base The workflow needs a **knowledge node**. Without one, the endpoint returns `409 KNOWLEDGE_NOT_ENABLED`. Add the node in Studio, or include it when you create the workflow. Adding the node makes subsequent completed runs eligible for ingestion. It does not backfill documents that completed earlier. A member starts **Index existing documents** or **Retry indexing** from the workflow's **Knowledge** tab when needed. A pack that already exists keeps serving while a later upload is re-indexed in the background, so there is no manual "update" action to trigger. These app actions are not public REST or MCP operations. The corpus is **workflow-scoped, not version-scoped**. It keeps the current parsed content for each live file across runs and versions. It is not a version history. ## Errors | Status | `error_code` | Meaning | | ------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `402` | `INSUFFICIENT_CREDIT` | The organization has no credits. The API refuses the question before it runs, and charges nothing. | | `404` | `NOT_FOUND` | Unknown workflow, or one belonging to another organization. | | `409` | `KNOWLEDGE_NOT_ENABLED` | The workflow has no Knowledge node. Do not retry: add the node first. | | `409` | `KNOWLEDGE_NOT_READY` | No knowledge snapshot has ever been published for this workflow. Wait if indexing is in progress; otherwise index existing documents from the app's Knowledge tab. Once a snapshot exists, later ingestion never brings this error back — a stale pack keeps serving while it re-indexes. | | `409` | `KNOWLEDGE_THREAD_MISMATCH` | The `thread_id` belongs to a different workflow. Use a new id or ask the workflow that owns it. | | `409` | `KNOWLEDGE_UNSUPPORTED_SNAPSHOT_FORMAT` | The published snapshot is in a format this deployment no longer reads. Permanent until an operator republishes it. | | `429` | `RATE_LIMITED` | The model is rate limited. Retry after the delay in `Retry-After`. | | `502` | `INTERNAL_ERROR` | The model refused the request. Not worth retrying unchanged. | | `503` | `INTERNAL_ERROR` | The model is temporarily unavailable. Retry. | | `504` | `GATEWAY_TIMEOUT` | The ask exceeded the agent time limit. Retry with a narrower question. | ## Follow-up questions Pass a `thread_id` you mint yourself, starting with `kb-`, to ask in the context of earlier questions in that thread. Omit it and each question stands alone. Reuse the same id to continue a conversation. ## Billing The platform charges per question and meters the text the agent reads. A narrow question over a small corpus therefore costs a fraction of a broad sweep over a large one. A question the organization cannot pay for is refused up front. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/knowledge/ask' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"question": "Which contracts renew before March and what notice do they need?"}' ``` ```python Python (SDK) theme={null} from anyformat.sdk import Client client = Client(api_key="YOUR_API_KEY") answer = client.ask( "0686bb97-8c30-70f0-8000-97669e000eb8", "Which contracts renew before March and what notice do they need?", ) print(answer) for citation in answer.citations: print(f" {citation.path} p.{citation.page}: {citation.quote}") ``` ```typescript TypeScript theme={null} const response = await fetch( 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/knowledge/ask', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ question: 'Which contracts renew before March and what notice do they need?', }), }, ); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const { answer, citations } = await response.json(); console.log(answer); ``` ```json Response (200 OK) theme={null} { "answer": "Two contracts renew before March. Meridian MSA renews 2026-02-01 and needs ninety days' notice to terminate. Aldrete supply agreement renews 2026-02-14 with thirty days' notice.", "citations": [ { "path": "2026-07/meridian-msa-2024.md", "quote": "Either Party may terminate upon ninety (90) days' notice.", "file_id": "0192f0c1-a2b3-4c5d-8000-abcdef012345", "block_id": "block-2", "page": 2, "bbox": { "x0": 0.08, "y0": 0.31, "x1": 0.92, "y1": 0.36 } } ], "path": [ { "tool": "kb_grep", "args": { "pattern": "renewal" } }, { "tool": "kb_read", "args": { "path": "2026-07/meridian-msa-2024.md" } } ], "steps_used": 2, "thread_id": null } ``` ```json Response (409, no knowledge base) theme={null} { "error": "The knowledge base is not enabled.", "error_code": "KNOWLEDGE_NOT_ENABLED", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` ```json Response (402, out of credit) theme={null} { "error": "The organization has no extraction credit left.", "error_code": "INSUFFICIENT_CREDIT", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` # Download Source: https://docs.anyformat.ai/api-reference-v3/knowledge/download GET /v3/workflows/{workflow_id}/knowledge/snapshot Get a presigned URL for a workflow's whole knowledge corpus, as a tar *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Returns a presigned URL for a workflow's whole knowledge corpus, packaged as a tar. Prefer [List](/api-reference-v3/knowledge/list) and [Read](/api-reference-v3/knowledge/read) for anything that stays inside this API — this endpoint exists for handing the whole archive to something outside it. **Possession of the URL is read permission for the workflow's corpus until it expires.** The URL is not scoped to your API key: whoever holds it can fetch the tar, and revoking the key that requested it does not revoke a URL already minted. `expires_in_seconds` (900, 15 minutes) is the only bound. The URL can 404 if a newer snapshot replaced it or the workflow was deleted meanwhile — mint a fresh one rather than caching it past its TTL. ## Requires a knowledge base Same requirement as `ask`: the workflow needs a **knowledge node**, or this returns `409 KNOWLEDGE_NOT_ENABLED`. `409 KNOWLEDGE_NOT_READY` means no usable snapshot has ever been published. Unlike `ask`, downloading is never billed, so `402` cannot occur here. ## Errors | Status | `error_code` | Meaning | | ------ | ----------------------- | ------------------------------------------------------------------------------------------------- | | `404` | `NOT_FOUND` | Unknown workflow, or one belonging to another organization. | | `409` | `KNOWLEDGE_NOT_ENABLED` | The workflow has no Knowledge node. Do not retry: add the node first. | | `409` | `KNOWLEDGE_NOT_READY` | No knowledge snapshot has ever been published for this workflow. Wait if indexing is in progress. | ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/knowledge/snapshot' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" url = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/knowledge/snapshot" headers = {"Authorization": "Bearer YOUR_API_KEY"} snapshot = requests.get(url, headers=headers).json() tar_bytes = requests.get(snapshot["url"]).content ``` ```json Response (200 OK) theme={null} { "corpus_version": "2026-09-11T12:00:00Z-3", "url": "https://static-dev.anyformat.ai/customers/org/.../snapshot.tar.gz?X-Amz-Signature=...", "size_bytes": 11534336, "document_count": 42, "built_at": "2026-09-11T12:00:00Z", "expires_in_seconds": 900 } ``` ```json Response (409, no knowledge base) theme={null} { "error": "Knowledge base not enabled", "detail": "This workflow has no knowledge base. Add a knowledge node. New completed runs then ingest documents; index earlier processed documents from the app's Knowledge tab.", "error_code": "KNOWLEDGE_NOT_ENABLED", "retryable": false } ``` # List Source: https://docs.anyformat.ai/api-reference-v3/knowledge/list GET /v3/workflows/{workflow_id}/knowledge/entries List a workflow's knowledge corpus tree: documents and navigation pages *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Lists a workflow's knowledge corpus: every source document plus the generated navigation pages, sorted by path. This is the tree an agent would browse instead of asking a question — for reading what documents exist and where, not for an answer. To ask a question with citations, use [Ask](/api-reference-v3/knowledge/ask). Each item's `kind` is `document` for a source file or `index` for a generated navigation page; a navigation page's `page_count` is always `0`. ## Requires a knowledge base Same requirement as `ask`: the workflow needs a **knowledge node**, or this returns `409 KNOWLEDGE_NOT_ENABLED`. `409 KNOWLEDGE_NOT_READY` means no usable snapshot has ever been published — wait if indexing is in progress. Unlike `ask`, listing is never billed, so `402` cannot occur here. ## Pagination One keyset page per call, sorted by `path`. Follow `next_cursor` until it is `null`. See [Pagination](/api-reference-v3/introduction#pagination). ## Folder Pass `folder` to narrow the tree to entries directly inside that folder or one beneath it. Omit it for the whole corpus. ## Errors | Status | `error_code` | Meaning | | ------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `404` | `NOT_FOUND` | Unknown workflow, or one belonging to another organization; or `folder` names no folder in this corpus. | | `409` | `KNOWLEDGE_NOT_ENABLED` | The workflow has no Knowledge node. Do not retry: add the node first. | | `409` | `KNOWLEDGE_NOT_READY` | No knowledge snapshot has ever been published for this workflow. Wait if indexing is in progress; otherwise index existing documents from the app's Knowledge tab. | | `409` | `KNOWLEDGE_UNSUPPORTED_SNAPSHOT_FORMAT` | The published snapshot is in a format this deployment no longer reads. Permanent until an operator republishes it. | | `422` | `VALIDATION_ERROR` | `cursor` is not a token this endpoint issued, or `folder` is malformed (a leading or trailing slash, or a `..` segment). | | `504` | `GATEWAY_TIMEOUT` | The listing exceeded the agent's own time ceiling. Retry. | ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/knowledge/entries?folder=2026-07' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" url = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/knowledge/entries" headers = {"Authorization": "Bearer YOUR_API_KEY"} page = requests.get(url, headers=headers, params={"folder": "2026-07"}).json() for entry in page["items"]: print(entry["path"], entry["kind"]) ``` ```json Response (200 OK) theme={null} { "items": [ { "path": "2026-07/aldrete-supply.md", "kind": "document", "folder": "2026-07", "title": "Aldrete supply agreement", "page_count": 5 }, { "path": "2026-07/meridian-msa.md", "kind": "document", "folder": "2026-07", "title": "Meridian MSA", "page_count": 3 } ], "next_cursor": null } ``` ```json Response (409, no knowledge base) theme={null} { "error": "Knowledge base not enabled", "detail": "This workflow has no knowledge base. Add a knowledge node. New completed runs then ingest documents; index earlier processed documents from the app's Knowledge tab.", "error_code": "KNOWLEDGE_NOT_ENABLED", "retryable": false } ``` # Read Source: https://docs.anyformat.ai/api-reference-v3/knowledge/read GET /v3/workflows/{workflow_id}/knowledge/entry Read one document's or navigation page's text from a workflow's knowledge corpus *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Reads one document's or navigation page's text from a workflow's knowledge corpus. Find `path` first with [List](/api-reference-v3/knowledge/list) or [Search](/api-reference-v3/knowledge/search). For an answer with citations instead of raw text, use [Ask](/api-reference-v3/knowledge/ask). ## Content cap `content` is capped; `truncated` is `true` when the document's text was cut off at the cap. There is no pagination for a single document's content — a long document returns its opening text with `truncated: true`. `path` is 1 to 1024 characters. ## Requires a knowledge base Same requirement as `ask`: the workflow needs a **knowledge node**, or this returns `409 KNOWLEDGE_NOT_ENABLED`. `409 KNOWLEDGE_NOT_READY` means no usable snapshot has ever been published. Unlike `ask`, reading is never billed, so `402` cannot occur here. ## Errors | Status | `error_code` | Meaning | | ------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `400` | `VALIDATION_ERROR` | `path` was empty or over 1024 characters. | | `404` | `NOT_FOUND` | Unknown workflow, one belonging to another organization, or `path` names no document or navigation page in this corpus. | | `409` | `KNOWLEDGE_NOT_ENABLED` | The workflow has no Knowledge node. Do not retry: add the node first. | | `409` | `KNOWLEDGE_NOT_READY` | No knowledge snapshot has ever been published for this workflow. Wait if indexing is in progress. | | `409` | `KNOWLEDGE_UNSUPPORTED_SNAPSHOT_FORMAT` | The published snapshot is in a format this deployment no longer reads. Permanent until an operator republishes it. | | `504` | `GATEWAY_TIMEOUT` | The read exceeded the agent's own time ceiling. Retry. | ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/knowledge/entry?path=2026-07%2Fmeridian-msa.md' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" url = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/knowledge/entry" headers = {"Authorization": "Bearer YOUR_API_KEY"} entry = requests.get(url, headers=headers, params={"path": "2026-07/meridian-msa.md"}).json() print(entry["content"]) ``` ```json Response (200 OK) theme={null} { "path": "2026-07/meridian-msa.md", "kind": "document", "folder": "2026-07", "title": "Meridian MSA", "page_count": 3, "content": "# Master Services Agreement\n\n...", "truncated": false, "file_id": "0192f0c1-a2b3-4c5d-8000-abcdef012345", "source_version_id": "0198c1e4-2f9a-7a3e-9f21-4e5b6c7d8e9f" } ``` ```json Response (409, no knowledge base) theme={null} { "error": "Knowledge base not enabled", "detail": "This workflow has no knowledge base. Add a knowledge node. New completed runs then ingest documents; index earlier processed documents from the app's Knowledge tab.", "error_code": "KNOWLEDGE_NOT_ENABLED", "retryable": false } ``` # Search Source: https://docs.anyformat.ai/api-reference-v3/knowledge/search GET /v3/workflows/{workflow_id}/knowledge/search Rank a workflow's knowledge corpus by relevance to a query *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Ranks a workflow's knowledge corpus by relevance (BM25) to `query` — for a topic-style question such as "where is termination discussed", not an exact string, a count, or a sum. For an answer with citations, use [Ask](/api-reference-v3/knowledge/ask); for the full tree, use [List](/api-reference-v3/knowledge/list). Each hit carries a relevance `rank` (higher first) and a `snippet`: a window of text around the best-matching term, wrapped in `«»`. Ranking is relevance, not match count — a document matching the query term fewer times can still outrank one matching it more, and a document matching no query term as a whole word is not returned. ## Requires a knowledge base Same requirement as `ask`: the workflow needs a **knowledge node**, or this returns `409 KNOWLEDGE_NOT_ENABLED`. `409 KNOWLEDGE_NOT_READY` means no usable snapshot has ever been published. Unlike `ask`, search is never billed, so `402` cannot occur here. ## Folder `folder` narrows ranking to one top-level source folder — the first path segment (e.g. `contracts`), not a nested path. Omitting it merges every folder's statistics into one corpus-wide ranking. An unrecognized folder name is a `404`. ## Query length `query` is 1 to 512 characters. ## Limit `limit` caps the number of hits, 1 to 50, default 10. ## Errors | Status | `error_code` | Meaning | | ------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `400` | `VALIDATION_ERROR` | `query` was empty or over 512 characters, or `limit` was outside 1 to 50. | | `404` | `NOT_FOUND` | Unknown workflow, one belonging to another organization, or `folder` names no folder in this corpus. | | `409` | `KNOWLEDGE_NOT_ENABLED` | The workflow has no Knowledge node. Do not retry: add the node first. | | `409` | `KNOWLEDGE_NOT_READY` | No knowledge snapshot has ever been published for this workflow. Wait if indexing is in progress. | | `409` | `KNOWLEDGE_UNSUPPORTED_SNAPSHOT_FORMAT` | The published snapshot is in a format this deployment no longer reads. Permanent until an operator republishes it. | | `504` | `GATEWAY_TIMEOUT` | The search exceeded the agent's own time ceiling. Retry. | ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/knowledge/search?query=termination+notice&limit=5' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" url = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/knowledge/search" headers = {"Authorization": "Bearer YOUR_API_KEY"} hits = requests.get(url, headers=headers, params={"query": "termination notice", "limit": 5}).json() for hit in hits: print(hit["path"], hit["rank"], hit["snippet"]) ``` ```json Response (200 OK) theme={null} [ { "path": "2026-07/meridian-msa.md", "folder": "2026-07", "title": "Meridian MSA", "page_count": 3, "snippet": "Either Party may terminate upon ninety (90) «days'» notice.", "rank": 4.2 } ] ``` ```json Response (409, no knowledge base) theme={null} { "error": "Knowledge base not enabled", "detail": "This workflow has no knowledge base. Add a knowledge node. New completed runs then ingest documents; index earlier processed documents from the app's Knowledge tab.", "error_code": "KNOWLEDGE_NOT_ENABLED", "retryable": false } ``` # MCP server Source: https://docs.anyformat.ai/api-reference-v3/mcp Connect an AI agent host to anyformat over the Model Context Protocol: endpoint, auth, client setup, and the tools available today. anyformat runs a remote [Model Context Protocol](https://modelcontextprotocol.io) server. Any MCP host, such as Claude Code, Claude Desktop or Cursor, connects to it and calls anyformat as tools. The tools are the same v3 endpoints you call by hand, under the same API key, rate limits and error envelope. ## Endpoint | | | | --------- | ------------------------------------------------------------------------------------------ | | URL | `https://api.anyformat.ai/mcp` | | Transport | Streamable HTTP, stateless. Every request is independent; there are no sessions to resume. | | Auth | `Authorization: Bearer ` | `/mcp` and `/mcp/` are the same endpoint. There is no stdio package; a host that only speaks stdio connects through a bridge, `mcp-remote` or `fastmcp-remote` (below). ## Auth The server accepts the same `af_...` API key as the REST API, as a bearer token. A key that works on `/v3` works on `/mcp`. Mint one at [app.anyformat.ai/api-key](https://app.anyformat.ai/api-key). A missing or unknown key returns `401` with the API's [error envelope](/api-reference-v3/introduction#errors) (`MISSING_API_KEY` or `INVALID_API_KEY`). Repeated invalid keys from one address are throttled and return `429` with `Retry-After`, as on the REST API. There is no OAuth flow today. ## Connect Add the server with the `claude mcp add` command. `--scope user` makes it available in every project; `--scope project` writes it to the project's `.mcp.json`. ```bash theme={null} claude mcp add --transport http anyformat https://api.anyformat.ai/mcp \ --header "Authorization: Bearer $ANYFORMAT_API_KEY" --scope user ``` Or write `.mcp.json` in the project root. Claude Code expands `${VAR}` from the environment. ```json theme={null} { "mcpServers": { "anyformat": { "type": "http", "url": "https://api.anyformat.ai/mcp", "headers": { "Authorization": "Bearer ${ANYFORMAT_API_KEY}" } } } } ``` Check with `claude mcp list`, then ask Claude to list your workflows. Add the server to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project). Cursor expands `${env:VAR}`. ```json theme={null} { "mcpServers": { "anyformat": { "url": "https://api.anyformat.ai/mcp", "headers": { "Authorization": "Bearer ${env:ANYFORMAT_API_KEY}" } } } } ``` A host that only launches local stdio servers connects through [`mcp-remote`](https://github.com/geelen/mcp-remote), which bridges stdio to the HTTP endpoint. Put the header value in an environment variable so the space in `Bearer ...` survives argument parsing. ```json theme={null} { "mcpServers": { "anyformat": { "command": "npx", "args": [ "mcp-remote", "https://api.anyformat.ai/mcp", "--header", "Authorization:${AUTH_HEADER}" ], "env": { "AUTH_HEADER": "Bearer af_..." } } } } ``` [`fastmcp-remote`](https://pypi.org/project/fastmcp-remote/) is the same bridge from the Python ecosystem, run with `uvx`. It takes the header as one `Name: Value` argument, so no environment variable is needed. On the command line: ```bash theme={null} uvx fastmcp-remote https://api.anyformat.ai/mcp \ --header "Authorization: Bearer $ANYFORMAT_API_KEY" ``` And as a host config entry: ```json theme={null} { "mcpServers": { "anyformat": { "command": "uvx", "args": [ "fastmcp-remote", "https://api.anyformat.ai/mcp", "--header", "Authorization: Bearer af_..." ] } } } ``` Both bridges default to the streamable HTTP transport, and both skip their OAuth flow when an `Authorization` header is given. For Claude Desktop the file is `claude_desktop_config.json`, under Settings, Developer. Send MCP JSON-RPC over HTTP `POST` to the endpoint with the bearer header. This `initialize` call shows the handshake: ```bash theme={null} curl -X POST https://api.anyformat.ai/mcp \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' ``` ## Tools The server exposes these 21 tools today. Each API tool calls the matching v3 endpoint under your key, so the endpoint page is the reference for its inputs, outputs and errors. The `Scope` column names the key scope the tool needs. For a worked end-to-end session with real payloads, see [Agents over MCP](/guides/mcp). | Tool | What it does | Endpoint | Scope | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ------- | | `list_workflows` | Lists your organization's workflows, newest first. Takes `query: { limit, cursor }`; `limit` is 1 to 100, default 20. Keyset-paginated: pass the response's `next_cursor` back as `cursor` until it is null. | [List workflows](/api-reference-v3/workflows/list) | `read` | | `get_workflow` | Returns a workflow with its complete typed graph inline: the latest version by default, or the one named by an optional `version` (a `version_id` from `list_workflow_versions`). Takes `workflow_id`. The `{ name, description, nodes, edges }` sub-shape is what `update_workflow` accepts. | [Get workflow](/api-reference-v3/workflows/get) | `read` | | `list_workflow_versions` | Lists a workflow's versions, newest first; item 0 of the first page is the version runs use. Takes `workflow_id` and `query: { limit, cursor }`. Keyset-paginated like `list_workflows`. Versions are read-only, for comparison and audit: read one with `get_workflow` and its `version`. | [List workflow versions](/api-reference-v3/workflows/versions) | `read` | | `create_workflow` | Creates a workflow from an explicit typed graph. Takes `body: { name, description, nodes, edges }`; omit `persistent_id` on every field. An invalid topology is rejected with `TOPOLOGY_INVALID` and a `detail.violations[]` list naming the rules broken. | [Create workflow](/api-reference-v3/workflows/create) | `write` | | `update_workflow` | Replaces a workflow's typed graph atomically. Takes `workflow_id` and `body`. Echo each existing field's `persistent_id` unchanged, including across renames; omit it only for new fields. | [Update workflow](/api-reference-v3/workflows/update) | `write` | | `delete_workflow` | Deletes a workflow and all its document packets and runs. Takes `workflow_id`. Irreversible. Returns `{ "deleted": "" }`. | [Delete workflow](/api-reference-v3/workflows/delete) | `write` | | `stage_files` | Turns files on the caller's machine into references the other tools accept. Takes `body: { files: [{ filename, declared_size, content_type }] }` (1 to 10) and returns, per file, a presigned upload form for the bytes plus a short `object_id` to refer to them by. Staged bytes belong to no workflow and expire within a day. | [Agents over MCP](/guides/mcp#1-stage-a-local-file) | `write` | | `upload_documents` | Imports files into one document packet, without running it. Takes `workflow_id` and `body: { urls, staged_files }`: HTTPS URLs fetched server-side, `{ object_id, filename }` references from `stage_files`, or a mix, 1 to 100 in total. A URL-fetched file is named by its response's `Content-Disposition`, else by the URL's path; a staged file by its `filename`. The packet is registered only after every fetch succeeded. | [Upload from URL](/api-reference-v3/workflows/upload-from-url) | `write` | | `run_document_packet` | Runs, or re-runs, a packet on the latest version of its workflow. Takes `document_packet_id` and an optional `idempotency_key`; every call without a key starts a new billed run. A keyed retry replays the original run with its current status, so a run that already finished answers `processed`. | [Run a document packet](/api-reference-v3/document-packets/run) | `write` | | `parse_document` | Parses one document with the fast lite parse. It is a quick one-off, not a production pipeline. Takes `body: { url }` with an HTTPS URL, or `body: { object_id, filename }` from `stage_files`. Returns a run reference. Once the run is `processed`, the markdown is at `results.parse.markdown`. Save it yourself: quick-parse output is not retained. | [Parse from URL](/api-reference-v3/parse/parse-from-url) | `write` | | `get_run` | Returns one run with its results inline; `results` is null until `status` is `processed`. Takes `run_id`, an optional `wait_seconds` from 0 to 60, and an optional `parse_output`. With `wait_seconds`, the tool polls every 2 seconds until the run is `processed`, `error` or `cancelled`, or until the budget runs out. `parse_output` selects how much of the parsed document comes back: `markdown` by default, or `none`, `text`, `blocks` or `all`. `none` is by far the smallest response when only the extracted fields matter. | [Get run](/api-reference-v3/runs/get) | `read` | | `list_workflow_runs` | Lists a workflow's runs, newest first. Takes `workflow_id` and `query: { limit, cursor, document_packet_created_after, document_packet_created_before }`. Keyset-paginated like `list_workflows`. | [List runs](/api-reference-v3/runs/list) | `read` | | `list_workflow_document_packets` | Lists a workflow's document packets, newest first. Takes `workflow_id` and `query: { limit, cursor }`. Keyset-paginated like `list_workflows`. | [List document packets](/api-reference-v3/document-packets/list) | `read` | | `get_document_packet` | Returns one packet: status, timestamps, its files and `latest_run_id`, which is null until the packet has been run. Takes `document_packet_id`. | [Get document packet](/api-reference-v3/document-packets/get) | `read` | | `delete_document_packet` | Deletes a packet and all its files. Takes `document_packet_id`. Irreversible. Returns `{ "deleted": "" }`. | [Delete document packet](/api-reference-v3/document-packets/delete) | `write` | | `ask_knowledge` | Asks a question about the content of a workflow's documents and returns an answer with citations, each resolved to a page and a region of the source PDF. Takes `workflow_id` and `body: { question, thread_id }`; a `thread_id` you mint (prefix `kb-`) keeps follow-ups in context. The workflow needs a [Knowledge](/guides/nodes/knowledge) node (`KNOWLEDGE_NOT_ENABLED` otherwise). `KNOWLEDGE_NOT_READY` means no usable snapshot is available. Wait when indexing is in progress; otherwise a member indexes or updates in the app's Knowledge tab. MCP exposes no indexing action. `KNOWLEDGE_THREAD_MISMATCH` means `thread_id` belongs to a different workflow — use a new one or ask the workflow that owns it. `KNOWLEDGE_UNSUPPORTED_SNAPSHOT_FORMAT` means the published snapshot is in a format this deployment no longer reads, permanent until an operator republishes it. Every question is billed and can take a few minutes. | [Ask the knowledge base](/api-reference-v3/knowledge/ask) | `write` | | `list_knowledge` | Lists a workflow's knowledge corpus tree: every source document plus the generated navigation pages (`kind: "index"`), sorted by path. Takes `workflow_id`, an optional `folder` narrowing to entries directly in it or beneath it, and an optional `cursor`. Keyset-paginated: pass the response's `next_cursor` back as `cursor` until it is null. Same `KNOWLEDGE_NOT_ENABLED`/`KNOWLEDGE_NOT_READY`/`KNOWLEDGE_UNSUPPORTED_SNAPSHOT_FORMAT` conditions as `ask_knowledge`, but never billed. | [List knowledge entries](/api-reference-v3/knowledge/list) | `read` | | `search_knowledge` | Ranks a workflow's knowledge corpus by relevance (BM25) to `query` — for a topic-style question, not an exact string. Takes `workflow_id`, `query` (1 to 512 characters), an optional `folder` narrowing to one top-level source folder, and an optional `limit` (1 to 50, default 10). An unrecognized `folder` is `NOT_FOUND`. Each hit carries a relevance `rank` and a `snippet` around the best-matching term. Same `KNOWLEDGE_NOT_ENABLED`/`KNOWLEDGE_NOT_READY`/`KNOWLEDGE_UNSUPPORTED_SNAPSHOT_FORMAT` conditions as `ask_knowledge`, but never billed. | [Search knowledge](/api-reference-v3/knowledge/search) | `read` | | `read_knowledge` | Reads one document's or navigation page's text from a workflow's knowledge corpus. Takes `workflow_id` and `path` (from `list_knowledge` or `search_knowledge`). `content` is capped; `truncated` is `true` when it was cut off. An unknown `path` is `NOT_FOUND`. Same `KNOWLEDGE_NOT_ENABLED`/`KNOWLEDGE_NOT_READY`/`KNOWLEDGE_UNSUPPORTED_SNAPSHOT_FORMAT` conditions as `ask_knowledge`, but never billed. | [Read knowledge](/api-reference-v3/knowledge/read) | `read` | | `download_knowledge` | Returns a presigned URL for a workflow's whole knowledge corpus, as a tar. Possession of the URL is read permission for the corpus until it expires (15 minutes) — prefer `list_knowledge`/`read_knowledge` for anything that stays inside this API. Takes only `workflow_id`. Same `KNOWLEDGE_NOT_ENABLED`/`KNOWLEDGE_NOT_READY` conditions as `ask_knowledge`, but never billed. | [Download knowledge](/api-reference-v3/knowledge/download) | `read` | | `request_approval` | Shows you an approval card with a `summary`, optional `details` and Approve/Reject buttons, then makes the agent wait for your click. Calls no endpoint and changes nothing on anyformat; the delete tools ask the agent to call it first. | none | none | The graph a tool takes or returns is the one on the [Node schemas](/api-reference-v3/node-schemas) page. A host reads the full input schema from the server, so an agent can compose a valid graph without you pasting the schema into the conversation. The document flow an agent follows is `upload_documents`, then `run_document_packet` with an `idempotency_key`, then `get_run` with `wait_seconds` set, so one call waits for the result instead of the agent polling. A file on the agent's machine enters the flow through `stage_files`: upload the bytes to the returned form, then pass `{ object_id, filename }` to `parse_document` or `upload_documents`. Pass the short id, never the kilobytes-long presigned `read_url`. `parse_document` is the one-call shortcut when the agent needs only the markdown of a single document. Save that markdown: its output is not retained. Every extracted `value` is a JSON string on the wire, whatever the field's declared type. A `float` field answers `"4594.62"`, so parse numbers before comparing them. A field's `confidence` is a number from 0 to 100, such as `87.5`, or `null` when none was produced. Each field carries the `evidence` text and the page it was read from. `delete_workflow` and `delete_document_packet` take effect at once: the API stops returning the workflow or packet immediately. Its content stays in storage for a grace window of 14 days, or 24 hours for an organization with zero data retention. Only after that window does the content become eligible for a permanent purge. No MCP tool undoes a delete. Their descriptions ask the agent to call `request_approval` with a one-line summary and to wait for your approval before deleting. That approval is a card the host shows you; a host that does not render it still sees the `destructiveHint` and can ask you its own way. ### Tool annotations Every API tool declares the standard MCP annotations, so a host can decide when to ask you before calling it. `request_approval` declares none: it touches nothing on anyformat. | Tool | `readOnlyHint` | `destructiveHint` | `idempotentHint` | | -------------------------------- | -------------- | ----------------- | ---------------- | | `list_workflows` | true | | true | | `get_workflow` | true | | true | | `list_workflow_versions` | true | | true | | `create_workflow` | false | | false | | `update_workflow` | false | | false | | `delete_workflow` | false | true | true | | `stage_files` | false | | false | | `upload_documents` | false | | false | | `run_document_packet` | false | | false | | `parse_document` | false | | false | | `get_run` | true | | true | | `list_workflow_runs` | true | | true | | `list_workflow_document_packets` | true | | true | | `get_document_packet` | true | | true | | `delete_document_packet` | false | true | true | | `ask_knowledge` | false | | false | | `list_knowledge` | true | | true | | `search_knowledge` | true | | true | | `read_knowledge` | true | | true | | `download_knowledge` | true | | true | | `request_approval` | | | | All API tools set `openWorldHint: false`: they only reach anyformat. ## Skill resources The server also serves the anyformat skill, the design know-how an agent reads before it builds a workflow, as MCP resources. It is the same content that ships as the [`@anyformat/skill`](https://www.npmjs.com/package/@anyformat/skill) npm package; the resource path needs no install step. | Resource | Content | MIME type | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | `skill://anyformat/SKILL.md` | The skill itself: auth, the tool flow, how to design a graph and read results. | `text/markdown` | | `skill://anyformat/_manifest` | `{ "skill": "anyformat", "files": [ { "path", "size", "hash" } ] }`: every file of the skill, so a host can check what it cached against what the server serves. | `application/json` | | `skill://anyformat/reference/curl.md` | Raw HTTP calls, the result and error envelopes, and the multipart local-file upload no MCP tool covers. | `text/markdown` | | `skill://anyformat/reference/sdks.md` | TypeScript and Python SDK quickstarts. | `text/markdown` | A host discovers them with `resources/list` and reads one with `resources/read` and its URI; any valid key can read them, whatever its scopes. In Claude Code, mention a resource as `@anyformat:skill://anyformat/SKILL.md` to pull it into the conversation. Over plain HTTP: ```bash theme={null} curl -X POST https://api.anyformat.ai/mcp \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"skill://anyformat/SKILL.md"}}' ``` ## Authorization and limits Authorization follows the API key. Whatever the key can do on `/v3`, it can do over `/mcp`, on the key's organization only. Each API tool needs one of the key's scopes: the list and get tools need `read`, the tools that create, run, update or delete need `write`; `request_approval` needs none. A tool the key cannot call is left out of the server's tool list, and a direct call to it is answered as an unknown tool. Every tool call passes through the gateway's normal rate limiting, so the [rate limit tiers](/api-reference-v3/introduction#rate-limits) apply per tool call as they do per request. ## Errors A failed tool call returns an MCP tool error whose message is the API's error envelope as JSON: `{ error, detail, error_code, retryable, request_id, status }`. Read `error_code` to branch, `status` for the equivalent HTTP status code, and quote `request_id` in a support request. A `TOPOLOGY_INVALID` error carries `detail.violations[]`, one entry per broken rule, with the node ids involved. ## Related * [Agents over MCP](/guides/mcp): a worked end-to-end session with real payloads, covering stage, build, run and read. * [Coding assistant](/guides/coding-assistant): the `@anyformat/skill` package, the same content as the [skill resources](#skill-resources) above. * [Python SDK](/api-reference-v3/sdks/python) and [TypeScript SDK](/api-reference-v3/sdks/typescript): the same calls from code. # Migrating from v2 Source: https://docs.anyformat.ai/api-reference-v3/migrating-from-v2 Path-by-path mapping from the v2 surface to v3, plus the semantic changes: keyset pagination, inline run status, persistent_id round-tripping, and idempotency. v3 replaces v2's file-centric surface with three first-class resources: [workflows](/concepts/workflows), [document packets](/concepts/document-packets), and [runs](/concepts/runs-and-results). The extraction engine, the results envelope, authentication, and the error envelope are unchanged. The paths, the identifiers, and the read model moved. **v2 keeps working through its deprecation window.** Every `/v2/` response carries `Deprecation` and `Sunset` headers announcing the retirement date, per [RFC 8594](https://datatracker.ietf.org/doc/html/rfc8594). Nothing breaks today. Start a new integration on v3, and plan the move for an existing one. See [Versioning & deprecation](/api-reference/introduction#versioning--deprecation) to alert on these headers automatically. The [v2 deprecation timeline](/api-reference/v2-migration) carries the exact window dates and the per-route successor `Link` headers. *** ## The one-sentence version Where v2 had a *file* or a *collection*, v3 has a **document packet**; where v2 polled a nested `/results/` path, v3 reads a flat **run** that returns status and results in one `200`. *** ## Path-by-path mapping ### Workflows | v2 | v3 | What changed | | ------------------------------------ | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `POST /v2/workflows/` | [`POST /v3/workflows/`](/api-reference-v3/workflows/create) | Same typed-graph body. | | `GET /v2/workflows/` | [`GET /v3/workflows/`](/api-reference-v3/workflows/list) | [Keyset pagination](#pagination-offset-to-keyset). | | `GET /v2/workflows/{id}/` | [`GET /v3/workflows/{workflow_id}/`](/api-reference-v3/workflows/get) | Now returns the **full typed graph** inline, with a `persistent_id` on every field. | | `GET /v2/workflows/{id}/definition/` | folded into [`GET /v3/workflows/{workflow_id}/`](/api-reference-v3/workflows/get) | v3 has no separate `/definition/` endpoint. The workflow read *is* the definition. | | `PUT /v2/workflows/{id}/` | [`PATCH /v3/workflows/{workflow_id}/`](/api-reference-v3/workflows/update) | [PUT → PATCH with `persistent_id` echo](#put-to-patch-field-identity-round-trips). | | `DELETE /v2/workflows/{id}/` | [`DELETE /v3/workflows/{workflow_id}/`](/api-reference-v3/workflows/delete) | Unchanged semantics. | ### Uploading documents | v2 | v3 | What changed | | ---------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST /v2/workflows/{id}/files/` | [`POST /v3/workflows/{workflow_id}/upload/`](/api-reference-v3/workflows/upload) | Returns `document_packet_id` instead of `file_collection_id`. Accepts 1–100 files as **one packet**. Supports [`Idempotency-Key`](#idempotency-new). | | `POST /v2/workflows/{id}/run/` | [`POST /v3/workflows/{workflow_id}/upload/run/`](/api-reference-v3/workflows/upload-and-run) | Returns a `run_id`, the handle you poll, instead of a collection id. | | `POST /v2/workflows/{id}/files/from-url/` | [`POST /v3/workflows/{workflow_id}/upload/from-url/`](/api-reference-v3/workflows/upload-from-url) | Takes 1–100 HTTPS URLs. The import is **all-or-nothing** and the response is final, with no pending fetch state. | | `POST /v2/workflows/{id}/files/{file_id}/run/` | [`POST /v3/document-packets/{document_packet_id}/run/`](/api-reference-v3/document-packets/run) | Flat path: the packet knows its workflow, so the call needs no `workflow_id`. Every call creates a **new run**. | v2's `text` form field accepted plain text instead of a file. v3 has no counterpart: upload the text as a small `.txt` file in the multipart `files` field. ### Reading packets and results | v2 | v3 | What changed | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `GET /v2/workflows/{id}/files/` | [`GET /v3/workflows/{workflow_id}/document-packets/`](/api-reference-v3/document-packets/list) | Keyset-paginated; the single-packet read carries `latest_run_id`. | | `GET /v2/workflows/{id}/files/{collection_id}/results/` | [`GET /v3/runs/{run_id}/`](/api-reference-v3/runs/get) | [No more 412-polling](#results-polling-412-to-inline-status). Status and results come back inline on a flat run read. | | `GET /v2/workflows/{id}/runs/` | [`GET /v3/workflows/{workflow_id}/runs/`](/api-reference-v3/runs/list) | Keyset-paginated; items reference `document_packet_id` and are followed up via `GET /v3/runs/{run_id}/`. | | `DELETE /v2/files/{collection_id}/` | [`DELETE /v3/document-packets/{document_packet_id}/`](/api-reference-v3/document-packets/delete) | Same semantics: deletes the packet, its files, and its results. | *** ## Identifier renames v2's `file_id` / `collection_id` / `file_collection_id` all collapse into one identifier: | v2 name | v3 name | | ------------------------------------------------------------------------- | -------------------- | | `file_collection_id`, `collection_id`, `file_id` (as the runnable handle) | `document_packet_id` | A v2 collection **is** a v3 document packet: the same underlying object under one name. Per-file ids still exist inside a packet, as `files[].id` on packet responses, for the rare case where you address one file of a multi-file packet. Path parameters are always snake\_case: `workflow_id`, `document_packet_id`, `run_id`. *** ## Semantic changes ### Results polling: 412 to inline status v2's results read returned `412 Precondition Failed` while the extraction was in flight, so clients had to treat an error status as "not yet". In v3, [`GET /v3/runs/{run_id}/`](/api-reference-v3/runs/get) **always returns `200`** while the run exists: * `status` tells you where the run is: `queued`, `in_progress`, `processed`, `error`, or `cancelled`. * `results` is `null` until `status` reaches `processed`, then carries the results envelope inline. * A run that ended in `error` or `cancelled` stays readable with `results: null`. The results envelope stays **byte-compatible** with v2's, down to the `parse`, `classifications`, `splits` and `extractions` sections. See [Response formats](/api-reference/response-formats). Only the read path moved. ```python theme={null} # v2: poll /results/, treat 412 as "not ready" r = requests.get(f"{BASE}/v2/workflows/{wf}/files/{col}/results/", headers=h) if r.status_code == 412: ...retry... # v3: poll the run, branch on status r = requests.get(f"{BASE}/v3/runs/{run_id}/", headers=h) run = r.json() if run["status"] == "processed": results = run["results"] ``` ### PUT to PATCH: field identity round-trips v2 edited workflows with `PUT /v2/workflows/{id}/`, and the round-trippable graph lived on a separate `GET .../definition/`. v3 folds both into the workflow resource: 1. `GET /v3/workflows/{workflow_id}/` returns the workflow with its graph inline. Every field carries its server-assigned `persistent_id`. 2. Mutate the graph and keep only `{name, description, nodes, edges}`, then send it back via `PATCH /v3/workflows/{workflow_id}/`. The read-only `id`, `created_at` and `updated_at` are rejected with `400` `VALIDATION_ERROR`. 3. **Echo each field's `persistent_id` unchanged, including across renames.** The field then keeps its identity: analytics history, ground truth, and quality metrics stay attached. 4. The PATCH response echoes the stored graph back in the exact GET shape, including new fields with their freshly assigned `persistent_id`s. Edit that and PATCH again. Omit `persistent_id` only for a genuinely new field, and the server assigns one. A field without `persistent_id` is always new. Omitting it on an existing field replaces that field and detaches its history. An echoed `persistent_id` that matches no field in the current version is rejected with `400`. v3 has no `PUT`. ### Pagination: offset to keyset v2 lists used `?page=` / `?page_size=` with a `count` total. v3 lists return `{items, next_cursor}`: * Pass `?cursor=` to fetch the next page; stop when `next_cursor` is `null`. * `?limit=` caps at 100. A larger value returns `400`; the API does not clamp it. * v3 returns **no totals or page numbers**. If you rendered `count` in a UI, switch to cursor-driven "load more" semantics. See [Pagination](/api-reference-v3/introduction#pagination). ### Idempotency (new) The packet-creating and run-triggering POSTs accept an `Idempotency-Key` header. Retrying with the same key replays the original response instead of creating a duplicate packet or billing a second extraction. v2 had no equivalent. Delete any retry-dedup logic you built client-side. See [Idempotency](/api-reference-v3/introduction#idempotency). ### Version header Every `/v3/` response is stamped `X-API-Version: 3.0.0`. A v2 response says `2.0.0` and also carries `Deprecation` and `Sunset`. If you tag metrics by API version, this header is the reliable source. *** ## What did *not* change * **Authentication**: the same API keys and the same `Authorization: Bearer` header. * **Error envelope**: the same `{error, detail, error_code, retryable, request_id}` shape. See [Errors](/api-reference/errors). * **Results envelope**: the same `parse`, `classifications`, `splits` and `extractions` sections. See [Response formats](/api-reference/response-formats). * **Rate limiting**: the same two-tier limits and `x-ratelimit-*` headers. * **Webhooks**: an existing subscription keeps firing, and management stays on the current endpoints. See [Webhooks](/api-reference/webhooks/overview). * **The typed-graph workflow body**: `POST /v3/workflows/` accepts the same `{name, description, nodes, edges}` shape as `POST /v2/workflows/`. *** ## Migration checklist 1. Swap base paths `/v2/` → `/v3/` per the tables above; rename `file_id` / `collection_id` variables to `document_packet_id`. 2. Replace `/results/` polling (412-tolerant) with `GET /v3/runs/{run_id}/` polling on `status`. 3. Replace `PUT` workflow edits with GET → mutate → `PATCH`, preserving `persistent_id`s. 4. Replace `page`/`page_size` pagination with `cursor`/`limit`; drop any use of `count`. 5. Add an `Idempotency-Key` to upload and run-trigger POSTs if you retry on timeouts. 6. Alert on the `Sunset` header so your v2 traffic cannot outlive the window unnoticed. See [the header reference](/api-reference/introduction#versioning--deprecation). # Node schemas Source: https://docs.anyformat.ai/api-reference-v3/node-schemas Every field the API accepts on each workflow node, the shared types they use, the edge shape, and the topology rules a graph must satisfy. This page is transcribed from the API's request schema. It lists the fields a public caller may set on each node of a [workflow](/concepts/workflows), with their type, whether they are required, and their default. The machine-readable form is the OpenAPI document at [https://api.anyformat.ai/schema/](https://api.anyformat.ai/schema/). Each node's guide page explains what the fields do and when to use them. A workflow body is `{ name, description, nodes, edges }`. Every node carries an `id` (unique within the graph) and a `type` (the discriminator). The API rejects unknown keys on any node with `400 VALIDATION_ERROR`, so a field that is not on this page is not accepted, even if the app shows it. Write only the fields you want to set. A `GET` echoes every node with every default filled in (for example `"cache": true` on a Parse node you created with only `id` and `type`), and every edge with `"branch": null` where you set none. That echoed shape is what `PATCH` accepts back. ## parse Reads the document and turns it into text, tables and layout. Every workflow has exactly one. Guide: [Parse](/guides/nodes/parse). | Field | Type | Required | Default | Description | | -------------------- | -------------------------------------------- | -------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | yes | | Stable identifier for this node within the graph. | | `type` | `"parse"` | yes | | | | `mode` | `standard` \| `agentic` \| `lite` \| `flash` | no | `standard` | The parse tier. `lite` is shown as **Fast** in the app. | | `prompt_hint` | string \| null | no | `null` | Free-form hint that biases the parse output. Standard, agentic and flash only; ignored in lite. | | `figure_enhancement` | boolean | no | `false` | Standard mode: extract structured data from charts and images. Extra processing per figure. Ignored in agentic and lite. | | `cache` | boolean | no | `true` | Reuse the parsed output when the same file was already parsed with the same mode and settings. A hit skips the whole parse. Set `false` to force a fresh parse. | | `effort` | `low` \| `mid` \| `accurate` | no | `mid` | Agentic mode only: quality and cost preset. | | `scanned_pages` | `ocr` \| `skip` \| `fail` | no | `ocr` | Flash mode only: what to do with a page whose text layer produced no words. `ocr` parses it like any other page, `skip` serves it blank and flags it, `fail` raises and names the page numbers. | A field that belongs to another tier is accepted and ignored. A `GET` also echoes `"ocr_effort": "high"`: a retired lite-tier knob, accepted and ignored, kept on the wire for compatibility. Do not set it. ```json theme={null} { "id": "parse_1", "type": "parse", "mode": "agentic", "effort": "accurate" } ``` Echoed by `GET`: ```json theme={null} { "id": "parse_1", "type": "parse", "mode": "agentic", "prompt_hint": null, "figure_enhancement": false, "cache": true, "effort": "accurate", "ocr_effort": "high", "scanned_pages": "ocr" } ``` ## extract Pulls the fields of a schema out of the parsed document, each with a value, a confidence and evidence. Guide: [Extract](/guides/nodes/extract). | Field | Type | Required | Default | Description | | ------------------------- | ------------------------------------------------ | -------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | yes | | Stable identifier for this node within the graph. | | `type` | `"extract"` | yes | | | | `mode` | `standard` \| `agentic` \| `lite` | no | `standard` | The extraction tier. `lite` is shown as **Fast** in the app. | | `extraction_schema` | [ExtractionSchema](#extractionschema-and-fields) | yes | | The fields this node extracts. At least one. | | `lookup_files` | string\[] | no | `[]` | URIs of the [Smart Lookup](/guides/nodes/smart-lookup) reference files stored on the node. Read it back from `GET`; write `lookup_file_uploads` to add files. | | `lookup_suggestion` | string \| null | no | `null` | Free-form hint shown to the smart-lookup matcher. | | `lookup_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high` \| null | no | `null` | Reasoning effort for the smart-lookup matcher. `null` is the model default. Higher effort improves match reliability on noisy join keys at higher latency and cost. | | `lookup_file_uploads` | [LookupFileUpload](#lookupfileupload)\[] | no | `[]` | Inline reference-file content. Each entry is uploaded and its URI is appended to `lookup_files`. Create-input only: the API does not store it on the node. | | `use_images` | boolean | no | `false` | Standard and lite modes: send each PDF page's rendered image alongside its parsed text, so the model can read layout the text missed. Adds vision cost on every page. | ```json theme={null} { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "invoice_number", "description": "The unique invoice identifier", "data_type": "string" }, { "name": "total_amount", "description": "Total invoice amount", "data_type": "float" } ] } } ``` Echoed by `GET`. Every field carries the server-assigned `persistent_id` and its `source`; echo both back on `PATCH`: ```json theme={null} { "id": "extract_1", "type": "extract", "mode": "standard", "extraction_schema": { "fields": [ { "persistent_id": "0686bb97-8c30-70f0-8000-97669e00aaaa", "name": "invoice_number", "description": "The unique invoice identifier", "source": "extraction", "data_type": "string" }, { "persistent_id": "0686bb97-8c30-70f0-8000-97669e00aaab", "name": "total_amount", "description": "Total invoice amount", "source": "extraction", "data_type": "float" } ] }, "lookup_files": [], "lookup_suggestion": null, "lookup_reasoning_effort": null, "lookup_file_uploads": [], "use_images": false } ``` ## classify Labels the document as one of your categories and routes it down that category's branch. Guide: [Classify](/guides/nodes/classify). | Field | Type | Required | Default | Description | | ------------- | ---------------------------------------- | -------- | ------- | ----------------------------------------------------------------------------------------- | | `id` | string | yes | | Stable identifier for this node within the graph. | | `type` | `"classify"` | yes | | | | `user_prompt` | string \| null | no | `null` | Extra instructions inserted between the classifier's system prompt and the document text. | | `categories` | [ClassifyCategory](#classifycategory)\[] | yes | | At least one. Category ids and names must each be unique within the node. | ```json theme={null} { "id": "classify_1", "type": "classify", "categories": [ { "id": "INVOICE", "name": "Invoice", "description": "A vendor invoice." }, { "id": "RECEIPT", "name": "Receipt", "description": "A point-of-sale receipt." } ] } ``` ## splitter Breaks one file that holds several documents into pieces, one rule per piece, and routes each rule down its own branch. The wire `type` is `splitter`; the app calls it **Split**. Guide: [Split](/guides/nodes/split). | Field | Type | Required | Default | Description | | ------- | -------------------------------- | -------- | ------- | --------------------------------------------------------------------- | | `id` | string | yes | | Stable identifier for this node within the graph. | | `type` | `"splitter"` | yes | | | | `rules` | [SplitterRule](#splitterrule)\[] | yes | | At least one. Rule ids and names must each be unique within the node. | ```json theme={null} { "id": "split_1", "type": "splitter", "rules": [ { "id": "STATEMENT", "name": "Statement", "description": "A bank account statement.", "partition_key": "account_number" }, { "id": "CHECK", "name": "Check", "description": "A scanned check." } ] } ``` ## validate Checks the values an Extract node produced against your rules and records a verdict per rule. Guide: [Validate](/guides/nodes/validate). | Field | Type | Required | Default | Description | | ------- | ------------------------------------ | -------- | ------- | ------------------------------------------------- | | `id` | string | yes | | Stable identifier for this node within the graph. | | `type` | `"validate"` | yes | | | | `rules` | [ValidationRule](#validationrule)\[] | yes | | At least one. | ```json theme={null} { "id": "validate_1", "type": "validate", "rules": [ { "id": "vendor-legit", "description": "The vendor is a real, named company." }, { "id": "totals-add-up", "kind": "deterministic", "check": { "type": "arithmetic", "operands": ["", ""], "equals": "", "tolerance": 0.01 } } ] } ``` ## if\_else Evaluates one check against the upstream extraction and routes the run down a `true` or a `false` branch. Guide: [If/Else](/guides/nodes/if-else). | Field | Type | Required | Default | Description | | ----------- | --------------- | -------- | ------- | ----------------------------------------------------------------------------------------------- | | `id` | string | yes | | Stable identifier for this node within the graph. | | `type` | `"if_else"` | yes | | | | `condition` | [Check](#check) | yes | | The check to evaluate. A `validation` check routes on the outcome of an upstream Validate rule. | The outgoing edges carry `"branch": "true"` and `"branch": "false"`. The `true` edge is required; the `false` edge may be left unwired. ```json theme={null} { "id": "if_else_1", "type": "if_else", "condition": { "type": "validation", "rule_id": "totals-add-up", "status": "fail" } } ``` ## slack\_alert Posts a rendered message to a Slack channel at the end of a branch. It emits nothing downstream. Guide: [Slack alert](/guides/nodes/slack-alert). | Field | Type | Required | Default | Description | | ------------------ | --------------------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | yes | | Stable identifier for this node within the graph. | | `type` | `"slack_alert"` | yes | | | | `channel_id` | string | yes | | Canonical Slack channel id, for example `C0123ABC`. Stable across renames. | | `channel_name` | string | yes | | The channel's display name at save time, for example `#finance`. Shown in logs and Studio; not re-resolved on every send. | | `message_template` | string | yes | | Message body. `${field.}` inserts an extracted value. With an upstream Validate node, `${validation..status}`, `.detail` and `.severity` insert a rule's outcome. A missing field or unknown rule id renders as ``. | | `severity` | `info` \| `warning` \| `critical` | no | `info` | Colour of the message's attachment bar. Presentational only. | ```json theme={null} { "id": "slack_alert_1", "type": "slack_alert", "channel_id": "C0123ABC", "channel_name": "#finance", "message_template": "Totals do not reconcile on ${field.0686bb97-8c30-70f0-8000-97669e00aaaa}: ${validation.totals-add-up.detail}", "severity": "warning" } ``` ## edit Detects the fillable fields on the parsed PDF, fills them, and returns the filled PDF. Terminal: nothing accepts an edge from it. Guide: [Edit](/guides/nodes/edit). | Field | Type | Required | Default | Description | | ------------------------ | --------------------------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | yes | | Stable identifier for this node within the graph. | | `type` | `"edit"` | yes | | | | `instructions` | string \| null | no | `null` | Free-text values to write into the detected fields, for example `Name: ACME SL; Date: 2026-01-01; I accept the terms: yes`. Wins over a reference document where both address the same field. | | `reference_document_ids` | string\[] | no | `[]` | Ids of reference documents uploaded to this workflow. Every id must belong to this workflow and be fully resolved, or the run is refused. | | `font` | `sans` \| `serif` \| `mono` | no | `sans` | Typeface the filled values are written in. | | `output_mode` | `flattened` \| `editable` | no | `flattened` | `flattened` paints the values on and the PDF is final. `editable` leaves each value in a live form field. | ```json theme={null} { "id": "edit_1", "type": "edit", "instructions": "Name: ACME SL; Date: 2026-01-01; I accept the terms: yes", "reference_document_ids": ["069dcc2c-e14c-7606-8000-2ee4fb17b4e2"] } ``` ## knowledge Indexes the parsed documents of a workflow into its knowledge base. It has no options. It accepts one inbound edge from `parse`, `classify`, `extract` or `validate`, emits nothing downstream, and appears at most once per workflow. Its presence makes completed runs eligible for ingestion. Guide: [Knowledge](/guides/nodes/knowledge). | Field | Type | Required | Default | Description | | ------ | ------------- | -------- | ------- | ------------------------------------------------- | | `id` | string | yes | | Stable identifier for this node within the graph. | | `type` | `"knowledge"` | yes | | | ```json theme={null} { "id": "knowledge_1", "type": "knowledge" } ``` ## Shared types ### ExtractionSchema and fields `extraction_schema` is `{ "fields": [ ... ] }` with at least one field. Every field carries these keys, plus the ones its `data_type` adds. See [Field types](/concepts/field-types) for what each type extracts. | Field | Type | Required | Default | Description | | --------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data_type` | `string` \| `integer` \| `float` \| `boolean` \| `date` \| `datetime` \| `enum` \| `multi_select` \| `object` | yes | | The discriminator. | | `name` | string | yes | | Field name. Used as the key in the extraction results. | | `description` | string | no | `""` | Free-form description shown to the extraction model. An empty description is accepted but degrades quality. | | `source` | `extraction` \| `smart_lookup` \| `lookup_if_missing` | no | `extraction` | Where the value comes from: the document, the lookup file (always overwrites), or the document first and the lookup file where extraction left no value. | | `persistent_id` | string (UUID) \| null | no | `null` | Server-assigned stable identity, returned by `GET`. Echo it on updates, including across renames. Omit for new fields. Sending one on create is rejected with `400`. | | `enum_options` | [EnumOption](#enumoption)\[] | `enum`, `multi_select` only | | At least one option. | | `nested_fields` | field\[] | `object` only | | At least one child field. Only one level of nesting: a child cannot be an `object`. | ### EnumOption | Field | Type | Required | Default | Description | | ------------- | ------ | -------- | ------- | ----------------------------------------- | | `name` | string | yes | | The option value. | | `description` | string | yes | | Free-form description shown to the model. | ### ClassifyCategory | Field | Type | Required | Default | Description | | ------------- | ------ | -------- | ------- | --------------------------------------------------------------------------------------------------- | | `id` | string | yes | | Stable category id. The `branch` value on the edge that leaves the Classify node for this category. | | `name` | string | yes | | Display name shown to the model. Must be unique across the node's categories. | | `description` | string | yes | | Free-form description shown to the model. | ### SplitterRule | Field | Type | Required | Default | Description | | --------------- | ------ | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | yes | | Stable rule id. The `branch` value on the edge that leaves the Split node for this rule. | | `name` | string | yes | | Display name shown to the model. Must be unique across the node's rules. | | `description` | string | yes | | Free-form description shown to the model. | | `partition_key` | string | no | `""` | Field that further partitions this rule into separate sub-documents, for example `invoice_number`. Each distinct value becomes its own split. Empty means the whole rule flows on as one document. | ### ValidationRule A rule is either AI-evaluated (`kind: "ai"`, a natural-language `description`) or deterministic (`kind: "deterministic"`, a structured `check`). An `ai` rule must not carry a `check`; a `deterministic` rule must carry one. | Field | Type | Required | Default | Description | | --------------- | ----------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------- | | `id` | string | yes | | Stable rule id. Round-trips through the validation results, and is what `if_else` and `slack_alert` reference. | | `name` | string \| null | no | `null` | Human-readable rule name. | | `kind` | `ai` \| `deterministic` | no | `ai` | How the rule is evaluated. | | `description` | string \| null | no | `null` | Natural-language rule sent to the model. Required when `kind` is `ai`. | | `check` | [Check](#check) \| null | no | `null` | Structured check run in code. Required when `kind` is `deterministic`. Must not contain a `validation` check. | | `severity` | `error` \| `warning` | no | `error` | How a failed rule is labelled in results. Never blocks the run. | | `source_fields` | string\[] | no | `[]` | Persistent ids of the fields this rule references. | ### Check A check is a JSON object discriminated on `type`. Field operands are `persistent_id`s, except in `expression`, which addresses fields by name. Combinators nest. | `type` | Fields | Description | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `range` | `field` (string, required), `min` (number \| null), `max` (number \| null) | The numeric field lies within the inclusive bounds. | | `date` | `field` (string, required), `earliest` (string \| null), `latest` (string \| null) | The date field lies within the inclusive bounds. Each bound is an ISO date `YYYY-MM-DD` or the literal `today`. | | `arithmetic` | `operands` (string\[], at least one, required), `operator` (`sum` \| `subtract` \| `product`, default `sum`), `equals` (string, required), `tolerance` (number, default `0`) | Combining the operands with the operator equals the `equals` field, within `tolerance`. | | `comparison` | `left` (string, required), `op` (`==` \| `!=` \| `>` \| `>=` \| `<` \| `<=`, required), `right` (required) | Compares the `left` field against `right`, which is `{ "source": "field", "field": "" }` or `{ "source": "literal", "value": }`. | | `one_of` | `field` (string, required), `allowed` (string\[], at least one, required), `case_sensitive` (boolean, default `false`) | The value is one of `allowed`. | | `regex` | `field` (string, required), `pattern` (string, required) | The value matches the pattern. Evaluated with RE2 (linear time). | | `required` | `field` (string, required) | The field has a value. | | `confidence` | `field` (string, required), `op` (`<` \| `<=` \| `>` \| `>=` \| `==` \| `!=`, required), `threshold` (integer 0 to 100, required) | Compares the field's extraction confidence against `threshold`. `field` is a `persistent_id` or the field's name. | | `expression` | `expression` (string, 1 to 1000 characters, required) | A CEL expression over the root variable `data`, for example `data.total == data.subtotal + data.tax`. Fields are addressed by name. | | `validation` | `rule_id` (string, required), `status` (`fail` \| `pass` \| `inconclusive`, default `fail`) | True when the named upstream Validate rule ended with `status`. Only valid on an `if_else` condition. | | `all_of` | `checks` (Check\[], 1 to 32, required) | Passes when every child passes. | | `any_of` | `checks` (Check\[], 1 to 32, required) | Passes when any child passes. | ### LookupFileUpload | Field | Type | Required | Default | Description | | ---------- | ------ | -------- | ------- | --------------------------------------------- | | `filename` | string | yes | | The file's name, for example `suppliers.csv`. | | `content` | string | yes | | The file's bytes, base64-encoded. | ## Edges `edges` is a list of directed connections between node ids. A workflow with only a Parse node needs no edges. | Field | Type | Required | Default | Description | | -------- | -------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ | | `source` | string | yes | | The `id` of the node the edge leaves. | | `target` | string | yes | | The `id` of the node the edge enters. | | `branch` | string \| null | no | `null` | The source port. Required on every edge that leaves a `classify`, `splitter` or `if_else` node; forbidden on every other edge. | `branch` must equal a category `id` on a Classify node, a rule `id` on a Split node, or `"true"` / `"false"` on an If/Else node. It is the id, not the name. A `GET` echoes an edge you wrote as `{ "source", "target" }` as `{ "source": "parse_1", "target": "extract_1", "branch": null }`. ```json theme={null} "edges": [ { "source": "parse_1", "target": "classify_1" }, { "source": "classify_1", "target": "extract_invoice", "branch": "INVOICE" }, { "source": "classify_1", "target": "extract_receipt", "branch": "RECEIPT" } ] ``` ## Topology rules The API validates the whole graph on create and on update. A graph that breaks a rule is rejected with `400` and `error_code: "TOPOLOGY_INVALID"`; `detail.violations[]` names every rule broken and the node ids involved, so one round trip shows every problem. Which node may feed which: | Node | Accepts an edge from | | ------------- | ------------------------------------------ | | `parse` | nothing. Parse is always first. | | `classify` | `parse` | | `splitter` | `parse`, `classify` | | `extract` | `parse`, `classify`, `splitter` | | `validate` | `extract` | | `if_else` | `extract`, `validate` | | `slack_alert` | `extract`, `validate`, `if_else` | | `knowledge` | `parse`, `classify`, `extract`, `validate` | | `edit` | `parse` | The graph-level rules: * Exactly one `parse` node. * Node ids are unique. Every edge names existing nodes. Every node except `parse` has an inbound edge. No cycles. * Only `classify`, `splitter` and `if_else` fan out. Every other node has at most one outgoing edge. * `validate`, `if_else` and `slack_alert` read one upstream extraction, so each accepts exactly one incoming edge. `extract` may receive several branches of one Classify node. * Each Split rule routes to its own target node. Two rules cannot share a target. * Within one node, category or rule `id`s are unique and `name`s are unique. Two edges leaving the same node cannot carry the same `branch`. * Every `if_else` has a `true` outgoing edge. * An `extract` cannot sit downstream of an `if_else` today. That fan-out shape is not yet supported. * Every `extract` has at least one field. * At most one `knowledge` node. * Every `rule_id` in an `if_else` condition, and every `${validation....}` token in a `slack_alert` template, names a rule on a Validate node reachable upstream of that node. * `edit` is terminal: no node accepts an edge from it. # Parse a Document Source: https://docs.anyformat.ai/api-reference-v3/parse/parse POST /v3/parse/ Parse one document with the platform's fast lite parse *Rate limit tier: **submission**, 60 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* The platform's fast lite parse handles **one document** per call. It is a one-call shortcut over the [workflow upload](/api-reference-v3/workflows/upload) and [run](/api-reference-v3/document-packets/run) primitives. It runs on your organization's system parse workflow, which the platform provisions on first use. Send the file as multipart form data under the `file` field. The response references a [run](/concepts/runs-and-results). Poll [`GET /v3/runs/{run_id}/`](/api-reference-v3/runs/get) until `status` is `processed`. The parsed markdown sits at `results.parse.markdown`. The results envelope carries the **first** file's parse output. For a multi-file extraction workflow, use [workflow upload](/api-reference-v3/workflows/upload) and [document-packets/run](/api-reference-v3/document-packets/run) directly. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v3/parse/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -F 'file=@/path/to/invoice.pdf' ``` ```python Python (requests) theme={null} import requests url = "https://api.anyformat.ai/v3/parse/" headers = {"Authorization": "Bearer YOUR_API_KEY"} with open("invoice.pdf", "rb") as f: triggered = requests.post(url, headers=headers, files={"file": f}).json() print(triggered["run_id"]) ``` ```typescript TypeScript theme={null} interface RunTriggered { run_id: string; document_packet_id: string; workflow_id: string; status: 'queued' | 'in_progress' | 'processed' | 'error' | 'cancelled'; } const formData = new FormData(); formData.append('file', fileInput.files[0]); const response = await fetch('https://api.anyformat.ai/v3/parse/', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY' }, body: formData, }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const triggered: RunTriggered = await response.json(); console.log(triggered.run_id); ``` ```json Response (202 Accepted) theme={null} { "run_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4fa", "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "status": "queued" } ``` # Parse a Document from a URL Source: https://docs.anyformat.ai/api-reference-v3/parse/parse-from-url POST /v3/parse/from-url/ Parse one document fetched from a URL with the platform's fast lite parse *Rate limit tier: **submission**, 60 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* The platform fetches **one document** server-side from a URL and parses it with the fast lite parse. This endpoint is the URL analogue of [`POST /v3/parse/`](/api-reference-v3/parse/parse). It runs on your organization's system parse workflow, which the platform provisions on first use. Provide one **HTTPS** URL. The response's `Content-Disposition` names the file, and the URL's path names it otherwise. See [File names](#file-names). The response references a [run](/concepts/runs-and-results). Poll [`GET /v3/runs/{run_id}/`](/api-reference-v3/runs/get) until `status` is `processed`. The parsed markdown sits at `results.parse.markdown`. A failed, non-2xx, or timed-out fetch returns `422` with the reason in `detail`. The results envelope carries the **first** file's parse output. To import several URLs into one packet, use [upload from URL](/api-reference-v3/workflows/upload-from-url) and [document-packets/run](/api-reference-v3/document-packets/run) directly. ## File names The response names a from-url file, not the request. The name matters twice. It carries the extension the import requires, and a URL whose path is an opaque key carries none. It is also the name you and your users see wherever the file is listed. The precedence is: 1. The `Content-Disposition` filename on the response, per [RFC 6266](https://www.rfc-editor.org/rfc/rfc6266). `filename*=UTF-8''…` wins over `filename=`, and any path is cut to its last segment. 2. Else the URL path's last segment: `https://example.com/invoices/april.pdf` imports as `april.pdf`. 3. Else the import fails with `422`. The name travels in the header rather than in a request field. HTTP already names a downloaded file there, and every object store signs that header into a presigned URL. The API therefore stays URL-only, with no parallel name list to match `urls` in length and order. On S3, `ResponseContentDisposition` signs it in: ```python boto3 theme={null} import boto3 s3 = boto3.client("s3") url = s3.generate_presigned_url( "get_object", Params={ "Bucket": "my-bucket", "Key": "uploads/7c1f3e9a", "ResponseContentDisposition": 'attachment; filename="april-invoice.pdf"', }, ExpiresIn=900, ) ``` GCS offers the same override through `response-content-disposition` on a V4 signed URL, and Azure through `rscd` on a SAS. Whichever name applies must end in a supported extension, such as `.pdf`, `.docx` or `.png`. A bare key such as `uploads/7c1f3e9a` names the file `7c1f3e9a`, which has no extension, so the import fails with `422`. A name that collides with a file already in the parse workflow is renamed: `april.pdf` becomes `april (1).pdf`. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v3/parse/from-url/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"url": "https://example.com/invoices/april.pdf"}' ``` ```python Python (requests) theme={null} import requests url = "https://api.anyformat.ai/v3/parse/from-url/" headers = {"Authorization": "Bearer YOUR_API_KEY"} body = {"url": "https://example.com/invoices/april.pdf"} triggered = requests.post(url, headers=headers, json=body).json() print(triggered["run_id"]) ``` ```json Response (202 Accepted) theme={null} { "run_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4fa", "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "status": "queued" } ``` ```json Response (422, fetch failed) theme={null} { "error": "Remote fetch failed", "detail": "Remote server returned 404", "error_code": "VALIDATION_ERROR", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` # Get Run Source: https://docs.anyformat.ai/api-reference-v3/runs/get GET /v3/runs/{run_id}/ Fetch a single run flat: lifecycle status plus the results envelope inline *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Fetches a [run](/concepts/runs-and-results) by its `run_id`. The path is flat: no workflow id, and no `/results/` sub-path. The response carries the run's status **and** its results envelope together, so reading a completed run costs one round-trip. ## Status model The endpoint **always returns `200` while the run exists**. An extraction in flight raises no precondition error. | `status` | Meaning | `results` | | ------------- | -------------------------------------- | -------------------- | | `queued` | Waiting for a processing slot | `null` | | `in_progress` | Extraction actively running | `null` | | `processed` | Terminal. The extraction succeeded | the results envelope | | `error` | Terminal. The extraction failed | `null` | | `cancelled` | Terminal. The extraction was cancelled | `null` | Poll until `status` is terminal. A run that ended in `error` or `cancelled` stays readable with `results: null`. The `results` envelope carries the `parse`, `classifications`, `splits`, `extractions` and `edits` sections. Each `extractions` entry carries its own `validations[]`. This is the same envelope v2 returned from its `/results/` route: only the read path changed. See [Runs & results](/concepts/runs-and-results) for what each section holds. [Parse](/guides/nodes/parse#what-it-returns) documents the `parse` section. The samples below shorten `blocks[]` to one block. Field values in `extractions[]` are strings on the wire. An unknown id returns `404`, as does a run belonging to another organization. ## Filled forms (`edits[]`) A workflow with an [Edit node](/guides/nodes/overview) fills the blank form it was given and returns `edits[]` alongside the other sections. `edits[]` holds one entry per file the node processed. Each entry lists every form field detected in the document, the value written into it, and a link to the filled PDF. The key is always present, and is `[]` when the workflow has no edit node. | Field | Meaning | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `download_url` | Presigned link to the filled PDF, valid for **15 minutes** from the moment the API built the response. Every read mints a fresh link, so re-request the run rather than storing the URL. It is `null` when the run stored no filled PDF. | | `fields[].value` | The value written into the field, or the literal string `true` or `false` for a `checkbox`. It is `null` when no instruction addressed the field. Every `state: "prefilled"` field is `null`, because the node never overwrites one. | | `fields[].confidence` | A raw, uncalibrated 0-100 match score for pairing an instruction with the field. It grades how surely the instruction addresses the field, not whether the written value is correct, and it is not a probability. | | `unmatched_instructions` | Instruction fragments, quoted verbatim, that matched no field in this document. | ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/runs/069dcc2c-e14c-7606-8000-2ee4fb17b4f9/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests import time run_id = "069dcc2c-e14c-7606-8000-2ee4fb17b4f9" url = f"https://api.anyformat.ai/v3/runs/{run_id}/" headers = {"Authorization": "Bearer YOUR_API_KEY"} for _ in range(100): run = requests.get(url, headers=headers).json() if run["status"] == "processed": print(run["results"]) break if run["status"] in ("error", "cancelled"): print(f"Terminal: {run['status']}") break time.sleep(3) ``` ```typescript TypeScript theme={null} interface RunDetail { id: string; workflow_id: string; document_packet_id: string; status: 'queued' | 'in_progress' | 'processed' | 'error' | 'cancelled'; created_at: string | null; updated_at: string | null; results: Record | null; } const runId = '069dcc2c-e14c-7606-8000-2ee4fb17b4f9'; const response = await fetch(`https://api.anyformat.ai/v3/runs/${runId}/`, { headers: { 'Authorization': 'Bearer YOUR_API_KEY' }, }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const run: RunDetail = await response.json(); if (run.status === 'processed') { console.log(run.results); } ``` ```json Response (200 OK, processed) theme={null} { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "status": "processed", "created_at": "2026-07-01T12:00:00.000Z", "updated_at": "2026-07-01T12:02:30.000Z", "results": { "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "verification_url": "https://app.anyformat.ai/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/files/069dcc2c-e14c-7606-8000-2ee4fb17b4e3?versionId=FGaV4I2JAA", "parse": { "markdown": "\n\n# INVOICE\n\n\n\nInvoice #: INV-001 \nIssue date: 2026-02-20\n\n\n\n...
", "text": "# INVOICE\n\nInvoice #: INV-001 ...", "parse_confidence": 90.6, "layout_confidence": 0.7, "blocks": [ { "id": "p1_b1", "type": "text", "page": 1, "bbox": { "x0": 0.114, "y0": 0.132, "x1": 0.322, "y1": 0.210 }, "parse_confidence": 96.5, "layout_confidence": 0.96, "content": "Invoice #: INV-001 \nIssue date: 2026-02-20", "hyperlinks": [], "rows": null, "image_base64": null } ] }, "classifications": [], "splits": [], "extractions": [ { "split_name": null, "partition": null, "fields": { "invoice_number": { "value": "INV-001", "value_override": null, "verification_status": "not_verified", "confidence": 95.0, "evidence": [{ "text": "Invoice #INV-001", "page_number": 1 }] } } } ], "edits": [] } } ``` ```json Response (200 OK, workflow with an Edit node) theme={null} { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "status": "processed", "created_at": "2026-07-01T12:00:00.000Z", "updated_at": "2026-07-01T12:02:30.000Z", "results": { "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "verification_url": "https://app.anyformat.ai/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/files/069dcc2c-e14c-7606-8000-2ee4fb17b4e3?versionId=FGaV4I2JAA", "parse": { "markdown": "\n\n# INVOICE\n\n\n\nInvoice #: ONB-001 \nIssue date: 2026-02-20\n\n\n\n...
", "text": "# INVOICE\n\nInvoice #: ONB-001 ...", "parse_confidence": 90.6, "layout_confidence": 0.7, "blocks": [ { "id": "p1_b1", "type": "text", "page": 1, "bbox": { "x0": 0.114, "y0": 0.132, "x1": 0.322, "y1": 0.210 }, "parse_confidence": 96.5, "layout_confidence": 0.96, "content": "Invoice #: ONB-001 \nIssue date: 2026-02-20", "hyperlinks": [], "rows": null, "image_base64": null } ] }, "classifications": [], "splits": [], "extractions": [], "edits": [ { "file_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e3", "file_name": "onboarding-form.pdf", "fields": [ { "form_field_id": "p1_f0", "label": "Full name", "kind": "text", "state": "empty", "page": 1, "bbox": { "x0": 0.1, "y0": 0.1, "x1": 0.5, "y1": 0.16 }, "value": "ACME SL", "confidence": 95 }, { "form_field_id": "p1_f1", "label": "Form reference", "kind": "text", "state": "prefilled", "page": 1, "bbox": { "x0": 0.6, "y0": 0.1, "x1": 0.9, "y1": 0.16 }, "value": null, "confidence": null } ], "unmatched_instructions": [], "download_url": "https://storage.anyformat.ai/filled/onboarding-form.pdf?X-Amz-Signature=..." } ] } } ``` ```json Response (200 OK, still processing) theme={null} { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "status": "in_progress", "created_at": "2026-07-01T12:00:00.000Z", "updated_at": "2026-07-01T12:00:15.000Z", "results": null } ``` ```json Response (200 OK, terminal with no results) theme={null} { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "status": "error", "created_at": "2026-07-01T12:00:00.000Z", "updated_at": "2026-07-01T12:02:30.000Z", "results": null } ```
# List Workflow Runs Source: https://docs.anyformat.ai/api-reference-v3/runs/list GET /v3/workflows/{workflow_id}/runs/ List a workflow's runs, newest first *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Returns one keyset page of a workflow's [runs](/concepts/runs-and-results), newest first. Follow `next_cursor` until it is `null`. See [Pagination](/api-reference-v3/introduction#pagination). Each item is slim: id, `document_packet_id`, `status` and timestamps. For the results envelope, fetch [`GET /v3/runs/{run_id}/`](/api-reference-v3/runs/get). ## Filter by upload day Two optional ISO 8601 datetime query params narrow the page to runs whose document packet was created inside the half-open interval `[after, before)`: * `document_packet_created_after` is the inclusive lower bound. * `document_packet_created_before` is the exclusive upper bound. Adjacent day-buckets tile without overlap. `after=2026-07-17T00:00:00 & before=2026-07-18T00:00:00` returns exactly July 17. That is the shape a daily-batch workflow wants: upload on day N, let cron enqueue the runs, fetch the results on day N+1. **Timezones.** Both bounds accept any ISO 8601 offset, such as `Z`, `+02:00` or `-05:00`. A value with no offset counts as UTC. Use your local offset when "one day" means a day in your timezone. The examples below cover UTC and Madrid summer time. ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/runs/?limit=20' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```bash curl (one day's uploads, UTC) theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/runs/?limit=100&document_packet_created_after=2026-07-17T00:00:00Z&document_packet_created_before=2026-07-18T00:00:00Z' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```bash curl (one day, Madrid time +02:00) theme={null} # July 17 in Europe/Madrid = 2026-07-16T22:00:00Z .. 2026-07-17T22:00:00Z curl --get 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/runs/' \ --data-urlencode 'limit=100' \ --data-urlencode 'document_packet_created_after=2026-07-17T00:00:00+02:00' \ --data-urlencode 'document_packet_created_before=2026-07-18T00:00:00+02:00' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" url = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/runs/" headers = {"Authorization": "Bearer YOUR_API_KEY"} # All runs uploaded on 2026-07-17, paged. # The cursor encodes pagination position only, NOT the filter. Keep # `document_packet_created_{after,before}` on every page, or the next page # returns runs from other upload days. Mutating the same `params` dict and # adding `cursor` carries the filters along automatically. params = { "limit": 100, "document_packet_created_after": "2026-07-17T00:00:00", "document_packet_created_before": "2026-07-18T00:00:00", } while True: page = requests.get(url, headers=headers, params=params).json() for run in page["items"]: print(run["id"], run["status"]) if not page["next_cursor"]: break params["cursor"] = page["next_cursor"] ``` ```json Response (200 OK) theme={null} { "items": [ { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4fa", "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "status": "processed", "created_at": "2026-07-02T09:00:00.000Z", "updated_at": "2026-07-02T09:02:30.000Z" }, { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "status": "error", "created_at": "2026-07-01T12:00:00.000Z", "updated_at": "2026-07-01T12:01:10.000Z" } ], "next_cursor": null } ``` # SDK migration: 0.x to 1.0 Source: https://docs.anyformat.ai/api-reference-v3/sdk-migration Upgrade the AnyFormat Python and TypeScript SDKs from the v2-based 0.x releases to the v3-based 1.0 release. The 1.0 SDKs use [API v3](/api-reference-v3/introduction). Workflow authoring and the results payload remain familiar, but uploads, runs, pagination, and workflow updates now follow v3's resource model. This guide covers both official packages: * Python: `anyformat` 0.x → 1.x * TypeScript: `@anyformat/sdk` 0.x → 1.x The 0.x packages continue to use API v2. Do not upgrade the package without making the code changes below. For the HTTP-level endpoint changes and the v2 sunset schedule, see [Migrating from v2](/api-reference-v3/migrating-from-v2). ## What changes in 1.0 | 0.x | 1.0 | | ---------------------------------------------------------- | ----------------------------------------------------------------------- | | Uploads expose `file_id` or `collection_id` | Uploads expose one `document_packet_id` and a list of files | | A run handle is effectively a collection id | A run has its own `run_id`, status, workflow id, and document packet id | | `wait()` polls results and treats HTTP 412 as "not ready" | `wait()` polls the run until `processed`, `error`, or `cancelled` | | Lists use `page`, `page_size`, and optional status filters | Lists use `limit` and an opaque `cursor` | | Workflow updates use v2 `PUT` | Workflow updates use v3 `PATCH` and preserve field `persistent_id`s | | A staged file can be run by its file id | A document packet is run by its packet id | Authentication, API keys, the fluent workflow schema, standalone parse helpers, and the extraction results envelope do not require a conceptual migration. The standalone parse helpers still use the published v2 parse operation because v3 does not yet have an equivalent. ## Install 1.0 The 1.0 Python package requires Python 3.10 or later. The TypeScript package requires Node.js 18 or later. ```bash Python theme={null} python -m pip install --upgrade "anyformat>=1,<2" ``` ```bash TypeScript theme={null} npm install @anyformat/sdk@^1.0.0 ``` Commit the updated lockfile, then run your type checker and tests. The removed methods and renamed properties are intentional compile-time migration signals. ## Python ### Creating and running a workflow The common create-and-run flow needs little change. In 1.0, keep the returned `Run` if you need its status or identifiers. ```python 0.x theme={null} workflow = ( client.workflow("Invoices") .parse() .extract(fields) .create() ) result = workflow.run("invoice.pdf").wait() ``` ```python 1.0 theme={null} workflow = ( client.workflow("Invoices") .parse() .extract(fields) .create() ) run = workflow.run("invoice.pdf") print(run.id, run.document_packet_id, run.status) result = run.wait() ``` `run.wait()` now reads the flat v3 run resource. It returns a `Result` on `processed`, raises `ExtractionFailed` on `error`, and raises `ExtractionCancelled` on `cancelled`. Both terminal exceptions subclass `RunFailed`. ### Staged uploads and document packets Replace upload `collection_id` and `file_id` access with `document_packet_id` and `files`. ```python 0.x theme={null} upload = workflow.upload("invoice.pdf") print(upload.collection_id, upload.file_id) run = upload.run() ``` ```python 1.0 theme={null} upload = workflow.upload("invoice.pdf") print(upload.document_packet_id) for packet_file in upload.files: print(packet_file.id, packet_file.name) run = upload.run() ``` A packet can contain between one and a hundred files. They are processed together as one document: ```python theme={null} run = workflow.run(files=["invoice.pdf", "annex.pdf"]) ``` If you stored a 0.x collection id, it identifies the same underlying resource in v3; rename it to `document_packet_id` in your application. Individual file ids inside a packet are not runnable handles. ### Removed file-centric methods Replace `list_files()` with packet listing, and replace `run_workflow(workflow_id, file_ids)` with a packet run. ```python 0.x theme={null} files = client.list_files(workflow.id) runs = client.run_workflow(workflow.id, [files[0].id]) result = runs[0].wait() ``` ```python 1.0 theme={null} packets = client.list_document_packets(workflow.id) packet = packets.items[0] run = client.run_document_packet(packet.id) result = run.wait() ``` Use `get_document_packet()`, `delete_document_packet()`, `get_run()`, and `list_runs()` when you need to manage those resources directly. ### Pagination All list methods now return `Page(items, next_cursor)`. Page numbers, totals, `page_size`, and the list-time `status` filter are gone. ```python 0.x theme={null} workflows = client.list_workflows(page=1, page_size=50, status="active") for workflow in workflows: print(workflow.id) ``` ```python 1.0 theme={null} page = client.list_workflows(limit=50) for workflow in page.items: print(workflow.id) while page.next_cursor is not None: page = client.list_workflows(limit=50, cursor=page.next_cursor) for workflow in page.items: print(workflow.id) ``` For a full lazy traversal, prefer the 1.0 iterators: ```python theme={null} for workflow in client.iter_workflows(limit=50): print(workflow.id) for packet in client.iter_document_packets(workflow_id): ... for run in client.iter_runs(workflow_id): ... ``` The async client has equivalent async iterators. ### Updating a workflow Fetch the v3 definition, mutate it, and send it back. Preserve every existing field's `persistent_id`, including when renaming a field, so analytics and ground truth stay attached. ```python theme={null} definition = client.get_workflow(workflow_id) for node in definition.nodes: if node.type != "extract": continue for field in node.extraction_schema.fields: if field.name == "vendor": field.name = "vendor_name" # persistent_id remains unchanged client.update_workflow(workflow_id, definition) ``` Omit `persistent_id` only for a new field. The builder's `.update(workflow_id)` now uses the same v3 PATCH behavior. ### Errors `NoResultsYet` has been removed because run status is explicit. Update terminal-run handling as follows: ```python 1.0 theme={null} from anyformat.sdk import RunFailed, SDKTimeout try: result = run.wait() except RunFailed as exc: print(exc.run_id, exc.status) # error or cancelled except SDKTimeout: # The run can still finish; retrieve it later with client.get_run(run.id). raise ``` HTTP exceptions now expose `retryable` and `request_id`. A 402 response raises `PaymentRequired`. The existing `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `RateLimited`, `ServerError`, and `SDKTimeout` names remain valid. ### Smart lookup fields Replace the 0.x `lookup=True` flag with an explicit field source: ```python 0.x theme={null} Schema.string("vendor_id", "Canonical vendor id.", lookup=True) ``` ```python 1.0 theme={null} Schema.string("vendor_id", "Canonical vendor id.", source="smart_lookup") ``` Use `source="lookup_if_missing"` when extraction should run first and lookup should only fill an empty value. ## TypeScript ### Create the workflow before running it In 0.x, `WorkflowBuilder.run()` could create and run in one chain. In 1.0, persist the workflow with `.create()`, then run the returned `Workflow`. ```ts 0.x theme={null} const result = await af .workflow("Invoices") .parse() .extract(fields) .run(file) .wait(); ``` ```ts 1.0 theme={null} const workflow = await af .workflow("Invoices") .parse() .extract(fields) .create(); const run = await workflow.run(file); const result = await run.wait(); ``` The 1.0 `Run` exposes `id`, `workflowId`, `documentPacketId`, and `status`. `Run.wait()` polls that run id instead of treating HTTP 412 as an in-progress result. ### Staged uploads and document packets ```ts 0.x theme={null} const upload = await workflow.upload(file); console.log(upload.collectionId, upload.fileId); const run = await upload.run(); ``` ```ts 1.0 theme={null} const upload = await workflow.upload(file); console.log(upload.documentPacketId, upload.files); const run = await upload.run(); ``` Pass an array to create a multi-file packet: ```ts theme={null} const run = await workflow.run([invoiceFile, annexFile]); ``` `Anyformat.runWorkflow(workflowId, fileIds)` has been removed. Run a staged packet instead: ```ts theme={null} const run = await af.runDocumentPacket(documentPacketId); ``` Packet management is available through `getDocumentPacket()`, `listDocumentPackets()`, and `deleteDocumentPacket()`. Run management is available through `getRun()` and `listRuns()`. ### Pagination List calls now return `{ items, nextCursor }` and accept `{ limit, cursor }`. ```ts 0.x theme={null} const workflows = await af.listWorkflows({ page: 1, pageSize: 50, status: "active" }); for (const workflow of workflows) console.log(workflow.id); ``` ```ts 1.0 theme={null} let cursor: string | undefined; do { const page = await af.listWorkflows({ limit: 50, cursor }); for (const workflow of page.items) console.log(workflow.id); cursor = page.nextCursor ?? undefined; } while (cursor); ``` For full traversal, use `iterWorkflows()`, `iterDocumentPackets(workflowId)`, or `iterRuns(workflowId)`: ```ts theme={null} for await (const workflow of af.iterWorkflows({ limit: 50 })) { console.log(workflow.id); } ``` ### Result and error names Rename `result.collectionId` to `result.documentPacketId`. The deprecated unsuffixed error aliases have been removed. Import the `Error`-suffixed classes: | Removed 0.x alias | 1.0 class | | ----------------- | ------------------- | | `BadRequest` | `BadRequestError` | | `Unauthorized` | `UnauthorizedError` | | `Forbidden` | `ForbiddenError` | | `NotFound` | `NotFoundError` | | `RateLimited` | `RateLimitedError` | | `SDKTimeout` | `SDKTimeoutError` | Handle terminal run outcomes through `RunFailedError` or its more specific subclasses: ```ts theme={null} import { RunFailedError, SDKTimeoutError } from "@anyformat/sdk"; try { const result = await run.wait(); } catch (error) { if (error instanceof RunFailedError) { console.error(error.runId, error.status); // error or cancelled } else if (error instanceof SDKTimeoutError) { // The run can still finish; retrieve it later with af.getRun(run.id). } else { throw error; } } ``` `APIError` additionally exposes `errorCode`, `retryable`, and `requestId`; 402 responses use `PaymentRequiredError`. ### Updating a workflow Use the typed GET → edit → PATCH round trip, preserving `persistent_id`: ```ts theme={null} const definition = await af.getWorkflow(workflowId); for (const node of definition.nodes) { if (node.type !== "extract") continue; for (const field of node.extraction_schema.fields) { if (field.name === "vendor") field.name = "vendor_name"; } } await af.updateWorkflow(workflowId, definition); ``` The builder's `.update(workflowId)` also uses PATCH in 1.0. ### Smart lookup fields ```ts 0.x theme={null} Schema.string("vendor_id", "Canonical vendor id.", { lookup: true }); ``` ```ts 1.0 theme={null} Schema.string("vendor_id", "Canonical vendor id.", { source: "smart_lookup" }); ``` Use `source: "lookup_if_missing"` to retain extraction as the primary source. ## Add idempotency to retries 1.0 supports idempotency keys on uploads and run triggers. Use a stable, operation-specific key whenever your application retries after a timeout. ```python Python theme={null} run = workflow.run( "invoice.pdf", idempotency_key="invoice-2026-07-20-upload-and-run", ) ``` ```ts TypeScript theme={null} const run = await workflow.run(file, { idempotencyKey: "invoice-2026-07-20-upload-and-run", }); ``` Reusing the same key for the same operation replays the original response instead of creating another packet or billable run. ## Migration checklist * Upgrade the runtime if needed: Python 3.10+ or Node.js 18+. * Upgrade the package and regenerate the lockfile. * TypeScript: add `.create()` before running a newly built workflow. * Rename collection/file handles to document packet handles. * Replace `listFiles`/`list_files` and `runWorkflow`/`run_workflow` with packet APIs. * Replace page-number pagination with `limit`/`cursor`, or use the new iterators. * Preserve `persistent_id` when editing existing workflow fields. * Update terminal-run and TypeScript error handling. * Replace smart-lookup `lookup` flags with `source`. * Add idempotency keys to retried uploads and run triggers. * Exercise create, upload, run, wait, list, and workflow-update paths in a staging environment. # SDKs and tools Source: https://docs.anyformat.ai/api-reference-v3/sdks The official Python and TypeScript clients for API v3, the afx command line, the MCP server, and the Claude Code skill. Both SDKs give you a fluent builder over the [typed workflow graph](/concepts/workflows), a `Workflow` handle to upload and run documents, a `Run` handle to wait on, and a typed `Result`. They target [API v3](/api-reference-v3/introduction): flat run reads, document packet handles, keyset pagination, and idempotent upload and run triggers. The 1.x SDKs target v3. The 0.x packages keep using v2 during its deprecation window. Upgrading needs code changes; follow the [0.x to 1.0 migration guide](/api-reference-v3/sdk-migration). ## Install ```bash Python theme={null} pip install anyformat ``` ```bash TypeScript theme={null} npm install @anyformat/sdk ``` Both clients take the API key explicitly: `Client(api_key="af_...")` and `new Anyformat({ apiKey: "af_..." })`. See [Authentication](/api-reference-v3/introduction#authentication). ## Pick your surface Sync and async clients, the builder, results with drawn layout boxes, knowledge ask, evals. The same builder and handles for Node.js 18+, with generated wire types. Parse, extract, run and manage workflows from a shell. Installed with the Python package. Give Claude Code, Cursor or any MCP host anyformat as tools. The Claude Code skill with the design know-how; the verbs come from the MCP server or the API reference. Every endpoint page has copy-paste examples. Start with upload and run. ## The same graph everywhere A workflow built with either SDK, the CLI, Studio or the MCP server is the same typed graph of nodes and edges. The [node schemas](/api-reference-v3/node-schemas) page lists every field each node accepts; each builder method maps onto one node. # Python SDK Source: https://docs.anyformat.ai/api-reference-v3/sdks/python The anyformat Python package: a fluent builder over the typed workflow graph, run and result handles, and the afx command line. The `anyformat` package is the Python client for [API v3](/api-reference-v3/introduction). It is hand-written on `httpx`. You build a workflow with a fluent builder, run a file through it, and read typed results. The [TypeScript SDK](/api-reference-v3/sdks/typescript) mirrors it method for method. Two import paths: `anyformat.sdk` holds the client, the handles and the errors; `anyformat.workflow` holds the schema factories and the node types. ## Install and auth Requires Python 3.10 or later. ```bash theme={null} pip install anyformat ``` Pass the API key to the client. The client does not read the environment for you. ```python theme={null} import os from anyformat.sdk import Client client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) ``` See [Authentication](/api-reference-v3/introduction#authentication) for how to mint a key. The package also installs the [`afx` command](/api-reference-v3/cli), which does read `ANYFORMAT_API_KEY`. ## Client `Client` is synchronous. `AsyncClient` has the same methods with `await`. Both take the same arguments. | Argument | Default | What it does | | ---------- | -------------------------- | ----------------------------------------- | | `api_key` | required | Your `af_...` key. | | `base_url` | `https://api.anyformat.ai` | Override for a non-production deployment. | | `timeout` | `60.0` | Per-request timeout in seconds. | Both clients are context managers that close the connection pool. Prefer that over a bare module-level client. ```python Sync theme={null} from anyformat.sdk import Client from anyformat.workflow import Schema with Client(api_key="af_...") as client: workflow = ( client.workflow("Invoices") .parse() .extract([Schema.string("vendor", "Vendor name on the invoice.")]) .create() ) result = workflow.run("invoice.pdf").wait() print(result.fields["vendor"].value) ``` ```python Async theme={null} import asyncio from anyformat.sdk import AsyncClient from anyformat.workflow import Schema async def main(): async with AsyncClient(api_key="af_...") as client: workflow = await ( client.workflow("Invoices") .parse() .extract([Schema.string("vendor", "Vendor name on the invoice.")]) .create() ) run = await workflow.run("invoice.pdf") result = await run.wait() print(result.fields["vendor"].value) asyncio.run(main()) ``` In the async client, `create()`, `update()`, `run()`, `upload()`, `wait()` and every client method are awaitable. The builder verbs (`parse`, `extract`, and so on) are not. ## Build a workflow `client.workflow(name, description=None)` returns a builder. Chain one verb per node, then `.create()` to register the workflow and get a `Workflow` handle, or `.update(workflow_id)` to replace an existing workflow's graph. `.build()` returns the typed `WorkflowDefinition` without a network call. The builder assigns node ids (`parse_1`, `extract_1`, ...) and wires the edges for you. Pass `id=` to any verb to choose the id yourself. Each verb constructs the same typed node the API accepts, so the [node schema](/api-reference-v3/node-schemas) is the reference for every argument. A builder mistake, such as `extract()` before `parse()`, raises `WorkflowBuilderError` before any request is sent. The builder has verbs for Parse, Classify, Split, Extract, Validate and Edit. For a graph with an [If/Else](/guides/nodes/if-else), [Slack alert](/guides/nodes/slack-alert) or [Knowledge](/guides/nodes/knowledge) node, build a `WorkflowDefinition` from the node classes in `anyformat.workflow.nodes` and pass it to `client.create_workflow(definition)`. ### parse Adds the one [Parse](/guides/nodes/parse) node. Call it first and once. `mode` selects the tier, and the type checker only offers the knobs that belong to that tier. ```python theme={null} client.workflow("Read only").parse() # mode="standard" client.workflow("Dense tables").parse(mode="agentic", effort="accurate") client.workflow("Born-digital").parse(mode="flash", scanned_pages="skip") ``` | Argument | Applies to | Default | | -------------------- | ------------------------------------------------ | -------------------------------------------------------------------- | | `mode` | all | `"standard"`. One of `"standard"`, `"agentic"`, `"lite"`, `"flash"`. | | `prompt_hint` | standard, agentic, flash | `None` | | `figure_enhancement` | standard, agentic, flash (only standard uses it) | `False` | | `cache` | all | `True` | | `effort` | agentic | `"mid"`. One of `"low"`, `"mid"`, `"accurate"`. | | `scanned_pages` | flash | `"ocr"`. One of `"ocr"`, `"skip"`, `"fail"`. | `lite` takes no knob of its own. All arguments are keyword-only. ### classify Adds a [Classify](/guides/nodes/classify) node after Parse. Takes one or more `ClassifyCategory` objects. ```python theme={null} from anyformat.workflow import ClassifyCategory invoice = ClassifyCategory(id="INVOICE", name="Invoice", description="A vendor invoice.") receipt = ClassifyCategory(id="RECEIPT", name="Receipt", description="A point-of-sale receipt.") builder = client.workflow("Invoice or receipt").parse().classify(invoice, receipt) ``` Every `extract()` that follows needs `branch=`. ### split Adds a [Split](/guides/nodes/split) node. Takes one or more `SplitterRule` objects. After `classify()`, pass `route_from=` to name the category that feeds the splitter. ```python theme={null} from anyformat.workflow import SplitterRule statement = SplitterRule(id="STATEMENT", name="Statement", description="A bank statement.", partition_key="account_number") check = SplitterRule(id="CHECK", name="Check", description="A scanned check.") builder = client.workflow("Statement batch").parse().split(statement, check) ``` ### extract Adds an [Extract](/guides/nodes/extract) node. Repeatable: one per branch. `fields` is a list built with `Schema`. ```python theme={null} from anyformat.workflow import Schema builder = ( client.workflow("Invoice or receipt") .parse() .classify(invoice, receipt) .extract([Schema.string("vendor", "Vendor name.")], branch=invoice) # object form .extract([Schema.string("merchant", "Merchant name.")], branch="RECEIPT") # id form ) ``` | Argument | Default | What it does | | --------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fields` | required | List of `Schema.*` fields. At least one. | | `branch` | `None` | The category or rule this extract hangs off. Required after `classify()` or `split()`, forbidden otherwise. A `ClassifyCategory`, a `SplitterRule`, or its id. | | `lookup_files` | `None` | Paths of [Smart Lookup](/guides/nodes/smart-lookup) reference files. The SDK reads each one and sends it inline as `lookup_file_uploads`. | | `lookup_file_uploads` | `None` | Already-encoded `LookupFileUpload(filename, content)` entries. | | `lookup_suggestion` | `None` | Free-form hint for the lookup matcher. | | `id` | auto | Node id. | `Schema` has one factory per [field type](/concepts/field-types): `string`, `integer`, `float`, `boolean`, `date`, `datetime`, `enum(name, description, options=[Schema.option(...)])`, `multi_select`, and `object(name, description, fields=[...])`. Every factory accepts `source="smart_lookup"` or `source="lookup_if_missing"`, and `persistent_id=` to pin a field's identity across updates. `client.workflow(...).extract()` does not take `mode`, `use_images` or `lookup_reasoning_effort` yet. Set them through the API JSON, in Studio, or with `client.get_workflow()` + `client.update_workflow()` on the typed definition. ### validate Adds a [Validate](/guides/nodes/validate) node after the most recent `extract()`, or after the extract on `branch=`. Takes one or more `ValidationRule` objects. ```python theme={null} from anyformat.workflow import ValidationRule, ArithmeticCheck builder = ( client.workflow("Invoice with checks") .parse() .extract([ Schema.float("subtotal", "Subtotal before tax."), Schema.float("tax", "Tax amount."), Schema.float("total", "Grand total."), ]) .validate( ValidationRule(id="vendor-legit", description="The vendor is a real, named company."), ValidationRule( id="totals-add-up", kind="deterministic", check=ArithmeticCheck(type="arithmetic", operands=["subtotal", "tax"], equals="total", tolerance=0.01), ), ) ) ``` ### edit Adds an [Edit](/guides/nodes/edit) node after Parse. Call it once. ```python theme={null} references = client.upload_edit_references(workflow_id, ["company-profile.csv"]) builder = ( client.workflow("Fill onboarding form") .parse() .edit( instructions="Name: ACME SL; Date: 2026-01-01; I accept the terms: yes", reference_document_ids=[r.id for r in references], ) ) ``` `upload_edit_references(workflow_id, files)` uploads the standing data an Edit node fills from (`.csv`, `.txt`, `.md`, `.rst`, `.pdf`). A PDF comes back `pending` while it is parsed once; poll `list_edit_references(workflow_id)` until `ready`. `delete_edit_reference(workflow_id, document_id)` removes one. The builder does not set `font` or `output_mode`; set those in the API JSON. ## Run and read ### Upload and run `workflow.run(...)` uploads the document as one [document packet](/concepts/document-packets) and starts a run. It accepts a path string, a `Path`, raw `bytes`, a list of up to 100 files via `files=`, or `text=`. Exactly one of the three. ```python theme={null} run = workflow.run("invoice.pdf") run = workflow.run(files=["invoice.pdf", "annex.pdf"]) # one packet, one document run = workflow.run(text="Plain text body") # sent as a .txt file run = workflow.run("invoice.pdf", idempotency_key="ingest-2026-08-25-0001") ``` Filenames are unique within a workflow. A colliding name is rejected with `409`; pass `on_conflict="rename"` to auto-rename instead. To upload without spending credits, use `workflow.upload(...)`. It returns an `Upload` with `document_packet_id` and `files`; call `upload.run()` later. ```python theme={null} upload = workflow.upload("invoice.pdf") run = upload.run(idempotency_key="run-2026-08-25-0001") ``` Retrying with the same `idempotency_key` replays the original upload or run instead of creating and billing a second one. ### wait `run.wait(timeout=300, poll_interval=3)` polls `GET /v3/runs/{run_id}/` until the status is terminal and returns a `Result`. A transient `429` or `5xx` while polling is retried with backoff and honours `Retry-After`. `wait()` raises `ExtractionFailed` on `error`, `ExtractionCancelled` on `cancelled`, and `SDKTimeout` when the budget runs out. ```python theme={null} result = run.wait(timeout=600) ``` ### Result | Attribute | Type | What it holds | | ------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `result.fields` | `dict[str, ExtractedField \| list[dict]]` | The untagged extraction's fields. A scalar field is an `ExtractedField` with `.value`, `.confidence` (0 to 100) and `.evidence`; an object field is a list of row dicts. | | `result.field(name)` | `ExtractedField \| None` | One scalar field, or `None` when missing or list-typed. Pass a type as the second argument to type `.value` for the checker: `result.field("total", float)`. | | `result.partitions` | `list[Partition]` | One entry per split partition, each with `split_name`, `partition`, its own `fields`, the `files` and `pages` it covers, and the splitter's `confidence` (0 to 100). Empty without a Split node. | | `result.splits` | `list[SplitResult]` | The Split node's categories, each with `name`, `confidence`, the `files` and `pages` under it, and its `partitions`. Empty without a Split node. | | `result.classifications` | `list[ClassificationResult]` | One verdict per Classify node, with `category`, `confidence` and `evidence`. Empty without a Classify node. | | `result.parse` | `ParseView \| None` | The Parse node's output: `.markdown`, `.text`, `.blocks`, `.parse_confidence`, `.layout_confidence`, and `.draw()`. | | `result.edits` | `list[Edit]` | One filled form per file from an Edit node, with `fields`, `unmatched_instructions` and a `download_url` valid for 15 minutes. | | `result.raw` | `dict` | The full results envelope as the API sent it. | ```python theme={null} result = run.wait() vendor = result.fields["vendor"] print(vendor.value, vendor.confidence, [e.text for e in vendor.evidence]) for row in result.fields["line_items"]: print(row["sku"].value, row["amount"].value) for partition in result.partitions: pages = [(f.file_name, f.pages) for f in partition.files] print(partition.split_name, partition.partition, partition.confidence, pages, partition.fields.keys()) for verdict in result.classifications: print(verdict.category, verdict.confidence, verdict.evidence) ``` `result.parse.draw("out/")` renders every block's bounding box onto its page, coloured by confidence, and writes one PNG per page. It uses the file you ran as the background; pass `source=` to use another. ### Quick parse For a one-off parse with no workflow, `client.parse(file)` runs the atomic parse operation and returns a `Run`. Poll `client.get_markdown(run.id)` until it stops raising `StillParsing`. It raises `ExtractionFailed` or `ExtractionCancelled` if the parse run ends that way. ```python theme={null} import time from anyformat.sdk import StillParsing run = client.parse("contract.pdf") while True: try: markdown = client.get_markdown(run.id) break except StillParsing: time.sleep(3) ``` ## Document packets and runs Packets and runs stay addressable after `wait()` returns. | Method | Returns | Notes | | ------------------------------------------------------------------ | ----------------------------- | ---------------------------------------------------------------- | | `client.get_document_packet(packet_id)` | `DocumentPacketDetail` | `.status`, `.files`, `.latest_run_id`. | | `client.run_document_packet(packet_id, idempotency_key=None)` | `Run` | Re-run on the workflow's latest version. Each call is a new run. | | `client.delete_document_packet(packet_id)` | `str` | Irreversible. | | `client.list_document_packets(workflow_id, limit=20, cursor=None)` | `Page[DocumentPacketSummary]` | Newest first. | | `client.get_run(run_id)` | `RunDetail` | `.status`, and `.result` inline once `processed`. | | `client.list_runs(workflow_id, limit=20, cursor=None)` | `Page[RunSummary]` | Newest first. | | `client.iter_runs(workflow_id, limit=20)` | `Iterator[RunSummary]` | Follows the cursor for you. | Run statuses: `queued`, `in_progress`, then `processed`, `error` or `cancelled`. ```python theme={null} packet = client.get_document_packet(run.document_packet_id) print(packet.status, packet.latest_run_id) for summary in client.iter_runs(workflow.id): print(summary.id, summary.status, summary.created_at) ``` ### Manage workflows | Method | Returns | Notes | | ------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------ | | `client.get_workflow(workflow_id)` | `WorkflowDefinition` | The typed graph. Fields carry their `persistent_id`. | | `client.update_workflow(workflow_id, definition)` | `Workflow` | Replaces the graph. Echo each `persistent_id` to keep field identity across renames. | | `client.create_workflow(definition)` | `Workflow` | Create from a `WorkflowDefinition` you built by hand. | | `client.list_workflows(limit=20, cursor=None)` | `Page[Workflow]` | Each item is a `Workflow` you can `.run()`. | | `client.iter_workflows(limit=20)` | `Iterator[Workflow]` | Follows the cursor. | | `client.delete_workflow(workflow_id)` | `str` | Deletes its packets and runs too. | ```python theme={null} definition = client.get_workflow(workflow.id) for node in definition.nodes: if node.type == "extract": for field in node.extraction_schema.fields: if field.name == "vendor": field.name = "vendor_name" # persistent_id travels with it client.update_workflow(workflow.id, definition) ``` ## Knowledge ask A workflow with a [Knowledge](/guides/nodes/knowledge) node answers questions about what its documents say. `workflow.ask(question)` and `client.ask(workflow_id, question)` call [Ask](/api-reference-v3/knowledge/ask) and return an `Answer`. ```python theme={null} answer = workflow.ask("Which contracts renew before March and what notice do they need?") print(answer.answer) for citation in answer.citations: print(citation.path, citation.page, citation.quote) ``` `Answer` has `.answer`, `.citations` (each with `path`, `quote`, `file_id`, `block_id`, `page`, `bbox`), `.steps_used` and `.thread_id`. Pass `thread_id="kb-..."` to ask a follow-up in the context of earlier questions on that thread. A workflow without a Knowledge node raises `APIError` with `error_code` `KNOWLEDGE_NOT_ENABLED`. ## Evals and datasets A [dataset](/concepts/evals) holds documents with known answers. Upload one document, with optional ground truth, as one packet: ```python theme={null} entry = workflow.upload_to_dataset( "invoice.pdf", ground_truth={"vendor": "ACME", "total": "42.00", "line_items": [{"sku": "A1", "qty": "2"}]}, ) print(entry.document_packet_id, entry.ground_truth_saved) ``` `ground_truth` keys are the schema's field identifiers. A scalar maps to `str | None`; an object field maps to a list of row dicts. The call is atomic: a rejected file or a ground-truth failure stores nothing. Bulk upload loops one atomic call per document: ```python theme={null} from anyformat.sdk import DatasetDocument workflow.upload_documents_to_dataset([ DatasetDocument(file="a.pdf", ground_truth={"vendor": "ACME"}), DatasetDocument(files=["b1.pdf", "b2.pdf"]), ]) ``` Then launch an eval over the whole dataset and poll it: ```python theme={null} launched = client.launch_eval(workflow.id, idempotency_key="eval-2026-08-25") print(launched.eval_id, launched.enqueued_count, launched.failed_count) detail = client.get_eval(workflow.id, launched.eval_id) while detail.status == "in_progress": time.sleep(10) detail = client.get_eval(workflow.id, launched.eval_id) print(detail.accuracy, detail.matched, detail.mismatched, detail.ungraded) ``` `launch_eval(workflow_id, version_id=None, idempotency_key=None)` evaluates the current version unless you pass one. `list_evals(workflow_id, limit, cursor)` and `iter_evals(workflow_id)` list past evals. See [Launch eval](/api-reference-v3/evals/launch). ## Pagination Every list returns a `Page(items, next_cursor)`. Pass `next_cursor` back as `cursor=` until it is `None`. There are no totals or page numbers; `limit` is capped at 100. ```python theme={null} cursor = None while True: page = client.list_runs(workflow.id, limit=100, cursor=cursor) for run in page.items: print(run.id, run.status) if page.next_cursor is None: break cursor = page.next_cursor ``` The `iter_*` methods (`iter_workflows`, `iter_document_packets`, `iter_runs`, `iter_evals`) do this loop for you and fetch each page lazily. ## Errors and retries Every HTTP failure raises a subclass of `APIError` carrying the v3 [error envelope](/api-reference-v3/introduction#errors): `.status_code`, `.error_code`, `.detail`, `.retryable` and `.request_id`. `.detail` keeps the shape the API sent: a string for most errors, a list of field errors for a validation failure, or an object such as the `{"errors": [...]}` of a `422 GT_INVALID`. | Status | Exception | Retry? | | ------ | ----------------- | --------------------------------------------- | | 400 | `BadRequest` | no. `VALIDATION_ERROR` or `TOPOLOGY_INVALID`. | | 401 | `Unauthorized` | no | | 402 | `PaymentRequired` | no. Top up credit first. | | 403 | `Forbidden` | no | | 404 | `NotFound` | no | | 422 | `APIError` | no | | 429 | `RateLimited` | yes, after `.retry_after` seconds | | 5xx | `ServerError` | yes | Four more exceptions live outside HTTP. `wait()` raises `RunFailed` on a terminal failure, as `ExtractionFailed` for `error` and `ExtractionCancelled` for `cancelled`. Both carry `.run_id` and `.status`. `wait()` raises `SDKTimeout` when it runs out of budget, and `MissingResults` when a processed run carries no results. `StillParsing` means a quick parse is not done. `WorkflowBuilderError` means builder misuse. ```python theme={null} import time from anyformat.sdk import APIError, RateLimited, RunFailed, SDKTimeout try: result = workflow.run("invoice.pdf").wait(timeout=120) except RateLimited as e: time.sleep(e.retry_after or 5) except RunFailed as e: print(f"run {e.run_id} ended {e.status}") except SDKTimeout: print("still running; poll client.get_run() later") except APIError as e: print(e.status_code, e.error_code, e.detail, e.request_id) ``` `wait()` retries a transient `429` or `5xx` itself, so those never reach your `except` while polling. ## Version pins | Package | Version | API | | ------------------ | ------- | ------------------------------------------------------------------------------------------- | | `anyformat` (PyPI) | 1.x | v3. | | `anyformat` 0.7.x | legacy | v2. Keeps working through the [v2 deprecation window](/api-reference-v3/migrating-from-v2). | Upgrading from 0.x changes uploads, runs, pagination and workflow updates. Follow the [0.x to 1.0 migration guide](/api-reference-v3/sdk-migration). ## Links * [PyPI](https://pypi.org/project/anyformat/) * [TypeScript SDK](/api-reference-v3/sdks/typescript): the same shape in TS * [afx command line](/api-reference-v3/cli): the CLI this package installs * [Node schemas](/api-reference-v3/node-schemas): every argument the builder maps to # TypeScript SDK Source: https://docs.anyformat.ai/api-reference-v3/sdks/typescript The @anyformat/sdk package: a fluent builder over the typed workflow graph, run and result handles, and typed errors. `@anyformat/sdk` is the TypeScript client for [API v3](/api-reference-v3/introduction). The wire types and the low-level HTTP client come generated from the API's OpenAPI document. The layer you use is hand-written, and mirrors the [Python SDK](/api-reference-v3/sdks/python) method for method. It covers `Anyformat`, `Schema`, `WorkflowBuilder`, `Workflow`, `Run` and `Result`. ## Install and auth The SDK needs Node.js 18 or later. The package ships ESM and CJS bundles with types. ```bash theme={null} npm install @anyformat/sdk ``` Pass the API key to the constructor. The client does not read the environment for you. ```typescript theme={null} import { Anyformat } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); ``` See [Authentication](/api-reference-v3/introduction#authentication) for how to mint a key. ## Client `new Anyformat(options)` is the entry point. Every method returns a `Promise`. | Option | Default | What it does | | --------- | -------------------------- | ----------------------------------------- | | `apiKey` | required | Your `af_...` key. | | `baseUrl` | `https://api.anyformat.ai` | Override for a non-production deployment. | ```typescript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af .workflow("Invoices") .parse() .extract([Schema.string("vendor", "Vendor name on the invoice.")]) .create(); const run = await workflow.run(new File([bytes], "invoice.pdf")); const result = await run.wait(); console.log(result.field("vendor")?.value); ``` ## Build a workflow `af.workflow(name, description?)` returns a `WorkflowBuilder`. Chain one method per node, then `await .create()` to register the workflow and get a `Workflow` handle, or `await .update(workflowId)` to replace an existing workflow's graph. `.build()` returns the request body without a network call. The builder assigns node ids (`parse_1`, `extract_1`, ...) and wires the edges. Pass `{ id }` in the options to choose the id yourself. Each method builds the same typed node the API accepts, so the [node schema](/api-reference-v3/node-schemas) is the reference for every option. A builder mistake, such as `extract()` before `parse()`, throws `WorkflowBuilderError` before any request is sent. The builder has methods for Parse, Classify, Split, Extract and Validate. For a graph with an [Edit](/guides/nodes/edit), [If/Else](/guides/nodes/if-else), [Slack alert](/guides/nodes/slack-alert) or [Knowledge](/guides/nodes/knowledge) node, write the `nodes` and `edges` arrays yourself and send them with `af.updateWorkflow(workflowId, definition)`, or call the [create endpoint](/api-reference-v3/workflows/create) directly. The wire types (`Edge`, `ClassifyCategory`, `SplitterRule`, `ValidationRule`, the field and check types) are exported for that. ### parse Adds the one [Parse](/guides/nodes/parse) node. Call it first and once. The options are a `mode`-discriminated union: each tier only accepts its own knobs, so a wrong-tier knob is a compile-time error. ```typescript theme={null} af.workflow("Read only").parse(); // mode "standard" af.workflow("Dense tables").parse({ mode: "agentic", effort: "accurate" }); af.workflow("Born-digital").parse({ mode: "flash", scannedPages: "skip" }); ``` | Option | Applies to | Default | | ------------------- | --------------------------- | -------------------------------------------------------------------- | | `mode` | all | `"standard"`. One of `"standard"`, `"agentic"`, `"lite"`, `"flash"`. | | `promptHint` | standard, agentic, flash | unset | | `figureEnhancement` | all (only standard uses it) | `false` | | `cache` | all | `true` | | `effort` | agentic | `"mid"` on the server. One of `"low"`, `"mid"`, `"accurate"`. | | `scannedPages` | flash | `"ocr"` on the server. One of `"ocr"`, `"skip"`, `"fail"`. | `lite` takes no knob of its own. ### classify Adds a [Classify](/guides/nodes/classify) node after Parse. Takes an array of `ClassifyCategory` objects (`id`, `name`, `description`). ```typescript theme={null} const invoice = { id: "INVOICE", name: "Invoice", description: "A vendor invoice." }; const receipt = { id: "RECEIPT", name: "Receipt", description: "A point-of-sale receipt." }; const builder = af.workflow("Invoice or receipt").parse().classify([invoice, receipt]); ``` Every `extract()` that follows needs `{ branch }`. ### split Adds a [Split](/guides/nodes/split) node. Takes an array of `SplitterRule` objects (`id`, `name`, `description`, optional `partition_key`). After `classify()`, pass `{ routeFrom }` to name the category that feeds the splitter. ```typescript theme={null} const statement = { id: "STATEMENT", name: "Statement", description: "A bank statement.", partition_key: "account_number" }; const check = { id: "CHECK", name: "Check", description: "A scanned check." }; const builder = af.workflow("Statement batch").parse().split([statement, check]); ``` ### extract Adds an [Extract](/guides/nodes/extract) node. Repeatable: one per branch. `fields` is an array built with `Schema`. ```typescript theme={null} const builder = af .workflow("Invoice or receipt") .parse() .classify([invoice, receipt]) .extract([Schema.string("vendor", "Vendor name.")], { branch: invoice }) // object form .extract([Schema.string("merchant", "Merchant name.")], { branch: "RECEIPT" }); // id form ``` | Option | Default | What it does | | ----------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `branch` | unset | The category or rule this extract hangs off. Required after `classify()` or `split()`, forbidden otherwise. A `ClassifyCategory`, a `SplitterRule`, or its id. | | `mode` | `"standard"` on the server | The extraction tier. One of `"standard"`, `"agentic"`, `"lite"`. | | `useImages` | `false` on the server | Send each PDF page's rendered image alongside its parsed text. Standard and lite only. | | `lookupFiles` | unset | Paths of [Smart Lookup](/guides/nodes/smart-lookup) reference files. The SDK reads each one from disk (Node.js only) and sends it inline as `lookup_file_uploads`. | | `lookupSuggestion` | unset | Free-form hint for the lookup matcher. | | `lookupReasoningEffort` | unset | `"minimal"`, `"low"`, `"medium"` or `"high"`. Used only when the node has a lookup field. | | `id` | auto | Node id. | `Schema` has one factory per [field type](/concepts/field-types): `string`, `integer`, `float`, `boolean`, `date`, `datetime`, `enum(name, description, options)`, `multiSelect(name, description, options)`, and `object(name, description, fields)`. `Schema.option(name, description)` builds an enum option. Every factory takes a trailing `{ source, persistentId }`: `source: "smart_lookup"` or `"lookup_if_missing"` for a lookup field, `persistentId` to pin a field's identity across updates. ### validate Adds a [Validate](/guides/nodes/validate) node after the most recent `extract()`, or after the extract on `{ branch }`. Takes an array of `ValidationRule` objects. ```typescript theme={null} const builder = af .workflow("Invoice with checks") .parse() .extract([ Schema.float("subtotal", "Subtotal before tax."), Schema.float("tax", "Tax amount."), Schema.float("total", "Grand total."), ]) .validate([ { id: "vendor-legit", description: "The vendor is a real, named company." }, { id: "totals-add-up", kind: "deterministic", check: { type: "arithmetic", operands: ["subtotal", "tax"], equals: "total", tolerance: 0.01 }, }, ]); ``` ## Run and read ### Upload and run `workflow.run(files, opts?)` uploads one `File` or an array of up to 10 as one [document packet](/concepts/document-packets) and starts a run. Every file needs a name so the server can detect its type: pass a `File`, not a bare `Blob`. ```typescript theme={null} const run = await workflow.run(new File([bytes], "invoice.pdf")); const run = await workflow.run([fileA, fileB]); // one packet, one document const run = await workflow.run(file, { idempotencyKey: "ingest-2026-08-25-0001" }); ``` Filenames are unique within a workflow. A colliding name is rejected with `409`; pass `{ onConflict: "rename" }` to auto-rename instead. To upload without spending credits, use `workflow.upload(files, opts?)`. It returns an `Upload` with `documentPacketId` and `files`; call `upload.run()` later. `workflow.uploadFromUrls(urls, opts?)` imports up to 10 HTTPS URLs server-side into one packet. ```typescript theme={null} const upload = await workflow.upload([fileA, fileB]); const run = await upload.run({ idempotencyKey: "run-2026-08-25-0001" }); const imported = await workflow.uploadFromUrls(["https://example.com/invoices/april.pdf"]); ``` Retrying with the same `idempotencyKey` replays the original upload or run instead of creating and billing a second one. ### wait `run.wait({ timeoutMs, pollMs })` polls `GET /v3/runs/{run_id}/` until the status is terminal and resolves with a `Result`. Defaults: 300 000 ms budget, 3 000 ms between polls. A transient `429` or `5xx` while polling is retried with backoff and honours `Retry-After`. `wait()` rejects with `ExtractionFailedError` on `error`, `ExtractionCancelledError` on `cancelled`, and `SDKTimeoutError` when the budget runs out. ```typescript theme={null} const result = await run.wait({ timeoutMs: 600_000 }); ``` ### Result | Member | Type | What it holds | | ------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `result.field(name)` | `ExtractedField \| ExtractedRows \| undefined` | One top-level field of the untagged extraction. A scalar field is an `ExtractedField` with `.value`, `.confidence` (0 to 100) and `.evidence`; an object field is an array of row records. `undefined` only when the field is absent. Type a scalar with `result.field("total")`. | | `result.extractions` | `Extraction[]` | Every extraction, including per-partition ones from a Split node (`split_name`, `partition`, `fields`). | | `result.parse` | `ParseView \| null` | The Parse node's output: `.markdown`, `.text`, `.blocks`, `.parseConfidence`, `.layoutConfidence`. | | `result.documentPacketId` | `string` | The packet this result belongs to. | | `result.raw` | `ResultsResponseV3` | The full results envelope, including `classifications`, `splits` and `edits`. | ```typescript theme={null} const result = await run.wait(); const vendor = result.field("vendor"); console.log(vendor?.value, vendor?.confidence); for (const row of result.field("line_items") as ExtractedRows) { console.log(row.sku.value, row.amount.value); } for (const extraction of result.extractions) { if (extraction.partition) console.log(extraction.split_name, extraction.partition); } for (const c of result.raw.classifications ?? []) console.log(c.category, c.confidence); ``` `ParseView` has no `draw()`; rendering pages needs PDF rasterising, which only the Python SDK ships. ### Quick parse For a one-off parse with no workflow, `af.parse(file)` runs the atomic parse operation and returns a run id. `af.getParseMarkdown(runId)` returns the markdown once the run is processed, or `null` while it is still running. It throws `ExtractionFailedError` or `ExtractionCancelledError` if the run ends that way. ```typescript theme={null} const runId = await af.parse(file); let markdown = await af.getParseMarkdown(runId); while (markdown === null) { await new Promise((r) => setTimeout(r, 3000)); markdown = await af.getParseMarkdown(runId); } ``` ## Document packets and runs Packets and runs stay addressable after `wait()` resolves. | Method | Resolves to | Notes | | --------------------------------------------------------- | ------------------------------------- | ---------------------------------------------------------------- | | `af.getDocumentPacket(packetId)` | `DocumentPacketDetailV3` | `.status`, `.files`, `.latest_run_id`. | | `af.runDocumentPacket(packetId, { idempotencyKey? })` | `Run` | Re-run on the workflow's latest version. Each call is a new run. | | `af.deleteDocumentPacket(packetId)` | `string` | Irreversible. | | `af.listDocumentPackets(workflowId, { limit?, cursor? })` | `Page` | Newest first. | | `af.getRun(runId)` | `RunDetailV3` | `.status`, and `.results` inline once `processed`. | | `af.listRuns(workflowId, { limit?, cursor? })` | `Page` | Newest first. | | `af.iterRuns(workflowId, { limit? })` | `AsyncIterableIterator` | Follows the cursor for you. | Packet statuses: `not_started`, `queued`, `in_progress`, then `processed`, `error` or `cancelled`. Run statuses are the same set without `not_started`. ```typescript theme={null} const packet = await af.getDocumentPacket(run.documentPacketId); console.log(packet.status, packet.latest_run_id); for await (const summary of af.iterRuns(workflow.id)) { console.log(summary.id, summary.status); } ``` ### Manage workflows | Method | Resolves to | Notes | | ------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `af.getWorkflow(workflowId)` | `WorkflowDetailV3` | The typed graph inline. Fields carry their `persistent_id`. | | `af.updateWorkflow(workflowId, definition)` | `WorkflowDetailV3` | Replaces the graph. `definition` is `{ name, description, nodes, edges }`. Echo each `persistent_id` to keep field identity across renames. | | `af.listWorkflows({ limit?, cursor? })` | `Page` | Each item is a `Workflow` you can `.run()`. | | `af.iterWorkflows({ limit? })` | `AsyncIterableIterator` | Follows the cursor. | | `af.deleteWorkflow(workflowId)` | `string` | Deletes its packets and runs too. | ```typescript theme={null} const detail = await af.getWorkflow(workflow.id); for (const node of detail.nodes) { if (node.type === "extract") { for (const field of node.extraction_schema.fields) { if (field.name === "vendor") field.name = "vendor_name"; // persistent_id travels with it } } } await af.updateWorkflow(workflow.id, detail); ``` ## Knowledge ask The TypeScript client has no `ask()` method yet. Call [Ask](/api-reference-v3/knowledge/ask) with `fetch`: ```typescript theme={null} const res = await fetch(`https://api.anyformat.ai/v3/workflows/${workflow.id}/knowledge/ask`, { method: "POST", headers: { Authorization: `Bearer ${process.env.ANYFORMAT_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ question: "Which contracts renew before March?" }), }); const answer = await res.json(); ``` ## Evals and datasets A [dataset](/concepts/evals) holds documents with known answers. Upload one document, with optional ground truth, as one packet: ```typescript theme={null} const entry = await workflow.uploadToDataset(new File([bytes], "invoice.pdf"), { groundTruth: { vendor: "ACME", total: "42.00", line_items: [{ sku: "A1", qty: "2" }] }, }); console.log(entry.documentPacketId, entry.groundTruthSaved); ``` `groundTruth` keys are the schema's field identifiers. A scalar maps to `string | null`; an object field maps to an array of row objects. The call is atomic: a rejected file or a ground-truth failure stores nothing. Bulk upload loops one atomic call per document: ```typescript theme={null} await workflow.uploadDocumentsToDataset([ { files: new File([a], "a.pdf"), groundTruth: { vendor: "ACME" } }, { files: [new File([b1], "b1.pdf"), new File([b2], "b2.pdf")] }, ]); ``` Both are also on the client: `af.uploadToDataset(workflowId, files, opts)` and `af.uploadDocumentsToDataset(workflowId, documents)`. The client has no eval methods yet; launch and read evals through the [endpoints](/api-reference-v3/evals/launch). ## Pagination Every list resolves to a `Page` with `items` and `nextCursor`. Pass `nextCursor` back as `cursor` until it is `null`. There are no totals or page numbers; `limit` is capped at 100. ```typescript theme={null} let cursor: string | undefined; do { const page = await af.listRuns(workflow.id, { limit: 100, cursor }); for (const run of page.items) console.log(run.id, run.status); cursor = page.nextCursor ?? undefined; } while (cursor); ``` The `iter*` methods (`iterWorkflows`, `iterDocumentPackets`, `iterRuns`) do this loop for you and fetch each page lazily. ## Errors and retries Every HTTP failure rejects with a subclass of `APIError` carrying the v3 [error envelope](/api-reference-v3/introduction#errors): `.status`, `.errorCode`, `.retryable`, `.requestId` and the raw `.body`. | Status | Class | Retry? | | ------ | ---------------------- | --------------------------------------------- | | 400 | `BadRequestError` | no. `VALIDATION_ERROR` or `TOPOLOGY_INVALID`. | | 401 | `UnauthorizedError` | no | | 402 | `PaymentRequiredError` | no. Top up credit first. | | 403 | `ForbiddenError` | no | | 404 | `NotFoundError` | no | | 422 | `APIError` | no | | 429 | `RateLimitedError` | yes, after `Retry-After` | | 5xx | `ServerError` | yes | Outside HTTP, all subclasses of `AnyformatError`: `RunFailedError` when `wait()` sees a terminal failure, subclassed as `ExtractionFailedError` (`error`) and `ExtractionCancelledError` (`cancelled`), each with `.runId` and `.status`; `SDKTimeoutError` when `wait()` runs out of budget; `WorkflowBuilderError` for builder misuse. ```typescript theme={null} import { APIError, RateLimitedError, RunFailedError, SDKTimeoutError } from "@anyformat/sdk"; try { const result = await (await workflow.run(file)).wait({ timeoutMs: 120_000 }); } catch (err) { if (err instanceof RateLimitedError) { // back off, then retry } else if (err instanceof RunFailedError) { console.error(`run ${err.runId} ended ${err.status}`); } else if (err instanceof SDKTimeoutError) { // still running; poll af.getRun() later } else if (err instanceof APIError) { console.error(err.status, err.errorCode, err.requestId, err.body); } else { throw err; } } ``` `wait()` retries a transient `429` or `5xx` itself, so those never reach your `catch` while polling. ## Version pins | Package | Version | API | | ---------------------- | ------- | ------------------------------------------------------------------------------------------- | | `@anyformat/sdk` | 1.x | v3. | | `@anyformat/sdk` 0.2.x | legacy | v2. Keeps working through the [v2 deprecation window](/api-reference-v3/migrating-from-v2). | Upgrading from 0.x changes uploads, runs, pagination and workflow updates. Follow the [0.x to 1.0 migration guide](/api-reference-v3/sdk-migration). ## Links * [npm](https://www.npmjs.com/package/@anyformat/sdk) * [Python SDK](/api-reference-v3/sdks/python): the same shape in Python * [Node schemas](/api-reference-v3/node-schemas): every option the builder maps to # Create Workflow Source: https://docs.anyformat.ai/api-reference-v3/workflows/create POST /v3/workflows/ Create a workflow from a typed graph of nodes (parse, extract, classify, splitter, validate, if_else, slack_alert, edit, knowledge) in a single atomic transaction *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Creates a workflow from a strongly-typed graph, in one atomic transaction. The body is `{name, description, nodes, edges}`. The node types are parse, extract, classify, splitter, validate, if\_else, slack\_alert, edit and knowledge. [Nodes](/guides/nodes/overview) says what each one does, [Node schemas](/api-reference-v3/node-schemas) lists every node's fields, and [Field types](/concepts/field-types) covers the field types. A body that violates graph topology is rejected with `400 TOPOLOGY_INVALID`. A missing parse node and an orphaned extract node both violate it. `detail.violations` lists each broken rule with the offending node ids. Fetch the stored graph back with [`GET /v3/workflows/{workflow_id}/`](/api-reference-v3/workflows/get). That response carries a server-assigned `persistent_id` on every field. Build the workflow in the [anyformat platform](https://app.anyformat.ai) instead, and iterate faster: configure fields visually and test against sample documents. Then copy the workflow id and run documents through the API. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v3/workflows/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "name": "Invoice Processing", "description": "Extract data from invoice documents", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "invoice_number", "description": "The unique invoice identifier", "data_type": "string" }, { "name": "total_amount", "description": "Total invoice amount including tax", "data_type": "float" } ] } } ], "edges": [ { "source": "parse_1", "target": "extract_1" } ] }' ``` ```python Python (requests) theme={null} import requests url = "https://api.anyformat.ai/v3/workflows/" headers = {"Authorization": "Bearer YOUR_API_KEY"} body = { "name": "Invoice Processing", "description": "Extract data from invoice documents", "nodes": [ {"id": "parse_1", "type": "parse"}, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ {"name": "invoice_number", "description": "The unique invoice identifier", "data_type": "string"}, {"name": "total_amount", "description": "Total invoice amount including tax", "data_type": "float"}, ] }, }, ], "edges": [{"source": "parse_1", "target": "extract_1"}], } workflow = requests.post(url, headers=headers, json=body).json() print(workflow["id"]) ``` ```json Response (201 Created) theme={null} { "id": "0686bb97-8c30-70f0-8000-97669e000eb8", "name": "Invoice Processing", "description": "Extract data from invoice documents", "created_at": "2026-07-01T12:00:00.000Z", "updated_at": "2026-07-01T12:00:00.000Z" } ``` ```json Response (400, invalid topology) theme={null} { "error": "Validation failed", "detail": { "message": "Workflow graph has 1 violation", "violations": [ { "rule": "single_parse_entrypoint", "message": "Exactly one parse node is required", "node_ids": [] } ] }, "error_code": "TOPOLOGY_INVALID", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` # Delete Workflow Source: https://docs.anyformat.ai/api-reference-v3/workflows/delete DELETE /v3/workflows/{workflow_id}/ Delete a workflow and all associated document packets and runs *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Deletes a workflow along with **all of its document packets and runs**. They leave the API at once, and their content stays in storage for a grace window of 14 days, or 24 hours for an organization with zero data retention. Only after that window does the content become eligible for a permanent purge. The API offers no call that undoes a delete. Returns `204 No Content` on success. An unknown id returns `404`, as does a workflow belonging to another organization. ```bash curl theme={null} curl -X DELETE 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" url = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/" headers = {"Authorization": "Bearer YOUR_API_KEY"} response = requests.delete(url, headers=headers) assert response.status_code == 204 ``` # Get Workflow Source: https://docs.anyformat.ai/api-reference-v3/workflows/get GET /v3/workflows/{workflow_id}/ Retrieve a workflow with its complete typed graph inline *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Returns a workflow with its **complete typed graph inline**. v3 has no separate `/definition/` endpoint: this read *is* the round-trippable definition. The `{name, description, nodes, edges}` subset is the exact shape [`POST /v3/workflows/`](/api-reference-v3/workflows/create) accepts. Strip the read-only `id`, `created_at` and `updated_at`, then feed the remainder to [`PATCH /v3/workflows/{workflow_id}/`](/api-reference-v3/workflows/update). Its Python example shows the one-liner. An unknown field is rejected with `400` `VALIDATION_ERROR`. Every field in an extract node carries its server-assigned `persistent_id`, the stable identity that survives a rename. Echo it unchanged when you PATCH, and analytics, ground truth and quality metrics stay attached to the field. See [the migration guide](/api-reference-v3/migrating-from-v2#put-to-patch-field-identity-round-trips). ## Reading an earlier version The response is the latest version by default. Pass `?version=` to read an earlier version's graph in the same shape. Take the `version_id` from [`GET /v3/workflows/{workflow_id}/versions/`](/api-reference-v3/workflows/versions). An unknown `version_id` answers `404` with a `detail` naming it. Any other query parameter is rejected with `400` `VALIDATION_ERROR`, so a misspelled `version` cannot silently read the latest. Versions are read-only, for comparison and audit. A run always uses the latest version, and a PATCH always builds on it. The [versions page](/api-reference-v3/workflows/versions#compare-two-versions) shows how to diff two versions' field descriptions. ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```bash curl (one version) theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/?version=AbCdEfGhIj' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" url = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/" headers = {"Authorization": "Bearer YOUR_API_KEY"} workflow = requests.get(url, headers=headers).json() for node in workflow["nodes"]: print(node["id"], node["type"]) ``` ```json Response (200 OK) theme={null} { "id": "0686bb97-8c30-70f0-8000-97669e000eb8", "name": "Invoice Processing", "description": "Extract data from invoice documents", "created_at": "2026-07-01T12:00:00.000Z", "updated_at": "2026-07-01T12:00:00.000Z", "nodes": [ { "id": "parse_1", "type": "parse", "mode": "standard", "prompt_hint": null, "figure_enhancement": false, "cache": true, "effort": "mid", "scanned_pages": "ocr" }, { "id": "extract_1", "type": "extract", "mode": "standard", "extraction_schema": { "fields": [ { "name": "invoice_number", "description": "The unique invoice identifier", "data_type": "string", "persistent_id": "8f14e45f-ceea-467f-a34e-9b6e2f7d3c1a" }, { "name": "total_amount", "description": "Total invoice amount including tax", "data_type": "float", "persistent_id": "45c48cce-2e2d-4fbd-aa47-1a8a5d8f6b2b" } ] }, "lookup_files": [], "lookup_suggestion": null, "lookup_reasoning_effort": null, "lookup_file_uploads": [], "use_images": false } ], "edges": [ { "source": "parse_1", "target": "extract_1", "branch": null } ] } ``` # List Workflows Source: https://docs.anyformat.ai/api-reference-v3/workflows/list GET /v3/workflows/ List your organization's workflows, newest first *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Returns one keyset page of your organization's workflows, newest first. Follow `next_cursor` until it is `null`. The response carries no totals and no page numbers. See [Pagination](/api-reference-v3/introduction#pagination). Each item is a slim summary. For the full typed graph, fetch [`GET /v3/workflows/{workflow_id}/`](/api-reference-v3/workflows/get). ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/?limit=20' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests url = "https://api.anyformat.ai/v3/workflows/" headers = {"Authorization": "Bearer YOUR_API_KEY"} cursor = None while True: params = {"limit": 20, **({"cursor": cursor} if cursor else {})} page = requests.get(url, headers=headers, params=params).json() for workflow in page["items"]: print(workflow["id"], workflow["name"]) cursor = page["next_cursor"] if cursor is None: break ``` ```typescript TypeScript theme={null} interface WorkflowSummary { id: string; name: string; description: string | null; created_at: string | null; updated_at: string | null; } interface WorkflowListPage { items: WorkflowSummary[]; next_cursor: string | null; } let cursor: string | null = null; do { const params = new URLSearchParams({ limit: '20' }); if (cursor) params.set('cursor', cursor); const response = await fetch(`https://api.anyformat.ai/v3/workflows/?${params}`, { headers: { 'Authorization': 'Bearer YOUR_API_KEY' }, }); const page: WorkflowListPage = await response.json(); page.items.forEach(w => console.log(w.id, w.name)); cursor = page.next_cursor; } while (cursor !== null); ``` ```json Response (200 OK) theme={null} { "items": [ { "id": "0686bb97-8c30-70f0-8000-97669e000eb8", "name": "Invoice Processing", "description": "Extract data from invoice documents", "created_at": "2026-07-01T12:00:00.000Z", "updated_at": "2026-07-01T12:00:00.000Z" } ], "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0wMVQxMjowMDowMFoifQ" } ``` # Update Workflow Source: https://docs.anyformat.ai/api-reference-v3/workflows/update PATCH /v3/workflows/{workflow_id}/ Replace a workflow's typed graph atomically, echoing persistent_id to keep field identity across renames *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Replaces a workflow's typed graph, atomically. The body is the `{name, description, nodes, edges}` sub-shape of [`GET /v3/workflows/{workflow_id}/`](/api-reference-v3/workflows/get). The intended loop is **GET, mutate, PATCH**. Each update mints a new workflow version. Earlier versions stay available for historical runs. v3 has no `PUT`. ## Field identity: `persistent_id` Every field in the GET response carries a server-assigned `persistent_id`. When you PATCH: * **Echo `persistent_id` unchanged**, including when you rename the field. The field then keeps its identity: analytics history, ground truth and quality metrics stay attached. * **Omit it only for a brand-new field**, and the server assigns one. A field without `persistent_id` is always new. A name that matches a prior field confers **no** identity, so omitting it on an existing field replaces that field and detaches its history. * An echoed `persistent_id` that matches no field in the current version is rejected with `400`. * Sending back an **unchanged graph** cuts no new version. The response is the updated workflow in the exact GET shape, including the `persistent_id`s just assigned to new fields. Edit that response and PATCH again. The echoed graph is this request's own write result, never the state left by a concurrent update. An invalid graph is rejected with `400 TOPOLOGY_INVALID`, on the same contract as [Create workflow](/api-reference-v3/workflows/create). The stored workflow stays untouched. ```bash curl theme={null} curl -X PATCH 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "name": "Invoice Processing", "description": "Extract data from invoice documents", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "invoice_reference", "description": "The unique invoice identifier", "data_type": "string", "persistent_id": "8f14e45f-ceea-467f-a34e-9b6e2f7d3c1a" } ] } } ], "edges": [ { "source": "parse_1", "target": "extract_1" } ] }' ``` ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" url = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/" headers = {"Authorization": "Bearer YOUR_API_KEY"} # GET → mutate → PATCH: rename a field, keep its persistent_id. workflow = requests.get(url, headers=headers).json() field = workflow["nodes"][1]["extraction_schema"]["fields"][0] field["name"] = "invoice_reference" # persistent_id stays, so identity survives the rename body = {k: workflow[k] for k in ("name", "description", "nodes", "edges")} updated = requests.patch(url, headers=headers, json=body).json() print(updated["updated_at"]) ``` ```json Response (200 OK) theme={null} { "id": "0686bb97-8c30-70f0-8000-97669e000eb8", "name": "Invoice Processing", "description": "Extract data from invoice documents", "created_at": "2026-07-01T12:00:00.000Z", "updated_at": "2026-07-05T09:30:00.000Z", "nodes": [ { "id": "parse_1", "type": "parse", "mode": "standard", "prompt_hint": null, "figure_enhancement": false, "cache": true, "effort": "mid", "scanned_pages": "ocr" }, { "id": "extract_1", "type": "extract", "mode": "standard", "extraction_schema": { "fields": [ { "name": "invoice_reference", "description": "The unique invoice identifier", "data_type": "string", "persistent_id": "8f14e45f-ceea-467f-a34e-9b6e2f7d3c1a" } ] }, "lookup_files": [], "lookup_suggestion": null, "lookup_reasoning_effort": null, "lookup_file_uploads": [], "use_images": false } ], "edges": [ { "source": "parse_1", "target": "extract_1", "branch": null } ] } ``` # Upload Document Packet Source: https://docs.anyformat.ai/api-reference-v3/workflows/upload POST /v3/workflows/{workflow_id}/upload/ Upload one or more files as a single document packet, without running it *Rate limit tier: **submission**, 60 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Uploads 1–100 files as a single [document packet](/concepts/document-packets), the unit a run addresses. Every file lands in **one** packet. Creation is all-or-nothing: one rejected file fails the whole request and stores nothing. An unsupported type and disguised bytes both cause a rejection. This endpoint uploads without processing. Trigger extraction afterwards with [`POST /v3/document-packets/{document_packet_id}/run/`](/api-reference-v3/document-packets/run). To do both in one call, use [Upload and Run](/api-reference-v3/workflows/upload-and-run). Send the files as multipart form data under the `files` field, repeating the field for a multi-file packet. A file above the per-file size cap is rejected before any bytes reach S3. See [Files](/concepts/files) for the cap and the supported formats. Bundle several files only when they form one document, such as a contract and its annexes. Put unrelated documents in separate packets. **Retries are safe with `Idempotency-Key`.** Pass any unique string. Retrying the request with the same key replays the original upload, so the API creates no duplicate packet. See [Idempotency](/api-reference-v3/introduction#idempotency). **Filenames are unique within a workflow.** The `on_conflict` form field decides what a collision does: * **`error`**, the default. The whole request fails with `409` before anything is uploaded. The response lists the conflicting names and the name each one would take. * **`rename`**. The colliding file gets a ` (n)` counter before its extension, so `contract.pdf` becomes `contract (1).pdf`. Each returned file's `name` is then the name it was stored under, and `original_name` holds the name you uploaded. `original_name` is `null` when no rename happened. **Attach caller context with the optional `metadata` form field.** It takes a JSON-encoded object, sent as a string because multipart carries no nested objects. [`GET /v3/document-packets/{document_packet_id}/`](/api-reference-v3/document-packets/get) echoes it back. A top-level key whose name matches an extract-schema field reaches the LLM and becomes the extracted value. See [Attaching metadata](/concepts/document-packets#attaching-metadata) for the full contract. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/upload/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Idempotency-Key: 5f2b6c3e-8a1d-4f7e-9c0b-1a2b3c4d5e6f' \ -F 'files=@/path/to/contract.pdf' \ -F 'files=@/path/to/annex-a.pdf' \ -F 'metadata={"customer_reference": "CR-2026-99001-ZTX"}' ``` ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" url = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/upload/" headers = { "Authorization": "Bearer YOUR_API_KEY", "Idempotency-Key": "5f2b6c3e-8a1d-4f7e-9c0b-1a2b3c4d5e6f", } with open("contract.pdf", "rb") as f1, open("annex-a.pdf", "rb") as f2: files = [("files", f1), ("files", f2)] packet = requests.post(url, headers=headers, files=files).json() print(packet["document_packet_id"]) ``` ```typescript TypeScript theme={null} interface DocumentPacketCreated { document_packet_id: string; workflow_id: string; files: { id: string; name: string; original_name: string | null }[]; } const workflowId = '0686bb97-8c30-70f0-8000-97669e000eb8'; const formData = new FormData(); formData.append('files', fileInput.files[0]); const response = await fetch( `https://api.anyformat.ai/v3/workflows/${workflowId}/upload/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Idempotency-Key': crypto.randomUUID(), }, body: formData, } ); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const packet: DocumentPacketCreated = await response.json(); console.log(packet.document_packet_id); ``` ```json Response (201 Created) theme={null} { "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "files": [ { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e2", "name": "contract.pdf", "original_name": null }, { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e3", "name": "annex-a.pdf", "original_name": null } ] } ``` # Upload and Run Source: https://docs.anyformat.ai/api-reference-v3/workflows/upload-and-run POST /v3/workflows/{workflow_id}/upload/run/ Upload a document packet and start a run in a single call *Rate limit tier: **submission**, 60 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Uploads 1–100 files as a single [document packet](/concepts/document-packets) and enqueues a [run](/concepts/runs-and-results) on the workflow's latest version. It composes [Upload](/api-reference-v3/workflows/upload) and [Run packet](/api-reference-v3/document-packets/run) into one call, the shortest path from bytes to structured data. The `202` response returns the new `run_id`. Poll [`GET /v3/runs/{run_id}/`](/api-reference-v3/runs/get) until `status` is terminal. The results arrive inline on that same read. Send the files as multipart form data under the `files` field, repeating it for a multi-file packet. A file above the per-file size cap is rejected before any bytes reach S3. Packet creation is all-or-nothing. **Retries are safe with `Idempotency-Key`.** Retrying with the same key replays the original packet **and** run. The API stores no duplicate upload and bills no second extraction. See [Idempotency](/api-reference-v3/introduction#idempotency). **Filenames are unique within a workflow.** The `on_conflict` form field decides what a collision does. Under the default, `error`, a colliding filename fails the request with `409` before anything is uploaded or run. Pass `rename` to rename instead, so `invoice.pdf` becomes `invoice (1).pdf`. This `202` response returns only the `run_id`, so read the stored names from [`GET /v3/document-packets/{document_packet_id}/`](/api-reference-v3/document-packets/get). **Attach caller context with the optional `metadata` form field.** It takes a JSON-encoded object, sent as a string because multipart carries no nested objects. The value flows into this run's extract prompt as a "Document Metadata" section. A top-level key whose name matches an extract-schema field reaches the LLM and becomes the extracted value. [`GET /v3/document-packets/{document_packet_id}/`](/api-reference-v3/document-packets/get) echoes the metadata back. See [Attaching metadata](/concepts/document-packets#attaching-metadata). An organization without extraction credit receives `402 PAYMENT_REQUIRED`. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/upload/run/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Idempotency-Key: 7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f' \ -F 'files=@/path/to/invoice.pdf' \ -F 'metadata={"customer_reference": "CR-2026-99001-ZTX"}' ``` ```python Python (requests) theme={null} import requests import time workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" base = "https://api.anyformat.ai" headers = {"Authorization": "Bearer YOUR_API_KEY"} with open("invoice.pdf", "rb") as f: triggered = requests.post( f"{base}/v3/workflows/{workflow_id}/upload/run/", headers={**headers, "Idempotency-Key": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"}, files=[("files", f)], ).json() run_url = f"{base}/v3/runs/{triggered['run_id']}/" for _ in range(100): run = requests.get(run_url, headers=headers).json() if run["status"] == "processed": print(run["results"]) break if run["status"] in ("error", "cancelled"): print(f"Terminal: {run['status']}") break time.sleep(3) ``` ```typescript TypeScript theme={null} interface RunTriggered { run_id: string; document_packet_id: string; workflow_id: string; status: 'queued' | 'in_progress' | 'processed' | 'error' | 'cancelled'; } const workflowId = '0686bb97-8c30-70f0-8000-97669e000eb8'; const formData = new FormData(); formData.append('files', fileInput.files[0]); const response = await fetch( `https://api.anyformat.ai/v3/workflows/${workflowId}/upload/run/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Idempotency-Key': crypto.randomUUID(), }, body: formData, } ); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const triggered: RunTriggered = await response.json(); console.log(triggered.run_id); ``` ```json Response (202 Accepted) theme={null} { "run_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "status": "queued" } ``` # Upload from URL Source: https://docs.anyformat.ai/api-reference-v3/workflows/upload-from-url POST /v3/workflows/{workflow_id}/upload/from-url/ Create a document packet by importing 1–100 HTTPS URLs server-side, all-or-nothing *Rate limit tier: **submission**, 60 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Creates a [document packet](/concepts/document-packets) from bytes anyformat fetches server-side. Use it when the documents already live at a public HTTPS URL, or in object storage you can presign such as S3, GCS or R2. Nothing streams through your own backend. Provide 1–100 **HTTPS** URLs. They all import into a **single packet, atomically**. The API registers the packet only after every fetch succeeds, so one failure imports nothing: no partial packet, and no orphan files. The response's `Content-Disposition` names each file, and the URL's path names it otherwise. See [File names](#file-names). Each fetch has a 10-second timeout and a 20 MB per-file cap. A failed, non-2xx, or timed-out fetch returns `422`, with the distinct failure reasons in `detail`. The API refuses a URL that resolves to a non-globally-routable address before it opens any connection. Loopback, private ranges, link-local and cloud-metadata addresses all qualify. The response is final: a `201` means the packet is fully imported. An import that runs past the gateway's 6-minute polling ceiling fails with `504` and `retryable: true`. Retrying with the same request body is safe. Trigger extraction with [`POST /v3/document-packets/{document_packet_id}/run/`](/api-reference-v3/document-packets/run). **Filenames are unique within a workflow.** The `on_conflict` body field decides what a collision does: * **`error`**, the default. The request fails with `409` once the fetches settle, listing the conflict and its suggested rename. * **`rename`**. The server renames the file, so `april.pdf` becomes `april (1).pdf`. Each returned `name` is then the name the file was stored under, and `original_name` holds the name it was fetched under. **Attach caller context with the optional `metadata` body field.** It takes a JSON object stapled to the packet. [`GET /v3/document-packets/{document_packet_id}/`](/api-reference-v3/document-packets/get) echoes it back. A top-level key whose name matches an extract-schema field reaches the LLM and becomes the extracted value. See [Attaching metadata](/concepts/document-packets#attaching-metadata) for the full contract. ## File names The response names a from-url file, not the request. The name matters twice. It carries the extension the import requires, and a URL whose path is an opaque key carries none. It is also the name you and your users see wherever the file is listed. The precedence is: 1. The `Content-Disposition` filename on the response, per [RFC 6266](https://www.rfc-editor.org/rfc/rfc6266). `filename*=UTF-8''…` wins over `filename=`, and any path is cut to its last segment. 2. Else the URL path's last segment: `https://example.com/invoices/april.pdf` imports as `april.pdf`. 3. Else the import fails with `422`. The name travels in the header rather than in a request field. HTTP already names a downloaded file there, and every object store signs that header into a presigned URL. The API therefore stays URL-only, with no parallel name list to match `urls` in length and order. On S3, `ResponseContentDisposition` signs it in: ```python boto3 theme={null} import boto3 s3 = boto3.client("s3") url = s3.generate_presigned_url( "get_object", Params={ "Bucket": "my-bucket", "Key": "uploads/7c1f3e9a", "ResponseContentDisposition": 'attachment; filename="april-invoice.pdf"', }, ExpiresIn=900, ) ``` GCS offers the same override through `response-content-disposition` on a V4 signed URL, and Azure through `rscd` on a SAS. Whichever name applies must end in a supported extension, such as `.pdf`, `.docx` or `.png`. A bare key such as `uploads/7c1f3e9a` names the file `7c1f3e9a`, which has no extension, so the import fails with `422`. The `on_conflict` policy applies to this name. On a rename, `original_name` in the response holds it. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/upload/from-url/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "urls": [ "https://example.com/invoices/april.pdf", "https://example.com/invoices/april-annex.pdf" ], "metadata": {"customer_reference": "CR-2026-99001-ZTX"} }' ``` ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" url = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/upload/from-url/" headers = {"Authorization": "Bearer YOUR_API_KEY"} body = { "urls": [ "https://example.com/invoices/april.pdf", "https://example.com/invoices/april-annex.pdf", ] } packet = requests.post(url, headers=headers, json=body).json() print(packet["document_packet_id"]) ``` ```json Response (201 Created) theme={null} { "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "files": [ { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e2", "name": "april.pdf", "original_name": null }, { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e3", "name": "april-annex.pdf", "original_name": null } ] } ``` ```json Response (422, a fetch failed and nothing imported) theme={null} { "error": "Remote fetch failed", "detail": "Remote server returned 404", "error_code": "VALIDATION_ERROR", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` # List Workflow Versions Source: https://docs.anyformat.ai/api-reference-v3/workflows/versions GET /v3/workflows/{workflow_id}/versions/ List a workflow's versions, newest first, for comparison and audit *Rate limit tier: **general**, 600 requests/min. See [Rate limits](/api-reference-v3/introduction#rate-limits).* Returns one keyset page of a workflow's versions, newest first. Follow `next_cursor` until it is `null`. The response carries no totals and no page numbers. See [Pagination](/api-reference-v3/introduction#pagination). Every save that changes the graph mints a new version. Item 0 of the first page is the version runs use. Each item carries the `version_id` and the display number `major.minor.patch`. To read a version's graph, pass its `version_id` as `?version=` on [`GET /v3/workflows/{workflow_id}/`](/api-reference-v3/workflows/get). **Versions are read-only.** A run always uses the latest version, and a [`PATCH`](/api-reference-v3/workflows/update) always builds on it. The API cannot run or restore an older version. An unknown workflow, or one that belongs to another organization, answers `404` with `error_code: NOT_FOUND`. ## Compare two versions List the versions, read two of them, and diff the field descriptions of the extract node: ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" base = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/" headers = {"Authorization": "Bearer YOUR_API_KEY"} versions = requests.get(f"{base}versions/", headers=headers).json()["items"] latest, previous = versions[0], versions[1] def field_descriptions(version_id): workflow = requests.get(base, headers=headers, params={"version": version_id}).json() extract = next(node for node in workflow["nodes"] if node["type"] == "extract") return {f["name"]: f["description"] for f in extract["extraction_schema"]["fields"]} before = field_descriptions(previous["version_id"]) after = field_descriptions(latest["version_id"]) for name in sorted(before.keys() | after.keys()): if before.get(name) != after.get(name): print(f"{name}: {before.get(name)!r} -> {after.get(name)!r}") ``` ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v3/workflows/0686bb97-8c30-70f0-8000-97669e000eb8/versions/?limit=20' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" url = f"https://api.anyformat.ai/v3/workflows/{workflow_id}/versions/" headers = {"Authorization": "Bearer YOUR_API_KEY"} cursor = None while True: params = {"limit": 20, **({"cursor": cursor} if cursor else {})} page = requests.get(url, headers=headers, params=params).json() for version in page["items"]: print(version["version_id"], version["version"], version["created_at"]) cursor = page["next_cursor"] if cursor is None: break ``` ```json Response (200 OK) theme={null} { "items": [ { "version_id": "FGaV4I2JAA", "version": "1.2.0", "created_at": "2026-07-03T09:00:00.000Z" }, { "version_id": "AbCdEfGhIj", "version": "1.1.0", "created_at": "2026-07-02T09:00:00.000Z" } ], "next_cursor": null } ``` ```json Response (404 Not Found) theme={null} { "error": "Resource not found", "detail": "Workflow not found", "error_code": "NOT_FOUND", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` # Authentication Source: https://docs.anyformat.ai/api-reference/authentication Authenticate your API requests using API keys ## Getting Your API Key 1. Log in to your [anyformat account](https://app.anyformat.ai). 2. Open the [API Key page](https://app.anyformat.ai/api-key), generate a key, and **copy it**. The page shows a key once. ## Authentication Method Use your API key in the `Authorization` header with Bearer format: ```bash theme={null} Authorization: Bearer YOUR_API_KEY ``` ## Using Your API Key Send your API key in the headers of every request: ```bash curl theme={null} curl "https://api.anyformat.ai/v2/workflows/" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```python Python (SDK) theme={null} import os from anyformat.sdk import Client # Pass explicitly client = Client(api_key="your_api_key_here") # Or read from the environment client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) ``` ```python Python (requests) theme={null} import requests headers = { "Authorization": "Bearer YOUR_API_KEY" } response = requests.get( "https://api.anyformat.ai/v2/workflows/", headers=headers ) ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.anyformat.ai/v2/workflows/', { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); const data = await response.json(); ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.anyformat.ai/v2/workflows/")) .header("Authorization", "Bearer YOUR_API_KEY") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); ``` ```go Go theme={null} package main import ( "net/http" ) func main() { client := &http.Client{} req, _ := http.NewRequest("GET", "https://api.anyformat.ai/v2/workflows/", nil) req.Header.Add("Authorization", "Bearer YOUR_API_KEY") resp, _ := client.Do(req) } ``` ```rust Rust theme={null} use reqwest; fn main() { let client = reqwest::blocking::Client::new(); let response = client.get("https://api.anyformat.ai/v2/workflows/") .header("Authorization", "Bearer YOUR_API_KEY") .send() .unwrap(); println!("{}", response.text().unwrap()); } ``` The API accepts the `Authorization` header in any casing, such as `authorization` or `AUTHORIZATION`. The HTTP specification makes header names case-insensitive. ## Verifying a Key Call [`GET /key-check/`](/api-reference/key-check) to confirm a key is active before your first real request. On success it returns `200` with the owning organization. Otherwise it returns the standard `401` error envelope, with `error_code` `MISSING_API_KEY` or `INVALID_API_KEY`. The call bills nothing and creates no run. ## API Key Scoping Each API key belongs to one organization. Every request made with that key operates inside that organization's data, so it reaches only that organization's workflows, files and webhooks. * A user who belongs to several organizations needs one API key per organization. * Generating a new API key revokes the previous key for the same organization. * An API key grants the same access level as the user who created it. Keys carry no granular scopes and no permission restrictions. API keys are managed in the [anyformat platform](https://app.anyformat.ai/api-key). The API does not expose key management endpoints. ## Public Endpoints These endpoints need no API key: * `/`, the API root * `/health/`, the health check * `/schema/`, the OpenAPI schema * `/docs/`, the Swagger UI documentation ## Revoking a Key ## Security Best Practices 1. **Never share your API key.** Treat it like a password. 2. **Rotate keys on a schedule.** Generate a new key regularly. 3. **Use environment variables.** Store keys in the environment in production. 4. **Monitor key usage.** Watch your account for suspicious activity. 5. **Never expose a key in client-side code.** Use an API key server-side only. If you suspect a key is compromised, open the [API Key page](https://app.anyformat.ai/api-key) at once and generate a new one. # Error Handling Source: https://docs.anyformat.ai/api-reference/errors Handle API errors with structured error responses and codes ## HTTP Status Codes | Status Code | Meaning | Description | | ----------- | --------------------- | ------------------------------------------------------------------------------------------------- | | `200` | OK | Request succeeded | | `201` | Created | Resource was successfully created | | `202` | Accepted | Request accepted for async processing | | `204` | No Content | Request succeeded with no response body | | `400` | Bad Request | Invalid request data or parameters | | `401` | Unauthorized | Missing API key, empty Bearer token, or a malformed or invalid API key | | `403` | Forbidden | Insufficient permissions, or the API key lacks the scope the call needs | | `404` | Not Found | Resource doesn't exist | | `412` | Precondition Failed | Results not complete yet. Poll again | | `422` | Unprocessable Entity | The request was well-formed but could not be processed. Read `error_code` for the specific reason | | `429` | Too Many Requests | Rate limit exceeded. Check the `Retry-After` header | | `500` | Internal Server Error | Unexpected server error | | `502` | Bad Gateway | Upstream service error | | `503` | Service Unavailable | Service temporarily unavailable | | `504` | Gateway Timeout | Backend did not respond in time | ## Error Response Format Every error response follows one JSON structure: ```json theme={null} { "error": "Brief, human-readable error description", "detail": "Detailed explanation of what went wrong", "error_code": "MACHINE_READABLE_ERROR_CODE", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` | Field | Description | | ------------ | -------------------------------------------------------------------------- | | `error` | Short, human-readable summary | | `detail` | Detailed explanation for debugging. A validation error sends an array here | | `error_code` | Machine-readable code for programmatic handling | | `retryable` | Whether the client should retry this request | | `request_id` | Unique request identifier for debugging and support | ## Error Codes Reference The status column is the registry default; a route may return a different status for the same code via a per-call override. | Error Code | Default HTTP Status | Retryable | Description | | -------------------------------------------- | ------------------- | --------- | ------------------------------------------------------------------------------- | | `ACCESS_DENIED` | 403 | No | You do not have access to this resource. | | `AGENTIC_MULTIFILE_UNSUPPORTED` | 409 | No | Agentic extraction cannot process a multi-file document. | | `ASSISTANT_SPEND_LIMIT_REACHED` | 429 | No | The organization has reached its daily spend limit for the assistant. | | `ATTACHMENT_LIMIT_EXCEEDED` | 422 | No | This would put too many attached files on the conversation. | | `AUTH_FAILED` | 401 | No | Authentication failed. | | `DATASET_DOCUMENT_REJECTED` | 422 | No | A file in the dataset document was rejected. | | `DATASET_GROUND_TRUTH_SAVE_FAILED` | 422 | No | The ground truth could not be saved. | | `DATASET_INVALID_REQUEST` | 422 | No | The dataset registration request is not valid. | | `EMPTY_EVAL` | 422 | No | The eval has no rows to run. | | `EXPORT_EXPIRED` | 410 | No | This export has expired. Run it again to get a fresh archive. | | `EXTRACTION_CANCELLED` | 422 | No | The extraction was cancelled. | | `EXTRACTION_FAILED` | 422 | No | The extraction failed. | | `EXTRACT_MODE_FORBIDDEN` | 403 | No | This organization cannot use that extract mode. | | `EXTRACT_NODE_UNRESOLVED` | 422 | No | The request does not say which extract node to use. | | `FILENAME_CONFLICT` | 409 | No | A file with that name already exists. | | `FILE_NOT_IN_DATASET` | 409 | No | The ground truth cites a file the dataset does not hold. | | `FILE_TOO_LARGE` | 413 | No | The file is larger than the maximum allowed size. | | `GATEWAY_TIMEOUT` | 504 | Yes | The upstream service did not answer in time. | | `GRAPH_READBACK_FAILED` | 502 | No | The workflow was saved and its graph could not be read back. | | `GT_INVALID` | 422 | No | The ground truth is not valid. | | `IDEMPOTENCY_KEY_REUSED` | 422 | No | The idempotency key was already used with a different request. | | `IMMUTABLE_WORKFLOW` | 409 | No | This is a system workflow and it cannot change. | | `INSUFFICIENT_CREDIT` | 402 | No | The organization has no extraction credit left. | | `INTEGRATION_NOT_CONNECTED` | 404 | No | The organization has no active integration. | | `INTERNAL_ERROR` | 500 | Yes | An internal error stopped the request. | | `INTERNAL_INCONSISTENCY` | 400 | No | The server found an internal inconsistency. | | `INVALID_API_KEY` | 401 | No | The API key is not valid. | | `INVALID_JSON` | 400 | No | The request body is not valid JSON. | | `INVALID_LOOKUP_FILE_ENCODING` | 400 | No | The lookup file uses an encoding the server cannot read. | | `INVALID_STATE` | 400 | No | The state value is not valid or it expired. | | `INVALID_VALUE` | 400 | No | A value in the request is not valid. | | `INVALID_WORKFLOW_CONFIG` | 400 | No | The workflow holds a node configuration that is not valid. | | `JOIN_REQUEST_CONFLICT` | 409 | No | A join request for this organization already exists. | | `KNOWLEDGE_NOT_ENABLED` | 409 | No | The knowledge base is not enabled. | | `KNOWLEDGE_NOT_READY` | 409 | Yes | The knowledge base is not ready yet. | | `KNOWLEDGE_THREAD_MISMATCH` | 409 | No | The thread belongs to another workflow. | | `KNOWLEDGE_UNSUPPORTED_SNAPSHOT_FORMAT` | 409 | No | The knowledge base's snapshot format is not supported. | | `LOOKUP_FIELD_WITHOUT_FILE` | 400 | No | A lookup field has no lookup file. | | `MEMBERSHIP_CONFLICT` | 409 | No | The user is already a member of this organization. | | `METHOD_NOT_ALLOWED` | 405 | No | The HTTP method is not allowed for this resource. | | `MISSING_API_KEY` | 401 | No | The request carries no API key. | | `NOT_ACCEPTABLE` | 406 | No | No representation matches the request's Accept header. | | `NOT_FOUND` | 404 | No | The resource does not exist. | | `NOT_IMPLEMENTED` | 501 | No | This endpoint is not implemented. | | `NOT_IN_DATASET` | 409 | No | The document is not in the workflow's dataset. | | `NO_ACTIVE_ORGANIZATION` | 400 | No | No active organization selected. | | `OBJECT_STORAGE_UNAVAILABLE` | 502 | Yes | Object storage did not complete the request. | | `OPERATOR_DEPRECATED` | 400 | No | This workflow version uses a deprecated operator and cannot run. | | `OPERATOR_NOT_AVAILABLE` | 400 | No | This workflow uses an operator that is no longer available. | | `OPTIMIZATION_ALREADY_RUNNING` | 409 | Yes | An optimization is already running for this workflow. | | `PACKET_CONTENT_PURGED` | 409 | No | The content of this document packet is permanently deleted. | | `PACKET_CONTENT_UNRECOVERABLE` | 409 | No | Some content of this document packet could not be brought back. | | `PACKET_NAME_CONFLICT` | 409 | No | A live document packet already holds this packet's name. | | `PACKET_RESTORE_WINDOW_EXPIRED` | 409 | No | The window to restore this document packet has passed. | | `PARSE_ERROR` | 400 | No | The server could not parse the request. | | `PARSE_MODE_FORBIDDEN` | 403 | No | This organization cannot use that parse mode. | | `PASSWORD_VALIDATION_FAILED` | 400 | No | The password does not meet the requirements. | | `PAYMENT_GATEWAY_ERROR` | 502 | Yes | The payment gateway did not complete the request. | | `PAYMENT_REQUIRED` | 402 | No | Payment is required to continue. | | `PERMANENT_REQUIRES_ANYFORMAT_OWNER` | 400 | No | Permanent debug is only available to an organization an AnyFormat user owns. | | `PRECONDITION_FAILED` | 412 | Yes | The results are not available yet. | | `RATE_LIMITED` | 429 | Yes | Too many requests. | | `REQUEST_TOO_LARGE` | 400 | No | The request body is larger than the maximum allowed size. | | `RUN_NOT_FINISHED` | 409 | Yes | The run has not finished yet. | | `SLACK_CHANNELS_FAILED` | 502 | Yes | Slack did not return the channels. | | `SLACK_CHANNEL_INACCESSIBLE` | 502 | No | The Slack channel is not accessible. | | `SLACK_EXCHANGE_FAILED` | 400 | No | The Slack token exchange failed. | | `SLACK_OAUTH_UNCONFIGURED` | 503 | No | Slack is not configured on this deployment. | | `SLACK_TEST_DELIVERY_FAILED` | 502 | Yes | Slack did not accept the test message. | | `SPLIT_WORKFLOW_DATASET_UNSUPPORTED` | 409 | No | Split workflows do not support datasets yet. | | `SUBSCRIPTION_ALREADY_ACTIVE` | 409 | No | The organization already has an active subscription. | | `TAG_NAME_CONFLICT` | 409 | No | A tag with that name already exists. | | `TOPOLOGY_INVALID` | 400 | No | The workflow topology is not valid. | | `UNKNOWN_FILTER_PARAM` | 422 | No | The request has an unknown filter parameter. | | `UNSUPPORTED_FILE_TYPE` | 400 | No | The file type is not supported. | | `UNSUPPORTED_LOOKUP_FILE` | 400 | No | The lookup file type is not supported. | | `UNSUPPORTED_MEDIA_TYPE` | 415 | No | The request media type is not supported. | | `UPLOAD_SNIFF_BUDGET_EXCEEDED` | 503 | Yes | The server could not check the uploaded files in time. | | `URL_UNREACHABLE` | 422 | No | The URL's hostname could not be resolved. | | `VALIDATION_ERROR` | 400 | No | The request failed validation. | | `WEBHOOK_SUBSCRIPTION_LIMIT` | 429 | No | The organization has reached its webhook subscription limit. | | `WORKFLOW_CONCURRENT_EDIT` | 503 | No | Another request is editing this workflow. | | `WORKFLOW_NAME_CONFLICT` | 409 | No | A workflow with that name already exists. | | `ZERO_DATA_RETENTION_REFUSES_LANGFUSE_DEBUG` | 400 | No | This organization keeps no data, so debug capture cannot start. | | `deprecated_operator_ack_required` | 409 | No | This workflow uses a deprecated operator, so the save needs an acknowledgement. | | `insufficient_api_key_scope` | 403 | No | The API key does not carry the scope this call needs. | | `stripe_customer_not_provisioned` | 409 | No | The organization has no billing account yet. | | `terms_acceptance_required` | 403 | No | You must accept the terms of service to continue. | | `too_many_in_request` | 409 | No | The request names more items than one call accepts. | | `voucher_already_redeemed_by_user` | 400 | No | You already redeemed this voucher. | | `voucher_expired` | 400 | No | This voucher expired or it reached its usage limit. | | `voucher_no_membership` | 403 | No | You are not a member of this organization. | | `voucher_not_found` | 400 | No | That voucher code does not exist. | | `voucher_only_for_new_orgs` | 400 | No | This voucher only applies to a new organization. | ### Authentication Errors **AUTH\_FAILED** (401). The API key is invalid. ```json theme={null} { "error": "Authentication failed", "detail": "Invalid or missing authentication credentials.", "error_code": "AUTH_FAILED", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` **MISSING\_API\_KEY** (401). The request has no `Authorization` header, or its Bearer token is empty. ```json theme={null} { "error": "The request carries no API key.", "detail": "No API key was provided. Send it as 'Authorization: Bearer '. Get a key at https://app.anyformat.ai/api-key.", "error_code": "MISSING_API_KEY", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` **ACCESS\_DENIED** (403). The API key is valid, but it has no access to this resource. ```json theme={null} { "error": "You do not have access to this resource.", "detail": "You do not have permission to perform this action.", "error_code": "ACCESS_DENIED", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` ### Validation Errors **VALIDATION\_ERROR** (400). The `detail` field holds an array of validation error objects. ```json theme={null} { "error": "Validation failed", "detail": [ {"type": "missing", "loc": ["body", "name"], "msg": "Field required"}, {"type": "missing", "loc": ["body", "fields"], "msg": "Field required"} ], "error_code": "VALIDATION_ERROR", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` **INVALID\_JSON** (400) ```json theme={null} { "error": "Invalid JSON format", "detail": "The request body contains invalid JSON. Please check your JSON syntax.", "error_code": "INVALID_JSON", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` ### Resource Errors **NOT\_FOUND** (404) ```json theme={null} { "error": "Resource not found", "detail": "The requested resource could not be found.", "error_code": "NOT_FOUND", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` ### Processing Status **PRECONDITION\_FAILED** (412). The results are not available yet. The `retryable` flag tells you whether to keep polling: * `retryable: true`. Processing is still running. Poll again with backoff. * `retryable: false`. Processing reached a terminal failure, `error` or `cancelled`. Stop polling. ```json theme={null} { "error": "Extraction not yet available", "detail": "Extraction status is 'processing'. Results are available when status is 'processed'.", "error_code": "PRECONDITION_FAILED", "retryable": true, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` ### Terminal Extraction Failures `GET /v2/workflows/{workflow_id}/files/{collection_id}/results/` returns these when the extraction reached a terminal state. Polling never moves the collection out of them. Stop polling and surface the failure to the caller. **EXTRACTION\_FAILED** (422). The extraction job did not complete successfully. ```json theme={null} { "error": "Extraction failed", "detail": "The extraction job did not complete successfully. Possible next steps: review the document, retry the upload, or open the collection in the AnyFormat dashboard for more context.", "error_code": "EXTRACTION_FAILED", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` **EXTRACTION\_CANCELLED** (422). The extraction job was cancelled. ```json theme={null} { "error": "Extraction cancelled", "detail": "The extraction job was cancelled. Possible next steps: review the document, retry the upload, or open the collection in the AnyFormat dashboard.", "error_code": "EXTRACTION_CANCELLED", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` ### Rate Limiting **RATE\_LIMITED** (429). The response carries a `Retry-After` header. ```json theme={null} { "error": "Rate limited", "detail": "Rate limit exceeded. Try again in 12s.", "error_code": "RATE_LIMITED", "retryable": true, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` `Retry-After` gives the number of seconds to wait before retrying, and appears only on a 429 response. Every successful response carries `x-ratelimit-limit`, `x-ratelimit-remaining` and `x-ratelimit-reset` for the tier that applies to that endpoint, extraction or general. ### Server Errors **INTERNAL\_ERROR** (500, or 502/503 when the gateway passes an unstructured upstream failure through with its status) ```json theme={null} { "error": "Internal server error", "detail": "An unexpected error occurred while processing your request. Please try again or contact support.", "error_code": "INTERNAL_ERROR", "retryable": true, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` **GATEWAY\_TIMEOUT** (504) ```json theme={null} { "error": "Gateway timeout", "detail": "The backend did not respond in time. Please try again.", "error_code": "GATEWAY_TIMEOUT", "retryable": true, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` Quote the `request_id` when you contact support; it is the sole correlation handle for an error. ## Error Handling Best Practices ### 1. Check HTTP Status Codes ```python theme={null} import requests response = requests.get( "https://api.anyformat.ai/v2/workflows/", headers={"Authorization": "Bearer your-api-key"} ) if response.status_code == 200: workflows = response.json() elif response.status_code == 401: print("Authentication failed. Check that you send a valid API key.") elif response.status_code == 403: print("Access denied. The API key has no access to this resource.") elif response.status_code == 404: print("Resource not found.") elif response.status_code == 412: print("Results not ready yet. Retry after a delay.") elif response.status_code == 429: retry_after = response.headers.get("Retry-After", "5") print(f"Rate limited. Retry after {retry_after}s.") elif response.status_code >= 500: print("Server error. Retry with backoff.") else: error = response.json() print(f"Error: {error.get('detail')}") ``` ### 2. Use Error Codes for Logic ```python theme={null} def handle_api_error(response): if response.status_code >= 400: error_data = response.json() error_code = error_data.get('error_code') if error_code in ('MISSING_API_KEY', 'INVALID_API_KEY', 'AUTH_FAILED'): print("Missing or invalid API key.") elif error_code == 'ACCESS_DENIED': print("The API key has no access to this resource.") elif error_code == 'VALIDATION_ERROR': show_validation_error(error_data.get('detail')) elif error_code == 'NOT_FOUND': handle_missing_resource() elif error_code == 'PRECONDITION_FAILED': # Still processing, so retry return retry_after_delay() elif error_code == 'RATE_LIMITED': retry_after = int(response.headers.get('Retry-After', 5)) time.sleep(retry_after) return retry_request() elif error_code == 'INTERNAL_ERROR': retry_with_backoff() elif error_code == 'GATEWAY_TIMEOUT': retry_with_backoff() else: log_unexpected_error(error_data) ``` ### 3. Implement Retry Logic Use exponential backoff for the retryable errors: 5xx, 429 and 412. ```python theme={null} import time import random def api_request_with_retry(url, headers, max_retries=3): for attempt in range(max_retries + 1): try: response = requests.get(url, headers=headers) # Don't retry non-retryable client errors if 400 <= response.status_code < 500 and response.status_code not in (412, 429): return response # Handle rate limiting if response.status_code == 429: delay = int(response.headers.get('Retry-After', 5)) time.sleep(delay) continue # Handle results polling if response.status_code == 412 and attempt < max_retries: time.sleep(5) continue # Retry server errors (5xx) if response.status_code >= 500 and attempt < max_retries: delay = (2 ** attempt) + random.uniform(0, 1) time.sleep(delay) continue return response except requests.exceptions.RequestException as e: if attempt < max_retries: delay = (2 ** attempt) + random.uniform(0, 1) time.sleep(delay) continue raise e ``` ### 4. Validate Before Sending ```python theme={null} def create_workflow(name, description, fields): # Validate required fields locally first if not name: raise ValueError("Workflow name is required") if not fields or len(fields) == 0: raise ValueError("At least one field is required") for field in fields: if not field.get('name'): raise ValueError("Field name is required") if not field.get('data_type'): raise ValueError("Field data_type is required") # Make API request response = requests.post( "https://api.anyformat.ai/v2/workflows/", headers={"Authorization": "Bearer your-api-key", "Content-Type": "application/json"}, json={"name": name, "description": description, "fields": fields} ) return handle_response(response) ``` ### 5. Log Errors for Debugging ```python theme={null} import logging def log_api_error(response, context=""): try: error_data = response.json() error_code = error_data.get('error_code', 'UNKNOWN') request_id = error_data.get('request_id', 'N/A') logging.error( f"API Error {response.status_code}: {error_code} - " f"{error_data.get('detail', 'No detail')} " f"[Context: {context}] [Request: {request_id}]" ) except ValueError: logging.error(f"API Error {response.status_code}: {response.text}") ``` ## Testing Error Scenarios When building your integration, test these common scenarios: | Scenario | How to Test | | ----------------------- | ------------------------------------------------------------------------------------------------------ | | Missing API key | Omit the `Authorization` header | | Invalid API key | Use a fake or expired key | | Missing required fields | Create workflow without `name` or `fields` | | Invalid JSON | Send malformed JSON in request body | | Nonexistent resource | Request a workflow or file ID that does not exist | | Results not ready | Poll `GET /v2/workflows/{workflow_id}/files/{id}/results/` before processing completes, and expect 412 | | Rate limit exceeded | Send rapid requests to trigger 429 with a `Retry-After` header | | Server errors | Test the retry logic against mocked 500 responses | # Upload File Source: https://docs.anyformat.ai/api-reference/files/create POST /v2/workflows/{workflow_id}/files/ Upload a file without triggering processing This endpoint uploads a file. It does **not** trigger processing. To upload and process in one step, use [Run Workflow](/api-reference/workflows/run) instead. **Filenames are unique within a workflow.** The `on_conflict` field decides what a collision does: * **`error`**, the default. The whole request fails with `409` and lists the conflicting names, and the name each one would take. Nothing is uploaded, so a rename is always an explicit opt-in. * **`rename`**. The colliding file gets a ` (n)` counter before its extension, so `invoice.pdf` becomes `invoice (1).pdf`. Each returned file's `filename` is then the name it was stored under, and `original_filename` holds the name you uploaded. `original_filename` is `null` when no rename happened. ## Request Body The endpoint takes `multipart/form-data`: | Field | Type | Required | Description | | ------------- | ------ | -------- | ------------------------------------------------------------------------------------------------ | | `files` | file | Yes | The file to upload | | `on_conflict` | string | No | How to handle a filename that already exists in the workflow: `error`, the default, or `rename`. | ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v2/workflows/550e8400-e29b-41d4-a716-446655440000/files/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -F 'files=@invoice.pdf' ``` ```python Python (requests) theme={null} import requests workflow_id = "550e8400-e29b-41d4-a716-446655440000" url = f"https://api.anyformat.ai/v2/workflows/{workflow_id}/files/" headers = { "Authorization": "Bearer YOUR_API_KEY" } with open("invoice.pdf", "rb") as f: files = [("files", ("invoice.pdf", f, "application/pdf"))] response = requests.post(url, headers=headers, files=files) print(response.json()) ``` ```javascript JavaScript theme={null} const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const formData = new FormData(); formData.append('files', fileInput.files[0]); const response = await fetch(`https://api.anyformat.ai/v2/workflows/${workflowId}/files/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY' }, body: formData }); const data = await response.json(); console.log(data); ``` ```typescript TypeScript theme={null} interface FileItem { filename: string; original_filename: string | null; status: string; } interface UploadFileResponse { id: string; name: string | null; files: FileItem[]; workflow_id: string; } const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const formData = new FormData(); formData.append('files', file); const response = await fetch(`https://api.anyformat.ai/v2/workflows/${workflowId}/files/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY' }, body: formData }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const data: UploadFileResponse = await response.json(); console.log(data.id); ``` ```json Response (201 Created) theme={null} { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": null, "files": [ { "filename": "invoice.pdf", "original_filename": null, "status": "uploaded" } ], "workflow_id": "550e8400-e29b-41d4-a716-446655440000" } ``` ```json Response (201, duplicate filename renamed with on_conflict=rename) theme={null} { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": null, "files": [ { "filename": "invoice (1).pdf", "original_filename": "invoice.pdf", "status": "uploaded" } ], "workflow_id": "550e8400-e29b-41d4-a716-446655440000" } ``` ```json Response (409, duplicate filename with the default on_conflict=error) theme={null} { "error": "Filename conflict", "detail": { "message": "One or more files share a name with an existing file in the workflow. Retry with on_conflict=rename to auto-rename them, or upload under different names.", "conflicts": [ { "filename": "invoice.pdf", "suggested_name": "invoice (1).pdf" } ] }, "error_code": "FILENAME_CONFLICT", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` # Upload File from URL Source: https://docs.anyformat.ai/api-reference/files/create-from-url POST /v2/workflows/{workflow_id}/files/from-url/ Create a file collection by pointing anyformat at an HTTPS URL Use this endpoint when the source document already lives at an HTTPS URL, such as a presigned S3 link or a hosted asset. anyformat fetches the bytes server-side, so you stream nothing yourself. To upload bytes directly, use [Upload File](/api-reference/files/create) instead. ## When to use this * The document sits in a bucket you can presign, such as S3, GCS or R2, and handing anyformat the link beats routing the bytes through your own backend. * The document lives at a public HTTPS URL. * You want one round-trip instead of a multipart upload. ## Constraints | | | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Scheme** | `https://` only. The gateway rejects `http://` with `400`. | | **Fetch timeout** | 10 seconds per network phase: connect, read, write and pool. | | **Size cap** | 20 MB, the same cap as the multipart upload. | | **Redirects** | Not followed. A `3xx` from the supplied URL is a guard rejection, returned as `400`. Your URL must serve the bytes directly, through no CDN redirect and no URL shortener. | | **SSRF guard** | The API rejects a URL whose hostname resolves to a non-globally-routable IP, before it attempts any fetch. IANA `is_global` decides: loopback, RFC1918, link-local including the cloud-metadata address `169.254.169.254`, IPv6 ULA, multicast, reserved and IPv4-mapped IPv6 all fail. | ## What happens after the 201 The endpoint returns at once with `"status": "pending"`, then fetches the bytes asynchronously, server-side. **The `status` field on the file listing reflects extraction state, not upload state.** `GET /v2/workflows/{workflow_id}/files/` keeps reporting `pending` until a run is triggered against the returned `collection_id`. That holds even once the fetch has finished and the bytes are in storage. Polling the file listing for an "upload-complete" transition never works. Start a run on the returned `collection_id` straight away. See [Run Workflow](/api-reference/workflows/run). The file's `status` then moves through `queued`, `in_progress` and `processed` as the extraction progresses, and the standard polling pattern on `GET .../files/` reports those transitions. **Filenames are unique within a workflow.** The `on_conflict` field decides what a collision does, exactly as on [Upload File](/api-reference/files/create): * **`error`**, the default. The request fails with `409` before the import is enqueued, listing the conflict and its suggested rename. * **`rename`**. The server renames the file, so `april.pdf` becomes `april (1).pdf`. The returned `filename` is then the name it was stored under, and `original_filename` holds the name you requested. ## Request body | Field | Type | Required | Description | | -------------- | ------ | -------- | ------------------------------------------------------------------------------------------------ | | `url` | string | Yes | HTTPS URL anyformat will fetch the file bytes from. | | `filename` | string | Yes | Filename to record on the uploaded file. | | `content_type` | string | No | MIME type of the file. Omit it to use the URL response's `Content-Type` header. | | `on_conflict` | string | No | How to handle a filename that already exists in the workflow: `error`, the default, or `rename`. | ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v2/workflows/550e8400-e29b-41d4-a716-446655440000/files/from-url/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "url": "https://my-bucket.s3.amazonaws.com/invoices/april.pdf?X-Amz-Signature=...", "filename": "april.pdf", "content_type": "application/pdf" }' ``` ```python Python (requests) theme={null} import requests workflow_id = "550e8400-e29b-41d4-a716-446655440000" url = f"https://api.anyformat.ai/v2/workflows/{workflow_id}/files/from-url/" headers = { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json", } response = requests.post(url, headers=headers, json={ "url": "https://my-bucket.s3.amazonaws.com/invoices/april.pdf?X-Amz-Signature=...", "filename": "april.pdf", "content_type": "application/pdf", }) print(response.json()) ``` ```javascript JavaScript theme={null} const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const response = await fetch(`https://api.anyformat.ai/v2/workflows/${workflowId}/files/from-url/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://my-bucket.s3.amazonaws.com/invoices/april.pdf?X-Amz-Signature=...', filename: 'april.pdf', content_type: 'application/pdf', }), }); const data = await response.json(); console.log(data); ``` ```typescript TypeScript theme={null} interface FileItem { filename: string; original_filename: string | null; status: string; } interface CreateCollectionResponse { id: string; name: string | null; files: FileItem[]; workflow_id: string; } const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const response = await fetch(`https://api.anyformat.ai/v2/workflows/${workflowId}/files/from-url/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://my-bucket.s3.amazonaws.com/invoices/april.pdf?X-Amz-Signature=...', filename: 'april.pdf', content_type: 'application/pdf', }), }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const data: CreateCollectionResponse = await response.json(); console.log(data.id); ``` ```json Response (201 Created) theme={null} { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": null, "files": [ { "filename": "april.pdf", "original_filename": null, "status": "pending" } ], "workflow_id": "550e8400-e29b-41d4-a716-446655440000" } ``` ## Error responses | Status | When | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | The SSRF guard or the fetcher rejected the input. The URL used a disallowed scheme such as `http://` or `file://`, resolved to a non-public IP, returned a `3xx` redirect, or returned a body over the 20 MB cap. | | `422` | The upstream URL prevented processing. The connection timed out, the hostname did not resolve, or the target returned a `4xx` or `5xx` response. | # Delete File Source: https://docs.anyformat.ai/api-reference/files/delete DELETE /v2/files/{collection_id}/ Permanently delete a file and its associated results The `collection_id` in the URL is the `id` that `POST /v2/workflows/{id}/files/` or `POST /v2/workflows/{id}/run/` returned. Deleting a file is permanent. It deletes every associated result too, and cancels any processing still in flight. ```bash curl theme={null} curl -X DELETE 'https://api.anyformat.ai/v2/files/b2c3d4e5-f6a7-8901-bcde-f12345678901/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests collection_id = "b2c3d4e5-f6a7-8901-bcde-f12345678901" url = f"https://api.anyformat.ai/v2/files/{collection_id}/" headers = { "Authorization": "Bearer YOUR_API_KEY" } response = requests.delete(url, headers=headers) print(f"Status: {response.status_code}") ``` ```javascript JavaScript theme={null} const collectionId = 'b2c3d4e5-f6a7-8901-bcde-f12345678901'; const response = await fetch(`https://api.anyformat.ai/v2/files/${collectionId}/`, { method: 'DELETE', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); console.log(`Status: ${response.status}`); ``` ```typescript TypeScript theme={null} const collectionId = 'b2c3d4e5-f6a7-8901-bcde-f12345678901'; const response = await fetch( `https://api.anyformat.ai/v2/files/${collectionId}/`, { method: 'DELETE', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } } ); if (response.status !== 204) { const error = await response.json(); throw new Error(`API error: ${error.error_code}`); } console.log('File deleted'); ``` ```json Response (204 No Content) theme={null} // No response body. The file is deleted. ``` # List Files Source: https://docs.anyformat.ai/api-reference/files/list GET /v2/workflows/{workflow_id}/files/ List files with pagination for a workflow ## Query Parameters | Parameter | Type | Default | Description | | ----------- | ------- | ------- | ----------------------------------------- | | `page` | integer | `1` | Page number, minimum 1 | | `page_size` | integer | `20` | Items per page, minimum 1 and maximum 100 | The API silently caps a `page_size` above 100 at 100. Each result's `id` is the **collection** id. The accompanying `file_id` is the file's UUID. Pass it to [Run Uploaded File](/api-reference/workflows/run-staged), at `POST /v2/workflows/{workflow_id}/files/{file_id}/run/`, to start extraction on a file you already uploaded. ### Possible `status` Values | Status | Description | | ------------- | ------------------------------------------- | | `pending` | The file exists. Processing has not started | | `queued` | Waiting for an available processing slot | | `in_progress` | Processing is actively running | | `processed` | Processing complete, results available | | `error` | Processing failed | | `cancelled` | Processing was cancelled | ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v2/workflows/550e8400-e29b-41d4-a716-446655440000/files/?page=1&page_size=20' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "550e8400-e29b-41d4-a716-446655440000" url = f"https://api.anyformat.ai/v2/workflows/{workflow_id}/files/" headers = { "Authorization": "Bearer YOUR_API_KEY" } params = { "page": 1, "page_size": 20 } response = requests.get(url, headers=headers, params=params) print(response.json()) ``` ```javascript JavaScript theme={null} const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const params = new URLSearchParams({ page: '1', page_size: '20' }); const response = await fetch(`https://api.anyformat.ai/v2/workflows/${workflowId}/files/?${params}`, { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); const data = await response.json(); console.log(data); ``` ```typescript TypeScript theme={null} interface FileItem { id: string; // collection id file_id: string | null; // file id, for the run-staged endpoint name: string | null; status: string; created_at: string | null; updated_at: string | null; } interface FileListResponse { results: FileItem[]; count: number; page: number; page_size: number; } const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const params = new URLSearchParams({ page: '1', page_size: '20' }); const response = await fetch(`https://api.anyformat.ai/v2/workflows/${workflowId}/files/?${params}`, { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const data: FileListResponse = await response.json(); console.log(data.results); ``` ```json Response (200 OK) theme={null} { "count": 15, "page": 1, "page_size": 20, "results": [ { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "file_id": "d4e5f6a7-b8c9-0123-defa-234567890123", "name": null, "status": "processed", "created_at": "2024-03-24T12:00:00.000Z", "updated_at": "2024-03-24T12:05:00.000Z" }, { "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "file_id": "e5f6a7b8-c9d0-1234-efab-345678901234", "name": null, "status": "in_progress", "created_at": "2024-03-24T13:00:00.000Z", "updated_at": "2024-03-24T13:00:30.000Z" } ] } ``` # Get File Results Source: https://docs.anyformat.ai/api-reference/files/results GET /v2/workflows/{workflow_id}/files/{collection_id}/results/ Retrieve processing results for a file, or poll for processing status Returns the results of every configured workflow node in a flat dictionary keyed by node category. The categories are parse, extraction, classification, splitter and edit. The `collection_id` in the URL is the `id` that `POST /v2/workflows/{workflow_id}/files/` or `POST /v2/workflows/{workflow_id}/run/` returned. The endpoint returns: * **200 OK** with the results once processing succeeds. * **412 Precondition Failed** while processing is in progress. Retry it. * **422 Unprocessable Entity** once the extraction is terminal, failed or cancelled. Read `error_code` to tell them apart. | HTTP | `error_code` | Meaning | Retry the GET? | | ---- | ---------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | none | Results available | No need | | 412 | `PRECONDITION_FAILED` | Still processing: `pending`, `queued` or `in_progress` | Yes, with backoff | | 422 | `EXTRACTION_FAILED` | The job did not complete successfully | **No.** Polling never changes the state. Review the document, retry the upload, or open the collection in the [AnyFormat dashboard](https://app.anyformat.ai). | | 422 | `EXTRACTION_CANCELLED` | The job was cancelled | **No.** Review the document, retry the upload, or open the collection in the [AnyFormat dashboard](https://app.anyformat.ai). | In production, use [webhooks](/api-reference/webhooks/overview) instead of polling. A webhook delivers results at once and consumes no rate limit. ## Polling Example with Backoff ```python Python (requests) theme={null} import requests import time headers = {"Authorization": "Bearer YOUR_API_KEY"} workflow_id = "550e8400-e29b-41d4-a716-446655440000" collection_id = "b2c3d4e5-f6a7-8901-bcde-f12345678901" max_attempts = 60 # 5 minutes at 5-second base intervals base_delay = 5 for attempt in range(max_attempts): response = requests.get( f"https://api.anyformat.ai/v2/workflows/{workflow_id}/files/{collection_id}/results/", headers=headers ) if response.status_code == 200: results = response.json() print(results) break error_code = response.json().get("error_code") if response.content else None if error_code == "PRECONDITION_FAILED": # Still processing, which usually takes 10-60 seconds delay = min(base_delay * (1.5 ** min(attempt, 5)), 30) time.sleep(delay) elif error_code in ("EXTRACTION_FAILED", "EXTRACTION_CANCELLED"): # Terminal. Polling never changes this. Review the document, retry the upload, or check the AnyFormat dashboard. print(f"Extraction terminal: {error_code}") break elif response.status_code == 429: retry_after = int(response.headers.get("Retry-After", 10)) time.sleep(retry_after) else: print(f"Error: {response.json().get('detail')}") break else: print("Polling timed out. Use webhooks in production.") ``` ## Validation Verdicts (`extractions[].validations[]`) A workflow with a [Validate node](/guides/studio/validate-rules) returns its verdicts inside each `extractions[]` entry, beside the `fields` the rules judged. Each entry covers one rule and carries `rule_id`, `severity`, `status`, `detail`, and the `compared_values` the rule looked at. `status` is `pass`, `fail` or `inconclusive`. In a split workflow, every subdocument carries its own verdicts. The key is **always present**, and is `[]` when the workflow has no validate node. [Response Formats](/api-reference/response-formats#extractions-validations-entries) documents it field by field. ## Filled Forms (`edits[]`) A workflow with an [Edit node](/guides/nodes/edit) also returns `edits[]`, one entry per file the node processed. Each entry carries every form field detected in the document, the value written into it, and a link to the filled PDF. The key is **always present**, and is `[]` when the workflow has no edit node. [Response Formats](/api-reference/response-formats) documents it field by field. `fields[].confidence` is a raw, uncalibrated 0-100 match score for pairing a filling instruction with a field. It grades how surely the instruction addresses that field, not whether the written value is correct, and it is not a probability. A field with `state: "prefilled"` is never overwritten, so it always reads back with `value: null` and `confidence: null`. `download_url` is a presigned link, valid for **15 minutes** from the moment the API built the response. Every read of this endpoint mints a fresh one. Download the PDF on receipt, or re-read the endpoint for a new link. Never store the URL. ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v2/workflows/550e8400-e29b-41d4-a716-446655440000/files/b2c3d4e5-f6a7-8901-bcde-f12345678901/results/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "550e8400-e29b-41d4-a716-446655440000" collection_id = "b2c3d4e5-f6a7-8901-bcde-f12345678901" url = f"https://api.anyformat.ai/v2/workflows/{workflow_id}/files/{collection_id}/results/" headers = { "Authorization": "Bearer YOUR_API_KEY" } response = requests.get(url, headers=headers) if response.status_code == 200: print(response.json()) else: body = response.json() error_code = body.get("error_code") if error_code == "PRECONDITION_FAILED": print("Still processing. Retry later.") elif error_code in ("EXTRACTION_FAILED", "EXTRACTION_CANCELLED"): print(f"Terminal: {error_code}. Review the document, retry the upload, or check the AnyFormat dashboard.") else: print(f"Error: {body.get('detail')}") ``` ```javascript JavaScript theme={null} const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const collectionId = 'b2c3d4e5-f6a7-8901-bcde-f12345678901'; const response = await fetch( `https://api.anyformat.ai/v2/workflows/${workflowId}/files/${collectionId}/results/`, { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } } ); if (response.status === 200) { const data = await response.json(); console.log(data); } else { const body = await response.json(); if (body.error_code === 'PRECONDITION_FAILED') { console.log('Still processing. Retry later.'); } else if (body.error_code === 'EXTRACTION_FAILED' || body.error_code === 'EXTRACTION_CANCELLED') { console.log(`Terminal: ${body.error_code}. Review the document, retry the upload, or check the AnyFormat dashboard.`); } } ``` ```typescript TypeScript theme={null} interface Evidence { text: string; page_number: number; } interface ExtractedField { value: string | null; value_override: string | null; verification_status: string | null; confidence: number; evidence: Evidence[]; } interface ResultsResponse { collection_id: string; verification_url: string | null; parse: { markdown: string | null } | null; extraction: Record | null; } const workflowId = '0686bb97-8c30-70f0-8000-97669e000eb8'; const collectionId = '069dcc2c-e14c-7606-8000-2ee4fb17b4e1'; const response = await fetch( `https://api.anyformat.ai/v2/workflows/${workflowId}/files/${collectionId}/results/`, { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } } ); if (response.status === 200) { const data: ResultsResponse = await response.json(); console.log(data); } else { const error = await response.json(); if (error.error_code === 'PRECONDITION_FAILED') { console.log('Still processing. Retry later.'); } else if (error.error_code === 'EXTRACTION_FAILED' || error.error_code === 'EXTRACTION_CANCELLED') { throw new Error(`Terminal: ${error.error_code}. Review the document, retry the upload, or check the AnyFormat dashboard.`); } else { throw new Error(`API error: ${error.error_code}`); } } ``` ```json Response (200 OK) theme={null} { "collection_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "verification_url": "https://app.anyformat.ai/workflows/.../files/...?versionId=...", "parse": { "markdown": "..." }, "extraction": { "invoice_number": { "value": "INV-001", "value_override": null, "verification_status": "not_verified", "confidence": 95.0, "evidence": [{"text": "Invoice #INV-001", "page_number": 1}] }, "total_amount": { "value": "1250.00", "value_override": null, "verification_status": "not_verified", "confidence": 92.0, "evidence": [{"text": "Total: $1,250.00", "page_number": 1}] } } } ``` ```json Response (200 OK, parse-only workflow with no extraction) theme={null} { "collection_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "verification_url": "https://app.anyformat.ai/workflows/.../files/...?versionId=...", "parse": { "markdown": "..." }, "extraction": null } ``` ```json Response (200 OK, workflow with an Edit node) theme={null} { "collection_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "verification_url": "https://app.anyformat.ai/workflows/.../files/...?versionId=...", "parse": { "markdown": "..." }, "extraction": null, "edits": [ { "file_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e3", "file_name": "onboarding-form.pdf", "fields": [ { "form_field_id": "p1_f0", "label": "Full name", "kind": "text", "state": "empty", "page": 1, "bbox": {"x0": 0.1, "y0": 0.1, "x1": 0.5, "y1": 0.16}, "value": "ACME SL", "confidence": 95 }, { "form_field_id": "p1_f1", "label": "VAT registered", "kind": "checkbox", "state": "empty", "page": 1, "bbox": {"x0": 0.1, "y0": 0.22, "x1": 0.13, "y1": 0.25}, "value": "true", "confidence": 88 }, { "form_field_id": "p1_f2", "label": "Form reference", "kind": "text", "state": "prefilled", "page": 1, "bbox": {"x0": 0.6, "y0": 0.1, "x1": 0.9, "y1": 0.16}, "value": null, "confidence": null } ], "unmatched_instructions": [], "download_url": "https://storage.anyformat.ai/filled/onboarding-form.pdf?X-Amz-Signature=..." } ] } ``` ```json Response (412 Precondition Failed, still processing) theme={null} { "error": "Results not yet available", "detail": "Processing status is 'in_progress'. Results are available when status is 'processed'.", "error_code": "PRECONDITION_FAILED", "retryable": true, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` ```json Response (422, extraction failed) theme={null} { "error": "Extraction failed", "detail": "The extraction job did not complete successfully. Possible next steps: review the document, retry the upload, or open the collection in the AnyFormat dashboard for more context. If contacting support, please include the request_id from this response and the collection_id from the request URL.", "error_code": "EXTRACTION_FAILED", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` ```json Response (422, extraction cancelled) theme={null} { "error": "Extraction cancelled", "detail": "The extraction job was cancelled. Possible next steps: review the document, retry the upload, or open the collection in the AnyFormat dashboard. If contacting support, please include the request_id from this response and the collection_id from the request URL.", "error_code": "EXTRACTION_CANCELLED", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` The `extraction` and `parse` keys are **always present** in a success response. Each is explicitly `null` when its node did not run. See [Response Formats](/api-reference/response-formats) for the canonical shape, documented field by field. # Introduction Source: https://docs.anyformat.ai/api-reference/introduction REST API overview: base URL, authentication, rate limits, response envelope, and error model. The anyformat REST API creates [workflows](/concepts/workflows), processes documents, and returns [results](/concepts/runs-and-results). This page orients you. For a step-by-step walkthrough, see the [quickstart](/guides/quickstart). For the terminology, such as workflow, field and run, start at [Concepts](/concepts/workflows). For worked examples over invoices, resumes and contracts, see [Recipes](/examples/index). *** ## Base URL ``` https://api.anyformat.ai/ ``` Every v2 endpoint uses the `/v2/` prefix, as in `https://api.anyformat.ai/v2/workflows/`. `/v2/` stops serving traffic on October 3, 2026. New integrations use [`/v3/`](/api-reference-v3/introduction). See [Versioning & deprecation](#versioning--deprecation) below to wire your integration for alerting. All endpoint paths require a trailing slash. Requests without one receive a `307 Temporary Redirect`, which preserves the request method and body. *** ## Authentication Every endpoint requires an API key, passed as a Bearer token. Only `/docs/` and `/schema/` are exempt. ```bash theme={null} curl -H "Authorization: Bearer YOUR_API_KEY" https://api.anyformat.ai/v2/workflows/ ``` See [Authentication](/api-reference/authentication) to create and manage API keys. *** ## Rate limits The API uses two-tier rate limiting. File submission endpoints have a stricter limit; all other endpoints share a higher general limit. | Tier | Endpoints | Limit | | -------------- | --------------------------------------------------------------------------------------------------- | ---------------- | | **Submission** | `POST /v2/workflows/{id}/run/`, `POST /v2/workflows/{id}/upload/`, `POST /v2/workflows/{id}/files/` | 60 requests/min | | **General** | All other authenticated endpoints | 600 requests/min | Each tier has its own counter. An extraction endpoint consumes no general quota, and the reverse holds too. Exceeding a limit returns `429 Too Many Requests`. Wait for the number of seconds in the `Retry-After` header before retrying. `Retry-After` appears only on a 429 response. Every successful response carries rate-limit headers for the tier that applies: | Header | Description | | ----------------------- | ------------------------------------ | | `x-ratelimit-limit` | Maximum requests allowed per window | | `x-ratelimit-remaining` | Requests remaining in current window | | `x-ratelimit-reset` | Seconds until the rate limit resets | *** ## Endpoints at a glance The API is organized around three resource groups. ### Workflows | Method | Endpoint | Description | | ------ | ---------------------------- | ---------------------------------------------------------------- | | POST | `/v2/workflows/` | [Create a workflow](/api-reference/workflows/create) | | GET | `/v2/workflows/` | [List workflows](/api-reference/workflows/list) | | GET | `/v2/workflows/{id}/` | [Get a workflow](/api-reference/workflows/get) | | DELETE | `/v2/workflows/{id}/` | [Delete a workflow](/api-reference/workflows/delete) | | POST | `/v2/workflows/{id}/run/` | [Run a workflow on a file](/api-reference/workflows/run) | | POST | `/v2/workflows/{id}/upload/` | [Upload a file (no processing)](/api-reference/workflows/upload) | | GET | `/v2/workflows/{id}/runs/` | [List workflow runs](/api-reference/workflows/runs) | ### Files | Method | Endpoint | Description | | ------ | ------------------------------------------------- | -------------------------------------------- | | POST | `/v2/workflows/{workflow_id}/files/` | [Upload a file](/api-reference/files/create) | | GET | `/v2/workflows/{workflow_id}/files/` | [List files](/api-reference/files/list) | | DELETE | `/v2/files/{id}/` | [Delete a file](/api-reference/files/delete) | | GET | `/v2/workflows/{workflow_id}/files/{id}/results/` | [Get results](/api-reference/files/results) | ### Webhooks | Method | Endpoint | Description | | ------ | -------------------- | -------------------------------------------------- | | POST | `/v2/webhooks/` | [Create a webhook](/api-reference/webhooks/create) | | GET | `/v2/webhooks/` | [List webhooks](/api-reference/webhooks/list) | | DELETE | `/v2/webhooks/{id}/` | [Delete a webhook](/api-reference/webhooks/delete) | See [Webhooks overview](/api-reference/webhooks/overview) for setup, signing, and delivery semantics. *** ## Response format A successful response is JSON. [Response formats](/api-reference/response-formats) documents the full envelope, including `as_lists=true` for tabular shapes. An error response follows one structured shape: ```json theme={null} { "error": "Brief, human-readable error description", "detail": "Detailed explanation of what went wrong", "error_code": "MACHINE_READABLE_ERROR_CODE", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` See [Errors](/api-reference/errors) for the complete error code reference and retry patterns. *** ## Versioning & deprecation The URL path carries the version. The current stable major is `/v3/`. A superseded major, such as `/v2/`, keeps serving traffic through an announced deprecation window before it shuts down. `/v2/` is deprecated and sunsets on **October 3, 2026**. See the [v2 to v3 migration guide](/api-reference/v2-migration) for the endpoint-by-endpoint mapping. ### Response headers Every response carries a version identifier, plus deprecation signals whenever the version you called is on the retirement path. | Header | When emitted | Value | | --------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `X-API-Version` | Every `/v2/` response | Current major version (e.g. `2.0.0`) | | `Deprecation` | Only while a version is deprecated | HTTP-date the deprecation was announced ([RFC 8594](https://datatracker.ietf.org/doc/html/rfc8594)) | | `Sunset` | Only while a version is deprecated | HTTP-date after which the version stops serving traffic | | `Link` | Only when a per-route successor exists | `; rel="successor-version"`, naming the equivalent endpoint on the newer version | A call to a deprecated endpoint answers like this: ```http theme={null} HTTP/1.1 200 OK X-API-Version: 2.0.0 Deprecation: Fri, 03 Jul 2026 00:00:00 GMT Sunset: Sat, 03 Oct 2026 00:00:00 GMT Link: ; rel="successor-version" Content-Type: application/json ``` A version that is not deprecated carries no `Deprecation` and no `Sunset` header. Their presence alone is therefore a reliable signal that migration work is due. ### Alert on `Sunset` A deprecation window runs **three months** from the `Deprecation` announcement to the `Sunset` cutoff. Wire your integration to detect these headers rather than tracking release notes by hand. A minimal setup: * **In your HTTP client middleware**, check every response for a `Sunset` header. If present, emit a warning to your logs or monitoring system that includes the endpoint path and the sunset date. * **In your metrics / observability stack**, add a counter or gauge indexed by `X-API-Version`, `Deprecation`, and `Sunset`. A dashboard panel or alert firing on "any response has `Sunset`" gives on-call visibility without human polling. * **Escalate as the sunset approaches**: for example, log-level warning at first sight, page on-call one month before `Sunset`, hard-fail your CI a week before if you still have live traffic on the deprecated version. This five-line Python wrapper around `requests` surfaces the signal: ```python theme={null} import logging import requests def call(method: str, url: str, **kw) -> requests.Response: response = requests.request(method, url, **kw) if sunset := response.headers.get("Sunset"): logging.warning( "anyformat API deprecation: %s %s sunsets %s (successor: %s)", method, url, sunset, response.headers.get("Link", "n/a"), ) return response ``` Every mainstream HTTP client offers an equivalent hook: event hooks in `httpx`, interceptors in `axios`, a wrapping function around `fetch`. If you do one thing, **alert on the `Sunset` header**. The endpoint, the announcement date and the successor URL all come from the same response. *** ## SDKs The official client libraries put a fluent builder over the typed-graph API: * [TypeScript SDK](/api-reference/sdks/typescript): `npm install @anyformat/sdk` * [Python SDK](/api-reference/sdks/python): `pip install anyformat` * [SDKs overview](/api-reference/sdks/overview): both client libraries and the Claude Code skill *** ## OpenAPI schema The full OpenAPI specification is available at: * **JSON**: [https://api.anyformat.ai/schema/?format=json](https://api.anyformat.ai/schema/?format=json) * **Swagger UI**: [https://api.anyformat.ai/docs/](https://api.anyformat.ai/docs/) # Check API Key Source: https://docs.anyformat.ai/api-reference/key-check GET /key-check/ Verify an API key without triggering a billable operation Confirms the caller's API key is active and names its owning organization. The endpoint is unversioned, so it serves v2 and v3 keys alike. * **200**: the key is valid. The body carries `organization_id`, and `organization_name` when that is available. * **401**: the standard [error envelope](/api-reference/errors). `error_code` is `MISSING_API_KEY` when you send no key, and `INVALID_API_KEY` when the key is not recognised. The endpoint bills nothing and creates no run, so use it as a cheap probe before the first real request. It makes the same `/me/organization/` round-trip as every authenticated request, so a successful check also warms the org cache. ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/key-check/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests response = requests.get( "https://api.anyformat.ai/key-check/", headers={"Authorization": "Bearer YOUR_API_KEY"}, ) response.raise_for_status() print(response.json()["organization_id"]) ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.anyformat.ai/key-check/', { headers: { 'Authorization': 'Bearer YOUR_API_KEY' }, }); if (!response.ok) throw new Error(`Invalid API key: ${response.status}`); const { organization_id, organization_name } = await response.json(); ``` ```json Response (200 OK) theme={null} { "valid": true, "organization_id": "7f4a1c2e-0000-4000-8000-000000000001", "organization_name": "Acme Corp" } ``` ```json Response (401 Unauthorized) theme={null} { "error": "Invalid API key", "detail": "The provided API key is not recognised.", "error_code": "INVALID_API_KEY", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` # Response Formats Source: https://docs.anyformat.ai/api-reference/response-formats The canonical response shapes for the anyformat API Every recipe and SDK reference defers to the shapes on this page. ## Paginated List Responses An endpoint that returns multiple items wraps them in a page: ```json theme={null} { "count": 25, "page": 1, "page_size": 20, "results": [ /* items */ ] } ``` | Field | Description | | ----------- | ----------------------------------------------------------------------------------------- | | `count` | Total number of items matching the query, across all pages. | | `page` | Current page number, 1-indexed. | | `page_size` | Number of items per page. The maximum is 100, and the API silently caps a larger request. | | `results` | Array of items for the current page. | ## File-Collection Results `GET /v2/workflows/{workflow_id}/files/{collection_id}/results/` returns this shape with HTTP 200 once processing completes. While processing runs, the endpoint returns **412 Precondition Failed**. See [Error Handling](/api-reference/errors) for the polling pattern. ```json theme={null} { "collection_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "verification_url": "https://app.anyformat.ai/workflows/.../files/...?versionId=...", "parse": { "markdown": "..." }, "classifications": [], "splits": [], "extractions": [ { "split_name": null, "partition": null, "fields": { "invoice_number": { "value": "INV-2024-0847", "value_override": null, "verification_status": "not_verified", "confidence": 97.0, "evidence": [{"text": "Invoice #INV-2024-0847", "page_number": 1}] }, "total_amount": { "value": "4087.50", "value_override": null, "verification_status": "not_verified", "confidence": 96.0, "evidence": [{"text": "Total: $4,087.50", "page_number": 2}] } }, "validations": [ { "rule_id": "total_positive", "description": "", "severity": "error", "status": "pass", "detail": "4087.5 > 0", "compared_values": {"total_amount": "4087.50"}, "source_fields": ["0686bb97-8c30-70f0-8000-97669e00aaaa"] } ] } ], "edits": [ { "file_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e3", "file_name": "onboarding-form.pdf", "fields": [ {"form_field_id": "p1_f0", "label": "Full name", "kind": "text", "state": "empty", "page": 1, "bbox": {"x0": 0.1, "y0": 0.1, "x1": 0.5, "y1": 0.16}, "value": "ACME SL", "confidence": 95}, {"form_field_id": "p1_f1", "label": "VAT registered", "kind": "checkbox", "state": "empty", "page": 1, "bbox": {"x0": 0.1, "y0": 0.22, "x1": 0.13, "y1": 0.25}, "value": "true", "confidence": 88}, {"form_field_id": "p1_f2", "label": "Form reference", "kind": "text", "state": "prefilled", "page": 1, "bbox": {"x0": 0.6, "y0": 0.1, "x1": 0.9, "y1": 0.16}, "value": null, "confidence": null} ], "unmatched_instructions": [], "download_url": "https://storage.anyformat.ai/filled/onboarding-form.pdf?X-Amz-Signature=..." } ], "extraction": { "invoice_number": {"value": "INV-2024-0847", "confidence": 97.0, "evidence": [{"text": "Invoice #INV-2024-0847", "page_number": 1}], "value_override": null, "verification_status": "not_verified"}, "total_amount": {"value": "4087.50", "confidence": 96.0, "evidence": [{"text": "Total: $4,087.50", "page_number": 2}], "value_override": null, "verification_status": "not_verified"} } } ``` ### Top-level fields | Field | Type | Description | | ------------------ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `collection_id` | string (UUID) | Identifier of the file collection. Same value as the `id` returned by `POST /v2/workflows/{wid}/run/`. | | `verification_url` | string \| null | Link to the Studio review page for the collection's first file, pinned to the workflow version that produced the results: `https://app.anyformat.ai/workflows//files/?versionId=`. `null` when the collection has no file. | | `parse` | object \| null | Output of the parse node. `null` if the workflow has no parse node. | | `extraction` | object \| null | **Deprecated.** Use `extractions` instead. It mirrors `extractions[0].fields` for a linear workflow, and is `null` for a parse-only or split workflow. A future major version removes it. | | `classifications` | array | Per-classifier-node verdicts. Each entry has `category`, `confidence`, `evidence`. Empty when the workflow has no classifier. | | `splits` | array | Splitter output: category-level geometry with optional partitions. Each entry has `name`, `files[]`, `confidence` and `partitions[]`. Empty when the workflow has no splitter. | | `extractions` | array | Flat list of extraction datapoints, one entry per `(split_name, partition)` pair. A linear workflow produces a single untagged entry, with `split_name=null` and `partition=null`. Each entry also carries `validations[]`, the validate node's verdicts on that entry's data. Empty when no extraction has run yet. | | `edits` | array | Filled-form output, one entry per file an edit node processed. Empty, never `null`, when the workflow has no edit node. | The keys `parse` and `extraction` are **always present**. Each is explicitly `null` when its node produced no results. Read `extractions[]` in new code. `extraction` survives for back-compat, and will be removed. ### `parse` object | Field | Type | Description | | ---------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `markdown` | string \| null | Document content rendered as structured markdown. A table becomes a `` element, and a figure becomes a base64-encoded `` tag. Each page sits in `` and `
` blocks carrying bounding-box coordinates. | See [Parse-Only Workflow](/examples/parse-only-workflow) for an example of the markdown structure. ### The `extraction` object: `ExtractedField` shape The `extraction` object is keyed by field name. Each value is an `ExtractedField`: | Field | Type | Description | | --------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `value` | string \| null | The extracted value, always as a string. A field declared `integer` returns `"3"`, not `3`. The `data_type` governs how the field is extracted and validated, not the JSON type you get back, so parse the string yourself. A document may write a number as `1.026,00`. It is `null` when the extraction found no value. | | `value_override` | string \| null | Human-supplied override of the extracted value, set during verification. It is `null` when no override exists. To pick the most-trusted value, test explicitly for `null`, as the reading pattern below does. Never use an `or` or `??` truthy fallback: a legitimate override of `""` is falsy and would be discarded. | | `verification_status` | string \| null | Verification state for this datapoint. It is usually `not_verified`, the default, or `verified`. It is `null` before review. | | `confidence` | number | Model confidence on a 0–100 scale. Higher means more certain. | | `evidence` | array | Source-text snippets the model used to derive this value. May be empty. | ### `evidence` entries | Field | Type | Description | | ------------- | ------- | ---------------------------------------------------------------- | | `text` | string | The exact source-text snippet that supports the extracted value. | | `page_number` | integer | 1-indexed page number where the snippet was found. | ### `extractions[].validations[]` entries One entry per rule of the [validate node](/api-reference/workflows/create#validate-node) that ran on this extraction. Verdicts sit beside the `fields` they judged, so in a split workflow each subdocument carries its own. The key is always present, and is `[]` when the workflow has no validate node. | Field | Type | Description | | ----------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `rule_id` | string | The rule's `id` as declared on the validate node. | | `description` | string | The rule's natural-language description. Empty for a deterministic rule. | | `severity` | string | `error` or `warning`, as declared on the rule. | | `status` | string | `pass`, `fail` or `inconclusive`. `inconclusive` means the rule could not be evaluated, because there was no extracted data or the expression did not resolve to a boolean. | | `detail` | string | Human-readable explanation of the verdict. | | `compared_values` | object \| null | The field values the rule looked at, keyed by field name. `null` when nothing was compared. | | `source_fields` | array | `persistent_id`s of the fields the rule declared as its inputs. | ### `edits[]` entries One entry per file an edit node processed: | Field | Type | Description | | ------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `file_id` | string (UUID) | The UUID of the file this form was filled from. | | `file_name` | string | The file's display name. | | `fields` | array | Every form field detected in the document, filled or not, in document order. See the shape below. | | `unmatched_instructions` | array | Instruction fragments, quoted verbatim, that matched no field in this document. Empty when every instruction landed. | | `download_url` | string \| null | Presigned link to the filled PDF, valid for **15 minutes** from the moment the API built this response. Every read of the results endpoint mints a fresh link, so re-read it rather than storing the URL. It is `null` when the run stored no filled PDF. | ### `edits[].fields[]` form-field shape | Field | Type | Description | | --------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `form_field_id` | string | Stable identifier for this form field within the document, in the form `p_f`. | | `label` | string | The field's own printed caption, verbatim from the document. | | `kind` | string | `checkbox` for a tick target, `text` for anything else. | | `state` | string | `empty` when the field was blank in the source document and therefore fillable. `prefilled` when the document already carried a value. A prefilled field is never overwritten, so it always reads back with `value: null`. | | `page` | integer | 1-indexed page number the field sits on. | | `bbox` | object | The field's fillable area as a normalised bounding box in \[0, 1] page coordinates, keys `x0`/`y0`/`x1`/`y1`. | | `value` | string \| null | The value written into the field, or the literal string `true` or `false` for a `checkbox`. It is `null` when no instruction addressed this field. | | `confidence` | integer \| null | Raw, **uncalibrated** 0–100 match score for pairing an instruction with this field. It grades how surely the instruction addresses the field, not whether the written value is correct, and it is not a probability. It is `null` when no instruction addressed this field. | ### The recommended reading pattern ```python theme={null} result = response.json() # parse markdown (always present, may be null for non-parse workflows) markdown = result["parse"]["markdown"] if result["parse"] else None # Extraction values live in `extractions[]`. A linear workflow produces one # untagged entry, with split_name=None and partition=None. A split workflow # produces one entry per (split, partition). for extraction in result["extractions"]: fields = extraction["fields"] field = fields["invoice_number"] # prefer human override if one exists, otherwise the model value. # Use `is not None`, not `or`: an override of 0, False or "" is falsy. final = field["value_override"] if field["value_override"] is not None else field["value"] if extraction["split_name"]: print(f"{extraction['split_name']}/{extraction['partition']}: {final}") else: print(final) # Filled forms: one entry per file an edit node processed. # `download_url` expires 15 minutes after this response was built, so fetch the # PDF now; re-read the endpoint for a fresh link instead of storing the URL. for edit in result["edits"]: if edit["download_url"]: filled_pdf = requests.get(edit["download_url"]).content for field in edit["fields"]: # `value` is None for prefilled fields and for fields no instruction addressed. if field["value"] is not None: print(f"{field['label']}: {field['value']}") for instruction in edit["unmatched_instructions"]: print(f"nothing on the page matched: {instruction}") # Deprecated singular form (kept for back-compat; only populated for linear # workflows). Prefer `extractions[]` above. legacy = result.get("extraction") ``` ## Run Response `POST /v2/workflows/{wid}/run/` returns this shape on `202 Accepted`: ```json theme={null} { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "status": "pending", "workflow_id": "0686bb97-8c30-70f0-8000-97669e000eb8", "version_id": "FGaV4I2JAA" } ``` `status` is the initial run state. It says the run was accepted and enqueued, not that extraction is complete. Poll the results endpoint, or wait for the webhook, before reading results. The `id` field is the **collection\_id**. Pass it as `{collection_id}` to the results endpoint. ## Error Responses Every error response shares one shape: ```json theme={null} { "error": "Brief, human-readable error description", "detail": "Detailed explanation of what went wrong", "error_code": "MACHINE_READABLE_ERROR_CODE", "retryable": false, "request_id": "a1b2c3d4e5f67890abcdef1234567890" } ``` See [Error Handling](/api-reference/errors) for the complete error-code reference, polling guidance, and best-practice retry logic. ## Identifier Format Every UUID in the API is a **hyphenated UUIDv7**, in the RFC 4122 `8-4-4-4-12` form: ``` 069dcc2c-e14c-7606-8000-2ee4fb17b4e1 ``` This holds for workflow IDs, collection IDs, file IDs and webhook IDs alike. A few legacy code paths returned the same value as 32-character hex, without hyphens. Those are deprecated and will be removed. Always treat a UUID as an opaque string. ## Supported Endpoints | Endpoint | Returns | | ---------------------------------------------------------------- | ------------------------------------------- | | `GET /v2/workflows/{workflow_id}/files/{collection_id}/results/` | File-collection results (this page's shape) | | `GET /v2/workflows/` | Paginated list response | | `GET /v2/workflows/{workflow_id}/runs/` | Paginated list response | | `GET /v2/workflows/{workflow_id}/files/` | Paginated list response | # SDKs Source: https://docs.anyformat.ai/api-reference/sdks/overview Official client libraries for the anyformat API anyformat ships hand-written client libraries for TypeScript and Python. Both expose the same fluent builder over the [typed-graph workflow definition](/concepts/workflows), so the only thing that differs across languages is the syntax. ## TypeScript The official TypeScript and Node client library. Install it with `npm install @anyformat/sdk`. It needs Node 18 or later. ```typescript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const result = await af .workflow("Invoice", "Extract invoice header and totals") .parse() .extract([Schema.string("vendor", "Vendor name on the invoice")]) .run(file) .wait(); console.log(result.field("vendor")?.value); ``` See the [TypeScript SDK guide](/api-reference/sdks/typescript) for builder methods, error handling, and the full surface. ## Python The official Python client library. Install it with `pip install anyformat`. It needs Python 3.13. ```python theme={null} from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key="af_...") # or read ANYFORMAT_API_KEY from env workflow = ( client.workflow("Invoices") .parse() .extract([Schema.string("vendor", "Vendor name on the invoice")]) .create() ) result = workflow.run("invoice.pdf").wait() print(result.fields["vendor"].value) ``` See the [Python SDK guide](/api-reference/sdks/python) for builder methods, async usage, error handling, and the full surface. ## Coding assistant Install the **anyformat Claude Code skill** into [Claude Code](https://claude.com/claude-code), or any compatible AI coding agent. The skill gives your agent the design know-how. The verbs come from the MCP server or the API reference. ```bash theme={null} npx @anyformat/skill # all projects: ~/.claude/skills/anyformat npx @anyformat/skill --project # this project only: ./.claude/skills/anyformat ``` See the [Coding assistant](/guides/coding-assistant) guide for installation, configuration, and example prompts. # Python SDK Guide Source: https://docs.anyformat.ai/api-reference/sdks/python Complete guide for the in-house anyformat Python SDK The hand-written Python SDK for anyformat, built on `httpx`. It mirrors the [TypeScript SDK](/api-reference/sdks/typescript) and exposes a fluent builder over the same [typed-graph workflow definition](/concepts/workflows). ## Installation The SDK needs Python 3.13. It pins `>=3.13,<3.14`. ```bash theme={null} pip install anyformat ``` The PyPI distribution is named `anyformat`. Import the transport from `anyformat.sdk` and the schema factories from `anyformat.workflow`. ## Authentication Pass the API key to `Client`, or set `ANYFORMAT_API_KEY` in the environment and read it from there. ```python theme={null} import os from anyformat.sdk import Client # Explicit client = Client(api_key="af_...") # Or from the environment client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) ``` See [Authentication](/api-reference/authentication) for how to mint an API key. ## Basic usage The full flow builds a workflow with the fluent builder, runs a document, and awaits the typed result. ```python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) # 1. Build and create the workflow: parse, then extract workflow = ( client.workflow("Invoice Processor") .parse() .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.float("total_amount", "Total invoice amount"), Schema.date("issue_date", "Date when the invoice was issued"), ]) .create() ) print(f"Workflow id: {workflow.id}") # 2. Submit a document. Accepts a Path, a path string, or bytes. run = workflow.run("invoice.pdf") # 3. Wait for results. Polls /results/: 412 means still processing, 200 means done. result = run.wait() # defaults: timeout=300s, poll_interval=3s print(result.fields["invoice_number"].value) print(result.fields["total_amount"].value) ``` For a linear workflow, which produces one untagged extraction, `result.fields[name]` gives typed scalar accessors. `result.validations` holds the validate node's verdicts on those fields, and each `result.partitions[i].validations` holds the per-partition verdicts. A split workflow puts its values on `result.partitions`: each partition carries its `fields`, the `files` and pages it covers, and the splitter's `confidence`. `result.splits` holds the splitter's categories and `result.classifications` holds the classifier verdicts. `result.raw` holds the full wire envelope. ## Async usage `AsyncClient` is the async sibling. Every builder, handle and result method has an async counterpart. ```python theme={null} import asyncio import os from anyformat.sdk import AsyncClient from anyformat.workflow import Schema async def main(): client = AsyncClient(api_key=os.environ["ANYFORMAT_API_KEY"]) try: workflow = await ( client.workflow("Invoice Processor") .parse() .extract([Schema.string("invoice_number", "...")]) .create() ) run = await workflow.run("invoice.pdf") result = await run.wait() print(result.fields["invoice_number"].value) finally: await client.aclose() asyncio.run(main()) ``` ## Builder methods Every node type in the [typed graph](/concepts/workflows) has a fluent method. `parse` is required. The rest are optional, and chain into any topology the API allows. | Method | What it adds | Notes | | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `.parse(*, mode='standard', prompt_hint=None, figure_enhancement=False, cache=True)` | The required parse node | Every argument is keyword-only. A per-mode `@overload` discriminates the knobs. `mode='agentic'` adds `effort='low'\|'mid'\|'accurate'`. `mode='lite'` adds nothing: it is one OCR-only recipe, with no `prompt_hint`. `mode='flash'` adds `scanned_pages='skip'\|'fail'`, and is the cheapest tier, with no model call and no OCR. | | `.classify(*categories)` | A classify node | Categories from `ClassifyCategory(id=..., name=..., description=...)` | | `.split(*rules, route_from=None)` | A splitter node | After `.classify()`, `route_from=` names the branch that fans out | | `.extract(fields, *, branch=None, mode='standard', lookup_suggestion=None, lookup_reasoning_effort=None, lookup_file_uploads=None, use_images=False)` | An extract node | `branch=` is required after `.classify()` or `.split()`. The `Schema.*` factories build the fields. `mode` selects the tier: `'standard'`, `'agentic'` or `'lite'`. [Smart lookup](#smart-lookup) needs three things together: `lookup_reasoning_effort`, one of `'minimal'`, `'low'`, `'medium'` or `'high'`; inline `lookup_file_uploads`; and a field with `source="smart_lookup"`. `use_images` feeds page images to the model. | | `.validate(*rules, branch=None)` | A validate node | Attaches to the most recent extract, or to the one `branch` names | | `.create()` | Persists the workflow | Returns a `Workflow` handle you can `.run(...)` on | | `.build()` | The same shape without the network call | Returns a `WorkflowDefinition`, useful for tests and inspection | `Workflow.run(file=None, *, text=None)` takes either `file`, as `bytes`, a `pathlib.Path` or a path string, or `text=`, as raw text. Raw text suits an email or a plain-text body. Set exactly one. The call returns a `Run` handle, and `Run.wait(timeout=300, poll_interval=3)` returns the `Result`. Filenames are unique within a workflow. By default, an upload whose filename already exists is rejected with `409` and the error code `FILENAME_CONFLICT`. Pass `on_conflict="rename"` to `run(...)` or `upload(...)` to rename instead, so `invoice.pdf` becomes `invoice (1).pdf`. A `text=` upload and an unnamed `bytes` upload get a synthesised unique filename, so they never collide. A filename you supply yourself does. ## Smart lookup A smart-lookup field is resolved by matching the document against a **reference file**, a CSV catalog, instead of being read off the page. Flag the field with `source="smart_lookup"`, and pass the reference file to `.extract(..., lookup_files=[...])`. The SDK reads each path and uploads it with the workflow. See [Smart Lookup](/guides/nodes/smart-lookup) for how matching works and for the Studio equivalent. In the example below, the model reads `vendor_name`, then resolves the canonical `vendor_id` from a vendor catalog. ```python theme={null} workflow = ( client.workflow("Invoice + vendor lookup") .parse() .extract( [ Schema.string("vendor_name", "Vendor as printed on the invoice."), Schema.float("total", "Grand total amount."), Schema.string("vendor_id", "Canonical vendor code from the catalog, joined on vendor_name.", source="smart_lookup"), ], lookup_files=["vendor_catalog.csv"], lookup_suggestion="Match the extracted vendor_name against the vendor_name column; return vendor_id.", ) .create() ) result = workflow.run("invoice.pdf").wait() print(result.fields["vendor_id"].value) # resolved from the catalog ``` Every `Schema.*` factory accepts `source="smart_lookup"`. The looked-up field comes back in `result.fields` alongside the others, with no separate section. An extract carrying a `lookup` field but no `lookup_files` is rejected with `400`. The lookup **overwrites** a smart-lookup field. Use `source="lookup_if_missing"` to extract the field from the document too, and let the lookup fill only what extraction missed. ## Managing workflows The client lists and deletes the workflows on your account: | Method | What it does | Returns | | ---------------------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------- | | `client.list_workflows(page=1, page_size=20, status=None)` | One page of your workflows. `page_size` maxes at 100 | `list[Workflow]`, each one `.run(...)`-able | | `client.delete_workflow(workflow_id)` | Soft-deletes a workflow | The deleted `workflow_id`. A 404 raises `NotFound` | ```python theme={null} # List your workflows, then run the first one workflows = client.list_workflows(page_size=50) for wf in workflows: print(wf.id, wf.name) result = workflows[0].run("invoice.pdf").wait() # Delete by id client.delete_workflow(workflows[0].id) ``` `AsyncClient` carries the async equivalents. `await client.list_workflows(...)` returns `list[AsyncWorkflow]`, and `await client.delete_workflow(id)` returns the id. ## Reading results ```python theme={null} result = run.wait() # Scalar fields, for a linear workflow with a single untagged extraction inv = result.fields["invoice_number"] # ExtractedField print(inv.value, inv.confidence, inv.evidence) # Parse output: markdown plus per-block confidence parse_markdown = result.parse.markdown if result.parse else None # Split workflows: one Partition per (split, partition), with the pages it covers for partition in result.partitions: pages = [(f.file_name, f.pages) for f in partition.files] print(partition.split_name, partition.partition, partition.confidence, pages) print(partition.fields) # Splitter categories and classifier verdicts for split in result.splits: print(split.name, split.confidence, [p.name for p in split.partitions]) for verdict in result.classifications: print(verdict.category, verdict.confidence, verdict.evidence) # result.raw is the validated wire envelope as a dict ``` See [Response formats](/api-reference/response-formats) for the full shape of every section. ## Error handling The SDK raises typed exceptions you can catch: ```python theme={null} import time from anyformat.sdk import ( APIError, BadRequest, # 400: validation error or bad request body Unauthorized, # 401: invalid or missing API key Forbidden, # 403: disallowed by the server NotFound, # 404: workflow, file or webhook does not exist RateLimited, # 429: slow down. .retry_after has the suggested delay ServerError, # 5xx: internal anyformat error SDKTimeout, # local timeout: `.wait()` exceeded `timeout` ) try: result = workflow.run("invoice.pdf").wait(timeout=60) except RateLimited as e: time.sleep(e.retry_after or 5) except NotFound: print("workflow or file is gone") except APIError as e: print(f"HTTP {e.status_code}: {e.detail}") except SDKTimeout: print("polling timed out. Use webhooks in production.") ``` `APIError` exposes `.status_code`, `.error_code`, `.retryable`, `.request_id` and `.detail`. `.detail` keeps the shape the API sent: a string for most errors, a list of field errors for a validation failure, or an object such as the `{"errors": [...]}` of a `422 GT_INVALID`. [Errors](/api-reference/errors) documents the wire-format error codes, such as `EXTRACTION_FAILED` and `RATE_LIMITED`. ## Webhooks `Client` does not expose the webhook endpoints yet. Until it does, register and delete webhooks with `httpx` directly: ```python theme={null} import os import httpx webhook = httpx.post( "https://api.anyformat.ai/v2/webhooks/", headers={"Authorization": f"Bearer {os.environ['ANYFORMAT_API_KEY']}"}, json={"url": "https://your-server.com/hook", "events": ["extraction.completed"]}, ).json() # webhook["secret"] comes back once. Store it. ``` See [Webhooks](/api-reference/webhooks/overview) for the payload shape and signature verification. ## Links * [PyPI](https://pypi.org/project/anyformat/): `pip install anyformat` * [TypeScript SDK](/api-reference/sdks/typescript): the same fluent shape in TypeScript * [Coding assistant](/guides/coding-assistant): drive anyformat from your editor with Claude # TypeScript SDK Guide Source: https://docs.anyformat.ai/api-reference/sdks/typescript Complete guide for the in-house anyformat TypeScript SDK The hand-written TypeScript SDK for anyformat. [Hey API](https://heyapi.dev) generates the wire types and the low-level HTTP client from the API's OpenAPI spec. The ergonomic layer is hand-written and mirrors the [Python SDK](/api-reference/sdks/python). It covers `Anyformat`, `Schema`, `WorkflowBuilder` and `Result`. ## Installation Requires Node 18+. ```bash theme={null} npm install @anyformat/sdk ``` The package ships ESM and CJS bundles, plus TypeScript types. ## Authentication Pass the API key to the `Anyformat` constructor, or read it from the environment. ```typescript theme={null} import { Anyformat } from "@anyformat/sdk"; // Explicit const af = new Anyformat({ apiKey: "af_..." }); // Or from the environment, under Node const af2 = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); ``` Override the base URL with `baseUrl` to run against a non-production deploy. See [Authentication](/api-reference/authentication) to mint an API key. ## Basic usage The full flow builds a workflow with the fluent builder, submits a document, and awaits the typed result. ```typescript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const file: File = /* a File with .name set, as in new File([bytes], "invoice.pdf") */; const workflow = await af .workflow("Invoice Processor", "Extract invoice header and totals") .parse() .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.float("total_amount", "Total invoice amount"), Schema.date("issue_date", "Date when the invoice was issued"), ]) .create(); // persists the workflow, returns a Workflow handle const run = await workflow.run(file); // submits the document const result = await run.wait(); // polls /results/ until 200 console.log(result.field("invoice_number")?.value); console.log(result.field("total_amount")?.value); ``` The TypeScript SDK splits "create workflow" from "submit document", mirroring the Python SDK. `create()` returns a `Workflow` handle whose `run(file)` reuses the existing workflow id. Processing N documents through one workflow therefore costs one `POST /v2/workflows/` plus N `POST /v2/workflows/{id}/run/`, not N create-and-run pairs. ## `wait` options `wait` defaults to a 300-second overall timeout and a 3-second poll interval. Override either one: ```typescript theme={null} const result = await run.wait({ timeoutMs: 180_000, pollMs: 3_000 }); ``` `wait` throws `SDKTimeout` when the overall deadline expires before the run completes. The internal poll handles a 412, meaning still processing, on its own. ## Builder methods Every node type in the [typed graph](/concepts/workflows) has a fluent method. `parse` is required. The rest are optional, and chain into any topology the API allows. | Method | What it adds | Notes | | ------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `.parse(opts?)` | The required parse node | `opts` is a `mode`-discriminated union. `{ mode?: "standard", promptHint?, figureEnhancement?, cache? }`. `{ mode: "agentic", effort? }`, where `effort` is `"low"\|"mid"\|"accurate"`. `{ mode: "lite" }` is one OCR-only recipe, with no knobs and no `promptHint`. `{ mode: "flash", scannedPages? }` takes `"skip"\|"fail"`, and is the cheapest tier, with no model call and no OCR. A wrong-mode knob is a compile-time error. | | `.classify(categories, opts?)` | A classify node | Categories come from `ClassifyCategory` objects, with `id`, `name` and `description` | | `.split(rules, opts?)` | A splitter node | `opts.routeFrom` names the classify branch that fans out | | `.extract(fields, opts?)` | An extract node | `opts.branch` is required after `.classify()` or `.split()`. Build the fields with `Schema.*`. `opts.mode` is `"standard"`, `"agentic"` or `"lite"`, and `opts.useImages` feeds page images. [Smart lookup](#smart-lookup) needs three things together: `opts.lookupFiles`; `opts.lookupReasoningEffort`, one of `"minimal"`, `"low"`, `"medium"` or `"high"`; and a `{ source: "smart_lookup" }` field. | | `.validate(rules, opts?)` | A validate node | Attaches to the most recent extract, or to `opts.branch` | | `.build()` | The same shape without the network call | Returns a `WorkflowCreateRequest` | | `.create()` | Persists the workflow | Returns a `Workflow` handle carrying the id and `.run(...)` | The `Workflow` handle returned by `.create()` has one method: | Method | What it does | Notes | | ------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `.run(file, opts?)` | Submits a document against the existing workflow id | To submit raw text instead of a file, pass `null` as the file and set `opts.text`. `opts.onConflict` sets the filename-conflict policy | `Workflow.run` returns a `Run`. `Run.wait(opts?)` returns a `Result` once processing finishes. Filenames are unique within a workflow. By default, an upload whose filename already exists is rejected with `409` and the error code `FILENAME_CONFLICT`. Pass `{ onConflict: "rename" }` to `.run(...)`, `.upload(...)` or `.uploadFromUrls(...)` to rename instead, so `invoice.pdf` becomes `invoice (1).pdf`. ## Smart lookup A smart-lookup field is resolved by matching the document against a **reference file**, a CSV catalog, instead of being read off the page. Flag the field with `{ source: "smart_lookup" }`, and pass the reference file to `.extract(..., { lookupFiles: [...] })`. The SDK reads each path and uploads it with the workflow. See [Smart Lookup](/guides/nodes/smart-lookup) for how matching works and for the Studio equivalent. In the example below, the model reads `vendor_name`, then resolves the canonical `vendor_id` from a vendor catalog. ```typescript theme={null} const workflow = await af .workflow("Invoice + vendor lookup") .parse() .extract( [ Schema.string("vendor_name", "Vendor as printed on the invoice."), Schema.float("total", "Grand total amount."), Schema.string("vendor_id", "Canonical vendor code from the catalog, joined on vendor_name.", { source: "smart_lookup" }), ], { lookupFiles: ["vendor_catalog.csv"], lookupSuggestion: "Match the extracted vendor_name against the vendor_name column; return vendor_id.", }, ) .create(); const result = await workflow.run(file).wait(); console.log(result.field("vendor_id")?.value); // resolved from the catalog ``` Every `Schema.*` factory accepts `{ source: "smart_lookup" }`. The looked-up field comes back through `result.field(...)` alongside the others, with no separate section. An extract carrying a `lookup` field but no `lookupFiles` is rejected with `400`. The lookup **overwrites** a smart-lookup field. Use `{ source: "lookup_if_missing" }` to extract the field from the document too, and let the lookup fill only what extraction missed. ## Managing workflows The client also lists and deletes workflows: | Method | What it does | Returns | | ------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------- | | `af.listWorkflows({ page?, pageSize?, status? })` | One page of your workflows. `pageSize` maxes at 100 | `Promise`, each one `.run(...)`-able | | `af.deleteWorkflow(workflowId)` | Soft-deletes a workflow | `Promise`, the deleted id. A 404 throws `NotFound` | ```typescript theme={null} const workflows = await af.listWorkflows({ pageSize: 50 }); for (const wf of workflows) console.log(wf.id, wf.name); const file = new File([/* bytes */], "invoice.pdf"); const run = await workflows[0].run(file); const result = await run.wait(); await af.deleteWorkflow(workflows[0].id); ``` ## Reading results ```typescript theme={null} const result = await run.wait(); // Scalar fields, for a linear workflow with a single untagged extraction const inv = result.field("invoice_number"); console.log(inv?.value, inv?.confidence, inv?.evidence); // For anything else, such as nested objects, split workflows or // classifications, read result.raw: the validated wire envelope. const markdown = result.raw.parse?.markdown; for (const extraction of result.extractions) { // Validate-node verdicts travel with the extraction they judged. for (const verdict of extraction.validations ?? []) { console.log(verdict.rule_id, verdict.status, verdict.severity, verdict.detail); } for (const [name, field] of Object.entries(extraction.fields)) { console.log(name, field); } } ``` See [Response formats](/api-reference/response-formats) for the full shape of every section. ## Error handling The SDK exports typed error classes: ```typescript theme={null} import { AnyformatError, // base class APIError, // every HTTP failure without a typed subclass BadRequest, // 400: validation error or bad request body Unauthorized, // 401: invalid or missing API key Forbidden, // 403: disallowed by the server NotFound, // 404: workflow, file or webhook does not exist RateLimited, // 429: slow down ServerError, // 5xx: internal anyformat error SDKTimeout, // local timeout: wait() exceeded timeoutMs WorkflowBuilderError, // builder-API misuse, such as .extract() before .parse() } from "@anyformat/sdk"; try { const workflow = await af.workflow("…").parse().extract([/* … */]).create(); const run = await workflow.run(file); const result = await run.wait(); } catch (err) { if (err instanceof RateLimited) { // back off, retry } else if (err instanceof NotFound) { // workflow or file is gone } else if (err instanceof APIError) { console.error(`HTTP ${err.status}`, err.body); } else if (err instanceof SDKTimeout) { // polling deadline exceeded } else { throw err; } } ``` [Errors](/api-reference/errors) documents the wire-format error codes, such as `EXTRACTION_FAILED` and `RATE_LIMITED`. The `APIError.body` field holds the parsed JSON body the server returned. ## Webhooks `Anyformat` does not expose the webhook endpoints yet. Until it does, register and delete webhooks with `fetch` or with the generated low-level client: ```typescript theme={null} const res = await fetch("https://api.anyformat.ai/v2/webhooks/", { method: "POST", headers: { Authorization: `Bearer ${process.env.ANYFORMAT_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://your-server.com/hook", events: ["extraction.completed"], }), }); const webhook = await res.json(); // webhook.secret comes back once. Store it. ``` See [Webhooks](/api-reference/webhooks/overview) for the payload shape and signature verification. ## Links * [npm](https://www.npmjs.com/package/@anyformat/sdk): `npm install @anyformat/sdk` * [Python SDK](/api-reference/sdks/python): the same fluent shape in Python * [Coding assistant](/guides/coding-assistant): drive anyformat from your editor with Claude # v2 → v3 migration Source: https://docs.anyformat.ai/api-reference/v2-migration The v2 API is deprecated and sunsets on October 3, 2026. Endpoint-by-endpoint mapping to v3. The v2 API, on the `/v2/` prefix, is **deprecated as of July 3, 2026** and **stops serving traffic on October 3, 2026**. The v3 API, on the `/v3/` prefix, is live and covers the full document-extraction surface. This page maps every v2 endpoint to its v3 successor. The [v3 migration guide](/api-reference-v3/migrating-from-v2) covers the semantic changes behind the new paths: keyset pagination, flat run reads, `persistent_id` round-tripping, and idempotency keys. **Sunset date: October 3, 2026.** After that date, `/v2/` endpoints stop serving traffic. Migrate before then, and wire your integration to [alert on the `Sunset` header](/api-reference/introduction#alert-on-sunset) so you never depend on release notes. ## Migration window | Milestone | Date | Signal | | --------------------- | --------------- | --------------------------------------------------------------------- | | Deprecation announced | July 3, 2026 | `Deprecation: Fri, 03 Jul 2026 00:00:00 GMT` on every `/v2/` response | | Sunset | October 3, 2026 | `Sunset: Sat, 03 Oct 2026 00:00:00 GMT` on every `/v2/` response | During the window, `/v2/` keeps working unchanged. No request or response shape changes. The deprecation headers are the only addition. ## Follow the headers Every `/v2/` response carries the deprecation signals. Where a direct v3 equivalent exists, it also carries a `Link` header pointing at it: ```http theme={null} HTTP/1.1 200 OK X-API-Version: 2.0.0 Deprecation: Fri, 03 Jul 2026 00:00:00 GMT Sunset: Sat, 03 Oct 2026 00:00:00 GMT Link: ; rel="successor-version" ``` Sometimes the v3 successor is addressed by an identifier your v2 request does not carry. The link is then a [URI Template (RFC 6570)](https://datatracker.ietf.org/doc/html/rfc6570) that leaves the variable literal, as in ``. Resolve the variable from the v3 response that produced it rather than following the template verbatim. A run id, for instance, comes from triggering a run. ## Endpoint mapping ### Workflows | v2 | v3 successor | Notes | | ------------------------------------ | ---------------------------- | ------------------------------------------------ | | `POST /v2/workflows/` | `POST /v3/workflows/` | Same shape | | `GET /v2/workflows/` | `GET /v3/workflows/` | Same shape | | `GET /v2/workflows/{id}/` | `GET /v3/workflows/{id}/` | Same shape | | `PUT /v2/workflows/{id}/` | `PATCH /v3/workflows/{id}/` | **Verb change.** A v3 update is partial | | `DELETE /v2/workflows/{id}/` | `DELETE /v3/workflows/{id}/` | Same shape | | `GET /v2/workflows/{id}/definition/` | `GET /v3/workflows/{id}/` | Definition is embedded in the v3 workflow detail | ### Uploading and running v3 groups uploaded files into a **document packet**, the successor of the v2 file collection. It is the same entity under the same id. Each execution is a first-class **run**. | v2 | v3 successor | Notes | | ---------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------- | | `POST /v2/workflows/{id}/upload/` | `POST /v3/workflows/{id}/upload/` | Returns a `document_packet_id` | | `POST /v2/workflows/{id}/files/` | `POST /v3/workflows/{id}/upload/` | Multi-file upload folds into the packet upload | | `POST /v2/workflows/{id}/files/from-url/` | `POST /v3/workflows/{id}/upload/from-url/` | Same HTTPS-URL ingestion | | `POST /v2/workflows/{id}/run/` | `POST /v3/workflows/{id}/upload/run/` | Same one-call upload-and-run; returns a `run_id` | | `POST /v2/workflows/{id}/files/{file_id}/run/` | `POST /v3/document-packets/{document_packet_id}/run/` | A run triggers on the packet, not the file. Use the packet id from the upload response | | `POST /v2/operations/parse` | `POST /v3/parse/` | Same one-file parse with no workflow to create; returns a `run_id` | ### Results and status | v2 | v3 successor | Notes | | ------------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------- | | `GET /v2/workflows/{id}/files/` | `GET /v3/workflows/{id}/document-packets/` | Lists packets instead of file collections | | `GET /v2/workflows/{id}/files/{collection_id}/results/` | `GET /v3/runs/{run_id}/` | Results hang off the **run**, not the collection. Poll the `run_id` the trigger returned | | `GET /v2/workflows/{id}/runs/` | `GET /v3/workflows/{id}/runs/` | Same shape | | `GET /v2/operations/parse/{job_id}` | `GET /v3/runs/{run_id}/` | The markdown is at `results.parse.markdown` once `status` is `processed` | | `DELETE /v2/files/{collection_id}/` | `DELETE /v3/document-packets/{id}/` | Same entity, same id | ### Endpoints without a v3 successor These keep working until sunset. They emit `Deprecation` and `Sunset`, but no `Link` header: * **Webhooks**, on `/v2/webhooks/…`. A v3 webhook surface has not shipped yet, so keep using v2. * **Suggestions**, on `/v2/organizations/{org_id}/suggestions/…`. These are internal webapp endpoints, outside the public surface. ## Versioning signals reference [Versioning & deprecation](/api-reference/introduction#versioning--deprecation) documents the header semantics and the alerting setup. The headers are `X-API-Version`, `Deprecation`, `Sunset` and `Link: rel="successor-version"`. # Create Webhook Source: https://docs.anyformat.ai/api-reference/webhooks/create POST /v2/webhooks/ Subscribe to extraction events with a webhook URL Creates a webhook subscription. The response carries a `secret` for verifying webhook signatures. Only this response carries it, and no endpoint returns it later. ## Request Body | Field | Type | Required | Default | Description | | -------- | --------- | -------- | ---------- | ------------------------------------------------------------------------------- | | `url` | string | Yes | none | HTTPS URL that receives webhook events. The API rejects an HTTP URL. | | `events` | string\[] | No | All events | The event types to subscribe to: `extraction.completed` and `extraction.failed` | Only the creation response carries the `secret`. Store it at once. If you lose it, delete the webhook and create a new one. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v2/webhooks/' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -d '{ "url": "https://your-server.com/webhooks/anyformat", "events": ["extraction.completed", "extraction.failed"] }' ``` ```python Python (requests) theme={null} import requests url = "https://api.anyformat.ai/v2/webhooks/" headers = { "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY" } data = { "url": "https://your-server.com/webhooks/anyformat", "events": ["extraction.completed", "extraction.failed"] } response = requests.post(url, headers=headers, json=data) webhook = response.json() # Store the secret securely. Only the creation response carries it. print(f"Webhook created: {webhook['id']}, secret: {webhook['secret']}") ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.anyformat.ai/v2/webhooks/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_API_KEY' }, body: JSON.stringify({ url: 'https://your-server.com/webhooks/anyformat', events: ['extraction.completed', 'extraction.failed'] }) }); const data = await response.json(); // Store the secret securely. Only the creation response carries it. console.log(data); ``` ```typescript TypeScript theme={null} interface WebhookCreateRequest { url: string; events?: string[]; } interface WebhookResponse { id: string; url: string; events: string[]; is_active: boolean; secret: string; created_at: string; } const body: WebhookCreateRequest = { url: 'https://your-server.com/webhooks/anyformat', events: ['extraction.completed', 'extraction.failed'] }; const response = await fetch('https://api.anyformat.ai/v2/webhooks/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_API_KEY' }, body: JSON.stringify(body) }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const data: WebhookResponse = await response.json(); // Store data.secret securely. Only the creation response carries it. console.log(data.id); ``` ```json Response (201 Created) theme={null} { "id": "wh_1234567890", "url": "https://your-server.com/webhooks/anyformat", "events": ["extraction.completed", "extraction.failed"], "is_active": true, "secret": "whsec_abc123def456...", "created_at": "2024-03-24T12:00:00.000Z" } ``` # Delete Webhook Source: https://docs.anyformat.ai/api-reference/webhooks/delete DELETE /v2/webhooks/{webhook_id}/ Delete a webhook subscription Deleting a webhook is permanent and cannot be undone. If you re-create the webhook, a new signing secret will be issued. ```bash curl theme={null} curl -X DELETE 'https://api.anyformat.ai/v2/webhooks/wh_1234567890/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests webhook_id = "wh_1234567890" url = f"https://api.anyformat.ai/v2/webhooks/{webhook_id}/" headers = { "Authorization": "Bearer YOUR_API_KEY" } response = requests.delete(url, headers=headers) print(f"Status: {response.status_code}") ``` ```javascript JavaScript theme={null} const webhookId = 'wh_1234567890'; const response = await fetch(`https://api.anyformat.ai/v2/webhooks/${webhookId}/`, { method: 'DELETE', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); console.log(`Status: ${response.status}`); ``` ```typescript TypeScript theme={null} const webhookId = 'wh_1234567890'; const response = await fetch( `https://api.anyformat.ai/v2/webhooks/${webhookId}/`, { method: 'DELETE', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } } ); if (response.status !== 204) { const error = await response.json(); throw new Error(`API error: ${error.error_code}`); } console.log('Webhook deleted'); ``` ```json Response (204 No Content) theme={null} // No response body - webhook successfully deleted ``` # List Webhooks Source: https://docs.anyformat.ai/api-reference/webhooks/list GET /v2/webhooks/ List all active webhook subscriptions A list response never carries the `secret` field. Only the create response returns it, once. This endpoint returns a flat array, not a paginated response. ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v2/webhooks/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests url = "https://api.anyformat.ai/v2/webhooks/" headers = { "Authorization": "Bearer YOUR_API_KEY" } response = requests.get(url, headers=headers) webhooks = response.json() for webhook in webhooks: print(f"{webhook['id']}: {webhook['url']} ({webhook['events']})") ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.anyformat.ai/v2/webhooks/', { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); const data = await response.json(); console.log(data); ``` ```typescript TypeScript theme={null} interface WebhookListItem { id: string; url: string; events: string[]; is_active: boolean; created_at: string; } const response = await fetch('https://api.anyformat.ai/v2/webhooks/', { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const webhooks: WebhookListItem[] = await response.json(); console.log(webhooks); ``` ```json Response (200 OK) theme={null} [ { "id": "wh_1234567890", "url": "https://your-server.com/webhooks/anyformat", "events": ["extraction.completed", "extraction.failed"], "is_active": true, "created_at": "2024-03-24T12:00:00.000Z" } ] ``` # Overview Source: https://docs.anyformat.ai/api-reference/webhooks/overview Receive real-time notifications when processing completes or fails A webhook removes the need to poll. Your server gets an HTTP callback the moment a processing event fires. ## Event Types | Event | Description | | ---------------------- | ------------------------------------------------------- | | `extraction.completed` | Processing finished successfully, results are available | | `extraction.failed` | Processing encountered an error | Omit the `events` field when creating a webhook, and it subscribes to every supported event. ## Requirements * **HTTPS required**: Webhook URLs must use HTTPS. HTTP URLs are rejected. * **API version**: Webhook endpoints require the v2 API (`/v2/webhooks/`). * **Limit**: Up to 50 active webhook subscriptions per organization. ## How It Works 1. [Create a webhook](/api-reference/webhooks/create) with your HTTPS endpoint URL. 2. Save the `secret` from the creation response. The API returns it once. 3. When processing completes or fails, your endpoint receives a POST request. 4. Verify the request signature with the secret. ## Payload Structure Every webhook delivery sends a JSON POST request with this structure: ```json theme={null} { "event": "extraction.completed", "timestamp": "2024-03-24T12:02:30.000Z", "data": { "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "collection_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "status": "processed", "workflow_id": "550e8400-e29b-41d4-a716-446655440000", "processed_at": "2024-03-24T12:02:30.000Z" } } ``` | Field | Type | Description | | -------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------- | | `event` | string | `extraction.completed` or `extraction.failed` | | `timestamp` | string | ISO 8601 timestamp of the event | | `data.extraction_id` | string | UUID of the extraction record | | `data.collection_id` | string | UUID of the file collection. Pass it to `GET /v2/workflows/{workflow_id}/files/{collection_id}/results/` to fetch results | | `data.status` | string | `processed` when the run completed, `error` when it failed | | `data.workflow_id` | string | UUID of the workflow used | | `data.processed_at` | string \| null | ISO 8601 timestamp when processing finished, `null` on failure | An `extraction.failed` payload has the same shape, with `status: "error"` and `processed_at: null`: ```json theme={null} { "event": "extraction.failed", "timestamp": "2024-03-24T12:02:30.000Z", "data": { "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "collection_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "status": "error", "workflow_id": "550e8400-e29b-41d4-a716-446655440000", "processed_at": null } } ``` ## Delivery Headers Each webhook request includes these headers: | Header | Description | | ----------------------- | ------------------------------------------------------------- | | `Content-Type` | `application/json` | | `X-Webhook-Signature` | The HMAC-SHA256 signature, as `sha256={hex_digest}` | | `X-Webhook-Event` | The event type: `extraction.completed` or `extraction.failed` | | `X-Webhook-Delivery-Id` | Unique UUID for this delivery attempt. Use it to deduplicate | | `User-Agent` | `AnyFormat-Webhooks/1.0` | ## Signature Verification **HMAC-SHA256** signs every webhook payload. To verify a delivery: 1. Read the raw request body, the JSON string exactly as received. 2. Compute `HMAC-SHA256(secret, body)` with the `secret` from webhook creation. 3. Compare the result with the value after `sha256=` in the `X-Webhook-Signature` header. ```python theme={null} import hmac import hashlib def verify_webhook(payload_body: bytes, signature_header: str, secret: str) -> bool: expected = hmac.new( secret.encode(), payload_body, hashlib.sha256 ).hexdigest() received = signature_header.removeprefix("sha256=") return hmac.compare_digest(expected, received) ``` Compare signatures in constant time, with `hmac.compare_digest`. Never use `==`: it leaks timing. ## Retry Policy | Setting | Value | | --------------------- | ---------------------------------- | | Max attempts | 3: the first attempt and 2 retries | | Delay between retries | 1 second, fixed | | Request timeout | 5 seconds per attempt | | Connection timeout | 3 seconds | When all 3 attempts fail, the platform drops the delivery and logs it. Delivery is best-effort, and a webhook failure never affects processing. An outage can lose events. List your files periodically with `GET /v2/workflows/{workflow_id}/files/`, and pick up any `processed` or `error` file you have not handled. ## Webhook Secret Creating a webhook returns a `secret`, a 64-character hex string. Only the creation response carries it; a list response never does. Store it securely. ## Endpoints | Method | Endpoint | Description | | ------ | -------------------- | -------------------------------------------------- | | POST | `/v2/webhooks/` | [Create a webhook](/api-reference/webhooks/create) | | GET | `/v2/webhooks/` | [List webhooks](/api-reference/webhooks/list) | | DELETE | `/v2/webhooks/{id}/` | [Delete a webhook](/api-reference/webhooks/delete) | # Create Workflow Source: https://docs.anyformat.ai/api-reference/workflows/create POST /v2/workflows/ Create a workflow from a typed graph of parse / classify / splitter / extract nodes in a single atomic transaction `POST /v2/workflows/` creates a workflow from a strongly-typed graph. Use it to: * **Configure parse-node settings**: standard or agentic mode, prompt hints, and figure enhancement. * **Build a parse-only workflow**, with no extract node, that returns markdown only. * **Route documents through a classifier or a splitter** to several extract nodes. * **Run a linear `parse → extract` workflow.** Build the workflow in the [anyformat platform](https://app.anyformat.ai) instead, and iterate faster: configure fields visually and test against sample documents. Then copy the workflow id and run workflows through the API. ## Request Body | Field | Type | Required | Description | | ------------- | ------- | -------- | ----------------------------------------------------- | | `name` | string | Yes | Workflow name | | `description` | string | No | Optional description | | `nodes` | Node\[] | Yes | At least one node. Exactly one must be `type="parse"` | | `edges` | Edge\[] | No | Directed edges. Empty for a parse-only workflow | ### Parse Node The entry-point node. Exactly one per workflow. | Field | Type | Default | Description | | -------------------- | ------------------------- | ------------ | --------------------------------------------------------------------------------------------- | | `id` | string | none | Stable identifier, such as `"parse_1"` | | `type` | `"parse"` | none | Discriminator | | `mode` | `"standard" \| "agentic"` | `"standard"` | Use `"agentic"` for per-block LLM routing through the typed text, table and figure strategies | | `prompt_hint` | string | `null` | Domain hint shown to the parser, such as "medical lab report, preserve numerics exactly" | | `figure_enhancement` | boolean | `false` | Extract structured descriptions of charts and images. Costs extra LLM spend | ### Extract Node Pulls structured fields from upstream parsed content. | Field | Type | Required | Description | | --------------------- | ----------- | -------------------- | -------------------------------------------------------------------------------------------------------------- | | `id` | string | Yes | Stable identifier | | `type` | `"extract"` | Yes | Discriminator | | `extraction_schema` | object | Yes | `{ "fields": [...] }`, with at least one field | | `use_images` | boolean | No (default `false`) | Pass rendered page images to the extraction model | | `lookup_file_uploads` | object\[] | No | Inline reference files for [smart lookup](#smart-lookup), each `{ "filename": string, "content": "" }` | | `lookup_files` | string\[] | No | Reference files already stored as S3 URIs. Use this or `lookup_file_uploads` | | `lookup_suggestion` | string | No | Free-form hint telling the matcher how to join the document against the reference file | [Field Types](/concepts/field-types) gives the field shape. Any field may set `"source": "smart_lookup"` to become a [smart-lookup field](#smart-lookup), resolved from a reference file rather than read from the document. `"source": "lookup_if_missing"` extracts the field too, and lets the lookup fill only the empty slots. The default is `"extraction"`. ### Classify Node Routes the document to one of `categories[]` based on an LLM verdict. Outgoing edges must set `branch` to a category `id`. | Field | Type | Required | Description | | ------------- | ------------ | -------- | ---------------------------------------------- | | `id` | string | Yes | Stable identifier | | `type` | `"classify"` | Yes | Discriminator | | `categories` | Category\[] | Yes | At least one category | | `user_prompt` | string | No | Optional prompt prefix shown to the classifier | Each `Category`: | Field | Type | Required | Description | | ------------- | ------ | -------- | --------------------------------------------------------------------- | | `id` | string | Yes | Stable category id; matched against an outgoing edge's `branch` value | | `name` | string | Yes | Display name shown to the LLM | | `description` | string | Yes | Free-form description shown to the LLM | ### Splitter Node Partitions a multi-document file into per-rule sub-documents. Each rule fires an outgoing edge whose `branch` matches the rule `id`. The downstream `extract` node runs once per resulting partition. | Field | Type | Required | Description | | ------- | ------------ | -------- | ----------------- | | `id` | string | Yes | Stable identifier | | `type` | `"splitter"` | Yes | Discriminator | | `rules` | Rule\[] | Yes | At least one rule | Each `Rule`: | Field | Type | Required | Description | | --------------- | ------ | ---------------- | -------------------------------------------------------------------- | | `id` | string | Yes | Stable rule id; matched against an outgoing edge's `branch` value | | `name` | string | Yes | Display name | | `description` | string | Yes | Free-form description shown to the splitter model | | `partition_key` | string | No, default `""` | Key used to label the resulting partitions. Empty means routing only | ### Validate Node Runs after an `extract` node and emits per-rule validation results. The results endpoint returns them as `extractions[].validations[]`, beside the fields they judged. See [Response Formats](/api-reference/response-formats#extractions-validations-entries). A rule is either AI-evaluated or deterministic. An AI rule sets `kind="ai"` and carries a natural-language `description`. A deterministic rule sets `kind="deterministic"` and carries a structured `check`, evaluated in pure Python with no model call. | Field | Type | Required | Description | | ------- | ------------ | -------- | ----------------- | | `id` | string | Yes | Stable identifier | | `type` | `"validate"` | Yes | Discriminator | | `rules` | Rule\[] | Yes | At least one rule | Each `Rule`: | Field | Type | Required | Description | | --------------- | ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------ | | `id` | string | Yes | Stable rule id. Round-trips through `ValidationResult.rule_id` | | `kind` | `"ai" \| "deterministic"` | No, default `"ai"` | Evaluation strategy | | `name` | string | No | Display name for the rule card | | `description` | string | Conditional | Natural-language prompt. Required when `kind="ai"`, forbidden when `kind="deterministic"` | | `check` | Check | Conditional | Structured deterministic check. Required when `kind="deterministic"`, forbidden when `kind="ai"` | | `severity` | `"error" \| "warning"` | No, default `"error"` | Severity of a violation | | `source_fields` | string\[] | No | `persistent_id`s of fields the rule references | The deterministic `check` shapes are discriminated on `type`. Every shape except `expression` names its field operands by `persistent_id`. `expression` addresses fields by extracted name instead. | `type` | Required fields | Optional fields | Meaning | | ------------ | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `range` | `field` | `min`, `max`, at least one | Numeric `field` is within `[min, max]` | | `date` | `field` | `earliest`, `latest`, as ISO `YYYY-MM-DD` or the literal `"today"` | Date `field` is within the inclusive bounds | | `arithmetic` | `operands[]`, at least one item, and `equals` | `operator`, one of `"sum" \| "subtract" \| "product"` and defaulting to `"sum"`, plus `tolerance`, defaulting to `0.0` | `operator(operands)` equals the value of the `equals` field, within `tolerance` | | `comparison` | `left`, `op`, `right` | none | `left right` holds. `op` is `"==" \| "!=" \| ">" \| ">=" \| "<" \| "<="`. `right` is `{"source": "field", "field": ""}` or `{"source": "literal", "value": }` | | `one_of` | `field`, `allowed[]`, at least one item | `case_sensitive`, defaulting to `false` | `field`'s value is one of `allowed` | | `regex` | `field`, `pattern` | none | `field` matches `pattern`. google-re2 evaluates it in linear time, and rejects stdlib `re` syntax that is not re2-safe | | `required` | `field` | none | `field` is present, meaning non-null and non-empty | | `expression` | `expression`, 1–1000 chars | none | A [CEL](https://github.com/google/cel-spec) expression over the whole extraction, bound to one root variable `data`. Fields are addressed by extracted name, as in `data.total` or `data.lineas.map(l, l.importe)`. CEL gains the helpers `num(v)`, `sum(list)` and `abs(x)`. Anything the evaluator cannot answer with a boolean is `inconclusive` | An arithmetic check for `subtotal + tax == total`, within ±1¢: ```json theme={null} { "id": "totals_balance", "kind": "deterministic", "check": { "type": "arithmetic", "operator": "sum", "operands": ["pid_subtotal", "pid_tax"], "equals": "pid_total", "tolerance": 0.01 }, "source_fields": ["pid_subtotal", "pid_tax", "pid_total"] } ``` ### Edges | Field | Type | Required | Description | | -------- | ------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `source` | string | Yes | Source node id | | `target` | string | Yes | Target node id | | `branch` | string | Conditional | Required when leaving a `classify` or `splitter` node, and forbidden otherwise. **It must equal a category or rule `.id`** on the source node, never its `.name`. Using `.name` is rejected with `400`. | ## Examples ### Linear parse → extract ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v2/workflows/' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -d '{ "name": "Invoice extractor", "nodes": [ {"id": "parse_1", "type": "parse"}, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ {"name": "invoice_number", "description": "Invoice ID", "data_type": "string"}, {"name": "total", "description": "Grand total", "data_type": "float"} ] } } ], "edges": [{"source": "parse_1", "target": "extract_1"}] }' ``` ```python Python theme={null} import requests response = requests.post( "https://api.anyformat.ai/v2/workflows/", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={ "name": "Invoice extractor", "nodes": [ {"id": "parse_1", "type": "parse"}, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ {"name": "invoice_number", "description": "Invoice ID", "data_type": "string"}, {"name": "total", "description": "Grand total", "data_type": "float"}, ] }, }, ], "edges": [{"source": "parse_1", "target": "extract_1"}], }, ) workflow_id = response.json()["id"] ``` ### Parse-only with agentic mode A workflow with one `parse` node and no edges. It produces markdown only, and extracts nothing. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v2/workflows/' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -d '{ "name": "Agentic parse-only", "nodes": [ { "id": "parse_1", "type": "parse", "mode": "agentic" } ], "edges": [] }' ``` ```python Python theme={null} import requests response = requests.post( "https://api.anyformat.ai/v2/workflows/", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={ "name": "Agentic parse-only", "nodes": [ { "id": "parse_1", "type": "parse", "mode": "agentic", } ], "edges": [], }, ) workflow_id = response.json()["id"] ``` **Agentic mode** routes each block through a typed text, table or figure strategy, using Gemini to route. The mode is binary: `mode: "agentic"` turns on the full agentic pipeline. See the [Agentic Parse to Markdown](/examples/agentic-parse-to-markdown) recipe for an end-to-end walkthrough. ### Classify-then-extract (branched) Each outgoing edge from a `classify` or `splitter` node sets `branch` to a category or rule **`.id`** on the source node. `.name` is the display label shown to the LLM, and is **not** a valid branch value: using it returns `400`. In the example below, `cat_invoice` routes the edge, and `Invoice` is the label the classifier sees. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v2/workflows/' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -d '{ "name": "Multi-doc", "nodes": [ {"id": "parse_1", "type": "parse"}, { "id": "classify_1", "type": "classify", "categories": [ {"id": "cat_invoice", "name": "Invoice", "description": "Vendor invoice."}, {"id": "cat_receipt", "name": "Receipt", "description": "POS receipt."} ] }, { "id": "extract_invoice", "type": "extract", "extraction_schema": {"fields": [{"name": "vendor", "description": "Vendor name.", "data_type": "string"}]} }, { "id": "extract_receipt", "type": "extract", "extraction_schema": {"fields": [{"name": "merchant", "description": "Merchant name.", "data_type": "string"}]} } ], "edges": [ {"source": "parse_1", "target": "classify_1"}, {"source": "classify_1", "target": "extract_invoice", "branch": "cat_invoice"}, {"source": "classify_1", "target": "extract_receipt", "branch": "cat_receipt"} ] }' ``` ### Smart lookup Enrich extracted values by matching them against a **reference file** instead of reading them off the page. Flag the looked-up field with `"source": "smart_lookup"`. Attach the reference file inline as base64 through `lookup_file_uploads`, or by S3 URI through `lookup_files`. Describe the join with `lookup_suggestion` if it helps. See [Smart Lookup](/guides/nodes/smart-lookup) for how matching works and for the Studio equivalent of these fields. In the example below, the model reads `vendor_name` from the document, then resolves the canonical `vendor_id` from a vendor catalog. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v2/workflows/' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -d '{ "name": "Invoice + vendor lookup", "nodes": [ {"id": "parse_1", "type": "parse"}, { "id": "extract_1", "type": "extract", "lookup_file_uploads": [ {"filename": "vendor_catalog.csv", "content": "dmVuZG9yX25hbWUsdmVuZG9yX2lkCkFjbWUgQ29ycCxWLTAwMDEK"} ], "lookup_suggestion": "Match the extracted vendor_name against the vendor_name column and return the matching vendor_id.", "extraction_schema": { "fields": [ {"name": "vendor_name", "description": "Vendor as printed on the document", "data_type": "string"}, {"name": "total", "description": "Grand total", "data_type": "float"}, {"name": "vendor_id", "description": "Canonical vendor code from the catalog, joined on vendor_name", "data_type": "string", "source": "smart_lookup"} ] } } ], "edges": [{"source": "parse_1", "target": "extract_1"}] }' ``` `lookup_file_uploads[].content` is the **base64-encoded** bytes of the reference CSV or text file. The results payload returns the lookup field, `vendor_id`, alongside the extraction fields. It has no separate response section. An extract node carrying a `source: smart_lookup` or `lookup_if_missing` field but no reference file is rejected with `400`. ## Topology Rules The endpoint enforces graph correctness. A violated rule returns `400`, and the API persists nothing. | Rule | Message | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Exactly one parse node | `workflow must contain exactly one \`parse\` node\` | | Unique node ids | `duplicate node ids: [...]` | | Edges reference existing nodes | `edge source X not in nodes` or `edge target Y not in nodes` | | Edge predecessor compatibility | `extract does not accept extract as predecessor` | | Branch routing | `edge from classify X requires \`branch\` to be set\` | | Branch matches source `.id` | `edge from classify 'classify_1' sets branch='Invoice', but no category on 'classify_1' has that id (valid ids: ['cat_invoice']). \`branch\` must match a category \`.id\`, not its \`.name\`.\` | | No fan-out from non-routers | `node Y (extract) has 3 outgoing edges; only classify and splitter may fan out` | ## Response `201 Created` returns the workflow resource: ```json theme={null} { "id": "0686bb97-8c30-70f0-8000-97669e000eb8", "name": "Invoice extractor", "description": "", "created_at": "2026-05-11T10:00:00Z", "updated_at": "2026-05-11T10:00:00Z" } ``` Save the `id`. You reuse it when uploading documents and fetching results. ## Next Steps End-to-end recipe: create, upload, results, using agentic parse mode Convert documents to markdown without extraction All supported `data_type` values for extraction fields The shape of `parse`, `extractions`, `splits` and `classifications` on the results endpoint # Delete Workflow Source: https://docs.anyformat.ai/api-reference/workflows/delete DELETE /v2/workflows/{workflow_id}/ Permanently delete a workflow and all associated results Deleting a workflow is permanent and cannot be undone. All associated results will also be deleted. ```bash curl theme={null} curl -X DELETE 'https://api.anyformat.ai/v2/workflows/550e8400-e29b-41d4-a716-446655440000/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "550e8400-e29b-41d4-a716-446655440000" url = f"https://api.anyformat.ai/v2/workflows/{workflow_id}/" headers = { "Authorization": "Bearer YOUR_API_KEY" } response = requests.delete(url, headers=headers) print(f"Status: {response.status_code}") ``` ```javascript JavaScript theme={null} const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const response = await fetch(`https://api.anyformat.ai/v2/workflows/${workflowId}/`, { method: 'DELETE', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); console.log(`Status: ${response.status}`); ``` ```typescript TypeScript theme={null} const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const response = await fetch( `https://api.anyformat.ai/v2/workflows/${workflowId}/`, { method: 'DELETE', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } } ); if (response.status !== 204) { const error = await response.json(); throw new Error(`API error: ${error.error_code}`); } console.log('Workflow deleted'); ``` ```json Response (204 No Content) theme={null} // No response body - workflow successfully deleted ``` # Get Workflow Source: https://docs.anyformat.ai/api-reference/workflows/get GET /v2/workflows/{workflow_id}/ Retrieve a workflow by ID including its field definitions To **update** a workflow's fields or configuration, send the new typed graph to `PUT /v2/workflows/{workflow_id}/`. See [Edit a workflow](/api-reference/workflows/update). Each update mints a new workflow version atomically. Earlier versions stay available for historical runs. ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v2/workflows/550e8400-e29b-41d4-a716-446655440000/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "550e8400-e29b-41d4-a716-446655440000" url = f"https://api.anyformat.ai/v2/workflows/{workflow_id}/" headers = { "Authorization": "Bearer YOUR_API_KEY" } response = requests.get(url, headers=headers) print(response.json()) ``` ```javascript JavaScript theme={null} const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const response = await fetch(`https://api.anyformat.ai/v2/workflows/${workflowId}/`, { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); const data = await response.json(); console.log(data); ``` ```typescript TypeScript theme={null} interface Field { name: string; description: string; data_type: string; } interface WorkflowResponse { id: string; name: string; description: string | null; created_at: string; updated_at: string; fields: Field[] | null; } const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const response = await fetch(`https://api.anyformat.ai/v2/workflows/${workflowId}/`, { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const data: WorkflowResponse = await response.json(); console.log(data); ``` ```json Response (200 OK) theme={null} { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Invoice Processing", "description": "Extract data from invoice documents", "created_at": "2024-03-24T12:00:00.000Z", "updated_at": "2024-03-24T12:00:00.000Z", "fields": [ { "name": "invoice_number", "description": "The unique invoice identifier", "data_type": "string" }, { "name": "total_amount", "description": "Total invoice amount including tax", "data_type": "float" } ] } ``` # List Workflows Source: https://docs.anyformat.ai/api-reference/workflows/list GET /v2/workflows/ List workflows with pagination and optional filtering The maximum `page_size` is 100. Requests exceeding this value are silently capped at 100. ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v2/workflows/?page=1&page_size=20' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests url = "https://api.anyformat.ai/v2/workflows/" headers = { "Authorization": "Bearer YOUR_API_KEY" } params = {"page": 1, "page_size": 20} response = requests.get(url, headers=headers, params=params) print(response.json()) ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.anyformat.ai/v2/workflows/?page=1&page_size=20', { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); const data = await response.json(); console.log(data); ``` ```typescript TypeScript theme={null} interface Workflow { id: string; name: string; description: string | null; created_at: string; updated_at: string; } interface WorkflowListResponse { count: number; page: number; page_size: number; results: Workflow[]; } const response = await fetch('https://api.anyformat.ai/v2/workflows/?page=1&page_size=20', { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const data: WorkflowListResponse = await response.json(); console.log(data.results); ``` ```json Response (200 OK) theme={null} { "count": 2, "page": 1, "page_size": 20, "results": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Invoice Processing", "description": "Extract data from invoice documents", "created_at": "2024-03-24T12:00:00.000Z", "updated_at": "2024-03-24T12:00:00.000Z" }, { "id": "550e8400-e29b-41d4-a716-446655440001", "name": "Receipt Processing", "description": null, "created_at": "2024-03-24T13:00:00.000Z", "updated_at": "2024-03-24T13:00:00.000Z" } ] } ``` # Run Workflow Source: https://docs.anyformat.ai/api-reference/workflows/run POST /v2/workflows/{workflow_id}/run/ Submit a file or text for processing using a workflow The run is asynchronous. The response's `id` is the **`file_collection_id`**, never a `file_id`. Poll for results with `GET /v2/workflows/{workflow_id}/files/{id}/results/`. That path segment reads `files`, but it takes the collection id. The endpoint returns 412 while processing and 200 once results are ready. See [Packets and files](/concepts/document-packets#packets-and-files). ## Input Methods Submit content in one of two ways: 1. **File upload.** Send a binary file as multipart form data. 2. **Text input.** Send plain text. One request carries one input method. Send either `file` or `text`, never both. ```bash curl (File Upload) theme={null} curl -X POST 'https://api.anyformat.ai/v2/workflows/550e8400-e29b-41d4-a716-446655440000/run/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -F 'file=@/path/to/document.pdf' ``` ```bash curl (Text Input) theme={null} curl -X POST 'https://api.anyformat.ai/v2/workflows/550e8400-e29b-41d4-a716-446655440000/run/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -F 'text=Invoice #12345 from Acme Corp. Total: $1,250.00' ``` ```python Python (requests) - File Upload theme={null} import requests workflow_id = "550e8400-e29b-41d4-a716-446655440000" url = f"https://api.anyformat.ai/v2/workflows/{workflow_id}/run/" headers = { "Authorization": "Bearer YOUR_API_KEY" } with open('document.pdf', 'rb') as f: response = requests.post( url, headers=headers, files={'file': f} ) print(response.json()) ``` ```javascript JavaScript theme={null} const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const formData = new FormData(); formData.append('file', fileInput.files[0]); const response = await fetch(`https://api.anyformat.ai/v2/workflows/${workflowId}/run/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY' }, body: formData }); const data = await response.json(); console.log(data); ``` ```typescript TypeScript theme={null} interface WorkflowRunResponse { id: string; status: string; workflow_id: string; } const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const formData = new FormData(); formData.append('file', fileInput.files[0]); const response = await fetch( `https://api.anyformat.ai/v2/workflows/${workflowId}/run/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY' }, body: formData } ); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const data: WorkflowRunResponse = await response.json(); console.log(data.id); ``` ```json Response (202 Accepted) theme={null} { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "pending", "workflow_id": "550e8400-e29b-41d4-a716-446655440000", "version_id": "FGaV4I2JAA" } ``` # Run Uploaded File Source: https://docs.anyformat.ai/api-reference/workflows/run-staged POST /v2/workflows/{workflow_id}/files/{file_id}/run/ Process a file you already staged with Upload File The companion to [Upload File](/api-reference/workflows/upload). Once a file is staged, call this endpoint to process it. Pass the `file_id` from the upload response as the `file_id` path parameter. The run is asynchronous. Poll `GET /v2/workflows/{workflow_id}/files/{collection_id}/results/` for the output, using the collection `id` from the response below. `file_id` is the `file_id` from the upload response: the individual **file** handle, not the `file_collection_id`. See [Packets and files](/concepts/document-packets#packets-and-files). To upload and process in one call, use [Run Workflow](/api-reference/workflows/run) instead. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v2/workflows/550e8400-e29b-41d4-a716-446655440000/files/a1b2c3d4-e5f6-7890-abcd-ef1234567890/run/' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "550e8400-e29b-41d4-a716-446655440000" file_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" # file_id from upload url = f"https://api.anyformat.ai/v2/workflows/{workflow_id}/files/{file_id}/run/" headers = {"Authorization": "Bearer YOUR_API_KEY"} response = requests.post(url, headers=headers) print(response.json()) ``` ```javascript JavaScript theme={null} const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const fileId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; // file_id from upload const response = await fetch( `https://api.anyformat.ai/v2/workflows/${workflowId}/files/${fileId}/run/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } } ); const data = await response.json(); console.log(data.id); ``` ```json Response (202 Accepted) theme={null} { "id": "c0ffee00-0000-4000-8000-000000000000", "status": "pending", "workflow_id": "550e8400-e29b-41d4-a716-446655440000", "version_id": "FGaV4I2JAA" } ``` # List Workflow Runs Source: https://docs.anyformat.ai/api-reference/workflows/runs GET /v2/workflows/{workflow_id}/runs/ List all runs for a workflow with pagination Each run corresponds to one file collection, identified by UUID. ## Query Parameters | Parameter | Type | Default | Description | | ----------- | ------- | ------- | ----------------------------------------- | | `page` | integer | `1` | Page number (minimum: 1) | | `page_size` | integer | `20` | Items per page (minimum: 1, maximum: 100) | ```bash curl theme={null} curl -X GET 'https://api.anyformat.ai/v2/workflows/550e8400-e29b-41d4-a716-446655440000/runs/?page=1&page_size=20' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ```python Python (requests) theme={null} import requests workflow_id = "550e8400-e29b-41d4-a716-446655440000" url = f"https://api.anyformat.ai/v2/workflows/{workflow_id}/runs/" headers = { "Authorization": "Bearer YOUR_API_KEY" } params = {"page": 1, "page_size": 20} response = requests.get(url, headers=headers, params=params) print(response.json()) ``` ```javascript JavaScript theme={null} const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const response = await fetch( `https://api.anyformat.ai/v2/workflows/${workflowId}/runs/?page=1&page_size=20`, { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } } ); const data = await response.json(); console.log(data); ``` ```typescript TypeScript theme={null} interface WorkflowRun { id: string; status: string; created_at: string | null; updated_at: string | null; } interface WorkflowRunListResponse { results: WorkflowRun[]; count: number; page: number; page_size: number; } const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const response = await fetch( `https://api.anyformat.ai/v2/workflows/${workflowId}/runs/?page=1&page_size=20`, { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } } ); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const data: WorkflowRunListResponse = await response.json(); console.log(data.results); ``` ```json Response (200 OK) theme={null} { "count": 42, "page": 1, "page_size": 20, "results": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "processed", "created_at": "2024-03-24T12:00:00.000Z", "updated_at": "2024-03-24T12:02:30.000Z" }, { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "status": "in_progress", "created_at": "2024-03-24T13:00:00.000Z", "updated_at": "2024-03-24T13:00:15.000Z" } ] } ``` # Edit a Workflow Source: https://docs.anyformat.ai/api-reference/workflows/update PUT /v2/workflows/{workflow_id}/ Replace a workflow's typed graph in a single atomic transaction `PUT /v2/workflows/{workflow_id}/` accepts the same typed graph shape as `POST /v2/workflows/` and replaces the workflow's definition atomically. Each call mints a new workflow version; earlier versions remain attached to past runs. An update is a **full replacement**, not a delta. Send the complete `nodes` and `edges` the new version should have. To change one field on a 60-field workflow, re-send the whole graph with that one field swapped. ## Recipe: change one field's data type or options The Python SDK builds the typed graph for you. The example below recreates a one-extract-node workflow and updates the `category_taxonomy` field to be an enum. ```python Python (anyformat SDK) theme={null} from anyformat import Client, Schema client = Client(api_key="YOUR_API_KEY") new_options = [ Schema.option("invoice", "Vendor bill or invoice"), Schema.option("receipt", "Point-of-sale receipt"), Schema.option("statement", "Account statement"), ] ( client.workflow("Invoice Processing") .parse() .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.float("total_amount", "Total invoice amount including tax"), Schema.enum( "category_taxonomy", "Document category", options=new_options, ), # …the remaining fields, unchanged ]) .update("550e8400-e29b-41d4-a716-446655440000") ) ``` ## Request body The body has the same shape as [`POST /v2/workflows/`](/api-reference/workflows/create): a typed graph of `nodes` and `edges`. The node types are parse, classify, splitter and extract. | Field | Type | Required | Description | | ------------- | ------- | -------- | ----------------------------------------------------- | | `name` | string | Yes | Workflow name | | `description` | string | No | Optional description | | `nodes` | Node\[] | Yes | At least one node. Exactly one must be `type="parse"` | | `edges` | Edge\[] | No | Directed edges between nodes | ```bash curl theme={null} curl -X PUT 'https://api.anyformat.ai/v2/workflows/550e8400-e29b-41d4-a716-446655440000/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "name": "Invoice Processing", "nodes": [ {"id": "parse_1", "type": "parse"}, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ {"name": "invoice_number", "description": "The unique invoice identifier", "data_type": "string"}, {"name": "total_amount", "description": "Total invoice amount including tax", "data_type": "float"} ] } } ], "edges": [{"source": "parse_1", "target": "extract_1"}] }' ``` ```json Response (200 OK) theme={null} { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Invoice Processing", "description": null, "created_at": "2024-03-24T12:00:00.000Z", "updated_at": "2025-06-17T09:42:11.000Z", "fields": [ {"name": "invoice_number", "description": "The unique invoice identifier", "data_type": "string"}, {"name": "total_amount", "description": "Total invoice amount including tax", "data_type": "float"} ] } ``` # Upload File Source: https://docs.anyformat.ai/api-reference/workflows/upload POST /v2/workflows/{workflow_id}/upload/ Upload a file to a workflow without triggering processing Useful when you want to stage files now and process them in a separate batch later. Each upload creates its own **single-file collection** and returns its `file_collection_id`. To process it, call [Run Uploaded File](/api-reference/workflows/run-staged) with that id. (For upload-and-process in one call, use [Run Workflow](/api-reference/workflows/run) instead.) `file_collection_id` is the handle you run and poll. See [Packets and files](/concepts/document-packets#packets-and-files). `file_id` identifies the single file inside it, and reads that file's content and results. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v2/workflows/550e8400-e29b-41d4-a716-446655440000/upload/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -F 'file=@/path/to/document.pdf' ``` ```python Python (requests) theme={null} import requests workflow_id = "550e8400-e29b-41d4-a716-446655440000" url = f"https://api.anyformat.ai/v2/workflows/{workflow_id}/upload/" headers = { "Authorization": "Bearer YOUR_API_KEY" } with open('document.pdf', 'rb') as f: response = requests.post( url, headers=headers, files={'file': f} ) print(response.json()) ``` ```javascript JavaScript theme={null} const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const formData = new FormData(); formData.append('file', fileInput.files[0]); const response = await fetch(`https://api.anyformat.ai/v2/workflows/${workflowId}/upload/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY' }, body: formData }); const data = await response.json(); console.log(data); ``` ```typescript TypeScript theme={null} interface UploadResponse { status: string; filename: string | null; file_collection_id: string | null; file_id: string | null; } const workflowId = '550e8400-e29b-41d4-a716-446655440000'; const formData = new FormData(); formData.append('file', fileInput.files[0]); const response = await fetch( `https://api.anyformat.ai/v2/workflows/${workflowId}/upload/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY' }, body: formData } ); if (!response.ok) { throw new Error(`API error: ${response.status}`); } const data: UploadResponse = await response.json(); console.log(data); ``` ```json Response (201 Created) theme={null} { "status": "uploaded", "filename": "document.pdf", "file_collection_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "file_id": "f0e1d2c3-b4a5-6789-0abc-def123456789" } ``` # October 2025 Source: https://docs.anyformat.ai/changelog/2025-10 Changes shipped in October 2025. ### October 27, 2025 **\[Improved]** Performance improvements across the platform General performance improvements across the platform. ### October 13, 2025 **\[Fixed]** CSV export field ordering Fixed field ordering in CSV exports to match workflow configuration. **\[Improved]** Consistent results ordering Extraction results now consistently respect field order. ### October 6, 2025 **\[API]** Endpoint to reorder fields within object-type fields A new endpoint lets you reorder fields within object-type fields. **\[Improved]** Verification URLs on job results Job results now include verification URLs for easy access. **\[Fixed]** Fields with similar names in nested structures Fixed handling of fields with similar names in nested structures. ### September 30, 2025 **\[Improved]** Redesigned extraction pipeline Redesigned extraction workflow for better reliability. # November 2025 Source: https://docs.anyformat.ai/changelog/2025-11 Changes shipped in November 2025. ### November 24, 2025 **\[New]** Create data rows from specific extractions You can now create new data rows directly from specific extractions. ### November 17, 2025 **\[Improved]** More accurate table extraction Improved accuracy when extracting data from complex tables. **\[API]** New `/user/is-enabled` endpoint A new endpoint lets you check account status. **\[Improved]** User info on file validation responses File validation responses now include the user who performed the validation. ### November 10, 2025 **\[Improved]** Better handling of long documents Better handling of documents with many pages via smart chunking. **\[Improved]** Validation tracking Track which values have been manually validated or edited. **\[Improved]** More accurate extraction Enhanced extraction accuracy with an updated default model. ### November 3, 2025 **\[Improved]** Better extraction on documents over the context limit Improved extraction quality for documents exceeding context limits. **\[Improved]** Highest-confidence merge When merging extractions, the highest-confidence value is now preserved. # December 2025 Source: https://docs.anyformat.ai/changelog/2025-12 Changes shipped in December 2025. ### December 15, 2025 **\[Improved]** Improved reliability Enhanced system stability and faster deployments. ### December 1, 2025 **\[Improved]** Faster results retrieval for workflows with many documents Optimized results retrieval for workflows that span many documents. **\[Improved]** Detailed usage tracking Added more detailed usage tracking for extraction operations. # January 2026 Source: https://docs.anyformat.ai/changelog/2026-01 Changes shipped in January 2026. ### January 27, 2026 **\[New]** Usage-based billing Introduced billing for data extraction operations with transparent per-page pricing. **\[Improved]** Faster, more reliable field suggestions Field suggestions are now faster and more reliable when creating workflows. **\[Fixed]** Field value upload formats Resolved an issue where certain field value formats could cause upload errors. ### January 20, 2026 **\[Improved]** Faster OCR processing Document processing speed improved with optimized text recognition. **\[New]** Extraction review from the dashboard Administrators can now review extractions directly from the dashboard. **\[Improved]** Organization credits management Added the ability to manage organization credits for usage billing. ### January 13, 2026 **\[Improved]** Faster document processing Significantly improved processing speed for large documents. **\[New]** Filter results by row count Filter extraction results by the number of rows extracted. **\[New]** Configurable per-workflow page rotation Configure custom page-rotation strategies per workflow. ### January 6, 2026 **\[Improved]** Enhanced results filtering on download Enhanced results download with additional filtering options. # February 2026 Source: https://docs.anyformat.ai/changelog/2026-02 Changes shipped in February 2026. ### February 27, 2026 **\[Improved]** Custom date range filter on the usage page The usage page now lets you filter by a custom date range instead of a fixed year, giving more precise control over billing and usage reports. **\[Fixed]** Reverting to the original value marks fields as verified Editing a field value back to its originally extracted value now correctly marks it as human-verified rather than human-edited. ### February 25, 2026 **\[Improved]** Better OCR accuracy Upgraded text recognition with better support for Latin languages. **\[Improved]** LLM cost tracking in billing Billing transactions now include per-extraction LLM token counts and costs for full cost transparency. **\[Improved]** More accurate table extraction Improved accuracy when extracting data from tables with closely spaced rows or surrounding text. **\[Improved]** Faster processing during peak usage Extraction now scales to handle high volumes, reducing wait times during busy periods. **\[New]** Sample workflows on new accounts New accounts are now prepopulated with sample workflows and documents for a guided onboarding experience. **\[Fixed]** Smart-lookup crash on empty corpus Smart lookup no longer crashes when the search corpus is empty. **\[Improved]** Faster extraction callbacks and field-list endpoints Performance improvements on extraction callbacks and field-list endpoints. **\[Fixed]** Readable file download filenames File download filenames are now readable by the frontend. ### February 21, 2026 **\[Improved]** Visual grounding 2.0 Completely revamped evidence highlighting with block-level and element-level precision, making it easier to trace extracted values back to their exact location in the source document. **\[New]** Billing dashboard New usage dashboard with remaining credits, weekly summaries, per-workflow usage breakdown, and CSV export. **\[Improved]** Advanced document layout analysis enabled in production The advanced document segmentation pipeline is now enabled in production for improved table and section detection. **\[Fixed]** Bounding-box highlights no longer stuck after navigating fields Highlights are now cleared correctly when you move between fields. **\[Fixed]** Confidence calibration Fixed calibration configuration for confidence scoring. ### February 14, 2026 **\[New]** Redesigned home page A personalized home with recent workflows, at-a-glance file-status indicators, and a streamlined workflow creation experience with templates. **\[New]** Redesigned schema builder A new two-step Define & Refine flow with AI-powered field suggestions, inline file previews, and drag-and-drop uploads. **\[Improved]** Faster page rotation About 2× faster page-orientation detection per page. **\[Improved]** More reliable AI field suggestions Field suggestions now use an upgraded model for higher reliability. **\[Fixed]** Smart-lookup settings saved on publish Smart-lookup settings are now correctly saved when publishing a workflow version. **\[Fixed]** Workflow studio sidebar overflow Fixed sidebar overflow in the workflow studio. ### February 7, 2026 **\[Improved]** New dashboard visual design Refreshed visual design across the platform with updated components, colors, and layout. **\[Improved]** Faster, more reliable evidence highlighting Evidence highlighting now uses an event-driven architecture, making interactions faster and more reliable across all views. **\[Improved]** Files and organization logos served securely through the backend Files and organization logos are now served securely through the backend, improving security and simplifying local development. **\[New]** AI workflow-name suggestions New AI-powered workflow-name suggestions based on your description and uploaded files. **\[Improved]** File status counts in workflow listings Workflow listings now show at-a-glance counts of files by processing status. **\[Fixed]** Document-element classification Fixed a classification regression that could cause incorrect layout detection in some documents. ### February 3, 2026 **\[New]** Smart Lookup New feature to enrich extracted data with external reference files using AI-powered matching. **\[Improved]** Field source tracking Fields now track their source (extraction vs. smart lookup) for better traceability. **\[Fixed]** Manual field creation processing Field data processing now correctly handles manually created fields. # March 2026 Source: https://docs.anyformat.ai/changelog/2026-03 Changes shipped in March 2026. ### March 30, 2026 **\[Improved]** Rate limits split between submission and general endpoints Rate limits are now applied independently to file-submission endpoints (60 RPM) and general endpoints (600 RPM). Submission operations (run, upload, create file collection) have a stricter limit; read and management endpoints share a higher limit. The two tiers use independent counters. ### March 26, 2026 **\[Breaking + API]** Removed deprecated `as_lists` parameter from the v2 extraction endpoint The `as_lists` query parameter on the v2 extraction endpoint has been removed. **Action required:** drop `as_lists` from any v2 extraction call. The response shape is the unified default introduced earlier this month. **\[New]** Paste images into the workflow creation chat Paste images directly into the workflow creation chat for more contextual AI suggestions (up to 3 images, 3 MB each). **\[Improved]** Concurrent edit protection on workflow saves Workflow configuration saves now detect concurrent edits and surface them, preventing accidental data loss when two users edit at once. **\[Fixed]** Image uploads in workflow suggestions accept files within size limit Image uploads in workflow suggestions are no longer rejected when they're within the allowed size limit. **\[Improved]** Other improvements and fixes Workflow-name loading indicator no longer gets stuck; buttons now show the pointer cursor on hover. ### March 25, 2026 **\[Breaking + API]** v2 extraction is now collection-first Run extraction now returns a file-collection UUID; poll results via `GET /v2/files/{id}/extraction/` (`412` while pending, `200` when ready). **Action required:** if your integration parsed extraction results synchronously from the run-extraction response, switch to polling the new collection-status endpoint. **\[Breaking + API]** Upload endpoint no longer accepts `file_collection_id` `POST /v2/files/` no longer accepts a `file_collection_id` parameter — collections are immutable after creation. **Action required:** create a new collection per upload batch instead of appending to an existing one. **\[Breaking + API]** Deprecated `/jobs/` endpoints removed from the v2 API The deprecated `/jobs/` endpoints are gone. **Action required:** use `GET /v2/files/{id}/extraction/` to poll extraction state instead. **\[Breaking + API]** Simpler v2 pagination shape v2 pagination responses now contain `count`, `page`, `page_size`. The old `total_pages`, `next`, and `previous` fields are gone. **Action required:** compute next/previous page numbers from `count`, `page`, and `page_size` client-side, or rely on `page` boundaries (`1` and `ceil(count / page_size)`). **\[Improved]** Upload multiple files in a single request `POST /v2/files/` now accepts multiple files in a single request. **\[API]** Customer-facing docs now show Bearer authentication Customer-facing API docs now reflect the `Authorization: Bearer ` authentication method. **\[API]** v2 documentation reflects path-based versioning All API documentation has been updated for v2 endpoints with path-based versioning (`/v2/` prefix). ### March 19, 2026 **\[New]** EML and MSG email file support You can now upload `.eml` and `.msg` (Outlook) email files for extraction. Embedded images in EML files are correctly inlined during conversion. **\[API]** New `allow_extend` parameter to append rows on submit Submit now supports an `allow_extend` parameter that lets agents append rows to existing submissions instead of replacing them. **\[API]** v3 types and graceful v2-to-v3 delegation Updated v3 types, v2 file endpoints now delegate to v3, and v3 errors are translated cleanly for v2 callers. **\[Improved]** Better OCR text detection OCR text detection now uses word-count and spatial-coverage heuristics instead of simple truthy checks, and the screenshot is always sent to the model for context. **\[Improved]** Smart-table resubmit replaces rows instead of skipping Smart-table resubmit now replaces the previous output instead of skipping, with scoped worker context and a consolidated briefing. **\[Improved]** Per-user rate limits via API keys Authentication and rate-limiting are now separated, with per-user rate limits derived from API key identity. **\[Improved]** HTTP security headers added per pentest findings Added HTTP security headers across the API per recent pentest recommendations. **\[Fixed]** Bounding boxes align on pages with different dimensions Bounding boxes no longer drift on documents that mix page sizes. **\[Improved]** Other improvements and fixes Table grounding improvements (HTML cell-id injection now gated and tuned), better login-page rendering with static assets loading correctly, cleaner full-page loader on the auth callback, and PDF viewer fixes for documents that mix page sizes (e.g. US Letter and A4). ### March 18, 2026 **\[New]** v2 API with versioned endpoints, pagination wrappers, and improved docs A full v2 API surface ships: versioned endpoints, pagination wrappers, workflow routes, list-extractions, provenance API, and richer OpenAPI documentation. **\[API]** Bearer authentication alongside API key The API now supports the standard `Authorization: Bearer` header alongside the existing API key header. **\[API]** API rate limiting The API now enforces per-endpoint rate limits. **\[Improved]** Smart-table extraction planning with table-scoped delegates The smart-table extraction planner now uses table-scoped delegates, agent limits that scale with document complexity, and improved sandbox tools. **\[Improved]** Better row-level grounding for tables Row-level grounding for tables is now more robust via token-overlap pre-mapping and accuracy fixes to anchor finders. **\[Improved]** Faster page rotation A cascade decision engine with landscape support and optimized center-crop reuse makes page-rotation detection faster. **\[Improved]** File metadata toggle and filtered counts A new metadata visibility toggle on the table header persists locally. File counts now reflect filtered results instead of selected rows, and action buttons show loading state. **\[Fixed]** File-collection creation works in production A regression that returned `403` for all file-collection creation calls in production has been fixed. **\[Fixed]** File download naming Markdown downloads no longer end up with doubled file extensions. **\[Improved]** Other improvements and fixes Security update for vulnerable dependencies; a botched migration that assigned the same UUID to all files has been corrected. ### March 5, 2026 **\[New]** Raw JSON viewer alongside validated results Raw JSON extraction results are now available in a dedicated tab alongside validated results, for easier inspection. **\[Improved]** Better document layout analysis Enhanced layout analysis for more accurate table and section detection. **\[Improved]** Hover cards on simple field results Simple field results now display hover cards with additional context. **\[Improved]** Resizable workflow creation chat The workflow creation chat panel is now resizable. **\[Improved]** Refined validation result cards Validation result cards have refined styling that makes validated datapoints easier to identify at a glance. **\[Fixed]** Confidence handling for undefined values Confidence scores no longer break when a value is undefined. **\[Fixed]** Clipboard copy errors now show clear feedback Clipboard copy errors are handled and surfaced with clear feedback instead of failing silently. **\[Improved]** Other improvements and fixes UI styling consistency improvements across result cards and the file viewer. ### March 4, 2026 **\[New]** Production-grade webhook system You can now create webhook subscriptions, receive real-time event notifications, and configure automatic retries on failure. **\[Improved]** Better error categorization Error messages now use clearer categories, making it easier to understand and resolve extraction issues. **\[Improved]** Schema editor auto-saves Fields and schema changes now auto-save when switching tabs or editing nested fields, preventing accidental data loss. **\[Improved]** Validation page with keyboard navigation The validation page now has improved result cards with keyboard navigation and better loading states. **\[Improved]** Faster OCR processing OCR text recognition is now significantly faster on large documents thanks to GPU-accelerated batch processing. **\[Fixed]** Race conditions in field-form submission Race conditions in field-form submission and save-state management have been eliminated. **\[Fixed]** Security vulnerability in a third-party dependency Fixed a security vulnerability in a third-party dependency. # April 2026 Source: https://docs.anyformat.ai/changelog/2026-04 Changes shipped in April 2026. ### April 30, 2026 **\[New]** New organizations get 5,000 free credits Every new organization is now automatically granted 5,000 credits on creation, recorded as a standard billing transaction. ### April 28, 2026 **\[Breaking + API]** Split confidence is now a 0–100 integer `SplitRange.confidence` is now an integer in `[0, 100]` end-to-end (graph output, backend validation, database, and frontend rendering) instead of a 0–1 float. Existing values were migrated automatically. **Action required:** if your integration reads `confidence` from split results, expect integers (`87`) instead of floats (`0.87`). Confidence badges no longer render `0.87%` or `8700%`. **\[Breaking + API]** Workflow results response shape is now canonical `GET /v2/workflows/{wid}/files/{cid}/results/` now always returns `{collection_id, verification_url, parse, extraction}` — parse-only workflows return `extraction: null` instead of omitting the key. The internal `field_id` no longer leaks in responses. **Action required:** if your integration relied on `extraction` being absent for parse-only workflows, check for `null` instead. Cookbook examples and the API reference have been rewritten to match. **\[New]** Classify nodes return evidence and confidence Classify nodes now return a structured result with category, evidence, and confidence. The UI surfaces confidence and evidence in the category data viewer, with a dedicated Classify tab at workflow level and a per-file Classify tab with verdict cards. The new `classifications` include option exposes the same data via the file API. **\[New]** HTML files render inside the document viewer You can now upload `.html` and `.htm` files and view them in the document viewer. Files render inside a sandboxed, sanitized iframe to block script execution and same-origin access. **\[API]** Better OpenAPI for SDKs and try-it surfaces `POST /v2/workflows/` now declares its JSON request body, so SDKs and AI agents stop falling back to raw `extra_body` dicts. Bearer authentication is now declared as a proper API-key security scheme, so try-it panels render the lock icon and treat auth as required. Webhook responses now expose `url` as an `HttpUrl` and `created_at` as a `datetime`. **\[Improved]** Smart-table search now reaches inside table cells Smart-table search now scans both page prose and table cells, returning cell matches with `{table, row, column, value}` provenance. Scalar values inside tables are no longer dropped behind prose hits. **\[Improved]** Per-category accuracy and confidence charts in monitoring The monitoring tab now groups per-field accuracy and confidence charts into per-category sections for multi-schema workflows, with sensible fallbacks and an improved empty state. **\[Improved]** Validate view now shows results for classify-routed files Per-file validation now displays extracted data for files routed via Classify by synthesizing entries from the file's results when pinned to an extract node. The partition selector and sibling tabs are hidden when they don't apply. **\[Fixed]** Improved processing of `.docx` and `.msg` uploads `.docx`, `.msg`, and other non-PDF uploads now hydrate from their converted PDF instead of feeding the original binary into the PDF parser. Markdown for these formats is now correct and no longer triggers spurious "corrupt PDF" warnings. **\[Improved]** Other improvements and fixes Reliable PDF callback uploads via short-lived presigned URLs (no more "Request Too Big" errors on large rendered PDFs), file-detail columns in splits and per-category tables, `search_text` helper exposed in the smart-table sandbox, object/list extraction fields no longer dropped from v3 results, timeouts and retries on frontend list requests, lighter `/files` list payloads, and `eval` and XFA disabled in the PDF viewer for a smaller attack surface. ### April 27, 2026 **\[Fixed]** Smart-table extraction no longer drops scalar fields Smart-table extraction plans now must assign every schema field to either a table or a `scalar_fields` list, rejecting incomplete plans so scalar fields can no longer be silently dropped. **\[Fixed]** Master-file + extract workflows now run without error Non-split workflows that combine a master-file node with an extract node now run cleanly — master-file nodes can augment an extract branch without triggering single-non-split-node validation. ### April 24, 2026 **\[Improved]** Tabbed data viewer on workflow detail Reworked the workflow detail data viewer into a tabbed layout with shared grid skeletons and improved loading behavior. Added an inline workflow-name editor and tighter studio reload/loader behavior when workflows change. **\[Fixed]** Empty splits tab now shows a clean overlay The splits tab now uses a proper no-rows overlay (resolving the previous "Element type is invalid" error) and filters out splits with no results, so empty entries no longer appear. **\[Improved]** More content captured during PDF parsing Text missed by layout segmentation now reaches downstream parsing through orphan OCR detection and synthetic text-block injection. Orphans are clustered into lines, merged into nearby blocks where appropriate, or promoted to new blocks; redundant smaller blocks are absorbed to prevent duplicates. **\[Fixed]** Date parsing in the smart-table sandbox no longer silently returns `None` The sandbox now allows `datetime.strptime`'s runtime dependencies, so parsed dates are correctly returned instead of being silently converted to `None`. **\[Fixed]** Smart-table no longer overwrites populated fields with empty values Storing an empty list can no longer overwrite an already-populated list field (e.g. a transactions table). Column-statistics summaries now clearly label examples and indicate additional unique values so the worker doesn't mistake samples for full datasets. **\[Improved]** Better table merging when headers differ Same-title tables with differing headers are disambiguated to avoid dropping rows during merge, and the block-parsing prompt and markdown assembly capture text the model sees that isn't covered by any block id. **\[Fixed]** Classifier results survive workflow amendments Category queries continue to return populated classifications and datapoints after prompt adjustments — the version-compatible result builder now maps datapoints across amended versions by their stable persistent identifier. ### April 23, 2026 **\[Fixed]** Partitioned splits now flow into Validate and Smart Lookup When a split rule declares a `partition_key` and the downstream extract is wired into a Validate or Smart Lookup, each partition is now processed independently — one validation pass per partition, one lookup pass per partition — and the results attach to the right per-partition child extraction. Previously the downstream node saw nothing. **\[Improved]** Per-partition fan-out is now bounded Per-partition fan-out is now capped (default `4` parallel branches) so a 100-partition document running through extract → validate → lookup doesn't saturate the model provider. ### April 20, 2026 **\[Breaking]** Smart Lookup is now part of Extract The standalone Smart Lookup node has been removed — lookup behavior now lives on the Extract node. You manage lookup files directly from the Extract menu, enable lookups per-field via a checkbox in the field editor, and see inline badges when lookup files are missing. **Action required:** none for most users — existing workflows migrate automatically and inherit Extract's model settings. If you scripted Smart Lookup as a standalone node, switch to enabling lookup fields on Extract. **\[Breaking]** Smart Table is now an agentic mode on Extract The standalone Smart Table node has been removed — replaced by a Standard / Agentic mode selector on the Extract node. **Action required:** none for most users — existing Smart Table workflows migrate automatically to Extract with `mode="agentic"`. **\[New]** Splits results tab in workflow results Added a Split tab in the extraction results UI showing split-group cards and a collapsible per-page table, plus a `document_split_groups` include option on the workflow-version file endpoint with category, partition value, page range, and confidence. **\[Improved]** Redesigned Classify and Split menus Both nodes now share a Config / Options tab layout with validated card-based editors, uniqueness checks, reserved "Other" handling for Split, click-to-edit from the Config preview tables, and cleaner Save-from-header behavior that closes open forms first. **\[API]** Split groups consolidated by category and partition value `document_split_groups` responses now group rows by `(category, partition_value)` and return consolidated `page_ranges` arrays instead of per-row `page_start` / `page_end` fields. **\[Improved]** Better figure parsing in PDFs The figure-enhancement prompt now uses chart-type-specific strategies, handles multi-panel figures, gives statistical-annotation guidance, and applies column-header rules. Segment deduplication now preserves the largest picture blocks to avoid losing visual context. **\[Improved]** Inline field-level errors in the field editor Replaced the top-level field-form error banner with inline, field-adjacent errors, column-level highlighting, and deduplicated summary messages for enum options and nested fields. **\[Improved]** Default extraction model upgraded to `gpt-5.2` The default extraction model is now `gpt-5.2` across markdown and PDF extraction paths. **\[Fixed]** OneDrive and Google Drive imports work reliably in production Fixed the cloud-connector reliability issues that affected OneDrive and Google imports in staging and production: a stricter content-security policy now allows the required SDK scripts and frames, SDK load errors surface instead of failing silently, consumer-vs-business OneDrive accounts are detected correctly, and a popup-polling leak is gone. **\[Improved]** Other improvements and fixes Validation tab only appears when the workflow includes a Validate node and the file has an extraction; `latest_version` endpoint returns a clean `400` for invalid workflow UUIDs instead of a noisy `500`; OAuth callback token exchange no longer stalls; Smart Lookup handles nested object fields correctly; agentic-extraction description typo fixed; partition tools only exposed when split rules define a partition key; null figure-parsing responses no longer crash; markdown viewer no longer has an infinite scroll loop and the markdown download button moved to the top of the panel. ### April 14, 2026 **\[New]** Redesigned Workflow Studio The Studio now ships redesigned Parse and Extract node menus, smart node placement with auto-connect, a way to add connected nodes from an output handle, empty-state guidance, an updated sidebar with hover cards, and save-from-header with per-node validation errors. **\[New]** Validate node for cross-document rule validation A new Validate node type lets you express cross-document rules that run after extraction. **\[New]** XML file support Added an XML-to-PDF converter for files with embedded base64 attachments, so XML files extract end-to-end. **\[Improved]** API keys are hashed at rest, with multiple named keys per user API keys are now hashed at rest, support naming, and you can hold multiple keys per user — better security and easier rotation. **\[Improved]** Per-cell parse confidence with logprob breakdowns Parse confidence now uses cell-level logprobs when available, with per-cell and per-row logprob breakdowns for table blocks. **\[Improved]** Studio warns when leaving with unsaved changes The Studio now blocks tab switches when configuration is dirty or an inline editor is open, and preserves in-progress work across tab navigation. **\[Improved]** Faster, paginated billing usage The billing usage table is now server-side paginated with summary cards, dramatically improving query performance on large datasets. **\[Improved]** Visual grounding toggle for local-model compatibility Added a toggle to disable visual grounding for environments where the local model doesn't support it. **\[API]** Files nested under workflows; multi-node results; richer OpenAPI File endpoints are now nested under workflows. A new multi-node results endpoint returns results across multiple graph nodes in one call. The OpenAPI spec is now enriched for better SDK documentation, and the billing API gained pagination, filtering, and ordering. **\[Improved]** Other improvements and fixes OCR confidence is now included in segment and table-cell metadata; the schema builder uses a local config store for faster field editing; toast colors, mobile sidebar, long descriptions in choice fields, lookup-file display overflow, and schema editor header sizing fixes; corrupt-PDF hydration errors are now downgraded to warnings. ### April 6, 2026 **\[New]** Agentic document splitter The batch LLM splitter has been replaced by an agentic workflow. You can now define custom split rules (name, description, optional partition key for grouping repeating sections) directly in the Studio SplitPages node, with a built-in "Other" catch-all rule. **\[Fixed]** Non-figure blocks no longer receive figure wrapping Generic non-figure blocks are no longer treated as pictures, so only true figures receive screenshot crops and figure markup in PDF-to-markdown output. **\[API]** Removed duplicate health check endpoint A duplicate health check endpoint has been removed from the API surface. ### April 1, 2026 **\[Breaking + API]** Workflow results endpoint always returns unified JSON `GET /v2/workflows/{id}/results/` now always returns unified JSON (parse markdown plus extraction data per file). The `output_format`, `as_lists`, and file-filter query parameters have been removed; filter to a single file with the new `file_id` query parameter. **Action required:** if your integration relied on `output_format`, `as_lists`, or the legacy file-filter param, remove them and use `file_id` instead. **\[Improved]** Workflow usage endpoint supports pagination and ordering The workflow usage endpoint now supports server-side pagination (`page`, `page_size`), ordering, and database-level filtering with the standard `results` response wrapper. **\[API]** v1 routes restored with deprecation headers v1 routes (workflows, jobs, uploads/results) are back with `Deprecation` and `Sunset` response headers and `X-API-Key` auth fallback for backward compatibility. # May 2026 Source: https://docs.anyformat.ai/changelog/2026-05 Changes shipped in May 2026. ### May 28, 2026 **\[Changed]** Validator nodes now consume credits `ValidateNode` previously ran for free. Each rule in a validator now costs 25 credits, billed once per extraction. The bill scales with the number of rules in the validator, not with the number of pages or files in the document packet. A new `validate` operator (`Credits(25)`) was added to `DEFAULT_OPERATOR_PRICES`. The internal billing dispatch was reshaped from `(item) -> list[OperatorName]` to `(item, page_count) -> list[LineItem]`, so non-page-priced operators like validators can ignore the page count and substitute `len(rules)` as the billed unit. The processor now issues a single `SpendCreditV4` call per extraction with all line items combined. **\[Changed]** Default operator prices realigned with the public pricing page Default credit prices for several operators now match [anyformat.ai/pricing](https://anyformat.ai/pricing): Parse 25 (was 30), Extract 35 (was 20), Splitter 25 (was 10), Parse (Agentic) 100 (was 75), Extract (Agentic) 150 (was 75). Classify is now billed at 10 credits/page (previously a no-op — running a Classify node was free regardless of the published rate). Organizations with enterprise-specific overrides on `OperatorPriceTable` are unaffected. **\[Improved]** Documentation restructured into Concepts / Guides / API Reference [docs.anyformat.ai](https://docs.anyformat.ai) has been reorganized into three scoped tabs. **Concepts** holds definitional pages (Workflows, Schemas, Fields, etc.). **Guides** holds task-oriented walkthroughs (Quickstart, Build workflows, Recipes). **API Reference** holds the REST spec only. The four overlapping "create → run → results" walkthroughs are merged into one Quickstart with UI / curl / Python tabs at each step, and every extraction recipe now uses the canonical typed-graph workflow shape (`{name, nodes, edges}`) instead of the legacy `{fields:[...]}` shortcut. Old URLs redirect. **\[Improved]** Post-signup `/welcome` page is now reachable only from the signup callback Loading `/welcome` directly (typed URL, bookmark, fresh tab) now bounces to `/`. The page is only reachable after a brand-new Auth0 signup callback, which closes a path that could let the upstream GA4 / GTM conversion event fire for already-signed-up users. **\[Improved]** `@anyformat/skill` synced to npm 0.2.2 The `@anyformat/skill` package version in this repo is now aligned with what's live on npm (`0.2.2`). The publish script uses token-based auth and bumps from `max(local, registry)` to prevent version collisions on the next release. **\[Improved]** Release-PR generation now audits merged PRs upfront The `/prod-release` Claude Code command (used to open this release PR) now treats the merged-PR list as the ground truth and the changelog as a derived artifact. The audit step surfaced the gap that produced the entries below — previously, PRs merged without a changelog entry could quietly drop out of the release notes. ### May 27, 2026 **\[New]** Attach smart lookup files to fields in the workflow SDK The Python and JS SDKs can now mark a field as a smart lookup field and attach a reference file when creating a typed workflow. Flag the field with `lookup` and point it at a local CSV or text file; the SDK reads the file and uploads it inline, and extraction resolves that field's values against your reference data. Lookup files are validated as UTF-8 text before anything is written — base64 payloads are never stored or returned. `POST /api/v2/workflows/` accepts a per-field `lookup` flag (mapped to `source=smart_lookup`) and inline base64 `lookup_files`. Files are pre-validated as UTF-8 text/CSV (with cp1252→UTF-8 transcoding); binary or non-text uploads are rejected with a `400 UNSUPPORTED_LOOKUP_FILE` before any S3 writes. Only S3-backed lookup file references are persisted; base64 payloads and URIs are never stored or echoed back. **\[Improved]** Excel workbooks now convert to one Markdown page per sheet Excel→Markdown conversion now emits a separate page for each worksheet, in workbook order, instead of concatenating every sheet into a single page. Multi-tab spreadsheets now classify and process per tab, keeping each sheet's content distinct downstream. **\[New]** Official Python SDK now available on PyPI `pip install anyformat-sdk` — the `anyformat-sdk` package is now live on PyPI. Use the fluent builder to author, create, run, and await workflows end-to-end without writing HTTP code; sync and async clients are included, with typed errors (`BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `RateLimited`, `ServerError`, `SDKTimeout`) and a `.raw` escape hatch on every response. The Claude Code skill at `@anyformat/skill` now documents the Python SDK alongside the JS/TS one. **\[New]** Post-signup `/welcome` landing page Brand-new signups now land on a dedicated `/welcome` page (instead of going straight to `/`), giving marketing a stable URL for Google Ads conversion configuration via GA4 / GTM URL match. Returning users continue to land on `/` (or the originally-requested URL) as before. **\[Fixed]** New signups for an already-registered email address are now rejected Across every API write site that can create a User row (Auth0 just-in-time provisioning, the E2E test-auth endpoint, the `create_user` management command), case-insensitive duplicate emails are now refused before a second row can be created. Auth0 email rotation that would collide with an existing user is also refused. Previously, mixed-case variants of the same address could silently mint duplicate users. **\[Fixed]** Resolved four frontend dependency vulnerabilities `qs` (DoS in stringify), `tmp` (path traversal), `uuid` (missing buffer bounds check), and `js-cookie` (prototype hijack) advisories are now resolved via package overrides in `anyformat/services/frontend`. Upstream parents (cypress, exceljs, segment-analytics) hadn't yet adopted the patched versions, so overrides were the minimally-disruptive path. **\[Improved]** Authenticated images are no longer refetched multiple times per page load The sidebar mounts the organization logo from three places, which previously meant three identical requests to `/api/v3/iam/organizations/{id}/logo/` on every page load. The image hook now uses TanStack Query so concurrent subscribers share one in-flight request and the result is cached for 5 minutes. **\[Improved]** Local development supports both `localhost` and `127.0.0.1` for OAuth For developers running the stack locally, both `http://localhost:/callback` and `http://127.0.0.1:/callback` are now valid OAuth callback URIs and CORS / CSRF origins. Previously only one was registered per environment, which caused login failures if the dev frontend was opened at the other hostname. **\[Improved]** JS SDK build toolchain refreshed `esbuild` (0.21.5 → 0.27.7) and `vitest` (2.1.9 → 4.1.7) in the JS SDK package have been updated. No behavioral changes to the SDK itself. ### May 26, 2026 **\[Improved]** Higher monthly allowance on the Business plan The Business plan's monthly credit grant has increased to 500,000 credits. **\[Changed]** Free tier now gets a one-time 50,000-credit signup grant instead of a monthly allowance Free organizations no longer participate in the monthly credit rotation. Each new free org now receives a single 50,000-credit signup grant that never expires — generous enough to evaluate the product, finite enough to make upgrading the natural next step. Paying tiers (Business, Enterprise) keep their recurring monthly allowance unchanged. **\[Changed]** Stripe top-up pricing is now tier-aware Top-up checkout rates depend on the organization's tier: free orgs pay €1.50 per 1,000 credits (€0.0015 / credit), Business and Enterprise orgs pay €1.00 per 1,000 credits (€0.001 / credit). Top-up amounts are now denominated in credits (minimum 30,000) instead of euros. ### May 25, 2026 **\[New]** Visualize parse layout with `result.parse.draw()` The Python SDK can now render a document's parsed layout as an image overlay. Call `result.parse.draw()` to get the page with detected blocks drawn on top — useful for debugging extraction and verifying bounding boxes. A new cookbook example walks through it. **\[Changed]** Simplified parse configuration The `engine` (Fast/Performant), `effort`, and `visual_grounding` parse knobs have been removed from Studio and the workflow SDK. `engine` was a no-op (both options mapped to the same model), and the other two now use sensible built-in defaults. `figure_enhancement` and `mode` remain configurable. Existing workflows are unaffected — stored values for the removed knobs are ignored gracefully. **\[New]** Install the anyformat Claude Code skill from npm The anyformat Claude Code skill is now published on npm as `@anyformat/skill`. Run `npx @anyformat/skill` to install it — Claude Code can then author, run, and inspect anyformat workflows directly from your editor using your `ANYFORMAT_API_KEY`. Public docs include a new "Claude Code Skill" section under the SDKs page. **\[Fixed]** Classify and split workflows authored via the Python SDK now route to extraction correctly Workflows built with the Python SDK's `classify(...)` / `split(...)` builders no longer silently skip downstream extraction when a category or split rule is attached by name. The typed graph renderer now resolves branch ports by name (matching the schema's edge labels) instead of internal IDs, so SDK-authored classify/split workflows produce the same extractions as Studio-authored ones. **\[Fixed]** OCR text containing NUL bytes no longer fails extraction Extractions whose parse output contained a U+0000 (NUL) character inside a text value previously failed at the database write step, marking the entire batch as errored. The worker now strips NUL bytes from text at the parsing boundary so these documents complete normally. **\[API]** Organization balance endpoint now returns a per-bucket breakdown The new organization balance endpoint reports credits broken down by their source bucket — monthly grant, top-ups, and each redeemed voucher — along with the organization's tier and the monthly anchor day. Read-only by design: querying the endpoint never triggers a balance reset or recompute. `GET /api/v3/billing/organizations/{org_id}/balance/` returns `{ tier, monthly_anchor_at, monthly: {...}, topup: {...}, vouchers: [...], overdraft: {...} }`. Pre-migration organizations return zeroed buckets with a stable shape. Membership is enforced; any role can read. **\[New]** New organizations receive a monthly credit grant on signup Newly created organizations now receive a monthly credit grant scoped to their tier, which resets on the organization's anchor day each month. This replaces the previous one-time welcome top-up and makes ongoing free usage predictable instead of front-loaded. **\[Improved]** Voucher credits now expire after a configurable lifetime Vouchers can now carry a `credit_lifetime_days` setting (default 90 days). When a voucher is redeemed, the granted credits expire after that many days, so promotional credits don't sit in a wallet indefinitely. Existing vouchers continue to behave as before until the new field is set. **\[Fixed]** Signup voucher hint shows voucher value only, in your locale The voucher preview on the create-organization and join-or-create pages no longer double-counts the welcome grant — a 5,000-credit voucher now renders as "5,000 credits" instead of being added on top of the standard signup grant. The number is also formatted using the browser's locale instead of being hardcoded to German formatting. ### May 22, 2026 **\[New]** Official Python SDK and `afx` CLI The `anyformat-sdk` package wraps the workflow API with sync and async clients so you can author, create, run, and await a workflow end-to-end without writing HTTP code. Typed errors (`BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `RateLimited`, `ServerError`, `SDKTimeout`) make integration code easier to write correctly, and a `.raw` escape hatch on every response exposes the underlying JSON when you need it. ```python theme={null} from pathlib import Path from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key="...") workflow = ( client.workflow("Invoices") .parse(mode="agentic") .extract([ Schema.string("vendor", "The company that issued the invoice"), Schema.string("total", "The total amount due, including currency"), ]) .create() ) run = workflow.run(file=Path("invoice.pdf")) result = run.wait(timeout=300) print(result.fields["vendor"].value) ``` `AsyncClient` mirrors the sync surface for async codebases. **\[New]** `afx` CLI for one-shot parse and extract The SDK ships an `afx` CLI for quick command-line use. Run `afx parse --file invoice.pdf` to get markdown, or `afx extract --file invoice.pdf --field "vendor:vendor company" --field "total:total amount"` to extract structured fields. Pass `--schema fields.json` to load a richer schema, `--json` for machine-readable output, and `-o/--output FILE` to write results to disk. ``` export ANYFORMAT_API_KEY=sk_... afx parse --file invoice.pdf afx extract --file invoice.pdf --field "vendor:vendor company" --field "total:total amount" afx extract --file invoice.pdf --schema fields.json --json -o results.json ``` Spinners, step breadcrumbs, and the Markdown/table renderers all detect TTYs and stay quiet when output is piped or `--json` is set, so the CLI is safe to script. **\[New]** Workflow versions are now semver-numbered Workflow versions now display as `v1.0`, `v1.1`, `v2.0` across Studio, version history, and the save toast. Saving a workflow only mints a new version when it actually changes — major bump on graph topology or node config changes, minor bump on extraction-schema changes, and no bump at all when you only move nodes on the canvas or reorder columns in the data viewer. **\[API]** Save layout and reorder fields without minting a new workflow version New workflow-centric write endpoints separate cosmetic edits from material ones. Layout drags and column reorders no longer create new versions or break stable links to a specific version of your workflow. The legacy v2 write endpoints continue to work but now respond with `Deprecation: true`. * `PATCH /api/v3/workflows/{id}/config/` — save (may bump major or minor) * `PATCH /api/v3/workflows/{id}/layout/?version_id=X` — canvas position only (never bumps) * `PATCH /api/v3/workflows/{id}/field-order/?version_id=X` — column reorder only (never bumps) Concurrent edits surface a `409` with `latest_version_id` in the response body so your client can rebase without losing local edits. The deprecated v2 endpoints (`PUT /api/v2/.../workflows/{id}/config/` and `PATCH /api/v2/.../workflows/{id}/reorder-fields/`) still function during the transition window. **\[New]** `npx anyformat-skill` installs the anyformat Claude Code skill If you use Claude Code, you can now install the anyformat skill with a single command: `npx anyformat-skill` drops it into `~/.claude/skills/anyformat`, or `npx anyformat-skill --project` installs it into the current project. The skill teaches Claude how to drive the typed-graph workflow API end to end — create a workflow with nodes and edges, upload a file, run extraction, and poll for results. **\[Fixed]** Multi-tab login no longer fails with a code-verifier error Logging in from two browser tabs at once could clobber the shared PKCE code verifier and surface as a generic "Login failed" page. The callback now silently retries once on this race and lands you on your original destination URL. ### May 21, 2026 **\[Improved]** Cleaner parsed markdown — no structural metadata wrappers Parsed markdown saved to S3 and returned from the parse-result endpoints no longer carries `` framing or `
` wrappers. Block boundaries are now expressed as inert `` anchors that render as nothing in any markdown viewer, and page boundaries are joined with blank lines. Bounding boxes, page numbers, and confidences remain available via the structured results endpoints — they just stop leaking into the human-readable file body. **\[New]** Modelo 200 workflow template (Spanish corporate income tax) A new "Modelo 200" template lands in the workflow gallery, oriented around credit underwriting and portfolio early-warning. It covers balance-sheet and profit-and-loss extraction with 45 root fields (liquidity, leverage, profitability, cash conversion, coverage ratios) plus two table fields for directors and principal shareholders. The template is pre-configured to use agentic parsing with Balanced effort. **\[Fixed]** Nested table columns no longer dropped by the smart-table extractor Columns of a list-of-objects field (for example, items inside a product table) could previously be missed because the smart-table planner tried to extract them as standalone scalars. Nested fields are now correctly treated as per-row columns of their parent table and reach the final extraction every time. **\[Fixed]** Editing object-table cells no longer loses focus mid-typing Validating a value in an object-field table sometimes stole keyboard focus from the input before you finished typing, occasionally swallowing characters. Cell focus is now preserved across both the validation round-trip and the background refetch, and screen-reader row labels stay in sync after row swaps. **\[Fixed]** Parse → split workflows now return their splits Workflows that parse and split a document without a downstream extract were silently dropping the resulting splits. Terminal-splitter workflows now persist and return their splits as expected, surfaced via the file-list endpoint with `?include=splits`. ### May 20, 2026 **\[API]** `POST /v2/workflows/` now takes a typed graph; the old fields-only body is gone `POST /v2/workflows/` now accepts the typed `nodes` + `edges` graph that previously lived at `POST /v2/workflows/with_graph/`. The legacy fields-only body and the `with_graph` path have been removed — there is one canonical workflow-create endpoint going forward. The `with_graph/` path no longer exists at `api.anyformat.ai/v2/workflows/`. Send the same payload (`{name, description?, nodes, edges}`) to `POST /v2/workflows/`. The legacy body `{name, fields}` is no longer accepted — wrap your fields in an explicit `parse → extract` graph instead. The legacy `multipart/form-data` upload path (creating a workflow from a downloaded `json_file`) has also been removed; rehydrate the workflow into the typed-graph body client-side. **\[Changed]** `POST /v2/workflows/{id}/run/` response `status` is now `"pending"` instead of `"success"` The public run endpoint accepts requests asynchronously (`202 Accepted` — the extraction is enqueued, not complete). The previous `"success"` value conflated acceptance with completion; the new `"pending"` matches the vocabulary used by `WorkflowRunListItem.status` elsewhere on the v2 surface. Clients that polled for results regardless of this field are unaffected. Clients that branched on `"success"` to skip polling should now poll on `"pending"`; semantics match what was always intended. **\[New]** Per-branch Validation tabs across workflow and file detail pages The Validate node now gets the same per-branch UX as Extract. The workflow page renders one Validation tab per branch alongside its existing Category and Extraction tabs, and the file detail page mirrors the same layout. Split workflows include a subdocument selector with chevrons and a `1/N` counter, so you can step through per-subdocument validation results without leaving the tab. **\[Improved]** Clear "Not validated" placeholders for rules added after extraction When you add or edit a validation rule, files that were processed before the change now render explicit "Not validated" cards and cells with a hover-card explanation, instead of an ambiguous "N/A". This makes it obvious which results reflect the current rule set and which would need a re-run. **\[Improved]** Redesigned Validate node configuration menu The Validate node menu now uses a tabbed Config / Rules layout aligned with the Classify menu, with a dedicated rule editor that supports inline field chips inside rule descriptions. The previous template-import button has been removed in favor of the new layout. **\[Fixed]** Validation verdicts now populate for split workflows In workflows with a Split node, validation verdicts could come back empty in the file list and detail view because the API only inspected the parent extraction. Validation results now traverse parent and child extractions so per-subdocument verdicts surface correctly across the workflow page Validation tab, the file detail page, and the file results UI. **\[Fixed]** CSV and master-file uploads decode reliably from non-UTF-8 encodings Uploaded CSV files and master files saved in cp1252 (Word/Outlook smart quotes, Spanish accents), Shift-JIS, GB18030, KOI8, and similar non-UTF-8 encodings could previously either silently produce mojibake or fail extraction with a Unicode decode error. Uploads are now transcoded to UTF-8 at the API boundary, with binary payloads disguised as text rejected up front, so downstream parsing and lookups receive consistent, correctly-decoded bytes. The master-file upload endpoint and workflow CSV upload path now run inputs through a strict Western decoding ladder (`utf-8-sig` → `utf-8` → `cp1252`) before falling back to charset detection, and persist the re-encoded bytes to storage with a truthful `Content-Type: …; charset=utf-8` header. Allowed text extensions are `csv`, `txt`, `md`, `markdown`, and `rst`. Non-text payloads (PDF, ZIP, images, executables) are rejected at upload time rather than being mis-decoded. ### May 19, 2026 **\[API]** Fetch parsed blocks with bounding boxes via the new parse-result endpoint The new parse-result endpoint returns the structured page-nested blocks produced by parsing — including bounding boxes, type, confidence, reading order, and layout — along with a presigned URL for the raw markdown. Studio's PDF viewer and JSON view now use these blocks to render overlays consistently across tabs. `GET /api/v3/files/{file_id}/parse-result/` returns `{ blocks: [...], markdown_url: "..." }`. Blocks are grouped per page and carry a flat `bbox: { x0, y0, x1, y1 }`. The endpoint accepts an optional `execution_id` to scope results to a specific workflow execution. **\[API]** Slim split results without nested rows You can now request slim per-split results that omit nested object rows entirely, and then load nested rows lazily per split via a dedicated endpoint. Each slim split carries a per-split version token so caches can invalidate at the split granularity instead of the whole file. Pass `?include=splits_lite` on the file results endpoint to receive slim per-split entries (mutually exclusive with `?include=splits`). Use `GET /workflow-versions/{version}/files/{file_id}/splits/{split_id}/object-results/{field_persistent_id}/` to fetch paginated object rows scoped to a single split. **\[Improved]** Faster Studio category tab on workflows with many splits The Studio category tab now requests slim payloads and fetches nested object rows lazily per split, so opening a file with many splits no longer waits on full results materialization up front. Bounded prefetching when hovering across many cells also keeps the page responsive. **\[Improved]** Parse JSON view shows the full block on expand Expanding a block in the Parse → JSON tab now reveals the complete block object — id, bbox, confidence, content, hyperlinks, rows, and `image_base64` — rendered as inert JSON. Pages are grouped under collapsible sticky headers (collapsed by default) for easier navigation in multi-page documents, and the displayed shape mirrors the public parse-result response exactly. **\[Improved]** Client-side markdown hydration with embedded images The Markdown tab now hydrates images directly in the browser using each block's bounding box — including `figure` blocks — so the in-tab view and Markdown downloads stay consistent. The dedicated server-side hydration pipeline has been retired in favor of this lighter approach. **\[New]** "Beta" badge on Studio nodes still being stabilized Studio nodes can now display a BETA badge in the sidebar, on the canvas header, and in the sidebar hover card. The **Validate** node is the first to use it, marking it as still being stabilized for general production use. **\[Fixed]** Workflows created via the typed graph endpoint now run extraction Workflows created via the typed `with_graph` create endpoint sometimes failed to run extraction and surfaced as `EXTRACTION_FAILED (422)` because the extract node was persisted without an engine. New workflows now always include an engine and execute end-to-end as expected. `POST /v2/workflows/with_graph/` now persists a default engine on every extract node it creates. **\[Fixed]** Object rows in sibling extract branches no longer collide When a workflow had sibling extract branches that defined fields with overlapping names, object rows from one branch could be written against the wrong field. Field schema resolution is now scoped per extract node, so each branch's rows land where they belong. **\[Fixed]** Parse results reliably appear in the UI for new workflow versions Some parsed files showed missing pages or stale `parsed_at` because parse callbacks resolved graph nodes by an identifier that wasn't unique across workflow versions. Callbacks now scope to the active workflow version, so parse results are persisted to the correct file every time. **\[Improved]** Other improvements and fixes Deleted object rows are now consistently excluded from both per-split and file-level row counts; the file results UI deduplicates parse-result requests when navigating between files; and extraction progress estimates fall back to organization-scoped percentiles instead of cross-tenant numbers. ### May 18, 2026 **\[New]** Handwritten text is now a first-class block type in agentic parsing Agentic PDF-to-Markdown now recognizes handwritten text as its own block type, with a dedicated extraction strategy that combines a vision-language transcription with OCR-derived hints. Handwritten blocks are labeled "Handwritten text" in the Studio UI, distinct from machine-printed text, and can be tuned independently from regular text extraction. **\[Improved]** More consistent agentic parsing when text-class blocks have multiple sources When a text-class block has multiple candidate text sources, the agentic parser now writes the chosen source back into the block's elements and regenerates its segments before rendering. Downstream grounding (overlays, citations) now matches the markdown that's actually rendered. ### May 14, 2026 **\[New]** "Beta" badge on agentic parsing mode The Parse node mode card now shows a "Beta" badge next to **Agentic parsing**, making it clear that this parse mode is still being stabilized for general production use. **\[New]** Calibrated confidence scores on smart-table extractions Both table cells and scalar fields produced by the smart-table extractor now carry a calibrated 0–100 confidence, so review UIs and downstream consumers can prioritize low-confidence values. The scores degrade gracefully when scoring is unavailable, so extractions still complete normally. Confidence is surfaced on each extracted value's `metadata.confidence` field as an integer in `[0, 100]`. Both judges flip together via the `smart_table_confidence_strategy` setting (`auto` / `judge` / `none`); the default is on. **\[New]** Review threshold filter for file results File results now include a confidence-threshold slider that filters Fields and JSON to show only extractions at or below the selected confidence — useful for prioritizing low-confidence rows that need human review. The threshold is persisted across reloads and tabs, and an inline hover card explains how the filter works. **\[Improved]** Image previews use the PDF viewer toolbar Image uploads (JPEG, PNG, GIF, BMP, TIFF) now render through the same viewer as PDFs and inherit its zoom, rotate, and full-page controls. Multi-page TIFFs gain a page selector, and the viewer now reports specific errors for unsupported formats or images that exceed browser size and canvas limits instead of failing silently. **\[Improved]** More reliable dense-table parsing in agentic mode Agentic PDF-to-Markdown table parsing no longer stalls on dense tables. Reasoning-token budgets are now properly honored across the heal, audit, and sub-region passes; partially valid heal plans are applied operation-by-operation instead of being discarded on a single bad op; and the table-parsing token cap has been raised so large tables are no longer truncated mid-document. Workflows that previously stalled for over 15 minutes now finish in roughly 3. **\[Improved]** Organization logos stay cached when unrelated settings change The organization logo URL is now invalidated only when the logo bytes themselves change, not whenever any field on the organization is updated. Renaming the org, toggling support access, or any other non-logo edit no longer forces every client to re-download the logo. **\[Fixed]** Bulk-download of selected files no longer 400s `GET /api/v2/saas_manager/workflow-versions/{id}/results/` rejected the `file__id__in` and `file__id__not_in` query parameters with `Field 'id' expected a number but got '...'` whenever the frontend passed UUIDs (the v2 file API now returns file `id` as a UUID string). The filter was still joining against the legacy integer PK; it now joins on the file UUID field, matching the regular file filter. Every bulk-download flow that relies on row selection — and the "select all minus N" pattern — is unblocked. **\[Fixed]** Stricter validation of logo and workflow file uploads Logo and workflow file uploads are now validated against their actual bytes via magic-byte sniffing rather than the client-supplied `Content-Type` header, and only file types the parse pipeline can actually process are accepted. Uploading a renamed executable, ZIP, or arbitrary JSON as a workflow file is now rejected at the API boundary instead of silently failing later in the pipeline. Stored objects also carry the correct `Content-Type` on the storage layer instead of being labeled `binary/octet-stream`. **\[Fixed]** Switching organizations on a resource page no longer reverts Switching the active organization from a workflow detail page (or any other resource page deep-linked from a different org) could occasionally snap back to the original org after showing a "switched" toast. The selection is now preserved on the first click. **\[Fixed]** Object-field row counts exclude deleted rows The paginated object-field results endpoint and the slim workflow-results list both now exclude soft-deleted rows from page contents and the row-count total. Counts shown in the UI and returned by the API now match the rows you can actually see. **\[Fixed]** Sub-table detail rows no longer clip at the bottom Expanded sub-tables inside object-field result tables previously cut off the bottom row because the row-height calculation didn't account for the surrounding padding and border. The detail row now renders at full height regardless of how many sub-rows it contains. **\[Fixed]** Classifier category tabs render again on workflow results Category tabs on classifier workflow results were rendering empty because the slim results response had stopped including the classifier's `graph_node_id`. The field is back in the response (with a batched prefetch to avoid per-file queries), so files routed by a classifier now appear under their correct tab. **\[Fixed]** Workflow list updates immediately after create, duplicate, or delete The home page workflow list previously kept the stale list cached after creating, duplicating, or deleting a workflow, so the change only appeared after a refresh. All three mutations now invalidate the workflow list cache and the list reflects the change right away. ### May 13, 2026 **\[Breaking + API]** Workflow creation no longer accepts file uploads `POST /api/v2/saas_manager/organizations/{org_id}/workflows/` previously accepted a `multipart/form-data` body to import a workflow from a JSON or XLSX template file. That entry point and its template downloads have been removed; the endpoint now accepts JSON request bodies only. **Action required:** if you were uploading a JSON or XLSX template to this endpoint, switch to sending the equivalent JSON body directly. The chat-driven and from-scratch flows in Studio are unaffected. **\[New]** Studio is now generally available The visual workflow editor (Studio) is now visible to every organization member from the workflow page and the new-workflow page. Write access is still controlled by your workflow role. The legacy schema editor at `/workflows/edit/...` has been removed — the Studio tab is now the single editing surface. **\[New]** Row-level actions on object-field result tables Object-field result tables now expose a per-row dropdown with **Validate row**, **Add row above**, **Add row below**, and **Remove row**, plus inline up/down chevrons for adjacent reordering. The trailing-column header has a new **Validate all rows** action that promotes every datapoint in the field to human-verified in a single click. New endpoints under `/api/v2/datapoints/object-fields/{field_id}/`: * `POST .../rows/` (with an `index` in `[0, n_live_rows]`) inserts an empty row and shifts live siblings up by one in the same transaction. * `POST .../rows/{a}/swap/{b}/` exchanges two adjacent live rows. * `POST .../rows/{position}/validate/` bulk-promotes every datapoint in a row to human-verified. * `POST .../validate-all/` does the same for every live row of the object field. All four endpoints require Member role. **\[New]** Signup vouchers now give live feedback during onboarding When a user lands on the signup flow with a voucher code, the create-organization screen shows the total welcome credits they will receive (the standard signup grant plus the voucher's value, fetched live from the backend) if the voucher is valid, or an amber "voucher no longer valid" hint if it is expired or unknown. Previously the page was silent in both cases. `GET /api/v3/iam/signup-vouchers/{code}/` returns a read-only preview of the voucher (`{ code, additional_credits }`) and never consumes a redemption — actual redemption still happens atomically inside organization creation. Returns `404 voucher_not_found` for unknown codes and `410 voucher_expired` for expired or exhausted ones. Responses set `Cache-Control: no-store, private` so admin edits to a voucher are reflected on the next page load. **\[Improved]** Workflow page list is dramatically faster The workflow page's file list now uses a slim response that omits per-cell datapoint detail until you actually expand an object row. On a workload of 50 files with 2,000 line-items each, the list now returns in \~240 ms instead of \~6 s and the response payload is roughly 770× smaller. Object cells pre-warm on hover so expanding feels instant; edits to a datapoint refresh the expanded sub-table without a poll cycle. `GET /api/v2/saas_manager/workflow-versions/{id}/files/?include=results_lite` returns scalar datapoints as `{ id, display, status }` and object fields as `{ row_count }`. Full sub-tables are fetched lazily through `GET /api/v2/saas_manager/workflow-versions/{id}/files/{file_id}/fields/{field_persistent_id}/object-results/`. The existing `include=results` (full) path is unchanged and still available for legacy integrators. **\[Improved]** Stripe top-up is now open to all users and confirms the purchased amount The **Top up** button on the Usage page now opens the Stripe checkout dialog for every user — the previous fallback to a `mailto:sales@anyformat.ai` link is gone. After a successful payment, the success page now states how many credits were added and tells the user they can start running workflows immediately. **\[Improved]** Cleaner extraction on scanned PDFs The agentic PDF-to-markdown parser now honors the routing model's verdict on which text source to trust (the embedded PDF text layer, OCR, or vision) for every block type, including miscellaneous blocks that previously ignored the verdict. On scanned PDFs where the embedded text layer is corrupted, blocks now render the clean OCR text instead of garbled bytes. **\[Fixed]** Returning from Stripe checkout no longer fails authentication If you stayed on Stripe long enough for the short-lived access token to expire, returning to anyformat used to land on an authorization error screen — even though the payment had already succeeded on Stripe's side. The app now silently refreshes the token in the background (and replays the first request that hits a 401), so the top-up success page renders cleanly even after a long checkout. **\[Fixed]** Cross-tab refresh storm and support-elevation isolation Opening the app in multiple tabs no longer triggers refetch storms when you navigate inside one tab. As a separate fix, support agents who elevate into a customer organization stay elevated only in the current tab — new tabs default back to their own organization until they explicitly re-elevate. **\[Fixed]** New users are immediately enabled after signup Users who created their own organization or requested to join an existing one were previously left in a disabled state until an admin reviewed their request, which blocked the create-org path entirely. Both onboarding paths now enable the profile immediately. Join-request approval is still required to gain membership in the target org. **\[Improved]** Other improvements and fixes * Structured-output extractions on Amazon Nova models no longer occasionally fail with an empty-JSON parsing error. * The Usage page no longer renders the "Page consumption" summary box at the top — the cleaner credit-balance card now stands on its own. * Long organization lists on the join-or-create page no longer get clipped below the fold. ### May 12, 2026 **\[Breaking + API]** v2 file responses now use UUID hex for file IDs Every v2 endpoint that previously returned a numeric file ID now emits a 32-character UUID hex string. The OpenAPI schema already declared these fields as UUID strings, so generated SDK clients are unaffected — but raw HTTP integrations that parsed the value as an integer, or that constructed URLs from cached numeric IDs, will need to update. Old integer-shaped URLs return `404`. **Action required:** treat v2 `file_id` values as opaque strings, and refresh any cached file URLs that embedded integer IDs. * File detail, upload responses, and file-position (`prev_id` / `next_id`) * Extraction responses and the verification URL builder * File filters: `id__in`, `id__not_in`, `file_id__in`, `file_id__not_in` now resolve against UUID hex **\[New]** Delete rows from object-field result tables Object-field result tables now have a per-row trash action. Remaining rows are reindexed in place so curated positions stay stable and your verification carries over correctly when the file is re-extracted. Rows the model produced are soft-deleted (so accuracy metrics still reflect the model's miss); rows you added yourself are removed entirely. `DELETE /api/v2/datapoints/object-fields/{id}/rows/{position}/`. Deletion is transactional. A partial unique constraint allows deleted rows to retain their original index, and the result-building SQL filters soft-deleted rows. **\[API]** Workflow responses now include `organization_id` Workflow and workflow-version responses (including the nested `versions` collection on workflow detail) now expose a normalized 32-character hex `organization_id`. Useful when a client needs to identify which org owns a workflow without an extra round-trip. **\[Improved]** Tax ID and billing address now collected during Stripe Checkout Top-up Checkout sessions now collect the customer's tax ID and billing address before invoices are finalized. This unblocks invoice correctness in EU/UK jurisdictions. No action required for existing customers — the values are persisted on the next Checkout. **\[Improved]** Smoother markdown viewer on long documents The "visual" markdown view is significantly snappier on long documents. Scroll-driven page detection is now frame-throttled and only fires on real page transitions, sections subscribe to fewer state changes, and highlight-scroll listeners share a single event bus instead of one per section. **\[Improved]** Faster agentic PDF-to-markdown parsing Agentic PDF→Markdown parsing now parallelizes work per page — single-call vision blocks fan out and agentic blocks run in a small per-page pool, so page latency tracks the slowest block instead of the serial sum. Layout-routing calls were also tuned for lower latency. **\[Fixed]** Org switcher no longer goes blank for orgs without a logo The organization switcher trigger now always renders the org's initials as the base layer and only overlays the logo image once it loads successfully — so switching to an org without a logo no longer flashes blank. **\[Improved]** Parse confidence is now reported even when the model omits logprobs Per-block parse confidence is now computed and stamped on the rendered markdown regardless of whether the parsing model returns token-level log probabilities. Downstream consumers can read `data-parse-confidence` consistently across all supported parsing providers. ### May 11, 2026 **\[Breaking + API]** v2 SaaS Manager list, create, and member endpoints now require an organization in the URL The bare `/api/v2/saas_manager/...` list and create paths, and the legacy `current` organization shortcuts, have been removed. Workflow versions, file collections, member management, and suggestion routes must now include the org id in the URL. Suggestion endpoints additionally require Member role — Viewers get `403`. **Action required:** template the org id into the URL for every v2 SaaS Manager request that previously relied on a bare list/create path or the `current` shortcut. * `GET / POST /api/v2/saas_manager/workflow-versions/` → `.../organizations/{org_id}/workflow-versions/` * `GET / POST /api/v2/saas_manager/file-collections/` → `.../organizations/{org_id}/file-collections/` * `PATCH /api/v2/saas_manager/organizations/current/` → `.../organizations/{id}/` * `PATCH /api/v2/saas_manager/organizations/current/logo/` → `.../organizations/{id}/logo/` * `GET / POST /api/v2/saas_manager/organizations/current/members/` → `.../organizations/{org_id}/members/` * `DELETE / PATCH /api/v2/saas_manager/organizations/current/members/{user_id}/[role/]` → `.../organizations/{org_id}/members/{user_id}/[role/]` * `POST /api/v2/suggestions/{fields,field-description,workflow-description,workflow-name,upload-sample}/` → `.../organizations/{org_id}/suggestions/{...}/` **\[New]** Self-service invoice downloads from the usage page Owners and admins can now open Stripe's hosted Customer Portal from the Usage page to download invoices for past top-ups. Members, reviewers, and viewers don't see the button — invoices are financial documents. `POST /api/v3/billing/organizations/{org_id}/portal-sessions/` mints a short-lived portal URL bound to the org's billing customer record. Orgs that have never run a top-up return `409 stripe_customer_not_provisioned`; the UI surfaces this as a "Top up first" toast. New environment variable `STRIPE_BILLING_PORTAL_RETURN_URL` falls through to `${FRONTEND_URL}/usage`. Configure the operator side (Invoice History toggle, ToS / privacy URLs, default return link) in the Stripe dashboard. **\[Improved]** Cross-tenant probes on v3 detail endpoints now return 404 v3 file results, file-collection extraction status, file-collection results, and webhook delete now derive the active organization from the resource itself. URLs are unchanged. Cross-organization requests get a `404` to mask resource existence; same-org callers are unaffected. **\[Improved]** Hovering a stacked bounding box highlights every overlapping box When several bounding boxes overlap, hovering reveals each one stacked under the cursor instead of just the topmost. Rapid mouse movement is collapsed to one diff per frame so the UI stays responsive. **\[Improved]** Consistent extracted-object UI across Define, Refine, and File Results Object and table fields now render the same way across the schema builder, refine step, and file-results panel — same headers, same skeletons, same status states. Refine correctly shows error / cancelled copy on failed extractions instead of an empty data view. ### May 9, 2026 **\[Breaking + API]** Detail and per-resource v2 endpoints no longer read `X-Current-Org` Datapoints, manual datapoints, extraction results, workflow-version files, file collections, workflow versions, and workflow detail endpoints now derive the active organization from the resource itself. Non-resource list/create endpoints (API key CRUD, manual-datapoint create, workflow list/create) moved under `/api/v2/saas_manager/organizations///`. **Action required:** stop sending `X-Current-Org` to v2 endpoints — derive the org from the resource (URLs unchanged for detail) or from the new org-scoped URL for list/create. Non-members on a same-resource probe now get `404`; in-org callers with insufficient role get `403`. ### May 8, 2026 **\[API]** Create workflows with full graph definition in a single call You can now define a workflow's parse, classify, split, and extract steps in one request — the workflow, its initial version, and all extraction fields are created atomically. The existing workflow-create endpoint is also fully typed, so SDKs get auto-complete and field-level validation. `POST /v2/workflows/with_graph/` accepts the complete graph plus per-extract-node fields and creates everything in a single transaction. The original `POST /v2/workflows/` body is now declared with discriminated field types. **\[New]** Python builder for typed workflows The Python SDK now ships a fluent `Workflow` builder: `.parse().classify(...).extract(...).split(...).build()` produces a fully validated request. `branch=` and `route_from=` accept either an id string or the typed category/rule you registered earlier, so typos fail at the call site instead of at build time. **\[Improved]** Workflow graph validation catches more errors before run Non-branching nodes with multiple outgoing edges, missing `route_from` on classify→split chains, and broken graph transitions are now rejected with clear validation errors instead of producing confusing runtime failures. The new typed graph payload also rolls back cleanly when persistence of one node fails partway. **\[New]** Voucher codes on signup grant extra wallet credit Organization creation now accepts an optional `voucher_code` that grants extra wallet credit to the new org. Vouchers (expiry, usage cap, amount) are managed in the admin panel; invalid or expired codes surface as a clear `400` instead of breaking signup. **\[Improved]** Doc-viewer table polish — resize, unified sync toggle, safer persisted state The object-results table is now vertically resizable with sensible bounds (header + one row min, all-rows-visible max). The PDF toolbar's separate auto-focus and auto-scroll preferences collapse into a single "Sync document and results" toggle, and the lookup-files dialog no longer overflows. Co-mounted views in the same tab stay in sync, and corrupt persisted preferences self-recover instead of breaking the UI on load. ### May 7, 2026 **\[Improved]** Org isolation on v2 detail endpoints is now resource-derived Detail endpoints (datapoints, manual datapoints, extractions, workflow-version files, file collections, workflow versions, workflows) now authorize the caller's role against the resource's organization instead of a request header. Cross-tenant probes return `404`; in-tenant callers with insufficient role return `403`. URLs are unchanged. **\[Improved]** More accurate extraction confidence via LLM judge For extractions where the model doesn't return logprobs (or where you prefer the judge), a separate judge model now scores each extracted value against its source markdown and outputs a calibrated 0–100 confidence. Object and table cells are batched for cost efficiency. Choose the strategy per task with `extraction_confidence_strategy = none | auto | judge`. **\[Fixed]** Login no longer spins on token-exchange failures The post-login callback now exits cleanly when token exchange or profile fetch fails: a 15-second watchdog renders an error page with a logout option, OAuth errors surface explicitly, and tokens are purged on error. **\[New]** Bounding boxes for split + extract workflows The PDF viewer now renders bounding boxes for split + extract workflows by scoping mapped locations per panel — the matching split when navigating by partition, the file's extraction otherwise. Parse, split, classify, and validation panels paint no boxes, as intended. **\[Fixed]** Renamed split categories no longer lose historical sub-documents When you rename a split rule across versions (e.g. "Invoice" → "Bill"), historical sub-documents continue to surface under the renamed tab — the UI now filters by the stable split-node identifier instead of the display string. ### May 6, 2026 **\[New]** Top up credits with Stripe Checkout You can now purchase wallet credit through Stripe Checkout from the Usage page. Sessions are idempotent (no duplicate charges on retry), and every successful top-up generates an invoice you can download later from the Customer Portal. `POST /api/v3/billing/organizations/{id}/checkout-sessions/` validates the requested credit count against per-session min/max bounds and returns a hosted-page URL. Inbound `checkout.session.completed` webhooks are HMAC-verified and serialized to guarantee exactly-once credit grant under contention. **\[New]** File results panel with explicit processing states The per-file extraction panel now renders explicit Unprocessed, Processing, Error, and Processed states with run and retry actions. Heavy result queries only run once the file is actually processed, removing wasted requests and inconsistent loaders for unprocessed files. **\[New]** Search inside the PDF viewer The PDF viewer now has a toolbar search bar with case-sensitive/insensitive matching, prev/next navigation that wraps, a `Cmd/Ctrl-F` shortcut, and cross-page highlighting. Search stays responsive on large PDFs thanks to debounced lazy text extraction. **\[API]** Workflow results now expose classifications, splits, and flat extractions `GET /v2/workflows/{wid}/files/{cid}/results/` now returns `classifications`, detailed `splits` (with per-file pages and partitions), and a flat `extractions` list keyed by `(split_name, partition)` alongside the existing fields. The v3 file-collection results payload exposes the same keys in place — no new endpoint required. **\[Improved]** Org switcher orders by ownership, membership, then support access Organizations are now returned in three alphabetically-sorted buckets — owned, member, support access — replacing the prior database-discovery order. Helpful for accounts that belong to many orgs. **\[Fixed]** Bulk extraction status no longer marks processed files as pending Bulk status resolution now aligns identifier formats and queries root extractions only, so processed parents are correctly reported instead of stuck on "pending". **\[Improved]** Other improvements and fixes File status badge in the file-results header, PDF toolbar staying visible on multi-line wrap, clearer toasts when a dropped file's type isn't accepted, and a fix for crashes on empty drag-and-drop selections. ### May 5, 2026 **\[Improved]** Smart Lookup retries based on residuals Smart Lookup now wraps the worker in an orchestrator + auditor loop that can iterate up to three times if residuals or tool-call signals suggest more work is possible. Search blends BM25 and Jaro-Winkler matching with a per-call query cap to improve recall while keeping runtime bounded. **\[Improved]** Splits panel is now navigable Clicking a split or partition now switches the extraction tab, persists the selected partition in a URL search param, and scrolls the PDF viewer to the matching page. Refresh-safe deep links to a specific partition just work. **\[Fixed]** Duplicating workflows with reused field names no longer fails Workflows that contain multiple extract nodes reusing the same top-level or nested field names can now be duplicated cleanly — field lookups are keyed per source node. **\[Fixed]** Classify-only workflows handle unwired LLM categories When the classifier returns a category with no downstream edge, the run now falls through to the end instead of crashing. Classify-only workflows also persist classification records so results are available afterwards. **\[Improved]** Smart-table list-of-object rows are page-ordered Smart-table list-of-object extraction rows are now sorted by source page and block, so output is stable and easy to align with the source document across runs. Rows with missing page info are placed last. **\[Improved]** Cleaner empty states for splits and category grids Workflows with no rows now render a clean overlay in the splits and category grids instead of stale placeholders. ### May 4, 2026 **\[Breaking]** Audio uploads are no longer accepted The dropzone no longer accepts audio files (`.mp3`, `.wav`, etc.). Existing audio files already in your collections remain viewable and downloadable. **Action required:** if your integration uploaded audio for transcription, switch to a supported format. No deprecation window — audio uploads are rejected starting today. **\[Fixed]** Authentication errors now return the correct status codes Unauthenticated requests now return `401` (with a `WWW-Authenticate` header) instead of being coerced to `403`. Missing email-claim or unknown-key authentication paths return `AuthenticationFailed` cleanly instead of `500` errors. **\[Improved]** Profile-fetch failures show a clear error page Repeated `/profile` failures after login now surface an error page with a logout option instead of stranding users on a spinner. Global query errors are also reported with the failing endpoint for faster triage. **\[Fixed]** Corrupted local preferences no longer break the UI on load Persisted UI preferences (sync toggles, panel sizes, etc.) are now safely parsed; corrupt keys are dropped on initial load and reported to monitoring with the offending storage key. # June 2026 Source: https://docs.anyformat.ai/changelog/2026-06 Changes shipped in June 2026. ### June 29, 2026 **\[New + API]** Deterministic validation rules — instant, free checks alongside AI rules A **Validate** step can now mix two kinds of rules. **AI** rules stay as they were: you describe the condition in plain language and a model judges it. **Deterministic** rules are new — structured checks that run in plain code with no model call, so they're instant and **free**. Seven check types ship: **number range**, **date range** (with a relative `today` bound for not-expired checks), **arithmetic** (`subtotal + tax = total`, with a tolerance), **comparison** (a field vs a value or another field), **one-of** (value in an allowed set), **pattern** (regex), and **required** (present and non-empty). Build them in Studio with the new **AI / Deterministic** toggle on each rule, or author them via the API and SDKs — deterministic rules reference fields by name and carry a `check` payload, AI rules carry a `description`. Pattern checks run on a linear-time regex engine, so a user-supplied pattern can't hang an extraction. Existing workflows are unchanged: rules default to `kind: "ai"`. See [Validation rules](/guides/studio/validate-rules). ### June 22, 2026 **\[New]** Self-serve Business subscriptions, recurring monthly credits, and a billing incident banner Business is now a self-serve plan. From the dashboard, an organisation owner or admin can click **Upgrade to Business** to open Stripe Checkout, enter a card, and become a paying Business customer in a single round-trip — no support ticket required. Once active, the organisation receives a fresh **100,000-credit monthly grant** on each successful renewal, replacing the previous month's grant rather than stacking on top of it. Enterprise is unchanged in spirit (still sales-led) but rides the same plumbing: support agrees on a custom monthly grant and price, mints a dedicated Stripe price, and pins both to the org on a `BillingPlan` row. Top-ups still work on top of the recurring grant for one-off credit additions. Subscription customers get a **Customer Portal** link from the **Usage** page to manage their payment method and cancel the subscription themselves. When Stripe reports a failed renewal, a **billing incident banner** appears on every authenticated page describing the current state (`PastDue` during the grace window, `Suspended` if grace expires, `Canceled` after cancellation) with a deep-link to the action that resolves it — update the card, reactivate, or restart the subscription. Late-payment recovery is exact: if a card is fixed after the grace window has zeroed the grant, the next successful invoice restores `cycle_monthly_grant − consumed_during_unpaid_cycle` credits (floored at zero), so a customer never pays for and then loses what they already used. This rollout is gated behind `BILLING_SUBSCRIPTIONS_ENABLED`. Existing Business and Enterprise organisations whose tier was set by the legacy one-shot upgrade keep their tier through a fallback path until their first real subscription invoice clears. ### June 18, 2026 **\[New + API]** `GET /v2/workflows/{workflow_id}/definition/` — round-trippable workflow definition A new sub-resource returns the workflow's typed graph in exactly the shape `PUT /v2/workflows/{workflow_id}/` accepts. SDK callers can now GET, mutate one field, and PUT the result back without re-authoring the rest of the schema. The PATCH side preserves `persistent_id` per `(node_id, field_name)` so an unchanged echo cuts no new version (`ChangeKind.NONE` modulo edge order), and genuine edits keep `persistent_id` stable for unchanged-by-id fields — analytics, ground-truth, and monitoring continuity all hold across edits. **\[Changed + API]** Field `description` is no longer required to be non-empty The typed surface (`AnyField.description`) previously enforced `min_length=1`, but Path A persistence (the Studio frontend's save path) never enforced it — so legacy workflows can carry fields with empty descriptions that the new GET-definition endpoint needs to round-trip cleanly. Empty descriptions are now accepted on `POST /v2/workflows/`, `PUT /v2/workflows/{id}/`, and the generated SDKs. SDKs and docs still encourage non-empty descriptions — an empty value degrades extraction quality, it isn't structurally invalid. **\[New + API]** `POST /v2/workflows/{workflow_id}/files/from-url/` — upload by URL instead of multipart A new route accepts an HTTPS URL and tells anyformat to fetch the bytes server-side, returning the same `CreateCollectionResponse` shape as the multipart `POST /v2/workflows/{workflow_id}/files/` upload. Use it when the source document already lives in object storage you can presign (S3, GCS, R2, …) or at a public HTTPS URL — no need to stream bytes through your own backend. The fetch is bounded by a 10-second timeout and the same 20 MB cap as multipart upload, and runs through the outbound-URL SSRF guard so URLs resolving to non-globally-routable IPs (loopback, RFC1918, link-local, cloud-metadata, IPv4-mapped IPv6) are refused before any connection opens. HTTPS-only at the gateway; non-2xx upstream, DNS failure, or timeout surfaces as `422`. Documented under [Upload File from URL](/api-reference/files/create-from-url) and in [Files](/concepts/files#how-to-provide-a-file). ### June 15, 2026 **\[Fixed + API]** SDK consumers can pull per-file results through `/v2/workflows/{wf}/results/?file_id=...` again The v3 route `GET /api/v3/files/{file_id}/results/` declared its `workflow_version_id` query parameter as `int`, but the v2 gateway forwards the 10-character `WorkflowVersion.external_id` string returned by `/api/v2/.../latest_version/`. Ninja's int coercion rejected the string with `422`, breaking any SDK call that chained `latest_version` into the per-file-results lookup. The route now accepts `workflow_version_id` as a string, resolves the `external_id` to the internal PK in-route, and returns `404` (not `422`) for an unknown id — matching every other v3 endpoint's "the public `external_id` is the single public identifier" contract. ### June 11, 2026 **\[Breaking + API]** Object fields support only one level of nesting Object fields have always been limited to **one level of nesting** — children of an object must be non-object types — but the API surface advertised the opposite: `ObjectField.nested_fields` was typed recursively, so the generated OpenAPI spec listed object-in-object schemas as valid. The contract is now enforced at the source: posting a schema with a nested object to `POST /v2/workflows/` returns `422`, and the same constraint is mirrored in the regenerated TypeScript SDK types and the Python SDK CLI (which now returns a clean "only one level of nesting is supported" error instead of a raw pydantic traceback). **Action required:** schemas that declared an object inside an object must be flattened before submission. Single-level object fields are unaffected. **\[Breaking + API]** `POST /api/v3/extractions/progress` removed; replaced by the workflow-progress digest The batch progress endpoint `POST /api/v3/extractions/progress` is gone. Callers that want per-extraction progress alongside per-status file counts for a workflow version should switch to the new `POST /api/v3/extractions/workflow-progress-digest` (see the \[New + API] entry below). Single-extraction progress via `GET /api/v3/extractions/{uuid}/progress` is unchanged. **\[New + API]** `POST /api/v3/extractions/workflow-progress-digest` — one round-trip for workflow polling A new digest endpoint returns per-extraction progress for a caller-supplied set of `extraction_uuids` AND unfiltered per-status file counts for the surrounding `workflow_version_id` in a single response. The dashboard now polls this endpoint once every 4 seconds while files are processing, replacing three separate polls (`/extractions/progress` at 2.5s, and the v2 `/files/count/` and `/files/` polls at 5s). Polling stops automatically when no extraction is queued or in-progress. Counts come back `null` for unknown or cross-organisation workflow versions; cross-organisation extraction UUIDs are silently dropped, matching the existing single-extraction-progress semantics. **\[New + API]** Cell-level grounding on extraction evidence Table-row and scalar evidence now carries true cell provenance end-to-end: `evidence` and `mapped_locations` entries on extraction responses gain optional `cell` and `row` keys identifying the exact source cell that fed each value. Evidence without cell-level grounding (non-table datapoints, or table rows the model couldn't ground precisely) stays byte-identical to before — the new keys are emitted only when present. On the reference invoice that motivated this work, table rows with precise grounding went from 0% to 100% (626/626) with zero wrong-row assignments, and scalars whose value lives in a header table (invoice ids, dates) now resolve to one precise evidence on an LLM-cited page instead of a page-backfilled guess. **\[New + API]** Test-set membership for files Files can now be designated as part of a curated, locked test set. A new boolean `is_test_file` is returned on file responses, and two v3 routes manage membership: `POST /api/v3/files/{file_id}/test-set` to add and `DELETE /api/v3/files/{file_id}/test-set` to remove. Files in the test set are **frozen from re-verification**: every verification write (verify, unverify, edit, discard at the datapoint level; verify-extraction; add/delete/swap/verify-row/verify-all at the datarow level, across both v2 and v3) now returns `409 Conflict` with code `ground_truth_locked`, so an extraction designated as ground truth cannot be mutated out from under a downstream accuracy scorer. Ground-truth values and accuracy reporting land in a follow-up release. **\[New]** Download lookup files from the Extract node UI Lookup files (master files attached to an Extract node) were upload-only — once added, you couldn't get them back. The **Manage files** dialog in the Extract node now has a download button next to each lookup file, backed by a new presigned-URL endpoint `GET /api/v2/saas_manager/workflows/{id}/master_file/download/?uri=`. Org membership and the workflow-scoped S3 key prefix are both enforced; the presign is pinned to the canonical bucket so a crafted cross-bucket URI cannot redirect the signed GET. **\[Improved]** Table extraction now always uses the refine engine Table parsing previously branched upfront on a heuristic between a cheap `simple_table` call (one shot, no retry — silent data loss on empty/truncated output) and a much slower `dense_table` agent (\~10–20× the cost and latency). The classification was fuzzy and data integrity depended on getting that one-shot guess right, so the routing prompt was biased toward dense, over-escalating and burning cost. Every table now takes the same path — cheap parse → audit critic → in-place refinement with tools — with a best-draft-wins guarantee, so a misclassification can no longer lose data. On an 18-document Iberia benchmark refine was **27% cheaper** (cheaper on every doc) and substantially faster on the slow tail (worst single doc 1346s → 317s), with all four `full_content` quality metrics improving. As part of the change the routing prompt now requires ≥2 rows × ≥2 columns of tabular evidence before sending a block to the table pipeline, so label/value pairs, signatures and logos are no longer misrouted as tables. **\[Improved + API]** Workflow analytics timeseries response is much smaller and faster `GET /api/v3/workflows/{id}/analytics/timeseries` now serves the default monitoring view in **a few KB of gzipped JSON** instead of 1.7–3.9 MB. The "Overall" series across all fields is shipped pre-collapsed as a new `overall_rows` array (one entry per extraction), and an opt-in repeatable `field_ids` query parameter narrows the legacy `rows` payload to only the fields you want — present-but-empty (`?field_ids=`) returns counts and `overall_rows` only. Calls that omit `field_ids` still receive the full legacy rows, so existing integrations keep working unchanged. The cold-path recomputation now scopes the live aggregation to cold versions instead of the full workflow, self-heals to all-warm on the first full-universe read, and the response is gzip-compressed. ### June 10, 2026 **\[New]** Per-user timezone preference for API datetimes User profiles now carry an IANA timezone preference, defaulting to `Europe/Madrid`. Datetime fields in API responses are serialized in the user's chosen zone, and the dashboard **Account** page gains a searchable timezone selector that persists through the existing profile-update flow. Storage stays UTC end-to-end — only the wire serialization (and transitively, what the dashboard renders) reflects the preference. **\[Fixed + API]** Oversized request bodies now return `400` instead of `500` Any v3 endpoint that reads a request body returned a `500` when the JSON payload exceeded the 20 MB limit (`DATA_UPLOAD_MAX_MEMORY_SIZE`): Django raised `RequestDataTooBig` while django-ninja read `request.body` during parameter resolution, before the view ever ran, so it could not be caught inside the endpoint. A global exception handler on the v3 API now returns `400` with `{"detail": "Request body is too large. Maximum allowed size is 20 MB."}`. The limit in the message is derived from the setting, so it stays accurate if the cap is tuned. **\[API]** Rate limiting now enforced on the external API in production The external API's rate limiter is now active in production. It had been silently disabled for roughly three months because `RATE_LIMIT_ENABLED` and `REDIS_URL` were added to the dev config but never propagated to prod. Callers exceeding the configured limits may now receive `429` responses. **\[Breaking + API]** Deprecated v2 datapoint and file-verification endpoints removed The following v2 endpoints are gone. They were already marked deprecated and verified unreachable from the dashboard, the external API, the SDKs, and the cronjobs before removal: * `PATCH /api/v2/.../datapoints/{id}/` and `POST` / `DELETE /api/v2/.../datapoints/{id}/validation/` * `POST /api/v2/.../files/{id}/verify/` **Action required:** integrations that still target these paths must switch to the v3 replacements: `POST /api/v3/extractions/{uuid}/datapoints/{verify,unverify}` for verification toggles, `POST /api/v3/extractions/{uuid}/datapoints/{edit,discard}` for value edits and discards, and `POST /api/v3/extractions/{uuid}/verify` for whole-extraction verification. **\[Fixed]** Verify Document button now works on split + extract workflows The header **Verify Document** button used to be a permanent no-op on workflows that combine a Split node with an Extract: it targeted the file-level extraction, but extracted data on a split lives on per-split child extractions, so verifying the parent verified nothing. The button now targets the **active split's child extraction** (and is labelled **Verify Extraction** on a split tab to match), and is disabled on parse, split, and validation tabs where there is nothing to verify. A split file is reported as verified only once every non-empty child extraction is verified — empty category tabs no longer wedge the rollup. Shift+Enter advances through unverified splits before moving on to the next file. **\[Improved + API]** New `verifier` API surface; `validator` deprecated The "human-validation → verification" rename now reaches the file-level API. A new endpoint `POST` / `PATCH /api/v2/.../files/{id}/verifier/` assigns and updates the verifier for a file, a `verifier__in` filter narrows file lists by assigned verifier, and `?include=verifier` returns the assigned verifier in file responses. The legacy `/validator` action, `validator__in` filter, and `?include=validator` option remain live for backward compatibility but are now flagged `deprecated` in the OpenAPI spec; integrations should plan the move. **\[Fixed + API]** Non-member reads of workflow analytics now return `404` `GET /api/v3/workflows/{id}/analytics/summary` and `GET /api/v3/workflows/{id}/analytics/timeseries` previously returned `403 Forbidden` to a caller outside the workflow's organisation, leaking the existence of the workflow. They now return `404 Not Found`, matching `GET /api/v3/workflows/{id}`. Members continue to receive analytics as before. **\[Fixed]** New free accounts now see their signup credits on the Usage page The **Usage** page counted only purchased and granted credits, so a brand-new free account — whose only balance is its signup bonus — showed a zero balance and the empty-state screen instead of the credits it could actually spend. Signup credits are now included in both the headline total and the per-source breakdown. ### June 9, 2026 **\[API]** New v3 endpoints for row-level edits on object fields The v3 API now exposes positional row operations on object-field tables: add, delete, swap, verify-row, and verify-all. Rows are addressed positionally by `(extraction_uuid, field_persistent_id, index)` instead of an opaque datarow id, and the dense `[0, n_live]` invariant is enforced at the boundary (negative indexes now return `422` instead of a silent `404`). The corresponding v2 actions on `DatarowViewSet` are now marked deprecated but remain live; integrations should plan to migrate. **\[Improved]** Document parsing now retries transient LLM failures more reliably The parse pipeline now retries `LengthFinishReasonError` (model hit max-tokens, often non-deterministic) and detects silent parse failures where the LLM returned a 200 but the response could not be coerced into the schema. Both previously skipped straight to the fallback model on the first attempt; they now go through the configured retry budget on the primary model first. The per-page parsing token budget was also raised from 8K to 32K to reduce length-truncation failures, and `ParseFailedError` now carries the underlying per-page error detail. **\[Fixed]** Lookup + Validate workflows now complete end-to-end Two independent bugs that broke the same Lookup + Validate workflow are fixed: * A `Validate` node downstream of an `Extract` node with lookup fields previously returned every rule as *inconclusive ("No extraction data available to validate.")*, because the validate step resolved its upstream to a synthetic lookup node and read a state key nothing ever wrote. It now resolves through smart-lookup hops back to the real producing extract. * The Gemini page-sum verification schema was missing a top-level `title`, which made model-build raise outside the per-task `try/except` and aborted the whole extraction whenever Phase-1 sum verification failed and Gemini verification was attempted. **\[Improved]** Faster admin pages, file listings, and date-filtered results Several database hotspots flagged by APM are now indexed and the corresponding queries rewritten to be sargable: * `Extraction.uuid` and `File.name` get btree indexes (built `CONCURRENTLY`), eliminating sequential scans on the hundreds of lookups per day they served. * A new composite `FileCollection(workflow_id, created_at DESC)` index backs the per-workflow collection listing. * `created_at` date filters across the admin dashboard, billing workflow-usage queries, and the public `ResultsFilter` are rewritten from `(created_at AT TIME ZONE 'UTC')::date` casts (which disabled the index, costing up to \~2s on a single filter path) to half-open `created_at` ranges. Behavior is unchanged because the deployment timezone is UTC. **\[Breaking + API]** Deprecated v2 datarow endpoints removed The v2 `DatarowViewSet` actions marked deprecated in the same release as the v3 datarow rollout — `create_empty`, `delete_by_position`, `swap`, `validate_row`, `validate_all` — and their five `/api/v2/.../files/{file_id}/datarows/` routes are now removed. **Action required:** integrations that still drive row-level edits through the v2 paths must migrate to the v3 surface: `POST /api/v3/extractions/{extraction_uuid}/datarows/{add,delete,swap,verify-row,verify-all}`. **\[Improved]** Per-category grid restores split-granular column filtering Workflows that include a Classify or Split node show their extracted data in the Per-Category grid in the data viewer. Column filters on that grid had been deliberately disabled because the previous file-paginated path compared against the wrong extraction. A new split-granular results endpoint backs the grid: a filter on an extracted field now narrows to exactly the matching split results, paginated and counted server-side, with filter and sort behavior matching the main file grid. ### June 5, 2026 **\[Improved]** Transient frontend request failures now auto-retry with backoff The web app retries idempotent React Query requests that fail with transient connectivity errors or 5xx responses, using capped exponential backoff (up to \~10s) and up to 3 attempts. Deterministic 4xx responses and unknown errors still fail fast. Connectivity errors are now detected via the Fetch spec's `TypeError` instead of message matching, which cuts down on false-positive error reports during brief network blips. **\[API]** Malformed `Authorization: Bearer` headers now return `401` `Authorization` headers with malformed bearer tokens (for example, an empty token or whitespace inside the token value) now return `401 Unauthorized` instead of `500 Internal Server Error`. The successful auth path and well-formed token-rejection responses are unchanged. **\[Fixed]** Newly created workflows lay out their nodes with wider default spacing Newly created workflow nodes are placed with a larger default gap so the auto-layout reads more clearly out of the box and matches the spacing the rest of the editor assumes. **\[Fixed]** Full-page loading spinner no longer jumps during initial load The full-page spinner stays anchored in place while the app boots, instead of shifting position as the surrounding layout settles in. **\[New]** How credits work — new pricing & credits docs page A new Concepts → Account page, **How credits work**, documents per-operator credit costs (including Validate at 5 credits per rule per page), plan €/credit conversion, included credits, usage tracking, and tips for keeping cost down. The Usage & Billing page links to it in place of the previous "contact us for pricing" stub. **\[Improved]** Monitoring over-time chart now plots on a true time axis The accuracy and confidence over-time chart on the Monitoring tab positions each extraction at its real timestamp on a time-scaled axis, instead of forcing points onto an evenly-spaced grid. Time-bucketing now adapts to the visible span, so long, dense histories coarsen gracefully while short spans stay at full resolution. Version markers sit at their exact creation time and merge into a single ranged label (for example `v1.0–v1.2`) when several versions ship the same day, and the empty states now distinguish between no rows, no reviewed extractions, and no recorded confidence scores. **\[Improved]** Large workflow versions no longer time out when loading file counts Loading the file counts for a high-volume workflow version no longer tips over the request timeout. The count is now served by a single annotated aggregate backed by an index-only scan, replacing the per-file status scan that caused worker timeouts on workflows with many files. **\[Fixed]** Filtering and sorting restored on smart-lookup columns Columns backed by a smart-lookup field keep their sort and filter controls in the data viewer while still showing the lookup indicator. Previously the custom lookup header replaced the entire header cell, hiding the filter button on those columns even though filtering still worked underneath. **\[Fixed]** Overlapping bounding-box highlights no longer wash out the PDF page In the PDF visualizer, hovering a stack of overlapping bounding boxes now reads as a single translucent tint on both light and dark pages, instead of compounding toward opaque as more boxes overlapped. Boxes shared by many fields, such as table sections, also render more efficiently. ### June 2, 2026 **\[Breaking + API]** Monitoring summary `total_extractions` now counts processed extractions only `GET /api/v3/workflows/{id}/analytics/summary` previously counted every queued extraction in `total_extractions`, including in-flight rows that had not yet finished processing. It now counts processed extractions only — matching the `verified`, `through`, and per-field counts already returned by the same endpoint, and matching what the cold path (no version filter) has always reported. **Action required:** if your integration reads `total_extractions` from the summary endpoint and expects in-flight rows to be included, switch to a non-summary query or add the in-flight count from your own tracking. The Studio monitoring UI is unaffected. **\[Improved]** Monitoring page now reveals data progressively Each card on the Monitoring tab paints its title and controls immediately and shows a skeleton only for the body that is still loading, off the data source that feeds it. A fast endpoint can paint without waiting on a slower sibling, instead of the whole page sitting behind a single page-level skeleton. The time-series chart also now keeps daily resolution on 30-day ranges instead of collapsing into \~5 weekly points. **\[Improved]** Faster workflow version analytics summary `GET /api/v3/workflows/{id}/analytics/summary` is significantly faster on high-volume workflow versions. Overall metrics, per-field metrics, verified counts, and throughput are now computed in two table scans instead of five, with no change to the response shape. The endpoint previously routed through the paginated read path and ran the per-extraction aggregate twice plus a discarded pagination subquery. It now serves the summary from a dedicated read that combines the by-field `GROUP BY` and a single per-extraction `COUNT(...) FILTER(...)` aggregate, with the paginated read path unchanged for the timeseries and detail endpoints. **\[Improved]** Faster workflow version analytics timeseries `GET /api/v3/workflows/{id}/analytics/timeseries` is now significantly faster on high-volume workflow versions, applying the same optimization as the summary endpoint. The response shape is unchanged. The timeseries endpoint consumes only the per-extraction detail rows, but was still served by the full five-scan paginated read — computing the overall sum, per-field `GROUP BY`, and through-rate counts only to discard them. It now uses a detail-only read, and resolves the latest N extractions from the `Extraction` table (which owns `processed_at`) instead of grouping the multi-million-row analytics cache — roughly 34× faster on the top-N lookup, with existing indexes only and no migration. **\[API]** Deprecated combined `/analytics` endpoint removed The deprecated `GET /api/v3/workflows/{id}/analytics` route (flagged for removal when the frontend migration completed) is gone. Use `GET /analytics/summary` for per-version overall and per-field metrics, and `GET /analytics/timeseries` for the cross-version series. **\[Fixed]** File validation status stays in sync with its datapoints When you confirm the last unverified datapoint on a file, the file badge now reliably flips to **Validated**, and when you unverify a previously-verified datapoint the file rolls back to **Processed**. Previously, the file-level validation status could drift from its datapoints — most commonly, the last cell confirmation would not flip the file badge. **\[Improved]** Organization switcher now scales to many organizations The sidebar organization switcher caps at 10 visible organizations by default with a **Show all** expander, and adds a search box that fuzzy-matches organization names across your full list. Organizations you're a direct member of are prioritized over support-access ones, and a per-user most-recently-used ordering surfaces the organizations you actually work in first. The current organization is always shown. **\[Fixed]** Deep links to a file's results now switch to the workflow's organization Opening a workflow file via a direct URL or in a new tab (middle-click) now elevates the active organization to the one the workflow belongs to, matching click-through navigation. Previously a cold-loaded file results page kept the UI on your own organization, hiding the support-access banner and showing the wrong organization in the sidebar. **\[Fixed]** Organization selector rows stay a readable size on long lists When you belong to many organizations, each row in the sidebar organization selector keeps a consistent, tappable height with visible spacing, instead of compressing to near edge-to-edge as the list grows. ### June 1, 2026 **\[New]** Workflow version selector The workflow page header now has a version selector (on the Studio and Monitoring tabs) so you can view any saved version of a workflow. Selecting a non-latest version on Studio disables editing with an explainer — only the latest version can be edited — while Monitoring scopes its analytics to the version you pick. **\[New]** Per-version monitoring history The Monitoring tab now shows version history: the accuracy/confidence chart spans all versions with markers at each version change, a changelog panel lists what changed, and KPIs and by-field metrics can be scoped to a selected version. The analytics endpoint was split into purpose-built reads — `GET /analytics/summary?version_id=` (per-version overall + per-field means and through-rate counts) and `GET /analytics/timeseries?limit=` (cross-version series) — and analytics are now paginated by extraction rather than by (extraction, field) row, so wide schemas no longer collapse the window. The old `/analytics` endpoint is deprecated but retained until the frontend migration completes. **\[API]** Python SDK now installs as `anyformat` The official Python SDK's PyPI distribution has been renamed from `anyformat-sdk` to `anyformat` — `pip install anyformat` — mirroring `@anyformat/sdk` on npm. The import path is unchanged (`from anyformat.sdk import Client`). The distribution starts at version 0.6.0 (the next free slot past the legacy `anyformat==0.5.0` on PyPI). The internal CLI binary that previously occupied the `anyformat` console-script slot was renamed to `af`; `from anyformat.cli import ...` still works. **\[Improved]** Workflow data sub-tabs keep their structural entry points When a workflow has many data sub-tabs, the collapsed row keeps the leading tabs (Documents, Split/Classify, the first categories) and pins the selected tab to the end, with the overflow in a "+N" menu — instead of hiding everything but Documents and the active tab. Rows with five or fewer sub-tabs show all of them. # July 2026 Source: https://docs.anyformat.ai/changelog/2026-07 Changes shipped in July 2026. ### July 28, 2026 **\[New]** Download every document's markdown as one ZIP The Results view's download menu gains **Markdown (ZIP)** — one `.md` per document, named after the original file, plus a `manifest.csv` mapping original filename to archive entry to status. It respects whatever the table is showing: tick documents and the menu says "200 selected", leave the selection empty and it exports everything matching the active filters. Unlike the extraction formats, it is available on the **Documents** tab too, which is where the checkboxes live. Documents whose parse hasn't completed are skipped and reported — both as a summary ("Exported 198 of 200 · 2 skipped") and per row in the manifest. Every archive is built in the background: a small one lands in your downloads within a few seconds, and any export also arrives in the notification bell with a download link that survives closing the tab. Exports consume no pages or credits: the markdown already exists, this only packages it. **\[Fixed]** Bulk downloads now honour the table's filters Filtering the Results table by name, status or date and then downloading returned the whole workflow rather than the filtered set. This affected CSV, Excel, JSON and JSONL. Filters on extracted-field values were unaffected. Selecting rows and then changing a filter also kept the stale selection; the selection now resets when the filter changes. **\[Improved]** Slack alerts ask for narrower permissions and join the channel themselves The Slack app now requests only the OAuth scopes a delivery needs, and joins the target channel on first delivery instead of asking you to invite it. In Studio, the Slack Alert node's preview and test-send render the template tokens with real values. ### July 13, 2026 **\[New + API]** Attach metadata to a document packet Every v3 create path accepts an optional `metadata` object, stapled to the packet at creation time and echoed back verbatim when you read it. When the packet runs, the extract step sees the metadata inline: a top-level key whose name matches an extract-schema field is used as that field's value, in preference to anything read from the document, and the field's evidence marks it as `metadata.` so you can tell the two sources apart. Use it to pass a customer reference, a batch id, or any context your own system already knows. See [Attaching metadata](/concepts/document-packets#attaching-metadata). On the multipart uploads, send `metadata` as a JSON-encoded string form field: `-F 'metadata={"customer_reference": "CR-2026-99001-ZTX"}'`. There is no schema beyond "must be a JSON object". Read it back on `GET /v3/document-packets/{document_packet_id}/`. ### July 7, 2026 **\[New + API]** API v3 launched — document packets and runs as first-class resources The public API's new stable major is live under `/v3/`, rebuilt around three resources: **workflows**, **[document packets](/concepts/document-packets)** (the runnable bundle of 1+ files — v2's "file collection", renamed and promoted), and **[runs](/concepts/runs-and-results)** (one execution attempt of a workflow against a packet, with its own id, status, and results). The full reference lives in the new [v3 API tree](/api-reference-v3/introduction); v2 users can follow the [path-by-path migration guide](/api-reference-v3/migrating-from-v2). Highlights: * **Flat run reads, no more 412-polling** — [`GET /v3/runs/{run_id}/`](/api-reference-v3/runs/get) always returns `200` with a `status` field; `results` carries the (unchanged) results envelope inline once `status` reaches `processed`. The `/results/` sub-path is gone. * **One upload shape** — [`POST .../upload/`](/api-reference-v3/workflows/upload) groups 1–10 files into a single packet atomically, [`.../upload/run/`](/api-reference-v3/workflows/upload-and-run) does upload + run in one call and returns a `run_id`, and [`.../upload/from-url/`](/api-reference-v3/workflows/upload-from-url) imports 1–10 HTTPS URLs all-or-nothing. * **Re-run without re-uploading** — [`POST /v3/document-packets/{id}/run/`](/api-reference-v3/document-packets/run) creates a fresh run each call; earlier runs stay readable. * **The workflow read is the definition** — [`GET /v3/workflows/{workflow_id}/`](/api-reference-v3/workflows/get) returns the complete typed graph with a `persistent_id` per field, and [`PATCH`](/api-reference-v3/workflows/update) echoes that shape back: echo the `persistent_id` and a renamed field keeps its identity (analytics, ground truth, and quality metrics stay attached). There is no separate `/definition/` endpoint and no `PUT` in v3. * **Keyset pagination everywhere** — every list returns `{items, next_cursor}` with `?limit=` (max 100) and `?cursor=`; no totals or page numbers. * **Idempotent submission** — the upload and run-trigger POSTs accept an [`Idempotency-Key` header](/api-reference-v3/introduction#idempotency); a retry with the same key replays the original response instead of creating a duplicate packet or billing a second extraction. * **`X-API-Version: 3.0.0`** is stamped on every `/v3/` response. v2 is unchanged and keeps serving traffic through its announced deprecation window (see the July 3 entry below). Authentication, the error envelope, the results envelope, webhooks, and rate limits are identical across both versions. ### July 6, 2026 **\[Changed + API]** Dataset membership moves to collection-keyed, idempotent endpoints Test-set membership is being renamed to the product's **dataset** vocabulary and re-keyed onto the collection — the identity a document already carries everywhere else (the `id` in every document response). Membership now lives under the workflow: `PUT /api/v3/workflows/{workflow_id}/dataset/files/{collection_id}/` adds a document to the dataset (optionally snapshotting a chosen extraction as ground truth via `{ "extraction_id": … }`), `DELETE` the same path removes it, and `POST /api/v3/workflows/{workflow_id}/dataset/files/bulk/` adds a filtered grid selection. All three writes are **idempotent** — re-adding a member or removing a non-member is a `200`/`204` no-op instead of a `409`. The test-file list rows now include a `collection_id` so callers can address membership without a second lookup. The previous file-scoped routes `POST /api/v3/files/{file_id}/test-set/` and `DELETE /api/v3/files/{file_id}/test-set/` still work but are **deprecated** and now idempotent too; migrate to the `dataset/` paths above. ### July 3, 2026 **\[Deprecated + API]** v2 enters its deprecation window — `Deprecation`/`Sunset` headers on every response With v3 as the new stable major, `/v2/` is frozen: no new features, existing behavior untouched. Every `/v2/` response now carries the [RFC 8594](https://datatracker.ietf.org/doc/html/rfc8594) deprecation signals — `Deprecation: Fri, 03 Jul 2026 00:00:00 GMT` and `Sunset: Sat, 03 Oct 2026 00:00:00 GMT`. Per-route `Link: <...>; rel="successor-version"` headers pointing at the v3 equivalent follow as the mappings are wired up. `/v3/` responses carry none of these headers. **Action recommended:** wire an alert on the `Sunset` header so migration work can't be missed — [Versioning & deprecation](/api-reference/introduction#versioning--deprecation) shows a five-line client middleware that does it. Then plan the move with the [v2 → v3 migration guide](/api-reference-v3/migrating-from-v2). No client changes are required today; v2 keeps working until the sunset date. # August 2026 Source: https://docs.anyformat.ai/changelog/2026-08 Changes shipped in August 2026. ### August 25, 2026 **\[New]** Documentation reorganised around nodes The docs are now organised around the workflow nodes. Parse, Extract, Classify, Split, If/Else, Validate, Edit and Knowledge each have their own page, with API and SDK examples side by side, and every example is written against API v3. The v2 version switcher is gone; v2 stays documented in the [migration guide](/api-reference-v3/migrating-from-v2) until its sunset. Start at the [node overview](/guides/nodes/overview). **\[New + API]** Drive anyformat from any agent host over MCP anyformat now serves a remote MCP server, so Claude Code, Claude Desktop, Cursor, ChatGPT or your own agent can work with your workflows through typed tools, with one connection and no SDK code to write. The first release covers the workflow lifecycle: list, get, create, update and delete. The server is mounted at `https://api.anyformat.ai/mcp` (Streamable HTTP, stateless) and authenticates with your existing `af_*` API key as a bearer token. Tools: `list_workflows`, `get_workflow`, `create_workflow`, `update_workflow`, `delete_workflow`. Every tool calls the matching `/v3/workflows/` route, so rate limits, ids and the error envelope are the ones you already know; a failed call returns the standard `{error, error_code, retryable, request_id}` envelope as the tool error. A missing or invalid key answers `401` with `MISSING_API_KEY` / `INVALID_API_KEY`. **\[Improved]** The Fast tier is now priced as the cheapest model tier Parse and extract in Fast mode now bill 12 and 17 credits per page, half the standard rate. They previously billed at the standard rate (25 and 35). See [How credits work](/concepts/how-credits-work). `mode: "lite"` on a parse or extract node bills under the new `lite_parse` and `lite_extract` operators. `mode: "max"` keeps billing as `extract`. ### August 24, 2026 **\[Improved]** Fast parse always runs the strongest OCR The Fast parse tier is now one recipe: the strongest deployed OCR with native layout detection, every time. The effort selector is gone from Studio and from the SDK builders. `ocr_effort` is still accepted on a parse node and ignored. Losing the knob re-keys the parse cache, so the first Fast parse of a document after this change is a cache miss. **\[New]** Preview DOCX, CSV and XLSX files before processing The document viewer now renders Word documents natively before a run has processed them, and CSV and Excel files open in a table viewer instead of a raw preview. **\[Fixed]** Downloading a DOCX or PPTX returns the original file Clicking a file name to download a Word or PowerPoint document handed back its PDF conversion. Download now returns the original bytes; the viewer keeps using the converted PDF for preview. `GET /api/v3/files/{file_id}/download/` serves the original file. A new `GET /api/v3/files/{file_id}/processed/` serves the PDF-converted variant and answers `404` when the file has none (native PDFs). **\[Fixed]** The Documents grid no longer lists dataset copies A document copied into a workflow's dataset appeared in the Documents grid as if it were an ordinary upload. Dataset copies are excluded now. Single-file documents also show their file name in the collection column. **\[Fixed]** Studio refuses to save a graph that breaks the topology rules An invalid connection could be saved from Studio and only fail when a document ran. The topology rules the API enforces now run on Studio's save path too, and a node that must have a single upstream is checked as such. ### August 21, 2026 **\[New]** Evals 2.0 is generally available The Health tab, with Datasets, Evaluations and the Optimizer, now shows for every organisation. The 1.0 / 2.0 toggle is gone. See [Evaluations](/guides/health/evaluations). **\[Fixed]** The Run button counted 0 documents after an upload After uploading documents, the Run button's count stayed at 0 until the page was reloaded. **\[Fixed]** If/Else workflows show the upstream extraction results When an If/Else run did not branch into a further step, the results view showed nothing. It now shows the upstream Extract results. **\[Improved]** Smart-table extraction reports a clear refusal on multi-file documents A smart-table field cannot run over a document packet with more than one file. The refusal now appears in the results view with a message that says so. **\[Fixed]** One node order and one node name in the run's progress trail The progress trail could list a run's nodes in a different order, or under a different name, from the workflow page. Both now come from one source. ### August 20, 2026 **\[Breaking + API]** The Fast parse tier is OCR-only; its review settings are removed Fast-mode parse (`mode: "lite"`) now runs a single OCR pass with no model review behind it. A workflow that wants model understanding on top of OCR should use `standard` or `agentic`. Four parse-node fields that configured the retired review stage are removed from the API: `skip_routing_review`, `figures`, `text_formatting` and `routing_effort`. The `region` field is removed too: Fast OCR always runs in the EU. Stored workflows were migrated automatically. **Action required:** remove those fields from any parse node your integration sends. A request that still carries one of the four review fields is rejected with `400` naming the field. Update to the latest SDK, which no longer offers them. **\[Improved]** Flash OCRs scanned and rotated pages Flash's `scanned_pages` option gains an `"ocr"` policy, and it is the new default: a page with no text layer, or a rotated page, is OCR'd and then flows through the same layout and reading-order steps as the rest of the document. `"skip"` and `"fail"` keep their behaviour. Because the default moved, a Flash workflow that never set the option now OCRs pages that used to come back blank. **\[Improved]** Flash parses multi-page documents faster Flash now runs its layout-detection batches concurrently, so a long document no longer waits on one page at a time. **\[Fixed]** Flash markdown follows the rendered reading order Flash served its markdown as a join of the element list instead of the rendered reading order, so columns and captions could come out in the wrong sequence. **\[Improved]** Validate leaves beta The Validate node no longer carries a beta label in Studio. **\[Improved]** Long-running extractions get 90 minutes The time limit on a single extraction is raised to 90 minutes, so a very large document no longer times out before it finishes. **\[Fixed]** Error-page counts on the Usage page The Usage page's error-pages figure was read from a source that could disagree with the Results view. It now counts from the run records. **\[Improved]** Results-page node tabs match the workflow page The node tabs on a file's results page now use the same labels as the workflow page. ### August 19, 2026 **\[New + API]** Flash: a CPU-only parse tier for born-digital documents Flash is the rung below Fast. It reads the PDF's own text layer, detects regions and reading order with a layout model, and serves markdown with no model call and no paid OCR, at 7 credits per page. Born-digital text and ruled tables report confidence 100; a heuristic structure reports no confidence rather than a fabricated one. Available in Studio's parse menu, on the API and in both SDKs. See [Parse](/guides/nodes/parse). Set `"mode": "flash"` on a parse node. `scanned_pages` decides what happens to a page without a text layer. Flash bills 7 credits per page under its own operator. **\[New]** If/Else branch results as tabs The results a document produced depend on the branch it took. The file results view and monitoring now show one tab per branch. Tabs are keyed by node id, so renaming a node keeps its history. **\[Improved]** Parsed markdown comes from one structured renderer The structured markdown renderer is now the only renderer, so parsed markdown is formatted consistently across documents and tiers. **\[Fixed]** A rejected model credential no longer fails extractions silently A workflow using your own model credential now reports a rejected credential as the reason for the failure instead of failing with no explanation. **\[Fixed]** Field references survive import, duplicate and new-field authoring If/Else, Validate and Slack Alert nodes that referenced a field broke when the workflow was imported or duplicated, or when the referenced field had just been authored. **\[Fixed]** If/Else condition chips render for Studio-authored filters A condition written in Studio could render as an empty chip on the node card. **\[Fixed]** Unexpected model response shapes no longer fail the run An extraction reply in an unexpected shape is repaired instead of failing the run, and a missing evidence key is defaulted rather than aborting the result. ### August 18, 2026 **\[API]** Filled forms from the Edit node on the results envelope A run with an Edit node now returns its filled forms with the results: one entry per filled file, with the detected fields, each field's link confidence, and a presigned URL for the filled PDF. The results envelope gains `edits[]`. It reaches both the v2 results endpoint and `GET /v3/runs/{run_id}/`, and the Python SDK surfaces it on `Result`. `confidence` is `int | None`: a raw linker match score, not a calibrated probability. The presigned URL is minted fresh on every read. **\[New]** Global search with ⌘K Press ⌘K (Ctrl+K) anywhere in the app to open a command palette and jump across the app. **\[Improved]** Standard parse runs the layout-aware pipeline Standard parse now runs the planner-routed, layout-aware pipeline for every document, and renders a page's table blocks concurrently. Figures always render, so the Figures toggle is gone from the standard parse menu. `figure_enhancement` is still accepted on a parse node and ignored. `ParseMode` and the SDKs are unchanged. **\[Improved]** Annie builds and reads Edit workflows Ask Annie to add a form-filling step and she wires an Edit node with its instructions. ### August 17, 2026 **\[Breaking + API]** A confidence the platform did not measure is now `null` Wherever results carry a confidence, a value that was never measured now reads `null` instead of a fabricated number. Classification confidence served `0.0` where nothing was measured; extraction and parse confidence served `100`. Split ranges, smart-table cells and layout confidence follow the same rule. The Results view's confidence filter surfaces these values instead of hiding them as perfect. **Action required:** treat every confidence field as nullable. A client that parsed the field as a required number will fail on `null`. **\[New + API]** Ask questions over a workflow's knowledge base With a Knowledge node in a workflow, every completed run's parsed documents are indexed into a per-workflow knowledge base. You can now ask that base a question over the API and get an answer with citations. See [Knowledge](/guides/nodes/knowledge). `POST /v3/workflows/{workflow_id}/knowledge/ask` with `{ "question": ... }`; `client.ask(...)` in the Python SDK (sync and async), and the JS SDK from the regenerated spec. An ask bills 3 credits per 10,000 input tokens. An organisation with no credit is refused with `402 PAYMENT_REQUIRED` before the question runs. `409 KNOWLEDGE_NOT_ENABLED` means the workflow has no Knowledge node; `409 KNOWLEDGE_NOT_READY` (retryable) means the index is still building. **\[Improved]** If/Else says where missing data goes When a condition cannot be evaluated, because a referenced field is missing or a confidence check has no score, the document takes the **False** branch. That rule is unchanged, but it was invisible: the condition builder now says so, with a link to [If/Else](/guides/nodes/if-else). **\[Improved]** Expression conditions on If/Else An If/Else condition can now use the Expression subject, the same one Validate rules use, so a condition can test an arithmetic relation across fields. **\[Improved]** Triggering a run responds about half a second faster The run trigger endpoint does less work before it answers. **\[Improved]** Sidebar: remembered collapse, New workflow, favourite from the menu The sidebar remembers whether you collapsed it, offers **New workflow** directly, and lets you favourite a workflow from its menu. **\[Fixed]** If/Else save, dirty and close-guard behaviour matches Validate Closing the If/Else editor with unsaved changes behaved differently from the Validate editor. Both now warn the same way. ### August 14, 2026 **\[New + API]** Edit and Knowledge nodes on the public API A workflow definition sent to the API can now carry an `edit` node and a `knowledge` node. The Edit node fills a form PDF; the Knowledge node opts the workflow into the knowledge base. Both are accepted on create, update and the read response, on v2 and v3, and in both SDKs. See [Edit](/guides/nodes/edit) and [Knowledge](/guides/nodes/knowledge). `{"type": "edit", ...}` accepts `reference_document_ids`; the resolved storage locations stay server-side and a caller-supplied `reference_uris` is rejected. `{"type": "knowledge"}` carries nothing but `id` and `type`. **\[New + API]** Validate with an expression over the whole extraction A deterministic Validate rule can now carry an expression that reads the entire extraction, which reaches the shapes a structural check cannot: "the line items add up to the gross amount, within one cent". Studio offers it as **Expression (advanced)**. A rule whose expression does not parse is rejected when you save the workflow, not when a document runs. See [Validation rules](/guides/studio/validate-rules). `{"kind": "deterministic", "check": {"type": "expression", "expression": "abs(sum(data.lines.map(l, l.amount)) - num(data.total)) <= 1.0"}}`. Expressions are CEL with the root variable `data` and the helpers `num()`, `sum()` and `abs()`. Anything that is not a boolean answer (a missing field, a division by zero) is `inconclusive`. An unparsable expression answers `400` on every write path, naming the rule and node. **\[New]** Fill a form from an uploaded reference document An Edit node can take a reference document, uploaded once into the node, whose text feeds the field linker, so you no longer retype the same data into every workflow. Text references (`.csv`, `.txt`, `.md`, `.rst`) are free; a PDF reference is parsed once at upload for 25 credits per page. Manual instructions override the reference when both address a field. Uploads go straight to storage, so the size cap is your tier's cap: 25 MB on Free, 100 MB on paid plans. **\[Improved]** Edit node: font, output mode, and an Original / Filled viewer Choose the font and the output mode for a filled form, and compare the original and the filled PDF in the Edit results tab. **\[New]** Billing for the Edit node and the knowledge index The Edit node bills 35 credits per page. The knowledge index bills 2 credits per page indexed, and only for pages whose content is new or changed since the last run, so re-running a workflow over a stable corpus costs nothing. See [How credits work](/concepts/how-credits-work). **\[Fixed]** An If/Else after a splitter routes each split independently Each split of a document is now routed on its own condition result rather than on the first split's. **\[Fixed]** Numeric and boolean fields can answer "not present" A number or boolean field could not report that the document does not contain the value; it now can. **\[Improved]** Standard parse layout accuracy Over the past week the standard parse tier stopped merging text across columns and absorbing figures into neighbouring text, resolves partially overlapping blocks, never lets a figure or chart be absorbed into a non-figure container, and keeps the document when the layout refiner fails instead of failing the parse. **\[Improved]** Studio polish: check cards, field picker, Knowledge node, New workflow button Validate and If/Else check cards use one grammar and wording. The field picker hides the children of object fields. The Knowledge node renders as a scope band around the graph rather than a wired-in step. A **New workflow** button sits on the home page. ### August 13, 2026 **\[New + API]** Datasets and evaluations over the v3 API You can now build a workflow's dataset and grade the workflow against it without opening the app: upload a document with optional ground truth into the dataset, launch an evaluation over the whole dataset, and poll it to completion to read its accuracy. Both SDKs gain matching methods, and a [workflow-evaluation recipe](/api-reference-v3/evals/launch) walks through the loop. `POST /v3/workflows/{workflow_id}/dataset/upload/` (multipart, optional ground truth; `GT_INVALID` and `EXTRACT_NODE_UNRESOLVED` error codes). `POST /v3/workflows/{workflow_id}/evals/` answers `202` with `{eval_id, run_number, enqueued_count, failed_count}`; `version_id` is optional and defaults to the current version; an `Idempotency-Key` replays the original eval instead of launching a second. `GET /v3/workflows/{workflow_id}/evals/{eval_id}/` and the keyset-paginated `GET /v3/workflows/{workflow_id}/evals/` return `status`, `accuracy`, `matched`, `mismatched`, `ungraded` (null while `in_progress`), `file_count` and `failed_count`. Python SDK: `upload_to_dataset`, `launch_eval`, `get_eval`, `list_evals`, `iter_evals`. **\[New]** Edit: a form-filling workflow node The Edit node completes a blank form instead of extracting from a filled one. It detects the form's fillable fields, links values from your instructions onto them, and renders a filled PDF you can download from the Edit results panel. It never invents a field and never writes over a value the form already had. A non-PDF file is skipped with a visible event on the run. **\[Improved]** The check builder starts from the subject Studio's deterministic check builder, shared by Validate and If/Else, is redesigned as a subject-first flow: pick what to check, then how. The Slack Alert node menu is tidied alongside. **\[Improved]** Annie wires a real Slack alert Annie now resolves your connected Slack channels, so a Slack Alert node she builds points at a real channel. **\[Fixed]** Studio: node ids, Slack Alert save errors, narrow viewports A new node's id is keyed off node ids rather than labels, so two nodes with the same label no longer collide. An incomplete or unconnected Slack Alert node no longer leaves a stale save error behind. The node editor now asks for a desktop-width viewport instead of rendering broken on a narrow one. **\[Fixed]** Deleting a workflow that has runs Hard-deleting a workflow with runs failed part way through. Runs are deleted first now. **\[Fixed]** A v2 run status could show an internal value The v2 status enum briefly exposed the backend's run acknowledgement as a status. Only the documented values are served now. ### August 12, 2026 **\[Improved]** Annie is available to every organisation Annie, the workflow assistant, is out of preview. Her replies render markdown fences as formatted prose, and a markdown reply can be copied as raw markdown or as rendered HTML. **\[Improved]** Document packet metadata is bounded before it reaches the model The `metadata` you attach to a document packet is inlined into the extraction prompt. That block is now hardened: control characters collapse to spaces, each value is capped at 512 characters, and the whole block at 8,192 characters, keeping as many top-level keys as fit in alphabetical order. A value sourced from metadata is also kept out of the confidence judge, so it cannot inflate a score. The packet still echoes your original JSON back verbatim. See [Attaching metadata](/concepts/document-packets#attaching-metadata). **\[Fixed]** Assistant-created graphs are laid out by topology A workflow Annie created could open in Studio with its nodes stacked on top of each other. Nodes are now placed by their position in the graph. **\[Improved]** Honest empty states and accessible names Empty states across the app say what is actually empty, and interactive controls carry accessible names for screen readers. **\[Fixed]** Studio: one If/Else check-editor action, no redundant Config tabs The If/Else check editor had two calls to action that did the same thing and a Config tab that duplicated the card. Both are gone. ### August 11, 2026 **\[New]** Confidence threshold as editable presets The confidence threshold slider on the results view is replaced by a combobox of presets you can also type into. **\[API]** Dataset registration honours `Idempotency-Key` Registering a dataset document with the same `Idempotency-Key`, or the same `file_id`, replays the original response instead of creating a duplicate member. **\[Fixed]** The dataset grid refreshes on every upload attempt An upload that failed part way left the grid stale until a reload. **\[Improved]** The knowledge base handles large documents Long documents are read in pages, search covers the full text of the corpus, and a per-document size cap keeps one huge file from crowding out the rest. **\[Improved]** Fast-tier parses report per-block confidence Every block a Fast parse serves declares who produced its text, and where the OCR engine reports word confidences, the block's confidence is read from them. **\[Fixed]** A list-shaped model reply no longer aborts the extraction ### August 10, 2026 **\[New]** If/Else and Slack Alert are available to every organisation Both nodes leave preview in Studio: every organisation sees them in the sidebar without a "soon" badge. An Extract step placed after an If/Else branch stays in preview. **\[New]** Results controls in the document view The document view gains an **unverified** switch that persists between visits, and a verified badge on sub-tables. **\[New]** Upload a consolidated ground-truth manifest Datasets accept a single manifest file carrying the ground truth for many documents, instead of one ground-truth upload per document. **\[Improved]** Validate tabs and mode selector The Validate node's tabs and its AI / Deterministic selector are polished. **\[Fixed]** The data viewer resets row selection when the sort changes **\[Fixed]** A run fails clearly when no model served the extraction A run whose extraction no model answered used to sit in an ambiguous state. It now fails with that reason. ### August 7, 2026 **\[Fixed]** Unmeasured confidence is shown, not hidden as perfect The results view's confidence filter treated a value with no measured confidence as 100 and hid it under any threshold. It now surfaces the value as unmeasured. **\[Fixed]** Extraction robustness with enum fields A multi-select enum field accepts a bare list of selections, enum option names are quoted in the prompt so a value containing spaces or punctuation is matched exactly, and the extractor is asked for the field shape the schema enforces. **\[Fixed]** The Optimizer survives a malformed proposal A malformed, truncated or stringified proposal no longer fails the optimization run, and a round that crashed keeps its candidate inspectable. **\[Improved]** Studio node header The node card header is redesigned; tooltip, PDF viewer and calendar fixes ride along. ### August 6, 2026 **\[Fixed]** The v3 collections list reports status for the requested workflow version `GET /v3/workflows/{workflow_id}/document-packets/` reported the status of the latest version's run even when another version was requested. **\[Fixed]** The performance dashboard counts a run's pages once ### August 5, 2026 **\[Improved]** Default models refreshed The default parse and extract models moved to the current generation across providers, with a failover attempt on every operator. **\[Fixed]** Markdown, CSV and spreadsheet uploads carry evidence anchors Values extracted from a markdown, CSV or spreadsheet upload now link back to the block they came from, like values from a PDF. **\[Fixed]** Annie builds parse-only workflows **\[Fixed]** Studio design-review polish Eight fixes reconcile Studio with its design: spacing, alignment and state colours across the node cards and menus. ### August 4, 2026 **\[Improved]** The Optimizer explains itself The Optimizer page is redesigned: an explainer banner, an accuracy chart across rounds, a timeline, and a per-iteration detail view with baseline descriptions and an accuracy-delta badge. A crashed round is masked rather than plotted. The [Optimizer guide](/guides/health/optimizer) replaces the "Coming soon" card. **\[Improved]** Usage page polish The tier badge is capitalised and the billing button is secondary, so the primary action stands out. **\[Fixed]** The Slack empty state points at the Connectors menu ### August 3, 2026 **\[New + API]** Route and alert on validation outcomes An If/Else node can now branch on the outcome of a Validate rule upstream, and a Slack Alert template can interpolate that outcome. Studio draws the Validate node's output handle, so edges from Validate to If/Else and Slack Alert are drawable, and the condition picker offers the validation check. The `Check` union gains a `validation` variant keyed on `rule_id` and `status`. Slack templates accept `${validation..}` tokens. Outcomes are evaluated per split, so one partition's result never leaks into another's message. **\[Fixed]** The mobile sidebar trigger overlapped the Home header **\[Improved]** A Slack connector whose token was revoked says so When Slack revokes the integration's token, the connector shows as revoked instead of failing silently on the next delivery. ### July 31, 2026 **\[Improved]** Check types follow the field's data type Studio's check-type picker offers only the checks that apply to the selected field's data type, and the field-picker popover scrolls when the list overflows. **\[Fixed]** The Slack "Open document" button opens the right file ### July 30, 2026 **\[Fixed]** Bulk download counts select-all minus its exclusions Selecting all rows and then unticking some downloaded, and counted, the full set. **\[Fixed]** Studio: If/Else add-condition, Slack connector state Adding a condition to an If/Else node no longer discards a condition you were still editing. The Slack connector's disconnect colour and its state refresh lag are fixed. ### July 29, 2026 **\[New]** An "Open document" button on Slack alerts Every Slack alert delivery now carries an **Open document** button that opens the document in the app. The [Slack alerts guide](/integrations/slack) documents the integration end to end. # September 2026 Source: https://docs.anyformat.ai/changelog/2026-09 Changes shipped in September 2026. ### September 11, 2026 **\[Breaking + API]** One consolidated error envelope across the API Every REST API error response now carries `{error, detail, error_code, retryable, request_id}`. Branch on `error_code`; `request_id` is the sole correlation handle to quote in a support request. (MCP tool errors add a numeric `status` field, because a tool error travels inside a 200 JSON-RPC response.) **Action required:** read the machine-readable code from `error_code`, not a separate `code` field. There is no `reference_id` — use `request_id`. Responses no longer include a `params` object, and there is no bare `{ "detail": … }` fallback shape; any occurrence-specific value, such as the scope a call is missing, is named in the `error` sentence. `detail` stays free-shaped developer diagnostics and may be omitted. See [Errors](/api-reference/errors) for the full envelope and code reference. # Changelog Source: https://docs.anyformat.ai/changelog/overview Stay up to date with the latest changes and improvements to the anyformat API. Every change that reaches the anyformat API, the SDKs and the app, grouped by the day it shipped. Each month has its own page; the newest is first. Breaking changes render at the top of their day in a warning box, with the action to take. ## Months * [September 2026](/changelog/2026-09) * [August 2026](/changelog/2026-08) * [July 2026](/changelog/2026-07) * [June 2026](/changelog/2026-06) * [May 2026](/changelog/2026-05) * [April 2026](/changelog/2026-04) * [March 2026](/changelog/2026-03) * [February 2026](/changelog/2026-02) * [January 2026](/changelog/2026-01) * [December 2025](/changelog/2025-12) * [November 2025](/changelog/2025-11) * [October 2025](/changelog/2025-10) # Ask annie Source: https://docs.anyformat.ai/concepts/annie Describe what you want in plain language. annie builds, runs, and improves your workflows. **annie** is anyformat's AI assistant. You chat with her in plain language. She builds the [workflow](/concepts/workflows), runs your documents, and improves the results. You assemble no nodes and write no fields by hand. The name is a wink at the company: *any-format → annie*. To start, type what you want on the **home** screen: *"Extract vendor, line items, and totals from my invoices"*. annie builds a Parse → Extract workflow and opens it in [Studio](/concepts/studio) for you to refine. *** ## What annie can do Ask in plain language and annie handles the rest: * **Build a workflow** from a description or a sample document. She picks the fields, types, and nodes. * **Edit fields and nodes.** Add, rename, retype, or remove a field. Add [Classify](/guides/nodes/overview) to sort documents by type, [Split](/guides/nodes/overview) to break up multi-document files, or [Validate](/guides/studio/validate-rules) to check results against your rules. * **Suggest fields and descriptions.** Ask "what else should I extract?" or "write a better description for this field". She proposes improvements you accept or ignore. * **Enrich values with [Smart Lookup](/guides/nodes/smart-lookup).** She matches an extracted value against a reference file you provide, such as a vendor list or a product catalog, to fill in codes or IDs. * **Run documents** and read back the results. She flags low-confidence values so you know what to review. * **Improve accuracy.** Record the correct values, called ground truth, then ask annie to improve the workflow. She measures accuracy against your examples and keeps only the changes that help. * **Generate code.** She writes a ready-to-run Python, JavaScript, or curl script that calls your workflow from your own app. *** ## Where to find annie The **Create with annie** chat on the home screen. Start a new workflow here. The **Assistant** item in the left sidebar opens a full-page chat with annie. Inside any workflow, annie is the **Chat** tab of the Studio sidebar. She edits the workflow on screen. *** ## From a prompt to a workflow Building a workflow is a conversation: On the home screen, type what you want to extract. Drop in a sample document or image too, if you have one. For example: *"Read supplier invoices and pull out the invoice number, date, supplier, and line items."* annie proposes a workflow and shows you a preview to approve. She reads the sample, picks sensible fields and types, and adds the nodes the document needs. Once you approve, annie opens the new workflow in [Studio](/concepts/studio). The conversation continues in the **Chat** tab. Keep refining by asking for changes, or edit the fields yourself. Add documents, run them, and review the results. Ask annie to tighten fields, add validation, or improve accuracy against examples you mark as correct. *** ## What's next? The visual editor annie opens: refine the workflow by hand or by chat The nodes annie assembles for you What a field is and the properties annie sets on each one Enrich extracted values against a reference file # Document packets Source: https://docs.anyformat.ai/concepts/document-packets The unit a workflow runs on: a bundle of one or more files anyformat treats as a single document. A workflow runs on one or more [files](/concepts/files) that anyformat treats as a single document. That bundle is a **document packet**. Almost always the packet holds **one** file. You upload a file, you get a packet, and the two line up 1-to-1. The packet becomes visible when you bundle several files that belong together for extraction, such as a contract and its two annexes. *** ## Why a packet, not just a file Extraction is scoped to the packet, not to the individual files inside it. That means: * Cross-file context survives. Parse and Extract see the whole bundle at once, not one file at a time. * Results come back at the packet level, as a single set of extracted fields for the whole document. * Classify, Split, and Validate operate on the packet as one input. For a one-file packet this looks identical to file-level processing. For a multi-file packet, it lets you treat "contract + annexes" or "invoice + supporting scans" as one document. *** ## Packets and files | | What it is | What it's for | | ------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------ | | **Document packet** | The bundle. The runnable unit. | The handle you upload, run, and pull results against. | | **File** | One artifact inside the packet: a PDF, an image, an email. | Reading an individual file's parsed content or per-file breakdown. | The packet has an id. Each file inside it has its own id. You work with packets almost all the time. A file id comes up only when you look at one specific file inside a multi-file packet. **Two API names, one object.** On **v3** every request and response uses `document_packet_id`. The [packet endpoints](/api-reference-v3/document-packets/get) show it. The frozen **v2** surface calls the same object a *file collection*, with `file_collection_id` or sometimes `collection_id`, used the same way. The ids are interchangeable across versions. The [migration guide](/api-reference-v3/migrating-from-v2#identifier-renames) has the mapping. *** ## How you get one You create a packet by uploading. Two ways hand anyformat the bytes: * **Direct upload**, multipart. You send the file bytes in the request. The response returns `status: "uploaded"` once the bytes are in storage, and you can run immediately. Use it when the bytes sit on the user's machine or in your app's memory. * **From a URL.** You give anyformat an HTTPS URL, such as a presigned S3 link or a hosted asset, and the server fetches the bytes. Use it when the file already lives in object storage you can presign, or at a public URL. Nothing streams through your backend. Both produce a packet id you run, poll, and pull results against. [Files](/concepts/files) lists supported formats and size limits. Upload with [`POST .../upload/`](/api-reference-v3/workflows/upload), which takes 1 to 100 multipart files into one packet. Or use [`POST .../upload/from-url/`](/api-reference-v3/workflows/upload-from-url), which takes 1 to 100 HTTPS URLs, all or nothing. Each imported file is named by its response's `Content-Disposition`, else by the URL's path. The URL import is atomic, so a `201` means the packet is fully in storage, with no pending-fetch state to reason about. *** ## One file vs. multiple files **One file, the default.** Every upload creates its own single-file packet. In everyday use the packet id and the file id are interchangeable. You address the packet. **Multiple files.** Bundle them into one packet only when they belong together as a single document. anyformat then processes the packet as a whole, and extraction runs over the bundle rather than file by file. Upload unrelated files as two packets, which keeps their results independent. *** ## Attaching metadata Every v3 create path accepts an optional **`metadata`** field: a free-form JSON object attached to the packet at creation time. There is no schema. The only validation is "must be a JSON object". [`GET /v3/document-packets/{document_packet_id}/`](/api-reference-v3/document-packets/get) echoes the value back verbatim. When you [run](/concepts/runs-and-results) the packet, the Extract node sees it inline in its prompt. Two uses come with it out of the box: * **Carry structured context alongside the bytes.** Anything you already know about the document travels with the packet, with no side channel: the vendor id you fetched it for, the account it belongs to, the batch you are processing. Fetch the packet later and the same JSON comes back. * **Let extraction source a datapoint from your context.** anyformat picks up a top-level key whose name matches an extract-schema field. The model prefers that metadata value over anything it reads in the document. The extracted field's evidence then carries the marker `text: "metadata."`. Key on that marker client-side to tell "sourced from caller metadata" apart from "sourced from the document". [Runs & results → Evidence](/concepts/runs-and-results#evidence) covers the evidence shape. anyformat hardens the prompt copy, because your metadata enters an instruction channel. Control characters collapse to spaces. Each value is capped at 512 characters. The whole block is capped at 8192 characters. A block over that cap keeps as many top-level keys as fit, in alphabetical order, and the platform logs which keys it dropped. The packet itself still echoes your original JSON back verbatim. On the multipart uploads, send it as a JSON-encoded string, because multipart cannot carry a nested object natively. That covers [`.../upload/`](/api-reference-v3/workflows/upload) and [`.../upload/run/`](/api-reference-v3/workflows/upload-and-run). On the URL import, [`.../upload/from-url/`](/api-reference-v3/workflows/upload-from-url), send it as a plain JSON object. ```bash theme={null} curl -X POST 'https://api.anyformat.ai/v3/workflows/{workflow_id}/upload/run/' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -F 'files=@/path/to/invoice.pdf' \ -F 'metadata={"customer_reference": "CR-2026-99001-ZTX", "batch_id": "b-9142"}' ``` *** ## What's next? Supported formats, size limits, and per-file mechanics What happens when you run a workflow on a packet, and the shape of what comes back # Datasets & evaluations Source: https://docs.anyformat.ai/concepts/evals The vocabulary behind measuring a workflow's quality: datasets, ground truth, sub-datasets, and evaluations Once a workflow runs, you ask: **is it getting better or worse as I change it?** To answer, you need a fixed set of documents with known-correct answers, and a repeatable way to score the workflow against them. anyformat gives you both in the **Health** area of each workflow. This page defines the four terms you meet there. The [Datasets guide](/guides/health/datasets) and the [Evaluations guide](/guides/health/evaluations) show how to use them. **The short version:** a **dataset** is a pool of documents with known-correct answers, called **ground truth**. An **evaluation** runs one workflow version over that dataset and scores it. Tag files into **sub-datasets** to read accuracy on a slice, such as "hard cases", apart from the overall number. *** ## Dataset A **dataset** is a fixed collection of documents you use to measure a workflow's quality. Each workflow has **one dataset**. The key property is that a dataset is **independent from production**: * Files enter a dataset two ways. Promote a **processed** document with **Add to dataset**, which **duplicates** it into the dataset. Or **upload** documents and their ground truth directly. Either way the dataset entry is its own independent entity. * Editing ground truth, removing a file from the dataset, or running an evaluation **never affects your production data**, and production runs never change the dataset. This separation makes an evaluation reproducible. The scored documents do not move underneath you. An evaluation scores a workflow's **extraction** output, so a workflow needs an **Extract** node. Parse-only and split workflows are excluded. *** ## Ground truth **Ground truth** is the set of **expected, correct values** for a dataset file. An evaluation scores the workflow's output against it. * Ground truth comes from one of two places. On the *Add to dataset* path you **validate** a processed file's datapoints. On the *upload* path you **supply it directly** as a `.json` file. Either way you edit it in **Health → Datasets**. * On the *Add to dataset* path, a file needs **every datapoint validated** first. You are establishing the correct answer, so it has to be complete. On the *upload* path ground truth is optional. Supply it in the `.json` file, or leave it blank and fill it in later in the dataset table. > Accuracy is only ever measured against ground truth. A field the dataset has no ground truth for is **not scored**. *** ## Sub-dataset (slice) A **sub-dataset** is a slice of the dataset defined by **tags**. Tag files by provider, document type, or "hard cases". You then read results **overall or per sub-dataset**. One dataset then answers *"95% overall, but only 60% on hard cases"* without splitting into separate datasets. Files with no tag form the **Untagged** slice. *** ## Evaluation An **evaluation**, or **eval**, is **one scored run of one workflow version over a dataset scope**. The scope is the full dataset or a single sub-dataset. Running an evaluation: 1. Runs an extraction on every in-scope dataset file. 2. Scores each result against that file's ground truth. 3. Records the run with a status and an **accuracy**. anyformat lists evaluations as a numbered **run list**: `#1`, `#2`, and so on. Each row shows its scope, workflow version, date, status, accuracy, and file count. An evaluation is an **immutable historical record**. Editing ground truth after **Eval #1** does not change **Eval #1**. Run **Eval #2** to measure the effect. That is what makes version-to-version comparison trustworthy. *** ## Accuracy For an evaluation, **accuracy** is the **share of graded fields that matched ground truth across the in-scope documents**. Accuracy and confidence work here as they do everywhere else in anyformat. Confidence guides where to look. Accuracy tells you how often the workflow is right. [Confidence & accuracy](/guides/workflows/analytics-quality) explains both numbers. *** ## How these fit together Add documents, edit ground truth, and tag files into sub-datasets. Score a workflow version and compare accuracy across versions. # Field types Source: https://docs.anyformat.ai/concepts/field-types Canonical reference for every field type: UI name, API name, example, and when to use it. The type tells anyformat what kind of value to expect. It shapes how anyformat validates and stores the value. This page is the single source of truth for field types. It lists every type, its name in the UI and in the [API](/api-reference-v3/introduction), and how to use it well. *** ## All field types at a glance | UI name | API `data_type` | Example value | | ----------------- | --------------- | --------------------------------------- | | Text | `string` | `"INV-001"` | | Decimal number | `float` | `1250.99` | | Integer number | `integer` | `42` | | Date | `date` | `"2024-03-15"` | | Date & time | `datetime` | `"2024-03-15T10:30:00Z"` | | Yes / No | `boolean` | `true` | | Select | `enum` | One of a predefined list | | Multiselect | `multi_select` | A list of values from a predefined list | | Object (Subtable) | `object` | Repeating structured rows | Field types There is no separate `list` data\_type. Use **Multiselect** (`multi_select`) for a list of values from a fixed set of options, and **Object** (`object`) for a list of structured rows, a subtable. For a free-form list of strings or numbers, model it as an Object with a single nested field. ### Not sure which to pick? A quick guide: * **Is it a number with decimals, like a price or percentage?** → Decimal number (`float`) * **Is it a whole number, like a count or quantity?** → Integer number (`integer`) * **Is it a date without a time?** → Date * **Is it a yes/no answer?** → Yes / No (`boolean`) * **Does it have to be one value from a fixed list of options?** → Select (`enum`) * **Could it be several values from that list?** → Multiselect (`multi_select`) * **Is it a repeating table, like invoice line items?** → Object (Subtable) (`object`) * **Anything else (names, IDs, addresses, free text)?** → Text (`string`) Picking the right type matters. anyformat returns the value in that shape: a real number you can sum, a real date you can sort. The type also tells the AI what to look for. *** ## Field definition Every field requires at least: ```json theme={null} { "name": "field_name", "description": "What this field represents", "data_type": "string" } ``` * **`name`**: a unique identifier in snake\_case. * **`description`**: a clear explanation of what to extract. The AI uses it as guidance. * **`data_type`**: one of the types listed above. The complex types `object`, `enum`, and `multi_select` take extra properties, documented below. *** ## Simple types **Best for:** values that vary widely, such as company names and addresses. **Watch out for:** values from a known list. Use **Select** instead. **Best for:** calendar dates when time does not matter. Returned as `YYYY-MM-DD`. **Watch out for:** a time in the document. Prefer **Date & time**. **Best for:** precise timestamps when a document carries a time, not just a date. Returned as `2024-03-15T10:30:00Z`. **Watch out for:** ambiguous timezones and date formats. Add an instruction when the document's format is unusual. **Best for:** money, percentages, measurements. **Watch out for:** values that should be whole numbers. **Best for:** counts, quantities, item numbers. **Watch out for:** currency. Use a decimal number. **Best for:** clear true/false questions, such as "Is it paid?" or "Is it signed?". **Watch out for:** vague cues like "maybe" or "often". Make the instruction explicit. *** ## Select (`enum`) Use `enum` when the extracted value must be **one** of a predefined set of options. The field requires an `enum_options` array. ```json theme={null} { "name": "payment_status", "description": "The current payment status of the invoice", "data_type": "enum", "enum_options": [ {"name": "pending", "description": "Payment has not been received"}, {"name": "paid", "description": "Payment has been received in full"}, {"name": "partial", "description": "Partial payment has been received"}, {"name": "overdue", "description": "Payment is past the due date"} ] } ``` If no option matches the document, the field value is `null`. **Why use Select:** * Enforces consistency * Avoids spelling variations * Makes analytics and filtering reliable **Best practices:** * Keep options short and unambiguous * Provide clear descriptions for each option * Avoid overlapping meanings * Prefer Select over Text when values repeat *** ## Multiselect (`multi_select`) Same shape as `enum`. The field returns **multiple** matched options as an array. ```json theme={null} { "name": "document_tags", "description": "Categories that apply to this document", "data_type": "multi_select", "enum_options": [ {"name": "urgent", "description": "Requires immediate attention"}, {"name": "confidential", "description": "Contains sensitive information"}, {"name": "reviewed", "description": "Has been reviewed by a team member"}, {"name": "pending_approval", "description": "Awaiting approval from management"} ] } ``` Returns an array of strings: ```json theme={null} { "document_tags": ["urgent", "confidential"] } ``` ### Select vs. Multiselect | Feature | Select (`enum`) | Multiselect (`multi_select`) | | ----------- | -------------------------- | ---------------------------- | | Selection | Single value | Multiple values | | Return type | `string` or `null` | `array` of strings | | Use case | Mutually exclusive options | Non-exclusive categories | *** ## Object / Subtable (`object`) Use `object` to extract a structured group of properties. Object fields require a `nested_fields` array. anyformat supports **one level of nesting** only. The children of an `object` field must be non-object types: `string`, `integer`, `float`, `date`, `datetime`, `boolean`, `enum`, `multi_select`. An `object` field cannot contain another `object` field. ### Single nested object ```json theme={null} { "name": "shipping_address", "description": "Customer shipping address details", "data_type": "object", "nested_fields": [ {"name": "street", "data_type": "string", "description": "Street address including number"}, {"name": "city", "data_type": "string", "description": "City name"}, {"name": "postal_code", "data_type": "string", "description": "ZIP or postal code"}, {"name": "country", "data_type": "string", "description": "Country name"} ] } ``` ### Repeating rows (Subtable) Object fields also capture **repeating tabular data**: invoice line items, transaction rows, any list of things with the same structure. ``` line_items (object) ├── description (string) ├── quantity (integer) ├── unit_price (float) └── line_total (float) ``` Each row in the document becomes one object in the resulting array. **Use Object when:** * You see the *same set of fields repeating* * The document contains a list or table where each row is one item * You need a structured value per row, not a blob of text **Best practices:** * Keep subtable fields minimal at first: 3 to 5 columns * Use clear row-level instructions: "Extract one row per item. Ignore headers and totals." * Add shared fields like `currency` at the top level, not inside each row * Start with the most reliable columns, description and amount, then expand Object field **Common mistakes:** * Using Object for a **single nested group**, such as a vendor address. That works, but consider whether top-level fields would be simpler. * Making the subtable too wide too early. Past 10 columns, ambiguity rises. * Nesting an Object **inside another Object**. Only one level of nesting is supported. *** ## Complete example A workflow definition that uses several field types together: ```json theme={null} { "name": "Invoice Processing", "description": "Extract invoice data with line items", "fields": [ {"name": "invoice_number", "description": "Unique invoice identifier", "data_type": "string"}, {"name": "issue_date", "description": "Date when the invoice was issued", "data_type": "date"}, {"name": "total_amount", "description": "Total invoice amount including tax", "data_type": "float"}, {"name": "is_paid", "description": "Whether the invoice has been paid", "data_type": "boolean"}, { "name": "payment_status", "description": "Current payment status", "data_type": "enum", "enum_options": [ {"name": "pending", "description": "Awaiting payment"}, {"name": "paid", "description": "Fully paid"}, {"name": "overdue", "description": "Past due date"} ] }, { "name": "vendor", "description": "Vendor information", "data_type": "object", "nested_fields": [ {"name": "name", "data_type": "string", "description": "Vendor company name"}, {"name": "address", "data_type": "string", "description": "Vendor address"} ] } ] } ``` *** ## Tips for better results 1. **Be specific in descriptions.** "The invoice number, usually starting with INV-" beats "Invoice number". 2. **Use appropriate types.** `float` for amounts and `date` for dates, never `string`. 3. **Keep field names consistent.** snake\_case throughout. 4. **Describe location only when it helps.** "Total amount shown at the bottom right" can disambiguate. The AI usually does not need it. *** ## What's next? The three properties every field has Write better extraction instructions # Fields Source: https://docs.anyformat.ai/concepts/fields What a field is and the three properties every field has. A **field** is one piece of information inside a [schema](/concepts/schemas). Examples: * Invoice number * Issue date * Total amount * Vendor name Fields drive data quality. Clear fields give better results and less review. Fields panel *** ## The three field properties Every field has at least three properties. A human-readable label that still makes sense outside anyformat. Examples: `invoice_number`, `issue_date`, `total_amount`. Use snake\_case so the name is safe to use as a JSON key or column header. The kind of value anyformat expects: text, date, number, and others. The type improves consistency, validation, and output quality. [Field types](/concepts/field-types) has the full list. Plain-English guidance for how to extract the value. Example: "Extract the final total including taxes. Ignore subtotals." [Instructions](/concepts/instructions) shows how to write a good one. Field properties *** ## Field source: extraction vs. smart lookup Every field also has a **source**. It tells anyformat where the value comes from. By default anyformat reads the value off the document. The other source matches the value against a reference file you attach. [Smart Lookup](/guides/nodes/smart-lookup) covers how that works and when to use it. *** ## What's next? The full list: text, date, number, object, enum, multi-select Write effective extraction instructions Enrich extracted values against a reference file # Files Source: https://docs.anyformat.ai/concepts/files What a file is in anyformat and what happens when you upload one. A file is what you upload: raw input with no structure yet. Examples: * PDFs * Images and scans * Multi-page documents anyformat reads the text, layout, tables, and visual cues out of the file. You configure no OCR engine and no preprocessing step. *** ## Supported file formats | Category | Formats | | ------------ | ---------------------------------------------------------------------------------- | | PDF | `.pdf` | | Documents | `.doc`, `.docx`, `.txt`, `.html`, `.htm`, `.rtf`, `.odt`, `.ppt`, `.pptx`, `.epub` | | Spreadsheets | `.xlsx`, `.xls` | | Markdown | `.md`, `.markdown` | | Images | `.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.tiff` | | Email | `.eml`, `.msg` | | Audio | `.mp3`, `.wav` | ### Limits * **Maximum file size:** 20 MB per file * **Page count:** No hard limit (usage-based billing applies) *** ## What happens when you upload a file anyformat reads the document anyformat detects the pages anyformat analyzes the content and makes it searchable The file is ready for a workflow *** ## How files relate to workflows A workflow never runs on a file directly. Each file goes into a [document packet](/concepts/document-packets), and the workflow runs on the packet. Most packets hold one file, so the distinction stays out of sight. It surfaces when you bundle several files as one document. *** ## What's next? The runnable unit: how anyformat groups files for a workflow How pages work within a file and why page boundaries matter How usage is calculated # How credits work Source: https://docs.anyformat.ai/concepts/how-credits-work Credits are anyformat's universal billing unit. This page lists what each node costs, how a run adds up, and what a credit is worth on your plan. Every workflow you run spends **credits**, anyformat's universal billing unit. anyformat bills every node a workflow runs. Each node costs a fixed number of credits, and that number is **the same on every plan**. Your plan decides two things: how many credits you start with, and what a credit costs if you buy more. The per-node cost is plan-independent. Estimate a run in credits once, and the number holds everywhere. *** ## What a credit is A credit is the smallest unit anyformat bills in. Nodes spend credits. Pages, files, and schema size matter only where they change how many nodes run, and over how much content. Two things never affect cost: * How complex your schema is * How many fields you define What does affect cost: * Which nodes your workflow runs * How many pages each node processes; most nodes are priced per page The [Usage & Billing](/concepts/usage-and-billing) page explains why usage works this way. This page gives the numbers. *** ## What each node costs | Node | Credits | Billed per | | ----------------------- | ------- | ------------------ | | Parse | 25 | page | | Classify | 10 | page | | Split | 25 | page | | Extract | 35 | page | | Edit | 35 | page | | Validate | 5 | rule ¹ | | Flash Parse | 7 | page | | Fast Parse | 12 | page | | Agentic Parse | 100 | page | | Fast Extract | 17 | page | | Agentic Extract | 150 | page | | Edit reference document | 25 | page ² | | Knowledge index | 2 | page indexed ³ | | Knowledge ask | 8 | 10k input tokens ⁴ | | Smart Lookup | 75 | page ⁵ | | Optimizer (one-shot) | 100 | run ⁶ | ¹ A validator is billed **per rule, once per extraction**. It is never billed per page or per file. A 3-rule validator costs 15 credits whether the document is 1 page or 100. ² Charged **once, when you upload a PDF reference** to an Edit node. Every run that uses it afterwards is free, however many runs that is. The per-page rate matches Parse, because it is a parse. Text references are free, because they need no parsing: `.csv`, `.txt`, `.md`, `.rst`. A reference whose parse fails is never charged. ³ Charged only for pages whose source content is **new or changed** in a completed run. Re-running unchanged documents adds nothing to the knowledge base and costs nothing. Backfilling existing parses, repairing a missing or corrupt snapshot, and republishing unchanged content are maintenance and do not spend knowledge-index credits. ⁴ Charged per 10,000 **input tokens the answer agent reads**. Text served from cache is weighted down. Cost tracks how much of the knowledge base a question makes the agent read, not how many documents the base holds. ⁵ Charged **per page of the document**, on top of Extract, when the Extract node has at least one [Smart Lookup](/guides/nodes/smart-lookup) field, however many lookups run. ⁶ Charged **once per Optimizer run**, whatever the size of the workflow: the one-shot optimizer reflects on existing data and does not re-run your documents. Most nodes are billed **per page**. A 10-page parse costs 10 × 25 = 250 credits. ### Tiers Parse and Extract each come in several tiers. You set the tier per node, with the `mode` key in the API: * **Flash**, parse only, `mode: "flash"`. It reads the PDF's own text layer and makes no model call. This is the cheapest tier, for born-digital documents. 7 credits per page. * **Fast**, `mode: "lite"`. One OCR pass for parse, a lighter model for extract. For clean to moderate documents. Half the standard rate, rounded down: Parse 12, Extract 17. * **Standard**, the default. It balances cost and quality for most documents and tables. Parse 25, Extract 35. * **Agentic**, a heavier multi-step pass for messy scans, complex tables, and low-quality inputs. It costs about **4×** standard: Parse 100 against 25, Extract 150 against 35. Reach for it only when standard output is not good enough. Start every workflow on standard. Move a node down to Fast or Flash for the document types where it holds up. Move it up to Agentic only where standard underperforms. These are the default prices. An Enterprise plan can negotiate custom per-node rates. If your organization has an override, the **Usage** page shows the rates you actually pay. *** ## How a run adds up Add up each node over the content it touches. Take a **3-page invoice** through parse → classify → extract, with a 2-rule validator on the result: | Node | Rate | Quantity | Credits | | --------- | --------- | -------- | ------- | | Parse | 25 / page | 3 pages | 75 | | Classify | 10 / page | 3 pages | 30 | | Extract | 35 / page | 3 pages | 105 | | Validate | 5 / rule | 2 rules | 10 | | **Total** | | | **220** | One 3-page invoice costs **220 credits**. At the Business rate that is €0.22. At the Free / pay-as-you-go rate it is €0.33. That gap is why anyformat quotes costs in credits, not euros. ### Common shapes, at a glance For the page-priced nodes, each common workflow costs this much **per page**: | Workflow | Credits / page | | ---------------------------------- | -------------- | | Flash Parse only | 7 | | Parse only | 25 | | Fast Parse → Fast Extract | 29 | | Parse → Extract | 60 | | Parse → Classify → Extract | 70 | | Parse → Split → Extract | 85 | | Parse → Edit (form filling) | 60 | | Parse → Extract (+ knowledge base) | 62 | | Agentic Parse → Agentic Extract | 250 | Multiply by your page count, then add any validators (5 credits per rule, billed once per run). Two costs sit outside the per-page arithmetic, because they do not recur with your runs: * A **PDF reference document** on an Edit node costs 25 credits per page, once, when you upload it. A 40-page handbook costs 1,000 credits the day you add it. Every run afterwards reuses it for free. * The **knowledge base** meters only fresh or changed source content from completed runs. A workflow re-running over a stable corpus settles towards 0 rather than paying 2 per page forever. Index backfill and snapshot repair do not add knowledge-index charges. The 62 above is the first pass over fresh documents. *** ## What a credit is worth A credit's cash value depends on your plan. Included credits spend at the per-node rates above. Top-ups are priced per plan: | Plan | Top-up rate | | -------------------- | ------------------------------------------ | | Free / pay-as-you-go | €1.50 per 1,000 credits (€0.0015 / credit) | | Business | €1.00 per 1,000 credits (€0.001 / credit) | | Enterprise | Custom, volume-discounted | Buy top-ups in credits from the **Usage** page in the app, through Stripe. The minimum is 30,000 credits. Every top-up generates a downloadable invoice. *** ## Included credits by plan Every plan comes with a credit allowance: | Plan | Included credits | | ---------- | -------------------------------------------------- | | Free | 50,000, a one-time signup grant that never expires | | Business | 500,000 per month | | Enterprise | Custom monthly allowance | A Free organization gets no monthly refill. Its signup grant never expires, and it can **top up** at any time. [anyformat.ai/pricing](https://www.anyformat.ai/pricing) lists current plan prices. Credits come from three sources, tracked separately on your balance: * **Plan grant:** your included monthly allowance. On Free it is a one-time grant. * **Top-ups:** bought any time, priced per plan in the table above. * **Vouchers:** promotional credits. A voucher may carry an expiry, 90 days by default. *** ## Tracking your usage The **Usage** page in the [app](https://app.anyformat.ai) shows: * Your current credit balance, split by source: plan grant, top-ups, and vouchers * Weekly usage summaries and a per-workflow breakdown * CSV export for your own reporting * A **Top up** button for Stripe checkout when you need more Watch the per-workflow breakdown to spot which workflows dominate your spend. Usually they run Agentic Parse or Agentic Extract over high page counts. *** ## Spending fewer credits A few habits keep credit usage low: * **Default to standard parse and extract.** Agentic costs about 4× as much. Use it only for document types where standard underperforms. * **Add Classify or Split only when documents are mixed.** Each adds a per-page node to every page. A single-type workflow does not need them. * **Keep validators lean.** Every rule costs 5 credits per run, billed once per rule and never per page. Validate what matters, not everything. * **Parse only when you need markdown.** To feed your own RAG index, search, or custom model, a [parse-only workflow](/guides/recipes/parse-only-workflow) skips extract entirely. *** ## What's next? The usage model behind credits: what anyformat measures, and why The nodes that consume credits, and how they wire together What happens when you run a workflow Parse → extract walkthrough in the UI, curl, and Python # Instructions Source: https://docs.anyformat.ai/concepts/instructions Plain-English guidance you attach to a field to improve extraction accuracy. An **instruction** tells anyformat how to extract one field. Write it in plain English. It covers: * What to look for * Where the value usually appears * How to read an ambiguous case **Example:** > "Extract the total amount charged, including taxes. Ignore subtotals." *** ## Why instructions matter * Reduce ambiguity * Improve accuracy * Lower review time * Are vague * Repeat the field name * Try to encode logic unnecessarily *** ## Instruction best practices Say exactly what you mean. Avoid shortcuts and assumptions. Say what you want, not where it appears on the page. If a value can appear in several forms, say which one to prefer. Guide the extraction. Do not mirror the source. Clear intent beats long explanations. *** ## Examples **Field:** `total_amount` **Instruction:** "Extract the final total amount including taxes. If multiple totals appear, use the one labeled 'Grand Total' or 'Amount Due'." **Field:** `total_amount` **Instruction:** "The total amount." **Why it fails:** it does not say which total to use, or whether to include taxes. *** ## What's next? Let annie suggest fields and write instructions for you Put your schema to work with repeatable workflows # Outputs Source: https://docs.anyformat.ai/concepts/outputs The formats anyformat produces, when to use each, and how to consume them outside the platform. An output is the data you asked for, in a structured and consistent form. It is ready to review, to export, or to connect to another system. An output follows your [schema](/concepts/schemas), which sets the fields and their structure. It also follows your [field types](/concepts/field-types) and your [instructions](/concepts/instructions). An output is **not**: * The original document * Raw OCR text * A screenshot of the page It is the clean data layer between documents and tools: **Files** → **Schema & Fields** → **Workflow** → **Outputs** *** ## Output formats anyformat holds one set of extracted data and presents it in **different representations**. Pick the one that suits what you do next. Output overview ### CSV Structured tabular data: * Each document becomes one row or more * Each field becomes a column * anyformat expands subtables the same way every time - Spreadsheets - Imports - Data analysis - Sharing with non-technical users CSV output ### Excel The same structured data as CSV, in a format that: * Opens more easily for business users * Preserves table structure * Supports several sheets, one per subtable or for metadata - Manual review is part of the process - Data is shared across teams - You want minimal friction for non-technical stakeholders Excel output ### JSON Structured data for systems and automation: * Fields become keys * Nested structures survive * Data types stay explicit - Integrating with other systems - Building automation - Preserving hierarchical data such as subtables JSON output ### Markdown A **human-readable representation** of the result, used to: * Inspect what was extracted * Verify that parsing worked correctly * Review results per document Markdown is **not a bulk export format**. anyformat generates it **per document**, to help you understand one result. It does not move data elsewhere. Markdown output *** ## Choosing the right format You do not decide upfront. Most users read more than one representation at different stages. | Output | Best for | | --------------- | ------------------------------------- | | **CSV / Excel** | Analysis, sharing, business workflows | | **JSON** | Integrations, automation | | **Markdown** | Review and verification | **Choose when you:** * Analyze data in spreadsheets * Share with business users or stakeholders * Import into a tool that accepts tabular data * Review and verify by hand **CSV vs. Excel:** * Use **CSV** for maximum compatibility and automation * Use **Excel** for non-technical users who prefer spreadsheets **Choose when you:** * Build an integration with another system * Automate a data pipeline * Need nested structures such as line items to survive * Work with APIs or developer tools **Choose when you:** * Review one result * Debug an issue * Verify that parsing worked Markdown is a review format, not an export format. *** ## Using outputs outside the platform Once you trust your results, take them out three ways: * Download one by hand * Export in bulk * Read them through the [API](/api-reference-v3/introduction) ### Export options Download individual results directly from the platform in your preferred format. Export multiple documents at once for batch processing or archival. Retrieve outputs programmatically for automation and integrations. ### Common integration patterns * **Data pipelines:** pull JSON outputs into your data warehouse * **Business tools:** export Excel or CSV into accounting or ERP systems * **Custom applications:** use the [API](/api-reference-v3/introduction) to embed processing in your own product *** ## What's next? Control what data appears in outputs Produce outputs reliably at scale # Pages Source: https://docs.anyformat.ai/concepts/pages How pages work within a file and why page boundaries matter. A file holds one page or many. An image is one page. A multi-page PDF is many. **anyformat handles page boundaries for you.** Two situations bring pages to your attention. *** ## Reviewing where a value came from Open a document. anyformat shows the original beside the extracted data, and highlights the exact spot on the page where it found each value. Each value carries a **confidence indicator**: a signal of how sure anyformat is. [Confidence & accuracy](/guides/workflows/analytics-quality) explains what confidence means. Use this view to check why a value was extracted, and to correct it when it is wrong. *** ## When a value spans multiple pages Pages trip you up when one thing splits across a page break. A table's rows continue onto the next page. A total sits on a different page from its line items. anyformat usually stitches these back together. If a result looks wrong, open the document and check the highlighted source. It names the page the value came from, so you can see whether a page boundary lost something. *** ## A note on usage Usage is counted per page, so a 10-page document counts as more than a 1-page one. See [Usage & billing](/concepts/usage-and-billing) for details. *** ## What's next? How usage is calculated based on pages and files Define the structure of the data you want to extract # Runs & results Source: https://docs.anyformat.ai/concepts/runs-and-results What happens when you run a workflow on a document, and the shape of what comes back: one section per atomic operation. Start a run by submitting a document. Use the UI, or call [`POST /v3/workflows/{workflow_id}/upload/run/`](/api-reference-v3/workflows/upload-and-run), which uploads and runs in one call. Or [upload](/api-reference-v3/workflows/upload) a packet first and [run it](/api-reference-v3/document-packets/run) separately. anyformat creates a **[document packet](/concepts/document-packets)** to hold the document, walks the workflow graph, and writes structured **results**. *** ## Run lifecycle Every run moves through a sequence of statuses: | Status | Description | | ------------- | ------------------------------------------------- | | `queued` | Waiting for an available processing slot | | `in_progress` | Processing is actively running | | `processed` | Processing complete, results available (terminal) | | `error` | Processing failed (terminal) | | `cancelled` | Processing was cancelled (terminal) | A run only exists once it is triggered, so runs start at `queued`. Before that, the [document packet](/concepts/document-packets) carries `status: not_started`. Read a run with [`GET /v3/runs/{run_id}/`](/api-reference-v3/runs/get). It always returns **200 OK** with the status above. `results` is `null` until the run reaches `processed`, then the results envelope arrives inline on the same read. Poll until the status is terminal, then stop. A run reads the same way at every status. Only two keys change: ```json A run being polled highlight={3,4} theme={null} { "id": "06a9571c-943f-762e-8000-99039eaf9823", "status": "in_progress", "results": null } ``` [What the results contain](#what-the-results-contain) below writes the envelope out in full. ## What the results contain This page is the one place the whole envelope is written out. Every other page shows the few lines its own topic needs and links back here. A run produces **one output section per node type that ran**. The envelope has a fixed shape. A section for a node you did not include comes back empty, `null` or `[]`, so your code reads every key without checking first. ```json The shape highlight={5,7} theme={null} { "id": "06a9571c-943f-762e-8000-99039eaf9823", "status": "processed", "results": { "parse": { "markdown": "…", "blocks": ["…"] }, "classifications": [], "extractions": [{ "fields": { "…": "…" } }], "splits": [], "edits": [], "extraction": { "…": "…" } } } ``` Line 6 is what the document says. Line 8 is what you asked for. The rest are sections this workflow did not use. It had a Parse node and an Extract node, so `classifications`, `splits` and `edits` are empty. `extraction` is the deprecated flat copy of a linear workflow's fields. Read `extractions[]` instead. Trimmed only where a value is long: `markdown` runs to the length of the document, and `blocks` carries one entry per block on every page. ```json theme={null} { "id": "06a9571c-943f-762e-8000-99039eaf9823", "workflow_id": "06a9571c-88c8-7a5c-8000-76b0c61462eb", "document_packet_id": "06a9571c-9438-713d-8000-d30bb424818a", "status": "processed", "created_at": "2026-08-31T12:19:04.132409Z", "updated_at": "2026-08-31T12:20:31.884201Z", "results": { "document_packet_id": "06a9571c-9438-713d-8000-d30bb424818a", "verification_url": "https://app.anyformat.ai/workflows/06a9571c-88c8-7a5c-8000-76b0c61462eb/files/06a9571c-9441-7a0e-8000-5c2a1f0b9d34?versionId=FGaV4I2JAA", "parse": { "markdown": "\n\n# INVOICE\n\n\n\nInvoice #: INV-2026-9001 \nIssue date: 2026-02-20 \n…", "text": "# INVOICE\n\nInvoice #: INV-2026-9001 …", "parse_confidence": 89.6, "layout_confidence": 0.7, "blocks": [ { "id": "p1_b0", "type": "title", "page": 1, "bbox": { "x0": 0.1148, "y0": 0.0665, "x1": 0.2692, "y1": 0.096 }, "parse_confidence": 100.0, "layout_confidence": 0.846, "content": "# INVOICE", "hyperlinks": [], "rows": null, "image_base64": null } ] }, "classifications": [], "splits": [], "extractions": [ { "split_name": null, "partition": null, "fields": { "vendor_name": { "value": "Wayne Enterprises", "value_override": null, "verification_status": "not_verified", "confidence": 97.0, "evidence": [ { "text": "From: Wayne Enterprises\n148 Eric Track, New Stephanie, NC 00575", "page_number": 1 } ] }, "supplier_id": { "value": "SUP-1042", "value_override": null, "verification_status": "not_verified", "confidence": null, "evidence": [] } }, "validations": [] } ], "edits": [], "extraction": { "vendor_name": "…" } } } ``` Two fields are shown, from a workflow that used [Smart lookup](/guides/nodes/smart-lookup). `vendor_name` was read off the page, so it carries a confidence and the phrase it came from. `supplier_id` was resolved from a reference file, so its `confidence` is `null` and its `evidence` is empty. There is no page it was read from. | Section | From which node | Shape | | --------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `verification_url` | none | Link to the Studio review page for the packet's first file, pinned to the workflow version the run used: `https://app.anyformat.ai/workflows//files/?versionId=`. `null` when the packet has no file. | | `parse` | the parse node | `markdown` holds the whole document, with an `` anchor before each block and HTML `
` elements. Also `text`, `parse_confidence`, `layout_confidence`, and `blocks[]`, the same content block by block with page, bbox, and confidences. See [Parse](/guides/nodes/parse#what-it-returns). `null` if the workflow has no parse node, which is rare because the topology requires parse. | | `classifications` | classify nodes | Array of `{category, confidence, evidence}`. Empty when no classifier ran. | | `splits` | the splitter node | Array of `{name, files, confidence, partitions}` describing the segments. Empty when no splitter ran. | | `extractions` | extract nodes | Array of `{split_name, partition, fields, validations}`. A linear workflow produces one untagged entry. A branched or split workflow produces one entry per `(split, partition)`. Empty when no extract ran, as in a parse-only workflow. | | `extractions[].validations` | the Validate node | Array of `{rule_id, description, severity, status, detail, compared_values, source_fields}`, one per rule, on the extraction entry the rules judged. `status` is `pass`, `fail`, or `inconclusive`. Empty when no Validate node ran. | | `edits` | the Edit node | Array of `{file_id, file_name, fields, unmatched_instructions, download_url}`. One entry per file the node filled. Empty when no Edit node ran. | | `extraction` | extract nodes (legacy) | **Deprecated.** Flat `{field_name → ExtractedField}` dict, populated only for linear workflows. Kept for backward compatibility. New code reads `extractions[]`. It will be removed in a future major version. | Each extracted field carries a `value`, an optional human-supplied `value_override`, a `confidence` score from 0 to 100, a `verification_status`, and an `evidence` array of source-text snippets with page numbers. [Get run](/api-reference-v3/runs/get) documents the full envelope. Read `value_override` when it is set, and `value` otherwise. **What the SDKs give you back.** The [TypeScript](/api-reference-v3/sdks/typescript) and [Python](/api-reference-v3/sdks/python) SDKs both return a `Result` from `Run.wait()`. For a linear `parse → extract` workflow the scalar values sit right there. In Python, read `result.fields["name"].value`. In TypeScript, read `result.field("name")?.value`. The full envelope lives at `result.raw` in both languages: parse markdown, classifications, splits, and multi-extraction entries. Python also exposes a typed `result.parse` view. *** ## Filled forms An [Edit node](/guides/nodes/overview) produces a document rather than data, so its output lands in its own section: `edits[]`, one entry per file the node processed. A packet of five blank forms comes back as five entries. Each entry records which fields the node found and what it wrote into them: * **`fields`**: every form field detected, filled or not, in document order. A field carries its printed `label`, its `kind` of `text` or `checkbox`, the `page` and `bbox` it occupies, and the `value` written into it. A field the document already carried a value for comes back as `state: "prefilled"`. anyformat never overwrites it, so it always reads back with `value: null`. * **`unmatched_instructions`**: the instruction fragments, quoted verbatim, that the linker matched to no field in this document. It covers instructions that found no field, not every reason a value can be missing from the rendered PDF, so treat the filled document itself as the final word. * **`download_url`**: a temporary link to the filled PDF. **The download link is short-lived.** `download_url` is valid for **15 minutes** from the moment the response was built. A fresh one is minted every time you read the run. Fetch the PDF when you receive it, or re-read the run for a new link. A stored URL will be dead. *** ## Confidence and evidence Two signals that travel with most outputs. ### Confidence A 0–100 score indicating how certain anyformat is about a value. * For `parse`: two document-level scores, plus the same pair on every entry of `blocks[]`. `parse_confidence` runs 0 to 100 and grades the **text** anyformat read. `layout_confidence` runs 0 to 1 and grades the **page layout** it detected. `parse_confidence` comes from the language model's token log-probabilities. `layout_confidence` comes from the YOLO layout-segmentation model. * For `extractions`: a per-field score on each extracted value. * For `classifications`: a per-verdict score. * For `edits`: a per-field score with a different meaning from the rest. It grades how surely a filling instruction addressed that field, not whether the value written in is correct. It is raw and uncalibrated, and it is not a probability. Treat it as a ranking signal for review, not as a quality score for the filled document. ### Evidence An **array** of metadata objects showing where a value came from. It is an array because some values are **inferred** across several spans rather than copied from one place. Each evidence object has: * The **snippet of text** the value came from * The **page number** in the document Evidence is the right signal to surface in a human-review UI. It lets reviewers jump straight to the source. **Metadata-sourced values carry a marker.** When a field comes from a top-level key of the [packet's `metadata`](/concepts/document-packets#attaching-metadata) rather than from the document, `evidence[0].text` is `metadata.`, such as `metadata.customer_reference`. Key on that prefix client-side to render metadata provenance differently, or to route the value to a "verified upstream" path. There is no page number to plot. *** ## What's next? Export formats for results: CSV, Excel, JSON, Markdown Upload a document and start a run in one call The flat read that returns a run with its results inline The unit a workflow runs on # Schemas Source: https://docs.anyformat.ai/concepts/schemas How you describe the shape of the data you want from documents. A **schema** lists the fields anyformat looks for in each document. It says two things: * Which fields you want: the invoice number, the date, the total. * What type of value each field holds: text, a number, a date. An invoice schema might hold three fields: **invoice number** as text, **issue date** as a date, and **total amount** as a decimal. Give anyformat that schema, and every invoice comes back with those three fields filled in. A schema does not read documents. It describes the **result** you want. The [workflow](/concepts/workflows) does the reading. The schema tells it what to aim for. Schema view *** ## When you use schemas You use a schema when: * You want consistent output across many documents * You care about structure, not just raw text * You plan to verify or improve results over time Most [workflows](/concepts/workflows) are built around a single schema. *** ## What a schema is not A schema is the *what*: the list of fields you want back. It is **not** the *how*. It does not say how to read the document or where on the page to look. anyformat handles that. You describe only what the finished result looks like. *** ## What's next? Define individual pieces of information within a schema Write plain-English guidance for better accuracy # Studio Source: https://docs.anyformat.ai/concepts/studio The visual editor where you build a workflow by connecting nodes on a canvas. **Studio** shows a [workflow](/concepts/workflows) as a flowchart you can edit. You place nodes on a canvas, connect them, and configure each one. Most workflows start with a description you give [annie](/concepts/annie). She builds a Parse → Extract workflow and opens it in Studio. From there you add nodes: sort documents by type, split a file that holds several documents, or check results against your own rules. Studio canvas *** ## How to open Studio * **Create a new workflow:** describe what you want to [annie](/concepts/annie) on the home screen. She builds the workflow and opens Studio. The conversation continues in the **Chat** tab. * **On an existing workflow:** open the workflow and click the **Studio** tab. *** ## The canvas and the palette The **canvas** holds your workflow: the nodes and the connections between them. Click a node to open its configuration panel on the right. The tab on the left labelled **Operators** is the palette. You drag nodes from it onto the canvas. Its section headers are **Intelligence**, **Logic**, **Alerts**, and **Annotations**: * **Intelligence**: Parse, Extract, Split, Classify, and, once enabled for your organization, Knowledge and Edit. * **Logic**: Validate and If/Else. * **Alerts**: Slack alert and Email alert. * **Annotations**: sticky notes. They document the canvas and take no part in the run. The Operators palette See **[Nodes](/guides/nodes/overview)** for what each node does and how to configure it. *** ## What's next? Step-by-step: open Studio, add nodes, connect them, and configure each one What each node does, what it returns, and what it costs # Usage & Billing Source: https://docs.anyformat.ai/concepts/usage-and-billing How anyformat measures usage and how it shows up in billing. anyformat charges for **how much document content it processes**. It ignores: * How complex your schema is * How many fields you define The driver is **pages**. anyformat counts the pages in each document it processes: * A 1-page invoice counts as 1 page. * A 50-page report counts as 50 pages. * 100 single-page invoices count as 100 pages. More fields in your schema, or more nodes in your workflow, do **not** raise usage. Only document content does. A document that fails with an error is not counted. *** ## Why usage works this way This model: * Scales predictably * Reflects real processing cost * Encourages starting simple *** ## Pricing anyformat bills usage in **credits**. Each node costs a fixed number of credits, the same on every plan. [How credits work](/concepts/how-credits-work) gives the per-node breakdown, a worked example, and what a credit is worth on your plan. For custom or enterprise pricing, write to [info@anyformat.ai](mailto:info@anyformat.ai). *** ## When to come back to this Re-read this page when: * You process large volumes * Costs start to matter * Documents behave unexpectedly * You debug a processing issue *** ## What's next? Per-node credit costs and a worked example Define what data you want Run your schema repeatedly at scale # Workflows Source: https://docs.anyformat.ai/concepts/workflows A workflow is the set of instructions that tells anyformat what to pull out of your documents. Build it once; it runs on every document you add. A **workflow** tells anyformat what to do with your documents. You build it once. It runs the same way on every document you give it. It is an assembly line of nodes: * **Parse** reads the document. * **Extract** pulls out the fields you care about. * **Classify** sorts documents by type, **Split** breaks a multi-document file apart, and **Validate** checks the results against your own rules. Add these when you need them. Each workflow targets **one kind of document** and **one shape of output**. For a different output, build a different workflow. Describe what you want to **[annie](/concepts/annie)**, anyformat's AI assistant, and she builds a Parse → Extract workflow for you. To arrange the nodes yourself, open **[Studio](/concepts/studio)**, the visual editor. *** ## The nodes Every workflow is a graph of nodes. Most workflows need only Parse and Extract. The rest handle harder documents, or return something other than data. **[Nodes](/guides/nodes/overview)** covers what each node does, what it returns, and what it costs. *** ## Three common shapes These are the same nodes arranged in different ways. You arrange them visually in **[Studio](/guides/studio/index)**. ### Read only Parse alone. You get clean text and tables back. Use this shape to feed anyformat's output into your own tools, such as a search index or a custom AI, instead of pulling out named fields. ### Read, then pull out: the usual one Parse into Extract. This is the default: "read this document and give me these fields." ### Sort first, then pull out Classify decides what kind of document it is, then sends it to an Extract node built for that type. Split works the same way for a file that holds several documents.
Split works the same way for a file that holds several documents.
The [examples](/examples/index) show each shape end to end. The [Studio guide](/guides/studio/index) shows how to build one yourself. *** ## How to think about workflows Most users follow the same lifecycle: Describe what you want to [annie](/concepts/annie) and she builds it. Or arrange the nodes yourself in [Studio](/guides/studio/index). Run a few sample documents through it, read the results, and tighten your fields and instructions. Apply it to many documents. Upload them, connect cloud storage, or call the API. In the [web platform](https://app.anyformat.ai) you build a workflow by [describing what you want to annie](/concepts/annie) from the home screen, then refine it in [Studio](/guides/studio/index). *** ## What's next? What each node does, what it returns, and what it costs The visual editor where you arrange the nodes into a workflow What happens when you run a workflow, and what comes back A step-by-step walkthrough in the UI, with no code # Agentic parse to markdown Source: https://docs.anyformat.ai/examples/agentic-parse-to-markdown Parse a document on the Agentic tier, pick an effort preset, and read the markdown, tables and per-block confidence it returns The Agentic tier is the same [Parse](/guides/nodes/parse) node with `mode` set to `agentic`. It parses in several steps and spends the most work on dense tables, so it is the tier to reach for when the Standard output on a complex layout is not good enough.
The document
INVOICE
What comes back
p1\_b0100
p1\_b586
p1\_b631
document
Nodes Credits100 per page You get
## The graph ```json Graph theme={null} { "name": "Agentic parser", "nodes": [{ "id": "parse_1", "type": "parse", "mode": "agentic", "effort": "mid" }], "edges": [] } ``` ```python Python theme={null} import os from anyformat.sdk import Client client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = client.workflow("Agentic parser").parse(mode="agentic", effort="mid").create() ``` ```typescript TypeScript theme={null} import { Anyformat } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af.workflow("Agentic parser").parse({ mode: "agentic", effort: "mid" }).create(); ``` `effort` is an Agentic-only knob: | `effort` | Trade-off | | ---------- | ------------------------------------------------------------------- | | `low` | Several times cheaper. More errors on dense or low-contrast tables. | | `mid` | The default. Balanced. | | `accurate` | Highest fidelity. Slowest and most expensive. | `prompt_hint` also applies, for example `"the second column is a date"`. `figure_enhancement` is ignored on this tier. ## Run and read Agentic runs are slower than Standard: 37 s for a one-page invoice against 28 s, and minutes for a long document. Give `wait()` a bigger budget. ```bash curl theme={null} curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/upload/run/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -F 'files=@report.pdf' # -> 202 {"run_id": "...", "status": "queued"} # Read the run. Repeat until "status" is "processed". curl "https://api.anyformat.ai/v3/runs/$RUN_ID/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" ``` ```python Python theme={null} result = workflow.run("report.pdf").wait(timeout=600) print(result.parse.parse_confidence) for block in result.parse.blocks: if block.type == "table": print(block.id, block.parse_confidence, len(block.rows or []), "rows") ``` ```typescript TypeScript theme={null} import { readFile } from "node:fs/promises"; const file = new File([await readFile("report.pdf")], "report.pdf", { type: "application/pdf" }); const run = await workflow.run(file); const result = await run.wait({ timeoutMs: 600_000 }); console.log(result.parse?.parseConfidence); for (const block of result.parse?.blocks ?? []) { if (block.type === "table") console.log(block.id, block.parse_confidence, block.rows?.length ?? 0, "rows"); } ``` ## What comes back The Agentic tier on a one-page invoice, trimmed. The shape is the same as every other tier; only the work behind it differs. ```json Response highlight={7,8} theme={null} { "results": { "parse": { "parse_confidence": 75.0, "blocks": [ { "id": "p1_b0", "type": "header", "page": 1, "parse_confidence": 100.0, "content": "INVOICE", "rows": null }, { "id": "p1_b5", "type": "table", "page": 1, "parse_confidence": 85.9, "rows": [[{ "cell_id": "r0c0", "text": "Description" }, { "cell_id": "r0c1", "text": "Qty" }]] }, { "id": "p1_b6", "type": "table", "page": 1, "parse_confidence": 31.0, "rows": ["…"] } ] } } } ``` `classifications`, `splits`, `extractions` and `edits` all come back empty, because only Parse ran. [Runs and results](/concepts/runs-and-results) has the full envelope, section by section. ## Reading the confidence * `blocks[].parse_confidence` is the number to act on. It is 0 to 100 per block. In the sample above the line-item table scored 86 and the totals block, which the tier read as a second table, scored 31. That is the block to send to review. * `parse.parse_confidence` is the document roll-up, weighted by the characters in each block. A long low-scoring table pulls it down more than a short heading pulls it up. * A block that no model scored comes back with `parse_confidence: null`. Treat `null` as "not scored", not as zero. ## When it goes wrong **Every document type is on Agentic.** Agentic costs 100 credits a page against 25 on Standard, four times the price for the same graph. Try Standard first, and move a document type up only when its tables come back wrong. **One table came back as two.** In the sample run the tier read the totals as a second table and scored it 31, next to 86 for the line items. That is the block to send to review, and `blocks[].parse_confidence` is what tells you so. Read the block's `rows` for the cells as a grid rather than parsing its `
` HTML. **The document score disagrees with the blocks.** `parse.parse_confidence` is a roll-up weighted by the characters in each block, so one long low-scoring table drags it down further than a short clean heading lifts it. Judge a run on the per-block numbers. A block that no model scored comes back with `parse_confidence: null`, which means "not scored", not zero, so test for `null` before you compare. **The run times out.** Agentic takes about 37 s on a one-page invoice against 28 s on Standard, and minutes on a long document. Raise the budget you hand to `wait()`: `timeout=600` in Python, `{ timeoutMs: 600_000 }` in TypeScript. **A dense table is still wrong at `effort: "mid"`.** Move to `accurate` for scanned, low-contrast or very wide tables. Drop to `low` on clean digital documents where you want the multi-step pass at a lower price. `prompt_hint` takes a sentence about the page, for example `"the second column is a date"`. `figure_enhancement` is ignored on this tier, so it will not help here. To turn the same tables into typed fields, add an Extract node after the Parse node. [Bank statement processing](/examples/bank-statement-processing) does exactly that. ## Next steps Every knob on the node The same graph on Flash, Fast and Standard The full run envelope The anyformat skill for Claude Code and other agents # Ask your filing cabinet Source: https://docs.anyformat.ai/examples/ask-your-filing-cabinet Index every document a workflow parses, then ask questions across all of them and get answers cited back to the page The [Knowledge](/guides/nodes/knowledge) node is in **alpha** and appears in the palette once the feature is switched on for your organization. Ask us to enable it. Extraction answers "what is on this document". Knowledge answers "what do these documents say about X", across the workflow's current corpus. Completed runs made while a [Knowledge](/guides/nodes/knowledge) node exists add their parsed documents to that corpus. Every answer comes back with the quotes it rests on, each resolved to a page and a region of the original PDF.
You ask
It answers
Nodes Credits27 per page You get
## The workflow The node has no options. Its presence opts the workflow into knowledge ingestion when runs complete. ```json API theme={null} { "name": "Ask the supplier file", "description": "Index every document the workflow parses, then ask it questions", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "knowledge_1", "type": "knowledge" } ], "edges": [{ "source": "parse_1", "target": "knowledge_1" }] } ``` ```python Python theme={null} import os from anyformat.sdk import Client, WorkflowDefinition from anyformat.workflow.definition import Edge from anyformat.workflow.nodes import KnowledgeNode, ParseNode client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = client.create_workflow( WorkflowDefinition( name="Ask the supplier file", description="Index every document the workflow parses, then ask it questions", nodes=[ ParseNode(id="parse_1", type="parse"), KnowledgeNode(id="knowledge_1", type="knowledge"), ], edges=[Edge(source="parse_1", target="knowledge_1")], ) ) ``` The corpus is **workflow-scoped, not version-scoped**. It keeps the current parsed content for each live file across runs and versions. It is not a version history. If documents were processed before you add Knowledge, a member opens the workflow's **Knowledge** tab and chooses **Index existing documents**. Use **Retry indexing** after a failure. Once a corpus exists it never needs a manual update: new or changed documents re-index in the background, and the existing corpus keeps serving in the meantime. These maintenance actions reuse processed documents and do not spend knowledge-index credits. ## Ask it Upload and run documents as you would with any workflow. Runs completed after Knowledge is present ingest normally. Then ask. ```bash curl theme={null} curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/knowledge/ask" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"question": "Which invoices are in this file, who issued each one, and what is the total of each?"}' ``` ```python Python theme={null} answer = workflow.ask("Which invoices are in this file, who issued each one, and what is the total of each?") print(answer.answer) for citation in answer.citations: print(citation.quote, "page", citation.page) ``` ## Response The `answer` is markdown, written to be shown to a person. The `citations` are the point: each one carries the exact `quote`, the `page`, and a `bbox` in normalized page coordinates, so you can draw the box on the PDF instead of asking anyone to trust the answer. `path` shows which tools the agent used to get there, and `steps_used` how many it took. ```json theme={null} { "answer": "The document `2026-08/mixed_partition.md` contains three invoices:\n\n* **INV-2026-1000**, issued by **Acme Industries**, with a total of **$1,810.50**.\n* **INV-2026-1001**, issued by **Globex Corporation**, with a total of **$932.45**.\n* **INV-2026-1002**, issued by **Initech LLC**, with a total of **$5,648.15**.\n\nThe file also includes a shipping label and a boarding pass.", "citations": [ { "path": "2026-08/mixed_partition.md", "quote": "Invoice #: INV-2026-1000", "file_id": "06a95794-11f6-77e1-8000-f49fa028d98e", "block_id": "p1_b1", "page": 1, "bbox": { "x0": 0.113, "x1": 0.325, "y0": 0.132, "y1": 0.21 } }, { "path": "2026-08/mixed_partition.md", "quote": "**TOTAL: $1,810.50**", "file_id": "06a95794-11f6-77e1-8000-f49fa028d98e", "block_id": "p1_b9", "page": 1, "bbox": { "x0": 0.584, "x1": 0.756, "y0": 0.431, "y1": 0.483 } } ], "path": [ { "tool": "kb_ls", "args": { "folder": "." } }, { "tool": "kb_read", "args": { "path": ["INDEX.md", "2026-08/mixed_partition.md"] } } ], "steps_used": 2, "thread_id": null } ``` ## Follow-up questions Mint a `thread_id` yourself, starting with `kb-`, and pass it to ask in the context of what came before. Reuse the same id to continue the conversation; omit it and every question stands alone. ```json theme={null} { "question": "Which of those is the largest?", "thread_id": "kb-invoices-review-1" } ``` ## When it goes wrong **`409 KNOWLEDGE_NOT_ENABLED`.** The workflow has no Knowledge node. This is permanent until you add one: do not retry. **`409 KNOWLEDGE_NOT_READY`.** No snapshot has ever been published for this workflow. Wait when the Knowledge tab shows **Indexing documents**. Otherwise, use **Index existing documents** or **Retry indexing** in that tab as appropriate. Once a snapshot exists, this error does not return: a stale corpus keeps serving while it re-indexes. **The answer is right but you cannot prove it.** Read the citations, not the prose. Each `bbox` is normalized to the page, so multiply by the rendered page size to draw it. An answer whose citations do not support it is the one to escalate. **It answers about the wrong document.** The corpus holds the current parsed content for every live file in the workflow. Give the question the scope you mean: name the supplier, the month or the file. **The bill is bigger than you expected.** Questions are metered on the text the agent actually reads, so a broad sweep over a large corpus costs more than a narrow question. Ask narrowly. ## Next steps The node, the corpus, and what it costs The endpoint, its errors and its rate limit # Bank statement processing Source: https://docs.anyformat.ai/examples/bank-statement-processing Extract the statement header and every transaction as a row, run many statements through one workflow, and get the data out This workflow reads a bank statement and returns the header (account, period, balances) as scalar fields and the transactions as rows of an `object` field. Create it once, then run every statement through it.
The statement
BANK STATEMENT
What comes back
account\_holder99%
account\_number99%
closing\_balance99%
transactions
NodesParse → Extract Credits60 per page You get
## The graph ```json Graph theme={null} { "name": "Bank statement processor", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "account_holder", "description": "Name of the account holder as shown on the statement", "data_type": "string" }, { "name": "account_number", "description": "Bank account number, may be partially masked", "data_type": "string" }, { "name": "statement_period_start", "description": "First day of the statement period", "data_type": "date" }, { "name": "statement_period_end", "description": "Last day of the statement period", "data_type": "date" }, { "name": "opening_balance", "description": "Account balance at the start of the period", "data_type": "float" }, { "name": "closing_balance", "description": "Account balance at the end of the period", "data_type": "float" }, { "name": "transactions", "description": "Every transaction listed on the statement, one row each", "data_type": "object", "nested_fields": [ { "name": "date", "description": "Date of the transaction", "data_type": "date" }, { "name": "description", "description": "Transaction description or memo", "data_type": "string" }, { "name": "amount", "description": "Transaction amount: positive for deposits, negative for withdrawals", "data_type": "float" }, { "name": "type", "description": "Type of transaction", "data_type": "enum", "enum_options": [ { "name": "deposit", "description": "Incoming deposit" }, { "name": "withdrawal", "description": "Outgoing withdrawal" }, { "name": "fee", "description": "Bank fee or charge" }, { "name": "transfer", "description": "Transfer between accounts" }, { "name": "interest", "description": "Interest earned or charged" } ] } ] } ] } } ], "edges": [{ "source": "parse_1", "target": "extract_1" }] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Bank statement processor") .parse() .extract([ Schema.string("account_holder", "Name of the account holder as shown on the statement"), Schema.string("account_number", "Bank account number, may be partially masked"), Schema.date("statement_period_start", "First day of the statement period"), Schema.date("statement_period_end", "Last day of the statement period"), Schema.float("opening_balance", "Account balance at the start of the period"), Schema.float("closing_balance", "Account balance at the end of the period"), Schema.object("transactions", "Every transaction listed on the statement, one row each", fields=[ Schema.date("date", "Date of the transaction"), Schema.string("description", "Transaction description or memo"), Schema.float("amount", "Transaction amount: positive for deposits, negative for withdrawals"), Schema.enum("type", "Type of transaction", options=[ Schema.option("deposit", "Incoming deposit"), Schema.option("withdrawal", "Outgoing withdrawal"), Schema.option("fee", "Bank fee or charge"), Schema.option("transfer", "Transfer between accounts"), Schema.option("interest", "Interest earned or charged"), ]), ]), ]) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af .workflow("Bank statement processor") .parse() .extract([ Schema.string("account_holder", "Name of the account holder as shown on the statement"), Schema.string("account_number", "Bank account number, may be partially masked"), Schema.date("statement_period_start", "First day of the statement period"), Schema.date("statement_period_end", "Last day of the statement period"), Schema.float("opening_balance", "Account balance at the start of the period"), Schema.float("closing_balance", "Account balance at the end of the period"), Schema.object("transactions", "Every transaction listed on the statement, one row each", [ Schema.date("date", "Date of the transaction"), Schema.string("description", "Transaction description or memo"), Schema.float("amount", "Transaction amount: positive for deposits, negative for withdrawals"), Schema.enum("type", "Type of transaction", [ Schema.option("deposit", "Incoming deposit"), Schema.option("withdrawal", "Outgoing withdrawal"), Schema.option("fee", "Bank fee or charge"), Schema.option("transfer", "Transfer between accounts"), Schema.option("interest", "Interest earned or charged"), ]), ]), ]) .create(); ``` ## Run and read One statement is one run. Keep each file in its own call so each statement gets its own result; a call that carries several files makes one document packet and one run. ```bash curl theme={null} for f in statement-jan.pdf statement-feb.pdf statement-mar.xlsx; do curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/upload/run/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -F "files=@$f" # -> 202 {"run_id": "...", "status": "queued"}; keep each run_id done # Read each run. Repeat until "status" is "processed". curl "https://api.anyformat.ai/v3/runs/$RUN_ID/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" ``` ```python Python theme={null} for path in ["statement-jan.pdf", "statement-feb.pdf", "statement-mar.xlsx"]: result = workflow.run(path).wait() print(result.field("account_holder").value, result.field("closing_balance").value) # An object field is a list of rows; each row maps a column name to a field for row in result.fields["transactions"]: if row["type"].value == "fee": print("fee:", row["description"].value, float(row["amount"].value)) ``` ```typescript TypeScript theme={null} import { readFile } from "node:fs/promises"; import type { ExtractedField, ExtractedRows } from "@anyformat/sdk"; for (const path of ["statement-jan.pdf", "statement-feb.pdf", "statement-mar.xlsx"]) { const file = new File([await readFile(path)], path); const run = await workflow.run(file); const result = await run.wait(); // field() returns a scalar or rows; narrow it to what the schema declares const scalar = (name: string) => result.field(name) as ExtractedField | undefined; console.log(scalar("account_holder")?.value, scalar("closing_balance")?.value); // An object field is an array of rows; each row maps a column name to a field const rows = result.field("transactions") as ExtractedRows; for (const row of rows) { if (row.type?.value === "fee") console.log("fee:", row.description?.value, Number(row.amount?.value)); } } ``` ## What comes back One statement with six transactions, trimmed to two header fields and two rows. The run took 21 seconds. ```json Response highlight={8,9} theme={null} { "results": { "extractions": [ { "fields": { "account_holder": { "value": "Jordan Reyes", "confidence": 99.0, "evidence": [{ "text": "Account holder: Jordan Reyes", "page_number": 1 }] }, "closing_balance": { "value": "2983.35", "confidence": 99.0, "evidence": [] }, "transactions": [ { "date": { "value": "2026-03-02", "confidence": 99.0 }, "description": { "value": "Salary - Brightline Ltd", "confidence": 99.0 }, "amount": { "value": "3200.0", "confidence": 99.0 }, "type": { "value": "deposit", "confidence": 95.0 } }, { "date": { "value": "2026-03-04", "confidence": 99.0 }, "description": { "value": "Rent - Harbor Apartments", "confidence": 99.0 }, "amount": { "value": "-1650.0", "confidence": 99.0 }, "type": { "value": "withdrawal", "confidence": 99.0 } }, "…" ] } } ] } } ``` Every field also carries `verification_status` and `value_override`. [Runs and results](/concepts/runs-and-results) has the full envelope, section by section. Read the fields from `extractions[0].fields`. A scalar field is one object with `value`, `confidence` and `evidence`. An `object` field is an array of rows, and every cell in a row is that same object. Every value is a string on the wire: numbers as `"2983.35"`, dates as `"2026-03-31"`, booleans as `"True"` or `"False"`. The SDKs keep them as strings too, so convert before you add them up. A field the model could not find comes back as `{"value": null, "confidence": null, "evidence": []}`. ## Getting the data out * **In the app**, open the workflow and export the results as CSV, Excel or JSON. Each statement is a row and `transactions` expands into its own sheet or nested array. See [Outputs](/concepts/outputs). * **Over the API**, read each run as shown above, or list a workflow's runs for a date range and flatten them yourself. [Daily CSV export](/examples/daily-csv-export) is a complete script for that. ## When it goes wrong **Rows are missing from a long transaction list.** A statement runs to hundreds of rows across several pages, and a single standard pass can drop one in a dense table. Ask for the totals the statement prints (`total_deposits`, `total_withdrawals`) as extra fields and compare them with the sum of the rows. A mismatch is the cheapest signal that a row was missed. When the tables stay dense, move the Parse node to the agentic tier with `"mode": "agentic"` at 100 credits per page. [Agentic parse to markdown](/examples/agentic-parse-to-markdown) shows the presets. **Withdrawals come back positive.** `amount` is a `float`, and nothing in the type says which direction the money went. Put the convention in the description: "positive for deposits, negative for withdrawals". The sign then holds across banks that print a Debit column instead of a minus sign. **A transaction type comes back empty.** `type` is an `enum`, so it returns only the names you declared. A statement that prints "standing order" or "card payment" has nothing to match among deposit, withdrawal, fee, transfer and interest. Add the option, or use a `string` field when the bank's vocabulary is open. **Your balances do not add up.** Every value arrives as a string: `"2983.35"`, `"-1650.0"`, `"2026-03-31"`. Cast each one to the type you declared before you do arithmetic on it. A field the model could not find comes back as `{"value": null, "confidence": null, "evidence": []}`, so test for `null` before you cast. **A batch of statements fails halfway through.** Upload and run is limited to 60 requests per minute. Submit statements serially with a short pause between them, or spread them across workers. Feed the workflow the XLSX or CSV the bank already offers when you have the choice: the cells are structured and no OCR runs. ## Next steps Object fields, modes and smart lookup CSV, Excel and JSON exports from the app Flatten a day of runs into one CSV over the API The model behind the envelope # Sort invoices from receipts Source: https://docs.anyformat.ai/examples/classify-and-route One workflow for a mailbox that receives both invoices and receipts: Classify decides which is which, and each kind gets its own Extract. A shared inbox receives invoices and receipts, and they need different fields. One workflow handles both: Parse reads the file, Classify labels it as an invoice or a receipt, and the edge `branch` sends it to the Extract node built for that kind. The document only pays for the Extract it reaches.
The file from the inbox
INVOICE
What comes back
category100%
invoice\_number98%
total\_amount99%
due\_date97%
Nodes Credits52 per page You get
The edge that leaves Classify names the category id it carries.
## The workflow ```json API theme={null} { "name": "Inbox: invoices and receipts", "nodes": [ { "id": "parse_1", "type": "parse", "mode": "flash" }, { "id": "classify_1", "type": "classify", "categories": [ { "id": "invoice", "name": "Invoice", "description": "A bill for goods or services with a total due" }, { "id": "receipt", "name": "Receipt", "description": "Proof of a completed payment" } ] }, { "id": "extract_inv", "type": "extract", "extraction_schema": { "fields": [ { "name": "invoice_number", "description": "Invoice identifier", "data_type": "string" }, { "name": "total_amount", "description": "Grand total, including tax", "data_type": "float" }, { "name": "due_date", "description": "Date payment is due", "data_type": "date" } ] } }, { "id": "extract_rec", "type": "extract", "extraction_schema": { "fields": [ { "name": "merchant", "description": "Who was paid", "data_type": "string" }, { "name": "amount_paid", "description": "Amount paid", "data_type": "float" } ] } } ], "edges": [ { "source": "parse_1", "target": "classify_1" }, { "source": "classify_1", "target": "extract_inv", "branch": "invoice" }, { "source": "classify_1", "target": "extract_rec", "branch": "receipt" } ] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema from anyformat.workflow.nodes import ClassifyCategory client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) invoice = ClassifyCategory(id="invoice", name="Invoice", description="A bill for goods or services with a total due") receipt = ClassifyCategory(id="receipt", name="Receipt", description="Proof of a completed payment") workflow = ( client.workflow("Inbox: invoices and receipts") .parse(mode="flash") .classify(invoice, receipt) .extract([ Schema.string("invoice_number", "Invoice identifier"), Schema.float("total_amount", "Grand total, including tax"), Schema.date("due_date", "Date payment is due"), ], branch=invoice) .extract([ Schema.string("merchant", "Who was paid"), Schema.float("amount_paid", "Amount paid"), ], branch=receipt) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const invoice = { id: "invoice", name: "Invoice", description: "A bill for goods or services with a total due" }; const receipt = { id: "receipt", name: "Receipt", description: "Proof of a completed payment" }; const workflow = await af .workflow("Inbox: invoices and receipts") .parse({ mode: "flash" }) .classify([invoice, receipt]) .extract([ Schema.string("invoice_number", "Invoice identifier"), Schema.float("total_amount", "Grand total, including tax"), Schema.date("due_date", "Date payment is due"), ], { branch: invoice }) .extract([ Schema.string("merchant", "Who was paid"), Schema.float("amount_paid", "Amount paid"), ], { branch: receipt }) .create(); ``` The edge that leaves Classify carries `branch`, the category **id**. The API rejects an edge out of Classify without one. ## Run it and read the result ```bash curl theme={null} curl -X POST https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/upload/run/ \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -F files=@invoice.pdf # → 202 { "run_id": "…", "status": "queued" } curl https://api.anyformat.ai/v3/runs/$RUN_ID/ \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" ``` ```python Python theme={null} result = workflow.run("invoice.pdf").wait() print(result.raw["classifications"][0]["category"]) # "Invoice" print(result.field("total_amount").value) # "4594.62" ``` ```typescript TypeScript theme={null} import { readFile } from "node:fs/promises"; import type { ExtractedField } from "@anyformat/sdk"; const run = await workflow.run(new File([await readFile("invoice.pdf")], "invoice.pdf")); const result = await run.wait(); console.log(result.raw.classifications[0]?.category); // "Invoice" console.log((result.field("total_amount") as ExtractedField | undefined)?.value); // "4594.62" ``` The run for a one-page invoice, trimmed. `classifications[].category` is the category **name**, and only the Extract on the taken branch produces fields: ```json Response highlight={4,8} theme={null} { "results": { "classifications": [ { "category": "Invoice", "confidence": 100.0, "evidence": "The document is explicitly titled \"INVOICE\" and includes invoice number INV-2026-9001, issue and due dates, itemized charges, subtotal, tax, and a total due of $4,594.62." } ], "extractions": [ { "fields": { "invoice_number": { "value": "INV-2026-9001", "confidence": 98.0, "evidence": [{ "text": "Invoice #: INV-2026-9001", "page_number": 1 }] }, "total_amount": { "value": "4594.62", "confidence": 99.0, "evidence": [{ "text": "TOTAL: $4,594.62", "page_number": 1 }] }, "due_date": { "value": "2026-03-22", "confidence": 97.0, "evidence": [{ "text": "Due date: 2026-03-22", "page_number": 1 }] } } } ] } } ``` [Runs and results](/concepts/runs-and-results) has the full envelope, section by section. ## When it goes wrong **Everything comes back as the same kind.** Classify picks the closest category it was given, so a thin description lets one category swallow the rest. Write each description the way a reader would tell the two apart, not as a label. "Proof of a completed payment" beats "receipt". **A document that belongs to neither kind still gets a label.** Classify always picks one; it never returns nothing. Add an explicit "Other" category with its own description and leave its branch without an Extract. The document is then labelled, and no extraction is billed for it. **Nothing reaches your Extract node.** The edge that leaves Classify carries the category **id** (`invoice`), not the category's display name (`Invoice`). The response reports the name in `classifications[].category`, so the two are easy to confuse. Check the `branch` on every edge against the `id` in `categories`. **A scan comes back with poor text.** Parse runs on the Flash tier here. Flash reads the PDF's own text layer and makes no model call, which is enough for born-digital PDFs and keeps the workflow at 52 credits per page. Set `"mode": "standard"` on the Parse node when scans arrive, at 25 credits per page instead of 7. **You are about to add a third Extract node.** Two categories can share one Extract when they need the same fields: point both branches at it. Add a node only where the fields differ. ## Next * [Classify](/guides/nodes/classify) and [Split](/guides/nodes/split) for files that hold several documents. * [Contract analysis](/examples/contract-analysis) to add Validate after an Extract. # Contract analysis Source: https://docs.anyformat.ai/examples/contract-analysis Extract key terms and clauses from a contract, then check them with validation rules This example reads a contract, extracts its key terms and the clauses it contains, and then runs a set of rules over those terms. Four deterministic rules catch a missing date or a short notice period for free. One AI rule judges whether the indemnification clause protects you.
The contract
MASTER SERVICES AGREEMENT
What comes back
contract\_type96%
auto\_renewal96%
notice\_days97%
liability\_cap95%
key\_clauses94%
Nodes Credits60 per page You get
## The workflow ```json API theme={null} { "name": "Contract analysis", "description": "Extract key terms and clauses, then check them", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "contract_type", "description": "The type of legal agreement", "data_type": "enum", "enum_options": [ { "name": "nda", "description": "Non-disclosure agreement" }, { "name": "service_agreement", "description": "Service or consulting agreement" }, { "name": "employment", "description": "Employment contract" }, { "name": "lease", "description": "Lease or rental agreement" }, { "name": "licensing", "description": "Licensing agreement" } ] }, { "name": "effective_date", "description": "Date the contract takes effect", "data_type": "date" }, { "name": "expiration_date", "description": "Date the contract expires or terminates", "data_type": "date" }, { "name": "auto_renewal", "description": "Whether the contract automatically renews at expiration", "data_type": "boolean" }, { "name": "governing_law", "description": "State or jurisdiction whose laws govern this contract", "data_type": "string" }, { "name": "termination_notice_days", "description": "Number of days advance notice required to terminate", "data_type": "integer" }, { "name": "key_clauses", "description": "Standard contract clauses that are present in this agreement", "data_type": "multi_select", "enum_options": [ { "name": "confidentiality", "description": "Confidentiality or NDA clause" }, { "name": "non_compete", "description": "Non-compete restriction" }, { "name": "indemnification", "description": "Indemnification or hold-harmless clause" }, { "name": "limitation_of_liability", "description": "Cap on damages or liability" }, { "name": "force_majeure", "description": "Force majeure or act-of-God clause" }, { "name": "arbitration", "description": "Mandatory arbitration clause" }, { "name": "intellectual_property", "description": "IP ownership or assignment clause" } ] }, { "name": "liability_cap", "description": "Maximum liability amount, if a cap is specified", "data_type": "float" } ] } }, { "id": "validate_1", "type": "validate", "rules": [ { "id": "has-effective-date", "kind": "deterministic", "severity": "error", "check": { "type": "required", "field": "effective_date" } }, { "id": "term-is-ordered", "kind": "deterministic", "severity": "error", "check": { "type": "comparison", "left": "expiration_date", "op": ">=", "right": { "source": "field", "field": "effective_date" } } }, { "id": "notice-at-least-90-days", "kind": "deterministic", "severity": "warning", "check": { "type": "range", "field": "termination_notice_days", "min": 90 } }, { "id": "liability-is-capped", "kind": "deterministic", "severity": "warning", "check": { "type": "required", "field": "liability_cap" } }, { "id": "indemnification-protects-us", "kind": "ai", "severity": "warning", "description": "The indemnification clause protects the Client, not only the Provider." } ] } ], "edges": [ { "source": "parse_1", "target": "extract_1" }, { "source": "extract_1", "target": "validate_1" } ] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import ComparisonCheck, RangeCheck, RequiredCheck, Schema, ValidationRule client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Contract analysis", "Extract key terms and clauses, then check them") .parse() .extract([ Schema.enum("contract_type", "The type of legal agreement", options=[ Schema.option("nda", "Non-disclosure agreement"), Schema.option("service_agreement", "Service or consulting agreement"), Schema.option("employment", "Employment contract"), Schema.option("lease", "Lease or rental agreement"), Schema.option("licensing", "Licensing agreement"), ]), Schema.date("effective_date", "Date the contract takes effect"), Schema.date("expiration_date", "Date the contract expires or terminates"), Schema.boolean("auto_renewal", "Whether the contract automatically renews at expiration"), Schema.string("governing_law", "State or jurisdiction whose laws govern this contract"), Schema.integer("termination_notice_days", "Number of days advance notice required to terminate"), Schema.multi_select("key_clauses", "Standard contract clauses that are present in this agreement", options=[ Schema.option("confidentiality", "Confidentiality or NDA clause"), Schema.option("non_compete", "Non-compete restriction"), Schema.option("indemnification", "Indemnification or hold-harmless clause"), Schema.option("limitation_of_liability", "Cap on damages or liability"), Schema.option("force_majeure", "Force majeure or act-of-God clause"), Schema.option("arbitration", "Mandatory arbitration clause"), Schema.option("intellectual_property", "IP ownership or assignment clause"), ]), Schema.float("liability_cap", "Maximum liability amount, if a cap is specified"), ]) .validate( ValidationRule(id="has-effective-date", kind="deterministic", severity="error", check=RequiredCheck(type="required", field="effective_date")), ValidationRule(id="term-is-ordered", kind="deterministic", severity="error", check=ComparisonCheck(type="comparison", left="expiration_date", op=">=", right={"source": "field", "field": "effective_date"})), ValidationRule(id="notice-at-least-90-days", kind="deterministic", severity="warning", check=RangeCheck(type="range", field="termination_notice_days", min=90)), ValidationRule(id="liability-is-capped", kind="deterministic", severity="warning", check=RequiredCheck(type="required", field="liability_cap")), ValidationRule(id="indemnification-protects-us", severity="warning", description="The indemnification clause protects the Client, not only the Provider."), ) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af .workflow("Contract analysis", "Extract key terms and clauses, then check them") .parse() .extract([ Schema.enum("contract_type", "The type of legal agreement", [ Schema.option("nda", "Non-disclosure agreement"), Schema.option("service_agreement", "Service or consulting agreement"), Schema.option("employment", "Employment contract"), Schema.option("lease", "Lease or rental agreement"), Schema.option("licensing", "Licensing agreement"), ]), Schema.date("effective_date", "Date the contract takes effect"), Schema.date("expiration_date", "Date the contract expires or terminates"), Schema.boolean("auto_renewal", "Whether the contract automatically renews at expiration"), Schema.string("governing_law", "State or jurisdiction whose laws govern this contract"), Schema.integer("termination_notice_days", "Number of days advance notice required to terminate"), Schema.multiSelect("key_clauses", "Standard contract clauses that are present in this agreement", [ Schema.option("confidentiality", "Confidentiality or NDA clause"), Schema.option("non_compete", "Non-compete restriction"), Schema.option("indemnification", "Indemnification or hold-harmless clause"), Schema.option("limitation_of_liability", "Cap on damages or liability"), Schema.option("force_majeure", "Force majeure or act-of-God clause"), Schema.option("arbitration", "Mandatory arbitration clause"), Schema.option("intellectual_property", "IP ownership or assignment clause"), ]), Schema.float("liability_cap", "Maximum liability amount, if a cap is specified"), ]) .validate([ { id: "has-effective-date", kind: "deterministic", severity: "error", check: { type: "required", field: "effective_date" } }, { id: "term-is-ordered", kind: "deterministic", severity: "error", check: { type: "comparison", left: "expiration_date", op: ">=", right: { source: "field", field: "effective_date" } } }, { id: "notice-at-least-90-days", kind: "deterministic", severity: "warning", check: { type: "range", field: "termination_notice_days", min: 90 } }, { id: "liability-is-capped", kind: "deterministic", severity: "warning", check: { type: "required", field: "liability_cap" } }, { id: "indemnification-protects-us", kind: "ai", severity: "warning", description: "The indemnification clause protects the Client, not only the Provider." }, ]) .create(); ``` ## Upload now, run later, poll Contracts often arrive in batches and get reviewed later. Upload each one as a document packet as it arrives, and start the run when you are ready. Then poll `GET /v3/runs/{run_id}/` until `status` is `processed`. `error` and `cancelled` are terminal too, so stop on those. ```bash curl theme={null} # 1. Upload the contract as a packet (no run yet) curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/upload/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -F 'files=@contract.pdf' # → 201 { "document_packet_id": "", ... } # 2. Run the packet when ready (a fresh run each call) curl -X POST "https://api.anyformat.ai/v3/document-packets/$PACKET_ID/run/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" # → 202 { "run_id": "", "status": "queued" } # 3. Poll until "status" is one of processed / error / cancelled until curl -s "https://api.anyformat.ai/v3/runs/$RUN_ID/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ | grep -qE '"status":"(processed|error|cancelled)"'; do sleep 5 done ``` ```python Python theme={null} import time upload = workflow.upload("contract.pdf") # packet only, no run run = upload.run() # start it when ready detail = client.get_run(run.id) while detail.status not in ("processed", "error", "cancelled"): time.sleep(5) detail = client.get_run(run.id) if detail.result is None: raise RuntimeError(f"run {run.id} ended in {detail.status}") result = detail.result # or: upload.run().wait() to block instead print(result.fields["contract_type"].value, result.fields["governing_law"].value) # A multi_select value is one comma-separated string of option names. clauses = (result.fields["key_clauses"].value or "").split(", ") if "indemnification" not in clauses: print("WARNING: no indemnification clause") ``` ```typescript TypeScript theme={null} import type { ExtractedField } from "@anyformat/sdk"; const file = new File([readFileSync("contract.pdf")], "contract.pdf", { type: "application/pdf" }); const upload = await workflow.upload(file); // packet only, no run const run = await upload.run(); // start it when ready const result = await run.wait(); // polls until a terminal status console.log((result.field("contract_type") as ExtractedField | undefined)?.value, (result.field("governing_law") as ExtractedField | undefined)?.value); // A multi_select value is one comma-separated string of option names. const clauses = ((result.field("key_clauses") as ExtractedField | undefined)?.value ?? "").split(", "); if (!clauses.includes("indemnification")) console.warn("no indemnification clause"); ``` ## Response The `results` envelope of a processed run, trimmed. Every value is a string on the wire: a `multi_select` is one comma-separated string of option names, with one evidence snippet per clause, and a `boolean` is `"True"` or `"False"`. ```json Response highlight={7,9} theme={null} { "results": { "extractions": [ { "fields": { "contract_type": { "value": "service_agreement", "confidence": 96.0, "evidence": [{ "text": "MASTER SERVICES AGREEMENT", "page_number": 1 }] }, "auto_renewal": { "value": "True", "confidence": 96.0, "evidence": [{ "text": "The Agreement automatically renews for successive one-year periods", "page_number": 1 }] }, "termination_notice_days": { "value": "60", "confidence": 97.0, "evidence": [{ "text": "upon sixty (60) days prior written notice", "page_number": 1 }] }, "liability_cap": { "value": "250000", "confidence": 95.0, "evidence": [{ "text": "shall exceed USD 250,000", "page_number": 1 }] }, "key_clauses": { "value": "confidentiality, intellectual_property, limitation_of_liability, indemnification, force_majeure, arbitration", "confidence": 94.0, "evidence": [{ "text": "3. CONFIDENTIALITY", "page_number": 1 }, "…"] }, "…": "effective_date, expiration_date and governing_law" } } ] } } ``` Every field also carries `verification_status` and `value_override`. [Runs and results](/concepts/runs-and-results) has the full envelope, section by section. Rule outcomes (`pass`, `fail`, `inconclusive`) appear in the **Validation** tab of the run in Studio, next to the extracted values. On this contract `notice-at-least-90-days` fails, because the notice period is 60 days. To act on a failure automatically, route the run with an [If/Else](/guides/nodes/if-else) node and send a [Slack alert](/guides/nodes/slack-alert) or an [email alert](/guides/nodes/email-alert). ## When it goes wrong **A clause you care about never appears in `key_clauses`.** A `multi_select` returns only the option names you declared, so an assignment clause or a most-favoured-nation term has nothing to match among the seven listed here. Add the option. Remember the field tells you a clause is present, not what it says: add a `string` field for each clause whose wording you need to read. **`key_clauses` is one string, not a list.** Every value is a string on the wire, and a `multi_select` arrives as its option names joined with `", "`. Split on that before you test for membership. A `boolean` behaves the same way: `auto_renewal` comes back as `"True"`, not as JSON `true`. **A range rule has nothing to compare.** A `range` check needs the number you declared. Declare `termination_notice_days` as `integer`, so `{ "type": "range", "min": 90 }` compares numbers and your calendar math works on the value directly. Declare `effective_date` and `expiration_date` as `date` for the same reason: `term-is-ordered` compares them. **A failed rule did not stop anything.** `severity` is a label. A failed `error` rule never blocks the run; it tells the reviewer where to look first. Read the outcomes in the Validation tab of the run in Studio. To act on a failure automatically, route the run with an [If/Else](/guides/nodes/if-else) node and end that branch in a [Slack alert](/guides/nodes/slack-alert). **Validation costs more than you expected.** An AI rule costs 5 credits, billed once per rule per extraction, not per page. A deterministic rule, including an expression, costs nothing. Use a deterministic rule for anything a calculator can check: it is instant, free and never flakes. A `required` check on `liability_cap` is the cheapest way to flag an uncapped contract. Keep AI rules for judgement calls, like whether the indemnification clause protects the Client. ## Next steps Every check type, expressions and severities Run or re-run a packet you uploaded earlier # Daily CSV export Source: https://docs.anyformat.ai/examples/daily-csv-export Bulk-export a workflow's runs for one local day as a flat CSV. The v2 "download as CSV" behavior, rebuilt on v3 with async concurrency, server-side date filtering and a client-side rate limit This recipe exports one local day of a workflow's runs as a flat CSV, one row per run. It runs no nodes of its own: it reads runs of any workflow that ends in an [Extract](/guides/nodes/extract) node, and those runs were billed when they ran.
One processed run
statusprocessed
invoice\_number
total\_amount
line\_items
One CSV row
Nodes Credits0 You get
## When to use this * **v2 to v3 migration.** v2 exposed a per-workflow CSV download and v3 does not, so reproduce it in a few lines of SDK code. * **Daily reporting jobs.** Grab yesterday's runs on a cron and drop the CSV in a bucket, a warehouse or an email. * **Ad-hoc pulls.** Analysts asking for "everything we processed on 2026-07-14 in Madrid time". ## What it does 1. Convert the requested local day (**Europe/Madrid**) into a half-open UTC interval and hand it to `GET /v3/workflows/{id}/runs/` via the `document_packet_created_after` and `document_packet_created_before` query params. That is **server-side** filtering, one round-trip per page. 2. Fan out `GET /v3/runs/{run_id}/` in parallel to fetch inline results. 3. Emit one CSV row per run: `run_id`, `packet_id`, then one column per top-level scalar field. Nested list-of-object fields such as line items are skipped, as v2's CSV also flattened only scalars. The script assumes a linear `parse → extract` workflow with no `split` or `classify` branches. Concurrency and rate limiting, stdlib only: * `asyncio.Semaphore(CONCURRENCY)` caps in-flight requests. * A sliding-window **token bucket** at 55 req/min takes one `acquire()` per outbound HTTP call. That sits under the API's 60 req/min ceiling with headroom for a 429 retry, which honours `Retry-After`. Progress is logged to `stderr` at `INFO`: every request with its query params, the computed local to UTC window, page sizes, and per-run status. Read it to verify the timezone conversion at a glance. ## End-to-end ```python theme={null} """Export a workflow's runs for a given day (Europe/Madrid) as one flat CSV.""" import argparse import asyncio import csv import logging import os import sys from collections import deque from datetime import date, datetime, time, timedelta, timezone from zoneinfo import ZoneInfo import httpx from anyformat.sdk import AsyncClient, RateLimited BASE_URL = "https://api.anyformat.ai" MADRID = ZoneInfo("Europe/Madrid") RATE_PER_MIN = 55 # API cap is 60/min; leave headroom for the 429 retry CONCURRENCY = 8 logger = logging.getLogger("daily_csv_export") class TokenBucket: """Sliding-window '<= N requests per 60s' gate. Await before every HTTP call.""" def __init__(self, per_minute: int) -> None: self._n = per_minute self._times: deque[float] = deque() self._lock = asyncio.Lock() async def acquire(self) -> None: async with self._lock: loop = asyncio.get_running_loop() now = loop.time() while self._times and now - self._times[0] >= 60.0: self._times.popleft() if len(self._times) >= self._n: await asyncio.sleep(60.0 - (now - self._times[0])) self._times.append(loop.time()) def parse_day(text: str) -> date: """`today`, `yesterday`, or an ISO `YYYY-MM-DD`. Interpreted in Madrid.""" today = datetime.now(MADRID).date() if text == "today": return today if text == "yesterday": return today - timedelta(days=1) return date.fromisoformat(text) async def gated(bucket: TokenBucket, coro_factory): """One `bucket.acquire()` per outbound HTTP call, with one 429 retry.""" while True: await bucket.acquire() try: return await coro_factory() except RateLimited as e: logger.warning("429 rate limited; sleeping %.1fs", e.retry_after or 5.0) await asyncio.sleep(e.retry_after or 5.0) async def list_run_ids( http: httpx.AsyncClient, api_key: str, workflow_id: str, after_iso: str, before_iso: str, bucket: TokenBucket, ) -> list[str]: """Paginate GET /v3/workflows/{id}/runs/ with server-side date filtering. The SDK doesn't (yet) forward `document_packet_created_after` / `_before`, so we call the endpoint directly; the SDK covers the per-run fetch below.""" ids: list[str] = [] cursor: str | None = None headers = {"Authorization": f"Bearer {api_key}"} while True: params = { "limit": 100, "document_packet_created_after": after_iso, "document_packet_created_before": before_iso, } if cursor: params["cursor"] = cursor logger.info("GET /v3/workflows/%s/runs/ params=%s", workflow_id, params) async def _fetch() -> dict: r = await http.get( f"{BASE_URL}/v3/workflows/{workflow_id}/runs/", params=params, headers=headers, ) if r.status_code == 429: raise RateLimited(429, retry_after=float(r.headers.get("Retry-After") or 5.0)) r.raise_for_status() return r.json() body = await gated(bucket, _fetch) items = body.get("items") or [] cursor = body.get("next_cursor") logger.info(" -> %d runs on this page; next_cursor=%s", len(items), cursor) ids.extend(item["id"] for item in items) if not cursor: return ids async def fetch_run(client: AsyncClient, run_id: str, bucket: TokenBucket, sem: asyncio.Semaphore): async with sem: logger.info("GET /v3/runs/%s/", run_id) run = await gated(bucket, lambda: client.get_run(run_id)) logger.info(" -> status=%s", run.status) return run def scalar_cells(run) -> dict[str, object]: """Top-level scalar values from the (single) extraction. Skips nested list-of-object fields such as line items; v2's CSV did the same. Reads `result.raw["extractions"][0]["fields"]` rather than the typed `result.fields`: the typed view is silently empty on any wire drift, which shows up as a CSV with no columns.""" extractions = run.result.raw.get("extractions") or [] if not extractions: return {} return { name: val.get("value") for name, val in (extractions[0].get("fields") or {}).items() if isinstance(val, dict) and "value" in val } async def main() -> int: logging.basicConfig( level=logging.INFO, stream=sys.stderr, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) ap = argparse.ArgumentParser() ap.add_argument("--workflow", required=True) ap.add_argument("--day", default="yesterday", help="today | yesterday | YYYY-MM-DD") ap.add_argument("--out", default="runs.csv") args = ap.parse_args() day = parse_day(args.day) start_local = datetime.combine(day, time.min, MADRID) end_local = start_local + timedelta(days=1) start_utc = start_local.astimezone(timezone.utc) end_utc = end_local.astimezone(timezone.utc) # API accepts naive ISO 8601 as UTC. Drop the offset for a clean value. after_iso = start_utc.strftime("%Y-%m-%dT%H:%M:%S") before_iso = end_utc.strftime("%Y-%m-%dT%H:%M:%S") logger.info( "window day=%s Madrid=[%s, %s) UTC=[%s, %s)", day, start_local.isoformat(), end_local.isoformat(), after_iso, before_iso, ) api_key = os.environ["ANYFORMAT_API_KEY"] bucket = TokenBucket(RATE_PER_MIN) sem = asyncio.Semaphore(CONCURRENCY) async with httpx.AsyncClient(timeout=60.0) as http, AsyncClient(api_key=api_key) as client: run_ids = await list_run_ids(http, api_key, args.workflow, after_iso, before_iso, bucket) logger.info("total runs in window: %d", len(run_ids)) runs = await asyncio.gather( *(fetch_run(client, rid, bucket, sem) for rid in run_ids) ) processed = [r for r in runs if r.status == "processed" and r.result] logger.info("processed=%d (of %d fetched)", len(processed), len(runs)) # CSV: union of extracted-field columns across runs, first-seen order. prefix = ["run_id", "packet_id"] columns: list[str] = [] seen: set[str] = set(prefix) rows: list[dict] = [] for r in processed: cells = scalar_cells(r) for c in cells: if c not in seen: seen.add(c) columns.append(c) rows.append({"run_id": r.id, "packet_id": r.document_packet_id, **cells}) header = [*prefix, *columns] with open(args.out, "w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=header, extrasaction="ignore") w.writeheader() w.writerows(rows) logger.info("wrote %d rows -> %s", len(rows), args.out) return 0 if __name__ == "__main__": raise SystemExit(asyncio.run(main())) ``` ## Run it ```bash theme={null} export ANYFORMAT_API_KEY=af_... python daily_csv_export.py --workflow wf_123 --day yesterday --out yesterday.csv python daily_csv_export.py --workflow wf_123 --day 2026-07-14 --out 2026-07-14.csv ``` Expected stderr (abridged): ``` INFO daily_csv_export: window day=2026-07-14 Madrid=[2026-07-14T00:00:00+02:00, 2026-07-15T00:00:00+02:00) UTC=[2026-07-13T22:00:00, 2026-07-14T22:00:00) INFO daily_csv_export: GET /v3/workflows/wf_123/runs/ params={'limit': 100, 'document_packet_created_after': '2026-07-13T22:00:00', 'document_packet_created_before': '2026-07-14T22:00:00'} INFO daily_csv_export: -> 100 runs on this page; next_cursor=eyJ... INFO daily_csv_export: GET /v3/workflows/wf_123/runs/ params={'limit': 100, ..., 'cursor': 'eyJ...'} INFO daily_csv_export: -> 34 runs on this page; next_cursor=None INFO daily_csv_export: total runs in window: 134 INFO daily_csv_export: GET /v3/runs/aaaa.../ INFO daily_csv_export: -> status=processed ... INFO daily_csv_export: wrote 134 rows -> yesterday.csv ``` Check that the Madrid to UTC offset for the day looks right: `+02:00` in summer (CEST) with UTC bounds at `22:00`, `+01:00` in winter with `23:00`. Spain observes DST, so the exact UTC hours shift by month. This is a starting point, not a production template. ## When it goes wrong **The line items are missing from the CSV.** `scalar_cells` keeps only top-level scalars, because v2's CSV flattened only scalars too. An `object` field never becomes a column. Iterate the object list at `extraction["fields"]["line_items"]` (a `list[dict[str, {"value": ...}]]`) and emit one row per child, or write a second CSV. The same function reads `extractions[0]` alone, so a workflow with a Split or a Classify branch needs one row per extraction, not one per run. **The CSV has a header and no columns.** The typed `result.fields` view comes back silently empty on any wire drift, which lands as a CSV with `run_id` and `packet_id` and nothing else. `scalar_cells` reads `result.raw["extractions"][0]["fields"]` for that reason. Keep it reading the raw envelope. **You cannot tell which rows to trust.** `value` is one of three keys on every cell: `confidence` and `evidence` sit beside it. Append a `_confidence` column per field, or fold the evidence text in, and sort the export by the lowest confidence in the row. **The export stops partway with a 429.** The API ceiling is 60 requests a minute. `RATE_PER_MIN` is set to 55 so the single retry has room, and `gated()` honours `Retry-After`. Raise it only if your limit was raised, and keep it strictly under the ceiling. A run that fails anyway takes the whole export with it, because `asyncio.gather(...)` propagates the first exception: pass `return_exceptions=True` and log the per-run failures instead. **The day is off by an hour.** The window is built in Europe/Madrid and converted to UTC, and Spain observes DST, so the UTC bounds move between summer and winter. Read the logged window before you trust the file. For a range wider than a day, swap `parse_day` for a `--from` and `--to` pair and pass the wider range through the same `document_packet_created_{after,before}` params. ## Next steps Run lifecycle, statuses, and the results envelope How packets group files and relate to runs # Email lead extraction Source: https://docs.anyformat.ai/examples/email-lead-extraction Turn an inbound sales email into a structured lead: sender, company, inquiry type, urgency and topics This workflow reads an email body and pulls out who wrote it, what they want and how urgent it is. The API takes files only, so the email body goes up as a `.txt` file; the Python SDK does that for you when you pass `text=`.
The email
What comes back
sender\_name99%
company\_name99%
inquiry\_type98%
urgency99%
topics
NodesParse → Extract Credits60 per page You get
## The graph ```json Graph theme={null} { "name": "Email lead extractor", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "sender_name", "description": "Full name of the person who sent the email", "data_type": "string" }, { "name": "sender_email", "description": "Email address of the sender", "data_type": "string" }, { "name": "company_name", "description": "Company or organization the sender represents", "data_type": "string" }, { "name": "inquiry_type", "description": "The primary category of this inquiry", "data_type": "enum", "enum_options": [ { "name": "pricing", "description": "Pricing or cost inquiry" }, { "name": "demo_request", "description": "Request for a product demo" }, { "name": "support", "description": "Technical support or help request" }, { "name": "partnership", "description": "Partnership or integration inquiry" }, { "name": "other", "description": "Other inquiry type" } ] }, { "name": "urgency", "description": "How urgent this request appears based on language and deadlines mentioned", "data_type": "enum", "enum_options": [ { "name": "low", "description": "No urgency signals" }, { "name": "medium", "description": "Moderate urgency or soft deadline" }, { "name": "high", "description": "Explicit deadline or urgent language" } ] }, { "name": "topics", "description": "Products or areas of interest mentioned in the email", "data_type": "multi_select", "enum_options": [ { "name": "enterprise", "description": "Enterprise plan or features" }, { "name": "integration", "description": "Integration or API capabilities" }, { "name": "pricing", "description": "Pricing or billing" }, { "name": "security", "description": "Security or compliance" } ] }, { "name": "summary", "description": "A one-sentence summary of what the sender is asking for", "data_type": "string" } ] } } ], "edges": [{ "source": "parse_1", "target": "extract_1" }] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Email lead extractor") .parse() .extract([ Schema.string("sender_name", "Full name of the person who sent the email"), Schema.string("sender_email", "Email address of the sender"), Schema.string("company_name", "Company or organization the sender represents"), Schema.enum("inquiry_type", "The primary category of this inquiry", options=[ Schema.option("pricing", "Pricing or cost inquiry"), Schema.option("demo_request", "Request for a product demo"), Schema.option("support", "Technical support or help request"), Schema.option("partnership", "Partnership or integration inquiry"), Schema.option("other", "Other inquiry type"), ]), Schema.enum("urgency", "How urgent this request appears based on language and deadlines mentioned", options=[ Schema.option("low", "No urgency signals"), Schema.option("medium", "Moderate urgency or soft deadline"), Schema.option("high", "Explicit deadline or urgent language"), ]), Schema.multi_select("topics", "Products or areas of interest mentioned in the email", options=[ Schema.option("enterprise", "Enterprise plan or features"), Schema.option("integration", "Integration or API capabilities"), Schema.option("pricing", "Pricing or billing"), Schema.option("security", "Security or compliance"), ]), Schema.string("summary", "A one-sentence summary of what the sender is asking for"), ]) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af .workflow("Email lead extractor") .parse() .extract([ Schema.string("sender_name", "Full name of the person who sent the email"), Schema.string("sender_email", "Email address of the sender"), Schema.string("company_name", "Company or organization the sender represents"), Schema.enum("inquiry_type", "The primary category of this inquiry", [ Schema.option("pricing", "Pricing or cost inquiry"), Schema.option("demo_request", "Request for a product demo"), Schema.option("support", "Technical support or help request"), Schema.option("partnership", "Partnership or integration inquiry"), Schema.option("other", "Other inquiry type"), ]), Schema.enum("urgency", "How urgent this request appears based on language and deadlines mentioned", [ Schema.option("low", "No urgency signals"), Schema.option("medium", "Moderate urgency or soft deadline"), Schema.option("high", "Explicit deadline or urgent language"), ]), Schema.multiSelect("topics", "Products or areas of interest mentioned in the email", [ Schema.option("enterprise", "Enterprise plan or features"), Schema.option("integration", "Integration or API capabilities"), Schema.option("pricing", "Pricing or billing"), Schema.option("security", "Security or compliance"), ]), Schema.string("summary", "A one-sentence summary of what the sender is asking for"), ]) .create(); ``` ## Run and read Send the email body as a text file. Any `.txt` works; a saved `.eml` works too and keeps the headers. ```bash curl theme={null} # The body of the email, saved as a file curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/upload/run/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -F 'files=@lead-email.txt;type=text/plain' # -> 202 {"run_id": "...", "status": "queued"} # Read the run. Repeat until "status" is "processed". curl "https://api.anyformat.ai/v3/runs/$RUN_ID/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" ``` ```python Python theme={null} email_text = """From: Maria Lopez Subject: Enterprise pricing for Q2 rollout Hi, I am the VP of Engineering at Acme Corp. We process about 40,000 supplier invoices a month ... Our board meets on June 12 and I need a quote and a security overview before then. ...""" # text= uploads the string as a .txt file for you result = workflow.run(text=email_text).wait() lead = {name: field.value for name, field in result.fields.items()} if lead["urgency"] == "high": print(f"HIGH PRIORITY: {lead['sender_name']} from {lead['company_name']}") ``` ```typescript TypeScript theme={null} import type { ExtractedField } from "@anyformat/sdk"; const emailText = `From: Maria Lopez Subject: Enterprise pricing for Q2 rollout Hi, I am the VP of Engineering at Acme Corp. ...`; const file = new File([emailText], "lead-email.txt", { type: "text/plain" }); const run = await workflow.run(file); const result = await run.wait(); // field() can return a scalar or rows; every field here is a scalar const field = (name: string) => result.field(name) as ExtractedField | undefined; if (field("urgency")?.value === "high") { console.log(`HIGH PRIORITY: ${field("sender_name")?.value} from ${field("company_name")?.value}`); } ``` ## What comes back The run above, trimmed. It took 10 seconds. ```json Response highlight={8,9} theme={null} { "results": { "extractions": [{ "fields": { "sender_name": { "value": "Maria Lopez", "confidence": 99.0, "evidence": [{ "text": "From: Maria Lopez ", "page_number": 1 }] }, "company_name": { "value": "Acme Corp", "confidence": 99.0, "evidence": [{ "text": "I am the VP of Engineering at Acme Corp.", "page_number": 1 }] }, "inquiry_type": { "value": "pricing", "confidence": 98.0, "evidence": [{ "text": "1. What does the enterprise plan cost at our volume?", "page_number": 1 }] }, "urgency": { "value": "high", "confidence": 99.0, "evidence": [{ "text": "Our board meets on June 12 and I need a quote and a security overview before then.", "page_number": 1 }] }, "topics": { "value": "enterprise, pricing, security, integration", "confidence": 98.0, "evidence": ["…"] }, "summary": { "value": "Maria Lopez is requesting an enterprise-volume quote, a security overview and ERP API integration information before June 12.", "confidence": 4.0, "evidence": ["…"] } } }] } } ``` Every field also carries `verification_status` and `value_override`. [Runs and results](/concepts/runs-and-results) has the full envelope, section by section. ## When it goes wrong **Your topics field looks like a single topic.** A `multi_select` value arrives as one comma-separated string: `"enterprise, pricing, security, integration"`. Split on `", "` to get the list back. **A good lead is rejected on a low confidence.** The `summary` above scored 4%. A generated sentence is not quoted from the email, so it has no source phrase to score against, and it scores low even when it is right. Gate on the extracted fields, and never on the summary. **Two inquiry types keep swapping.** `pricing` and `demo_request` read alike to a model given only their names. The `enum_options` descriptions are what separate them, so write the description that names the close case: "Pricing or cost inquiry" against "Request for a product demo". **`parse.blocks` is empty and `parse_confidence` is `null`.** A `.txt` upload has no layout to ground, so there are no blocks and no parse score. That is expected for text input. Gate a text pipeline on the extracted fields instead. **A mailbox backs up.** Create the workflow once and call upload and run per email. The submission limit is 60 requests per minute, so pace the queue rather than firing the whole inbox at once. ## Next steps Field types, modes and smart lookup Multipart fields, conflicts and idempotency Route each email type to its own Extract node Every data type a field can take # Evaluating a workflow Source: https://docs.anyformat.ai/examples/evaluating-a-workflow Score a workflow against ground truth over the v3 API. Upload ground truth, launch an eval, poll its status, and read the accuracy headline An [eval](/concepts/evals) is one graded run of a workflow against a target version: it re-extracts every document in the dataset, compares each result to that document's ground truth, and freezes an accuracy headline. This recipe drives that loop over the v3 API, so you can wire it into CI, a nightly job or a regression gate.
The document and its ground truth
invoice\_no
total
The frozen headline
accuracy
matched47
mismatched3
ungraded1
run\_number3
Nodes Credits35 per page You get
The loop is four calls: 1. **Upload** files and ground truth into the workflow's dataset with [`POST /v3/workflows/{workflow_id}/dataset/upload/`](/api-reference-v3/datasets/upload). 2. **Launch** the eval with [`POST /v3/workflows/{workflow_id}/evals/`](/api-reference-v3/evals/launch), and keep the returned `eval_id`. 3. **Poll** the eval until its `status` leaves `in_progress`, with [`GET /v3/workflows/{workflow_id}/evals/{eval_id}/`](/api-reference-v3/evals/get). 4. **Read** the headline (`accuracy`, `matched`, `mismatched`, `ungraded`), or [list prior runs](/api-reference-v3/evals/list) to compare over time. ## When to use this * **Regression gate in CI.** Run an eval on every workflow change and fail the build if accuracy drops below a threshold. * **Nightly accuracy tracking.** Launch a run on a cron and chart `accuracy` per `run_number` over time. * **Version comparison.** Evaluate two versions on the same dataset and diff their headlines. Throughout, set `ANYFORMAT_API_KEY` and a `WORKFLOW_ID` (the target workflow's UUID): ```bash theme={null} export ANYFORMAT_API_KEY=af_... export WORKFLOW_ID=0686bb97-8c30-70f0-8000-97669e000eb8 ``` ## Step 1: Upload files and ground truth Seed the dataset one document at a time. Each call uploads 1–100 files as a single [document packet](/concepts/document-packets) plus an optional `ground_truth`, the expected values for that document. **Ground-truth keys are the workflow schema's field `persistent_id`s**; a scalar field maps to `string | null`, a table/object field to an array of row objects. Creation is all-or-nothing, so a rejected file or a bad ground-truth payload stores nothing. ```bash curl theme={null} curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/dataset/upload/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -H 'Idempotency-Key: 5f2b6c3e-8a1d-4f7e-9c0b-1a2b3c4d5e6f' \ -F 'files=@/path/to/invoice_001.pdf' \ -F 'ground_truth={"invoice_no": "INV-1", "total": "90.00"}' ``` ```python Python (SDK) theme={null} from anyformat.sdk import Client, DatasetDocument client = Client(api_key="YOUR_API_KEY") workflow_id = "0686bb97-8c30-70f0-8000-97669e000eb8" # One atomic call per document. There is no batch endpoint. uploads = client.upload_documents_to_dataset( workflow_id, [ DatasetDocument(file="invoice_001.pdf", ground_truth={"invoice_no": "INV-1", "total": "90.00"}), DatasetDocument(file="invoice_002.pdf", ground_truth={"invoice_no": "INV-2", "total": "120.00"}), ], ) for u in uploads: print(u.document_packet_id, u.ground_truth_saved) ``` ```typescript TypeScript theme={null} interface DatasetUpload { document_packet_id: string; files: { id: string; name: string; original_name: string | null }[]; ground_truth_saved: boolean; } const workflowId = '0686bb97-8c30-70f0-8000-97669e000eb8'; const formData = new FormData(); formData.append('files', fileInput.files[0]); formData.append('ground_truth', JSON.stringify({ invoice_no: 'INV-1', total: '90.00' })); const response = await fetch( `https://api.anyformat.ai/v3/workflows/${workflowId}/dataset/upload/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Idempotency-Key': crypto.randomUUID(), }, body: formData, } ); if (!response.ok) throw new Error(`API error: ${response.status}`); const upload: DatasetUpload = await response.json(); console.log(upload.document_packet_id, upload.ground_truth_saved); ``` Ground truth attaches to the workflow's **current version**. Fields the dataset has no ground truth for aren't scored: they land as `ungraded`, never a failure. See [Upload to Dataset](/api-reference-v3/datasets/upload) for conflict handling (`on_conflict`) and multi-file packets. ## Step 2: Launch the eval Launching is **async and fire-and-forget**: it enqueues a fresh extraction for every dataset document, pins the realized cohort into a new eval, and returns `202` immediately with an `eval_id` and `run_number`. Grading finalizes on a worker, and you poll for it in the next step. Omit `version_id` to evaluate the **current version**; pass one to pin a specific version. The public API always evaluates the **whole dataset**. There is no way to narrow the scope from the endpoint. The [app](/guides/health/evaluations) can additionally scope a run to a single **sub-dataset**, but that narrowing isn't exposed on the API yet, so an API-launched eval always covers every dataset document. ```bash curl theme={null} curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/evals/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -H 'Idempotency-Key: 9e8d7c6b-5a4f-3e2d-1c0b-a9b8c7d6e5f4' ``` ```python Python (SDK) theme={null} launched = client.launch_eval( workflow_id, idempotency_key="9e8d7c6b-5a4f-3e2d-1c0b-a9b8c7d6e5f4", ) print(launched.eval_id, launched.run_number, launched.enqueued_count) ``` ```typescript TypeScript theme={null} interface EvalLaunched { eval_id: string; run_number: number; enqueued_count: number; failed_count: number; } const response = await fetch( `https://api.anyformat.ai/v3/workflows/${workflowId}/evals/`, { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, // Omit the body to evaluate the current version. body: JSON.stringify({}), } ); if (!response.ok) throw new Error(`API error: ${response.status}`); const launched: EvalLaunched = await response.json(); console.log(launched.eval_id, launched.run_number); ``` The response carries **counts, never internal ids**: `enqueued_count` documents were queued, `failed_count` failed to enqueue. If the dataset is empty (or every document fails to enqueue) the call returns `422 EMPTY_EVAL` and **no eval is created**, so upload at least one document in Step 1 first. ## Step 3: Poll until grading finishes `GET .../evals/{eval_id}/` **always returns `200` while the eval exists**. There are no precondition errors while grading is in flight. Poll it until `status` is terminal: | `status` | Meaning | Headline | | ------------- | ------------------------------------------- | --------- | | `in_progress` | Extractions running / grading not finalized | `null` | | `processed` | Graded, terminal | populated | | `error` | Run failed, terminal | `null` | ```bash curl theme={null} # Repeat until "status" is "processed" or "error". curl -X GET "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/evals/$EVAL_ID/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" ``` ```python Python (SDK) theme={null} import time for _ in range(100): an_eval = client.get_eval(workflow_id, launched.eval_id) if an_eval.status == "processed": print(f"accuracy={an_eval.accuracy} " f"({an_eval.matched}/{an_eval.mismatched}/{an_eval.ungraded})") break if an_eval.status == "error": raise RuntimeError("Eval failed") time.sleep(5) ``` ```typescript TypeScript theme={null} interface EvalDetail { id: string; run_number: number; version_id: string; status: 'in_progress' | 'processed' | 'error'; accuracy: number | null; matched: number | null; mismatched: number | null; ungraded: number | null; file_count: number; failed_count: number; created_at: string | null; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function pollEval(evalId: string): Promise { for (let i = 0; i < 100; i++) { const res = await fetch( `https://api.anyformat.ai/v3/workflows/${workflowId}/evals/${evalId}/`, { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } } ); if (!res.ok) throw new Error(`API error: ${res.status}`); const detail: EvalDetail = await res.json(); if (detail.status !== 'in_progress') return detail; await sleep(5000); } throw new Error('Eval still in progress after timeout'); } ``` ## Step 4: Read the headline On a `processed` eval, `accuracy` is the `matched / (matched + mismatched)` fraction. It stays `null` when the graded denominator is 0, which means nothing was gradable: every field landed `ungraded` for lack of ground truth. Treat a `null` accuracy as "no signal", not as "zero". ```json Response (200 OK, processed) theme={null} { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "run_number": 3, "version_id": "abcdef1234", "status": "processed", "accuracy": 0.94, "matched": 47, "mismatched": 3, "ungraded": 1, "file_count": 42, "failed_count": 0, "created_at": "2026-07-05T10:00:00.000Z" } ``` To compare over time, [list the workflow's evals](/api-reference-v3/evals/list) newest-first. Each item is the same headline, so a fresh page can mix graded and still-running runs: ```bash curl theme={null} curl -X GET "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/evals/?limit=20" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" ``` ```python Python (SDK) theme={null} # iter_evals follows next_cursor across pages for you. for an_eval in client.iter_evals(workflow_id): print(an_eval.run_number, an_eval.status, an_eval.accuracy) ``` ```typescript TypeScript theme={null} interface EvalPage { items: EvalDetail[]; next_cursor: string | null; } let cursor: string | null = null; do { const base = `https://api.anyformat.ai/v3/workflows/${workflowId}/evals/`; const url = cursor ? `${base}?cursor=${cursor}` : base; const res = await fetch(url, { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); if (!res.ok) throw new Error(`API error: ${res.status}`); const page: EvalPage = await res.json(); for (const e of page.items) console.log(e.run_number, e.status, e.accuracy); cursor = page.next_cursor; } while (cursor); ``` ## End-to-end The whole loop as one runnable script: upload a folder of documents with their ground truth, launch, poll, and print the headline. ```python theme={null} """Evaluate a workflow against ground truth: upload -> launch -> poll -> read.""" import os import time import uuid from anyformat.sdk import Client, DatasetDocument client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow_id = os.environ["WORKFLOW_ID"] # A stable id for this run keeps both steps rerunnable: a retried CI job (same # commit) replays the original upload packets and eval instead of duplicating # them; an ad-hoc local run gets a fresh id, so it seeds and evaluates anew. run_key = os.environ.get("GITHUB_SHA") or uuid.uuid4().hex # 1. Upload documents and ground truth, one atomic call per document. A stable # per-document idempotency key makes a rerun replay the original packet # rather than 409 on the duplicate filename. documents = [ DatasetDocument(file="invoice_001.pdf", ground_truth={"invoice_no": "INV-1", "total": "90.00"}, idempotency_key=f"{run_key}:invoice_001"), DatasetDocument(file="invoice_002.pdf", ground_truth={"invoice_no": "INV-2", "total": "120.00"}, idempotency_key=f"{run_key}:invoice_002"), ] uploads = client.upload_documents_to_dataset(workflow_id, documents) print(f"uploaded {len(uploads)} documents") # 2. Launch an eval over the whole dataset on the current version. Keying the # launch to the same run id makes a retried job replay the original eval # instead of billing a second full-dataset run. launched = client.launch_eval(workflow_id, idempotency_key=f"{run_key}:eval") print(f"launched eval {launched.eval_id} (run #{launched.run_number}), " f"{launched.enqueued_count} enqueued") # 3. Poll until grading is terminal. while True: an_eval = client.get_eval(workflow_id, launched.eval_id) if an_eval.status != "in_progress": break time.sleep(5) # 4. Read the headline. if an_eval.status == "error": raise SystemExit("eval failed") print(f"accuracy={an_eval.accuracy} " f"matched={an_eval.matched} mismatched={an_eval.mismatched} " f"ungraded={an_eval.ungraded} over {an_eval.file_count} documents") # CI gate: fail the build on a regression. `accuracy` is None when nothing # was gradable (no ground truth). Treat that as no signal, not a pass. THRESHOLD = 0.90 if an_eval.accuracy is None or an_eval.accuracy < THRESHOLD: raise SystemExit(f"accuracy {an_eval.accuracy} below {THRESHOLD}") ``` ## When it goes wrong **A retried job bills the whole dataset twice.** Launching runs a fresh extraction per document, and re-launching runs the whole cohort again. Without an `Idempotency-Key` every launch creates a **new** eval, so a retried CI job pays for a second full run. Supply the header and a retry with the same key returns the **original** eval. See [Idempotency](/api-reference-v3/introduction#idempotency) and [How credits work](/concepts/how-credits-work). **A replayed key returns `422` after you publish a new version.** When you omit `version_id`, the replay resolves against whatever version is current, and a publish makes that a different target. Pass an explicit `version_id` when the replay has to survive a version change. **`accuracy` is `null` on a `processed` eval.** Accuracy is the `matched / (matched + mismatched)` fraction, and it stays `null` when the graded denominator is 0. That happens when every field landed `ungraded` for lack of ground truth. Treat `null` as "no signal" rather than zero, and fail the CI gate on it instead of passing. **Your fields score `ungraded` although you uploaded ground truth.** Ground-truth keys are the schema's field `persistent_id`s, not the display names, and ground truth attaches to the workflow's **current version**. A key that matches no field is never scored, and it never fails either. Check the `persistent_id`s before you blame the model. **The launch returns `422 EMPTY_EVAL`.** A launch with nothing to enqueue creates no eval at all. Upload at least one document through Step 1 first. Note also that each run is immutable: editing ground truth or refining the workflow never moves a past eval, so launch a fresh run to measure a change. That is what makes the delta between two runs real signal. ## Next steps Full launch contract: target version, idempotency, error codes. The status model and frozen-headline response in detail. Keyset pagination over a workflow's run history. Drive the same loop from Health → Evaluations, with per-file and per-field drill-down. # The form you have filled forty times Source: https://docs.anyformat.ai/examples/fill-a-blank-form Hand the workflow a blank form and get the completed PDF back, with the fields detected for you Every supplier sends their own onboarding form and every one of them wants the same six facts about your company. The [Edit](/guides/nodes/edit) node reads the blank form, finds the gaps itself, writes your values into them and returns a filled PDF. You map nothing by hand.
The blank form
New Supplier Onboarding
The filled PDF
Gotham
Nodes Credits60 per page You get
## The workflow Values come from two places: free-text `instructions` on the node, and reference documents you upload to the workflow once, so every run fills from your standing data without retyping it. ```json API theme={null} { "name": "Fill the onboarding form", "description": "Hand it a blank form, get the filled PDF back", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "edit_1", "type": "edit", "instructions": "Company name: Wayne Enterprises, Inc.; VAT number: ESB12345678; City: Gotham" } ], "edges": [{ "source": "parse_1", "target": "edit_1" }] } ``` ```python Python theme={null} import os from anyformat.sdk import Client client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Fill the onboarding form", "Hand it a blank form, get the filled PDF back") .parse() .edit(instructions="Company name: Wayne Enterprises, Inc.; VAT number: ESB12345678; City: Gotham") .create() ) ``` ```typescript TypeScript theme={null} // The TypeScript builder has no edit() method yet. Send the node in the // workflow's `nodes` array with an edge from the Parse node. const nodes = [ { id: "parse_1", type: "parse" }, { id: "edit_1", type: "edit", instructions: "Company name: Wayne Enterprises, Inc.; VAT number: ESB12345678; City: Gotham", }, ]; const edges = [{ source: "parse_1", target: "edit_1" }]; ``` To fill from standing data instead of a string, upload a company profile once and pass `reference_document_ids`. The node reads the answers out of it. ## Response The run returns an `edits` entry per file. Each one lists every form field the node **found** on the page, with the label it read, the box it sits in, and the value written into it. `unmatched_instructions` is the list of things you asked for that had nowhere to go, and it is the first thing to read when a form comes back looking wrong. ```json theme={null} { "status": "processed", "results": { "edits": [ { "file_id": "06a95798-8e0a-741a-8000-bf69b42aa123", "file_name": "blank_onboarding_form.pdf", "fields": [ { "form_field_id": "p1_f0", "label": "Company name:", "kind": "text", "state": "empty", "page": 1, "bbox": { "x0": 0.375, "x1": 0.882, "y0": 0.217, "y1": 0.257 }, "value": "Wayne Enterprises, Inc.", "confidence": 100 }, { "form_field_id": "p1_f1", "label": "VAT number:", "kind": "text", "state": "empty", "page": 1, "bbox": { "x0": 0.375, "x1": 0.882, "y0": 0.33, "y1": 0.371 }, "value": "ESB12345678", "confidence": 100 }, { "form_field_id": "p1_f2", "label": "City:", "kind": "text", "state": "empty", "page": 1, "bbox": { "x0": 0.375, "x1": 0.882, "y0": 0.444, "y1": 0.485 }, "value": "Gotham", "confidence": 100 } ], "unmatched_instructions": [], "download_url": "https://s3.eu-west-1.amazonaws.com/static.anyformat.ai/customers/org/...?Signature=...&Expires=..." } ] } } ``` `state` describes the field **on the form you sent**, so `empty` means the node found a blank waiting to be filled. `confidence` is 100 because the node wrote the value rather than reading it: it is not a claim about the form, and it is not comparable to an extraction confidence. `download_url` is a presigned link and it expires. Fetch the PDF when you read the run, or store it yourself. ## When it goes wrong **A value you asked for is missing from the PDF.** Read `unmatched_instructions`. An instruction with no matching field on the form lands there instead of being written somewhere arbitrary, which is the behaviour you want. **The node found fewer fields than the form has.** It detects fillable gaps from the parsed document, so a form that is a flat scan with printed rules gives it less to work with than a born-digital one. Parse the difficult ones on the [agentic tier](/guides/nodes/parse) first. **The link 404s an hour later.** `download_url` is presigned and short-lived. Download it in the same pass that reads the run. **You want the values, not the PDF.** Then you want [Extract](/guides/nodes/extract). Edit is for handing a completed document back to someone. ## Next steps Reference documents, fonts and the output mode Every way a run gives you a file # Examples Source: https://docs.anyformat.ai/examples/index Complete workflows you can copy: the graph, the code to create and run it, and a real response Every example is one workflow, end to end: the graph as JSON and as SDK code, how to upload a document and read the run, a trimmed real response, and the tips that matter for that document type. Pick the one whose shape matches your problem. The nodes it uses are on every card.
Invoice processing
The header and every line item as its own row, each value carrying the phrase it was read from.
ParseExtract
The scan from accounting
One file holding three invoices, a shipping label and a boarding pass. Cut it up, extract each kind.
ParseSplitExtract
Everyone spells your supplier differently
The name on the invoice becomes the supplier code your ERP files it under.
Smart lookupExtract
Bank statement processing
The statement header plus every transaction as a row, run across a folder of statements.
ParseExtract
Big invoices need a human
Everything under the limit goes straight through; the rest takes a branch, and pings a channel.
ExtractIf/ElseSlack alert
Contract analysis
Key terms and clauses, then rules that flag the ones nobody should sign unread.
ParseExtractValidate
Receipt scanning
Merchant, totals and payment method off creased photos and thermal till rolls.
ParseExtract
Resume parsing
Contact details, skills, education and work history, from a pile of PDF and DOCX resumes.
ParseExtract
Email lead extraction
Sender, inquiry type, urgency and topics, from a raw email body sent as text.
ParseExtract
Sort invoices from receipts
One inbox, two kinds of document, each routed to the fields that suit it.
ParseClassifyExtract
Ask your filing cabinet
Index everything the workflow parses, then ask it questions and get answers cited to the page.
ParseKnowledge
ask\_knowledge
Give your agent a knowledge base
One real MCP session: build the workflow, upload the files, then list, search, read, ask and download the corpus.
ParseKnowledge
The form you have filled forty times
Hand it a blank onboarding form; get the completed PDF back.
ParseEdit
Parse-only workflow
The same page on Flash, Fast, Standard and Agentic, with what each tier returns and costs.
Parse tiers
Agentic parse to markdown
Dense tables on the agentic tier, with the effort presets and per-block confidence.
Parse
cron
Daily CSV export
One local day of runs, pulled in parallel and flattened into a spreadsheet.
Runs API
Evaluating a workflow
Ground truth, an eval run, and the accuracy number that fails the build when a prompt drifts.
EvalsDatasets
## The three shapes Every example above is one of three graphs. [Parse](/guides/nodes/parse) alone returns the document as markdown and layout blocks. Parse into [Extract](/guides/nodes/extract) returns the fields you defined, each with a confidence and the evidence it came from. [Classify](/guides/nodes/classify) or [Split](/guides/nodes/split) in front of Extract handles a pile that holds more than one kind of document. Add [Validate](/guides/nodes/validate) to check the values and [If/Else](/guides/nodes/if-else) to route on them. ## Before you start * Get an API key at [app.anyformat.ai](https://app.anyformat.ai) and export it as `ANYFORMAT_API_KEY`. * Every example uses the same three calls: [create a workflow](/api-reference-v3/workflows/create), [upload and run](/api-reference-v3/workflows/upload-and-run), and [get the run](/api-reference-v3/runs/get). * The SDK examples need `pip install anyformat` or `npm install @anyformat/sdk`. The [Quickstart](/guides/quickstart) walks through the first workflow. * The [Nodes overview](/guides/nodes/overview) lists every node, what it returns and what it costs. # Invoice processing Source: https://docs.anyformat.ai/examples/invoice-processing Extract the header and the line items of an invoice with a Parse to Extract workflow This example reads an invoice and returns its header fields plus every line item as a structured row. One `object` field captures the line-item table, so each row carries its own values, confidence and evidence.
The document
INVOICE
What comes back
invoice\_number98%
issue\_date98%
total\_amount98%
currencyUSD
line\_items
NodesParse → Extract Credits60 per page You get
## The workflow ```json API theme={null} { "name": "Invoice processing", "description": "Extract the invoice header and its line items", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "invoice_number", "description": "The unique invoice identifier", "data_type": "string" }, { "name": "vendor_name", "description": "Name of the company that issued the invoice", "data_type": "string" }, { "name": "issue_date", "description": "Date the invoice was issued", "data_type": "date" }, { "name": "due_date", "description": "Date by which payment is due", "data_type": "date" }, { "name": "subtotal", "description": "Amount before tax", "data_type": "float" }, { "name": "tax_amount", "description": "Total tax amount", "data_type": "float" }, { "name": "total_amount", "description": "Final total amount due including tax", "data_type": "float" }, { "name": "currency", "description": "Currency of the invoice amounts", "data_type": "enum", "enum_options": [ { "name": "USD", "description": "US Dollar" }, { "name": "EUR", "description": "Euro" }, { "name": "GBP", "description": "British Pound" } ] }, { "name": "line_items", "description": "Individual line items listed on the invoice, one row per item", "data_type": "object", "nested_fields": [ { "name": "description", "description": "Description of the item or service", "data_type": "string" }, { "name": "quantity", "description": "Number of units", "data_type": "integer" }, { "name": "unit_price", "description": "Price per unit", "data_type": "float" }, { "name": "amount", "description": "Total amount for this line item", "data_type": "float" } ] } ] } } ], "edges": [{ "source": "parse_1", "target": "extract_1" }] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Invoice processing", "Extract the invoice header and its line items") .parse() .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.string("vendor_name", "Name of the company that issued the invoice"), Schema.date("issue_date", "Date the invoice was issued"), Schema.date("due_date", "Date by which payment is due"), Schema.float("subtotal", "Amount before tax"), Schema.float("tax_amount", "Total tax amount"), Schema.float("total_amount", "Final total amount due including tax"), Schema.enum("currency", "Currency of the invoice amounts", options=[ Schema.option("USD", "US Dollar"), Schema.option("EUR", "Euro"), Schema.option("GBP", "British Pound"), ]), Schema.object("line_items", "Individual line items listed on the invoice, one row per item", fields=[ Schema.string("description", "Description of the item or service"), Schema.integer("quantity", "Number of units"), Schema.float("unit_price", "Price per unit"), Schema.float("amount", "Total amount for this line item"), ]), ]) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af .workflow("Invoice processing", "Extract the invoice header and its line items") .parse() .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.string("vendor_name", "Name of the company that issued the invoice"), Schema.date("issue_date", "Date the invoice was issued"), Schema.date("due_date", "Date by which payment is due"), Schema.float("subtotal", "Amount before tax"), Schema.float("tax_amount", "Total tax amount"), Schema.float("total_amount", "Final total amount due including tax"), Schema.enum("currency", "Currency of the invoice amounts", [ Schema.option("USD", "US Dollar"), Schema.option("EUR", "Euro"), Schema.option("GBP", "British Pound"), ]), Schema.object("line_items", "Individual line items listed on the invoice, one row per item", [ Schema.string("description", "Description of the item or service"), Schema.integer("quantity", "Number of units"), Schema.float("unit_price", "Price per unit"), Schema.float("amount", "Total amount for this line item"), ]), ]) .create(); ``` ## Run it and read the result Upload the invoice, then poll the run until its `status` is `processed`. The results arrive inline on that same read. ```bash curl theme={null} # 1. Create the workflow with the JSON above curl -X POST 'https://api.anyformat.ai/v3/workflows/' \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -H 'Content-Type: application/json' \ -d @invoice-workflow.json # → 201 { "id": "", ... } # 2. Upload the invoice and start a run curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/upload/run/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -F 'files=@invoice.pdf' # → 202 { "run_id": "", "document_packet_id": "...", "status": "queued" } # 3. Poll until "status" is "processed" curl "https://api.anyformat.ai/v3/runs/$RUN_ID/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" ``` ```python Python theme={null} result = workflow.run("invoice.pdf").wait() print(result.fields["invoice_number"].value) print(result.fields["total_amount"].value) # An object field is a list of rows; each cell is an ExtractedField. for row in result.fields["line_items"]: print(row["description"].value, row["quantity"].value, row["amount"].value) ``` ```typescript TypeScript theme={null} import { readFileSync } from "node:fs"; import type { ExtractedField, ExtractedRows } from "@anyformat/sdk"; const file = new File([readFileSync("invoice.pdf")], "invoice.pdf", { type: "application/pdf" }); const result = await (await workflow.run(file)).wait(); console.log((result.field("invoice_number") as ExtractedField | undefined)?.value); console.log((result.field("total_amount") as ExtractedField | undefined)?.value); // An object field comes back as rows; each cell is an ExtractedField. for (const row of result.field("line_items") as ExtractedRows) { console.log(row.description?.value, row.quantity?.value, row.amount?.value); } ``` ## Response The `results` envelope of a processed run, trimmed to the interesting fields. Every scalar field carries `value`, `confidence`, `evidence`, `verification_status` and `value_override`; the `line_items` rows repeat that shape per cell. ```json theme={null} { "id": "06a8d8f5-1f88-7513-8000-637f6625c2c6", "status": "processed", "results": { "document_packet_id": "06a8d8f5-1dbc-7497-8000-d61ff7a5fecf", "parse": { "markdown": "\n\n# INVOICE\n\n...", "parse_confidence": 98.0, "blocks": [] }, "classifications": [], "splits": [], "extractions": [ { "split_name": null, "partition": null, "fields": { "invoice_number": { "value": "INV-2026-9001", "confidence": 98.0, "evidence": [{ "text": "Invoice #: INV-2026-9001", "page_number": 1 }], "verification_status": "not_verified", "value_override": null }, "total_amount": { "value": "4594.62", "confidence": 98.0, "evidence": [{ "text": "**TOTAL: $4,594.62**", "page_number": 1 }], "verification_status": "not_verified", "value_override": null }, "line_items": [ { "description": { "value": "Cloud hosting, March", "confidence": 97.0, "evidence": [], "verification_status": "not_verified", "value_override": null }, "quantity": { "value": "1", "confidence": 97.0, "evidence": [], "verification_status": "not_verified", "value_override": null }, "amount": { "value": "1200.00", "confidence": 97.0, "evidence": [], "verification_status": "not_verified", "value_override": null } } ] } } ], "edits": [] } } ``` ## When it goes wrong **A currency comes back empty.** An `enum` constrains the output to the names you declared, so an invoice in a currency you did not list has nothing to match. List every currency you expect in `enum_options`, or use a `string` field when the set is open. **Your totals are off by a factor.** Numbers arrive as JSON strings (`"4594.62"`). Cast each one with the type you declared before doing arithmetic on it. **Rows are missing from a dense line-item table.** Standard extraction is a single pass. Move the Extract node to the [agentic tier](/guides/nodes/extract) (150 credits per page), which maps every table to the schema and reasons across sections. **A value is wrong and you cannot see why.** Read its `evidence`: every value carries the phrase and the `page_number` it was read from. When the evidence points at the wrong block, the field description is the ambiguous part. Name the label as it appears on the page. "Final total amount due including tax" extracts better than "total". **The numbers do not add up.** Extraction reports what it read, not what is consistent. To check that `subtotal + tax_amount = total_amount`, add a [Validate](/guides/nodes/validate) node with an arithmetic rule. [Contract analysis](/examples/contract-analysis) shows a graph with validation. ## Next steps Object, enum and the other field shapes Every section of the results envelope # Give your agent a knowledge base Source: https://docs.anyformat.ai/examples/knowledge-base-over-mcp One real MCP session: build a workflow with a Knowledge node, upload a service note, an invoice and a vendor list, then let the agent list, search, read, ask and download the corpus The [Knowledge](/guides/nodes/knowledge) node is in **alpha** and appears once the feature is switched on for your organization. Ask us to enable it. [Ask your filing cabinet](/examples/ask-your-filing-cabinet) shows the Knowledge node from the app and the REST API. This page is the same idea from an agent's seat: every step is an [MCP](/api-reference-v3/mcp) tool call, and every payload below comes from one live session, with identifiers shortened. An operations team keeps three kinds of document in one workflow: the service note for a piece of equipment, the invoices that come in for it, and the list of approved vendors. The agent builds the workflow, feeds it the files, and then works the corpus with five read tools.
The agent asks
It gets back
Nodes Tools Credits
The session in order: 1. `create_workflow` builds a parse → knowledge graph. 2. `stage_files` and `upload_documents` put three local files into the workflow. 3. `run_document_packet` and `get_run` parse them; the corpus indexes on its own right after. 4. `ask_knowledge` answers questions with citations, and keeps a thread for follow-ups. 5. `list_knowledge`, `search_knowledge` and `read_knowledge` work the corpus without a model. 6. `download_knowledge` hands over the whole corpus as one archive. ## 1. Create the workflow The Knowledge node has no options. Its presence opts the workflow into indexing: every completed run adds its parsed documents to the corpus. `mode: "lite"` on the parse is enough for text documents; use `standard` when the pages are scans. ```json theme={null} // create_workflow { "body": { "name": "Torvik Ridge site records", "description": "Service notes, invoices and the vendor list for one substation", "nodes": [ { "id": "parse_1", "type": "parse", "mode": "lite" }, { "id": "kb_1", "type": "knowledge" } ], "edges": [ { "source": "parse_1", "target": "kb_1" } ] } } ``` ```json theme={null} { "id": "06aa837e-2bfe-7e1c-8000-55ae77b75f66", "name": "Torvik Ridge site records", "…": "…" } ``` ## 2. Stage and upload the files A tool call carries JSON, not bytes. `stage_files` returns one upload form per file plus a short `object_id` to refer to it by. Send the real byte length as `declared_size`. ```json theme={null} // stage_files { "body": { "files": [ { "filename": "knowledge_note.pdf", "declared_size": 2423, "content_type": "application/pdf" }, { "filename": "single_invoice.pdf", "declared_size": 2087, "content_type": "application/pdf" }, { "filename": "vendor_catalog.csv", "declared_size": 1188, "content_type": "text/csv" } ] } } ``` The agent POSTs each file as multipart/form-data to its slot, every `upload.fields` entry first, then the file. All three answered `204`. ```bash theme={null} curl -F 'key=…' -F 'policy=…' -F '…=…' -F file=@vendor_catalog.csv "https://s3.eu-west-1.amazonaws.com/…" ``` `upload_documents` then registers the three as one document packet, atomically. A CSV or a spreadsheet goes in exactly like a PDF. ```json theme={null} // upload_documents { "workflow_id": "06aa837e-2bfe-7e1c-8000-55ae77b75f66", "body": { "staged_files": [ { "object_id": "06aa837e5e16…", "filename": "knowledge_note.pdf" }, { "object_id": "06aa837e604f…", "filename": "single_invoice.pdf" }, { "object_id": "06aa837e62a6…", "filename": "vendor_catalog.csv" } ] } } ``` ```json theme={null} { "document_packet_id": "06aa837e-6674-7659-8000-adeb079141b4", "files": [ "…3 files…" ] } ``` ## 3. Run it, then let the index catch up ```json theme={null} // run_document_packet { "document_packet_id": "06aa837e-6674-7659-8000-adeb079141b4" } ``` `get_run` with `wait_seconds` long-polls; three small text documents were `processed` in about three seconds. Indexing starts on its own when the run completes and takes a few more seconds. An `ask_knowledge` call in that window is refused with a retryable error, not a wrong answer: ```json theme={null} { "error_code": "KNOWLEDGE_NOT_READY", "retryable": true, "status": 409, "request_id": "6aa8383c…" } ``` Retry every few seconds while indexing is in progress. The first two asks of this session got that envelope; the third answered. New runs later re-index in the background while the existing corpus keeps serving, so this wait only happens the first time. ## 4. Ask, and keep the thread A question that spans two documents, with a `thread_id` so the next question can build on it. Mint the id yourself with the prefix `kb-`. ```json theme={null} // ask_knowledge { "workflow_id": "06aa837e-2bfe-7e1c-8000-55ae77b75f66", "body": { "question": "What spares are held on site for the Torvik Ridge flux stabiliser, and which vendor in the catalog would we order more desiccant cartridges from?", "thread_id": "kb-torvik-spares" } } ``` Forty-seven seconds later: ```json theme={null} { "answer": "### Spares Held on Site\nThe following spares are held on site for the Torvik Ridge flux stabiliser (a Halvorsen Meridian unit, part number QX-7731-KAPPA):\n* **Two desiccant cartridges**\n* **One coolant pump**\n* **A set of gaskets for the toroid housing**\n\n### Ordering Desiccant Cartridges\nThe provided knowledge base **does not contain information** regarding which vendor in the catalog we would order desiccant cartridges from.\n\nThe vendor catalog (`2026-09/vendor_catalog.md`) lists five vendors and their contact information but does not include any inventory lists …", "citations": [ { "path": "2026-09/knowledge_note.md", "quote": "Spares held on site: two desiccant cartridges, one coolant pump, and a set of gaskets for the toroid housing.", "block_id": "p1_b4", "page": 1, "bbox": { "x0": 0.1138, "y0": 0.4115, "x1": 0.8243, "y1": 0.4663 } }, { "path": "2026-09/vendor_catalog.md", "quote": "
", "block_id": "md_p1_b2", "page": null, "bbox": null }, "…5 more…" ], "steps_used": 7, "thread_id": "kb-torvik-spares" } ``` Two things to read off that answer. The agent said what the corpus does **not** contain instead of guessing a vendor: an answer is served only when every quote it rests on was found in a document. And the citations differ by source: on the PDF each quote resolves to a page and a box in page-relative coordinates, ready to highlight; on the CSV there is no page to point at, so `page` and `bbox` are `null` and the block id locates the row inside the rendered table. The follow-up carries the same `thread_id` and no restated context: ```json theme={null} { "workflow_id": "06aa837e-2bfe-7e1c-8000-55ae77b75f66", "body": { "question": "And what are that vendor's payment terms?", "thread_id": "kb-torvik-spares" } } ``` ```json theme={null} { "answer": "As the knowledge base does not specify which of the catalog vendors supplies the desiccant cartridges, the specific vendor cannot be identified.\n\nHowever, all five vendors listed in the catalog (`vendor_catalog.md`) share the exact same payment terms: **Net 30**. …", "citations": [ { "path": "2026-09/vendor_catalog.md", "quote": "\n\n", "block_id": "md_p1_b2", "page": null, "bbox": null }, "…4 more…" ], "steps_used": 3, "thread_id": "kb-torvik-spares" } ``` Fourteen seconds: the thread remembered which vendor was in question and the agent only had to search for the terms. A question about the invoice, in a fresh thread, answers the issuer, number, total and date with one citation each, every one with a box on page 1: ```json theme={null} { "answer": "* **Issuer:** Wayne Enterprises\n* **Invoice Number:** `INV-2026-9001`\n* **Total:** `$4,594.62` (USD)\n* **Issue Date:** `2026-02-20`", "citations": [ { "path": "2026-09/single_invoice.md", "quote": "From: Wayne Enterprises", "block_id": "p1_b5", "page": 1, "bbox": { "…": "…" } }, { "path": "2026-09/single_invoice.md", "quote": "Invoice #: INV-2026-9001", "block_id": "p1_b1", "page": 1, "bbox": { "…": "…" } }, { "path": "2026-09/single_invoice.md", "quote": "TOTAL: $4,594.62", "block_id": "p1_b11", "page": 1, "bbox": { "…": "…" } }, { "path": "2026-09/single_invoice.md", "quote": "Issue date: 2026-02-20", "block_id": "p1_b2", "page": 1, "bbox": { "…": "…" } } ], "steps_used": 6, "thread_id": null } ``` Every `ask_knowledge` call is billed and runs an agent over the corpus, so it takes seconds to a couple of minutes. The three tools below cost nothing and answer in about a second; reach for them first when the agent already knows what it is looking for. ## 5. List, search and read, without a model `list_knowledge` returns the corpus tree. Documents are filed by upload month unless a [Classify](/guides/nodes/classify) node runs before Knowledge, in which case the category names the folder. The `.kb/` pages are generated navigation (`kind: "index"`), never evidence. ```json theme={null} // list_knowledge { "workflow_id": "06aa837e-2bfe-7e1c-8000-55ae77b75f66", "folder": "2026-09" } ``` ```json theme={null} { "items": [ { "path": "2026-09/knowledge_note.md", "kind": "document", "folder": "2026-09", "title": "Torvik Ridge Substation - Flux Stabiliser Service Note", "page_count": 1 }, { "path": "2026-09/single_invoice.md", "kind": "document", "folder": "2026-09", "title": "INVOICE", "page_count": 1 }, { "path": "2026-09/vendor_catalog.md", "kind": "document", "folder": "2026-09", "title": "Sheet: …", "page_count": 0 } ], "next_cursor": null } ``` Pages of up to 200 entries; pass `next_cursor` back as `cursor` to continue. `search_knowledge` ranks documents for a query with the same index the agent's own search uses. The snippet marks the matched term: ```json theme={null} // search_knowledge { "workflow_id": "06aa837e-2bfe-7e1c-8000-55ae77b75f66", "query": "desiccant cartridges", "limit": 3 } ``` ```json theme={null} { "result": [ { "path": "2026-09/knowledge_note.md", "folder": "2026-09", "title": "Torvik Ridge Substation - Flux Stabiliser Service Note", "page_count": 1, "snippet": "…a quarterly inspection of the toroid housing, an annual replacement of the «desiccant» cartridges, and a firmware check against the vendor's release notes…", "rank": 2.6152 } ] } ``` `read_knowledge` returns one document as the corpus stores it: a frontmatter block, then the parsed markdown with an anchor before every block. Those anchors are the `block_id` values the citations use. ```json theme={null} // read_knowledge { "workflow_id": "06aa837e-2bfe-7e1c-8000-55ae77b75f66", "path": "2026-09/knowledge_note.md" } ``` ```json theme={null} { "path": "2026-09/knowledge_note.md", "kind": "document", "folder": "2026-09", "title": "Torvik Ridge Substation - Flux Stabiliser Service Note", "page_count": 1, "content": "---\ntype: \"document\"\ntitle: \"Torvik Ridge Substation - Flux Stabiliser Service Note\"\nsource_file: \"knowledge_note.pdf\"\nfile_id: \"06aa837e5e16…\"\npages: 1\n---\n\n\n\n# Torvik Ridge Substation - Flux Stabiliser Service Note\n\n\n\nThe Halvorsen Meridian flux stabiliser installed in bay 4 of the Torvik Ridge substation carries the manufacturer part number QX-7731-KAPPA. …", "truncated": false, "file_id": "06aa837e5e16…" } ``` A document longer than 20,000 characters comes back cut there with `truncated: true`; `download_knowledge` below is the way to get the whole text. A path that is not in the corpus is a plain `NOT_FOUND`: ```json theme={null} { "error": "The resource does not exist.", "detail": "Knowledge entry not found", "error_code": "NOT_FOUND", "retryable": false, "status": 404, "request_id": "6aa838ab…" } ``` ## 6. Download the corpus `download_knowledge` takes only the workflow id and returns a presigned URL for the current corpus as one `tar.gz`, valid for 15 minutes. It is the way to hand the whole corpus to something outside anyformat: a local search index, a backup, another agent. ```json theme={null} { "corpus_version": "2fdecfca8922…", "url": "https://s3.eu-west-1.amazonaws.com/…", "size_bytes": 4235, "document_count": 3, "built_at": "2026-09-14T18:07:49Z", "expires_in_seconds": 900 } ``` The archive holds the same tree `list_knowledge` shows, plus the structure the citations resolve against: ```text theme={null} .kb/INDEX.md navigation the agent reads first .kb/manifest.json every document and block with byte ranges, pages and boxes .kb/navigation/2026-09/INDEX.md .kb/views/category/… .kb/views/date/… 2026-09/knowledge_note.md the document, as read_knowledge returns it 2026-09/knowledge_note.blocks.json page and box per block (PDF only) 2026-09/single_invoice.md 2026-09/single_invoice.blocks.json 2026-09/vendor_catalog.md .meta.json ``` Possession of the URL is read permission for the corpus until it expires. It survives revoking the API key that requested it, and it can stop answering after a re-index or a deletion. Treat it like the bytes themselves. ## When to use which tool | The agent wants | Tool | Model | Billed | | --------------------------------------- | -------------------- | ----- | ------------ | | The answer to a question, with evidence | `ask_knowledge` | yes | per question | | To see what is in the corpus | `list_knowledge` | no | no | | The documents about X | `search_knowledge` | no | no | | One document's text | `read_knowledge` | no | no | | Everything, as files | `download_knowledge` | no | no | Every read tool answers `KNOWLEDGE_NOT_ENABLED` on a workflow without the node, `KNOWLEDGE_NOT_READY` while the first corpus is still building, and a flat `404` when the caller cannot see the workflow. ## Related * [Agents over MCP](/guides/mcp): staging, the typed graph, retry-safe runs and reading extraction results. * [Ask your filing cabinet](/examples/ask-your-filing-cabinet): the same node from the app and the REST API, with the credit model. * [MCP server reference](/api-reference-v3/mcp): connection setup, scopes and the full tool table. * [Knowledge endpoints](/api-reference-v3/knowledge/ask): the REST routes behind these tools. # Parse-only workflow Source: https://docs.anyformat.ai/examples/parse-only-workflow Turn a document into markdown and layout blocks on the Flash, Fast, Standard or Agentic tier, with no extraction A parse-only workflow is one [Parse](/guides/nodes/parse) node and no edges. It returns the document as markdown plus one block per region, each with a position and a confidence, and it runs no extraction. Use it to preview a document before you write fields, to feed your own model or search index, or to see how a document reads.
The document
INVOICE
What comes back
markdown
parse\_conf
p1\_b0
p1\_b5
extractions
NodesParse Credits25 per page You get
To parse one document on the Fast tier without creating a workflow, call [`POST /v3/parse/`](/api-reference-v3/parse/parse) (or [`POST /v3/parse/from-url/`](/api-reference-v3/parse/parse-from-url)) with the file. It runs against a parse workflow that anyformat keeps for your organization and returns a run you poll like any other. ## The graph ```json Graph theme={null} { "name": "Document parser", "nodes": [{ "id": "parse_1", "type": "parse", "mode": "standard" }], "edges": [] } ``` ```python Python theme={null} import os from anyformat.sdk import Client client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = client.workflow("Document parser").parse(mode="standard").create() ``` ```typescript TypeScript theme={null} import { Anyformat } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af.workflow("Document parser").parse({ mode: "standard" }).create(); ``` `mode` selects the tier. Nothing else in the graph changes. ## Pick a tier Each row is what came back for the same one-page invoice. | Tier | `mode` | What runs | What you get back | | ------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | **Flash** | `flash` | The PDF's own text layer plus layout grounding. No model call. A page without a text layer is OCR'd (`scanned_pages`). | Plain text per block, tables as markdown pipe tables, `parse_confidence` of 100 on a born-digital PDF. Done in about 12 s. | | **Fast** | `lite` | One OCR pass, no LLM correction. | One block per text line, tables as bare HTML `
Umbrella LogisticsUmbrella Logisticsap@umbrellalogistics.exampleNet 30
`, `parse_confidence` and `layout_confidence` are `null`. Done in about 14 s. | | **Standard** | `standard` | Page-by-page parsing with reading-order correction. The default. | Merged blocks with headings marked, HTML tables with a `data-cell-id` per cell, `parse_confidence` of 89. Done in about 28 s. | | **Agentic** | `agentic` | Adaptive, multi-step parsing that works hardest on dense tables. `effort` sets the preset. | The Standard shape, with the most work spent on tables. Done in about 37 s. See [Agentic parse to markdown](/examples/agentic-parse-to-markdown). | Start on Standard. Move a document type down to Fast or Flash when its output holds up, and up to Agentic only where Standard falls short. The [Parse node](/guides/nodes/parse) page lists the credits per page for each tier. ## Run and read ```bash curl theme={null} # Upload one file and start a run curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/upload/run/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -F 'files=@document.pdf' # -> 202 {"run_id": "...", "document_packet_id": "...", "status": "queued"} # Read the run. Repeat until "status" is "processed". curl "https://api.anyformat.ai/v3/runs/$RUN_ID/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" ``` ```python Python theme={null} result = workflow.run("document.pdf").wait() print(result.parse.parse_confidence) print(result.parse.markdown[:500]) for block in result.parse.blocks: print(block.id, block.type, block.page, block.parse_confidence) ``` ```typescript TypeScript theme={null} import { readFile } from "node:fs/promises"; const file = new File([await readFile("document.pdf")], "document.pdf", { type: "application/pdf" }); const run = await workflow.run(file); const result = await run.wait(); console.log(result.parse?.parseConfidence); console.log(result.parse?.markdown?.slice(0, 500)); for (const block of result.parse?.blocks ?? []) { console.log(block.id, block.type, block.page, block.parse_confidence); } ``` ## What comes back The Standard tier on a one-page invoice, trimmed to the two blocks worth looking at. `classifications`, `splits`, `extractions` and `edits` are all empty, because only Parse ran. ```json Response highlight={8,9} theme={null} { "results": { "parse": { "markdown": "…", "text": "INVOICE\nInvoice #: INV-2026-9001 …", "parse_confidence": 89.1, "blocks": [ { "id": "p1_b0", "type": "title", "page": 1, "parse_confidence": 100.0, "layout_confidence": 0.846, "content": "# INVOICE", "bbox": { "x0": 0.115, "y0": 0.066, "x1": 0.269, "y1": 0.096 } }, { "id": "p1_b5", "type": "table", "page": 1, "parse_confidence": 85.9, "rows": [[{ "cell_id": "r0c0", "text": "Description" }, { "cell_id": "r0c1", "text": "Qty" }]] } ] } } } ``` [Runs and results](/concepts/runs-and-results) has the full envelope, section by section. * Each `` anchor in `markdown` names the block with the same `id` in `blocks`. That is how you map a passage back to its `bbox` (page fractions, 0 to 1) and its confidences: `parse_confidence` is 0 to 100, `layout_confidence` is 0 to 1. * A `table` block also carries `rows`, the cells as a grid, so you do not have to parse the HTML. * Block ids carry the page: `p2_b0` is the first block on page 2. Every page is parsed. ## When it goes wrong **Your confidence gate never fires.** The Fast tier makes no model call, so `parse_confidence` and `layout_confidence` come back `null`. A gate written as "below 80" never trips on a `null`. Read the tier before you read the number, or stay on Standard, where the number is real. **A scanned page comes back as text you did not expect.** Flash reads the PDF's own text layer. A page with no words in that layer is OCR'd instead, silently, because `scanned_pages` defaults to `ocr`. Set it to `skip` to serve that page blank and flagged, or to `fail` to raise an error naming the page numbers. **A table arrives with no addressable cells.** Fast returns bare HTML `
` and one block per text line. Standard merges the blocks, marks headings, gives every cell a `data-cell-id`, and fills `rows` with the cells as a grid. Move the node to Standard when you need to point at a cell. **You fixed the source file and got the old parse back.** Parse caches per file, tier and settings, and a hit skips the parse and its cost. Set `"cache": false` on the node to force a fresh parse of the same file. **You are about to create a second parse-only workflow.** Nothing in a Parse node depends on the kind of document, so one workflow reads them all. Create another only when a document type needs a different `mode`. ## Next steps Every knob on the node, and the credits per tier The Agentic tier and its effort presets The full run envelope Add an Extract node and get fields back # Receipt scanning Source: https://docs.anyformat.ai/examples/receipt-scanning Extract merchant, totals and payment method from receipt photos and scans This example reads a receipt photo or scan and returns the merchant, the totals, the item count and how the purchase was paid. An `enum` field pins the payment method to a fixed set of names, and a `boolean` flags alcohol for expense policies.
The photo
What comes back
store\_name97%
total\_amount98%
payment\_method95%
contains\_alcohol92%
tax\_amount
NodesParse → Extract Credits60 per page You get
## The workflow ```json API theme={null} { "name": "Receipt scanning", "description": "Extract receipt details from photos and scans", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "store_name", "description": "Name of the store or merchant", "data_type": "string" }, { "name": "store_address", "description": "Full address of the store location", "data_type": "string" }, { "name": "receipt_date", "description": "Date of the purchase transaction", "data_type": "date" }, { "name": "total_amount", "description": "Total amount charged including tax", "data_type": "float" }, { "name": "tax_amount", "description": "Total tax amount", "data_type": "float" }, { "name": "number_of_items", "description": "Total number of items purchased", "data_type": "integer" }, { "name": "payment_method", "description": "Payment method used for the transaction", "data_type": "enum", "enum_options": [ { "name": "cash", "description": "Cash payment" }, { "name": "credit_card", "description": "Credit card payment" }, { "name": "debit_card", "description": "Debit card payment" }, { "name": "mobile_payment", "description": "Mobile or digital wallet payment" } ] }, { "name": "contains_alcohol", "description": "Whether any alcoholic beverage was included in the purchase", "data_type": "boolean" } ] } } ], "edges": [{ "source": "parse_1", "target": "extract_1" }] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Receipt scanning", "Extract receipt details from photos and scans") .parse() .extract([ Schema.string("store_name", "Name of the store or merchant"), Schema.string("store_address", "Full address of the store location"), Schema.date("receipt_date", "Date of the purchase transaction"), Schema.float("total_amount", "Total amount charged including tax"), Schema.float("tax_amount", "Total tax amount"), Schema.integer("number_of_items", "Total number of items purchased"), Schema.enum("payment_method", "Payment method used for the transaction", options=[ Schema.option("cash", "Cash payment"), Schema.option("credit_card", "Credit card payment"), Schema.option("debit_card", "Debit card payment"), Schema.option("mobile_payment", "Mobile or digital wallet payment"), ]), Schema.boolean("contains_alcohol", "Whether any alcoholic beverage was included in the purchase"), ]) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af .workflow("Receipt scanning", "Extract receipt details from photos and scans") .parse() .extract([ Schema.string("store_name", "Name of the store or merchant"), Schema.string("store_address", "Full address of the store location"), Schema.date("receipt_date", "Date of the purchase transaction"), Schema.float("total_amount", "Total amount charged including tax"), Schema.float("tax_amount", "Total tax amount"), Schema.integer("number_of_items", "Total number of items purchased"), Schema.enum("payment_method", "Payment method used for the transaction", [ Schema.option("cash", "Cash payment"), Schema.option("credit_card", "Credit card payment"), Schema.option("debit_card", "Debit card payment"), Schema.option("mobile_payment", "Mobile or digital wallet payment"), ]), Schema.boolean("contains_alcohol", "Whether any alcoholic beverage was included in the purchase"), ]) .create(); ``` ## Run it and read the result Send the photo as the `files` field. JPG, PNG and PDF all work. Poll the run until its `status` is `processed`. ```bash curl theme={null} # 1. Create the workflow with the JSON above curl -X POST 'https://api.anyformat.ai/v3/workflows/' \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -H 'Content-Type: application/json' \ -d @receipt-workflow.json # → 201 { "id": "", ... } # 2. Upload the receipt photo and start a run curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/upload/run/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -F 'files=@receipt.jpg' # → 202 { "run_id": "", "document_packet_id": "...", "status": "queued" } # 3. Poll until "status" is "processed" curl "https://api.anyformat.ai/v3/runs/$RUN_ID/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" ``` ```python Python theme={null} result = workflow.run("receipt.jpg").wait() print(result.fields["store_name"].value) print(result.fields["total_amount"].value) print(result.fields["payment_method"].value) print(result.fields["contains_alcohol"].value) ``` ```typescript TypeScript theme={null} import type { ExtractedField } from "@anyformat/sdk"; const file = new File([readFileSync("receipt.jpg")], "receipt.jpg", { type: "image/jpeg" }); const result = await (await workflow.run(file)).wait(); console.log((result.field("store_name") as ExtractedField | undefined)?.value); console.log((result.field("total_amount") as ExtractedField | undefined)?.value); console.log((result.field("payment_method") as ExtractedField | undefined)?.value); console.log((result.field("contains_alcohol") as ExtractedField | undefined)?.value); // "True" or "False" ``` ## Response The `results` envelope of a processed run, trimmed. Every value is a string on the wire, including booleans (`"True"` / `"False"`) and numbers. A field the model could not find comes back with `value: null` and `confidence: null`, as `tax_amount` does on this receipt. ```json Response highlight={6,8} theme={null} { "results": { "extractions": [{ "fields": { "store_name": { "value": "Corner Grocery", "confidence": 97.0, "evidence": [{ "text": "CORNER GROCERY", "page_number": 1 }] }, "total_amount": { "value": "23.45", "confidence": 98.0, "evidence": [{ "text": "TOTAL 23.45", "page_number": 1 }] }, "tax_amount": { "value": null, "confidence": null, "evidence": [] }, "payment_method": { "value": "credit_card", "confidence": 95.0, "evidence": [{ "text": "VISA ****1234", "page_number": 1 }] }, "contains_alcohol": { "value": "False", "confidence": 92.0, "evidence": [] } } }] } } ``` Every field also carries `verification_status` and `value_override`. [Runs and results](/concepts/runs-and-results) has the full envelope, section by section. ## When it goes wrong **The tax line comes back `null`.** Faded thermal paper loses the tax line before it loses the total. A field the model cannot find returns `value: null` and `confidence: null`, which means "not on the receipt", not zero. Treat it as missing, and do not default it to `0` before someone has looked. **A boolean branch never fires.** `contains_alcohol` arrives as the string `"True"` or `"False"`, like every other value on the wire. `if (row.contains_alcohol)` is true for both. Compare against the string, or cast it, before you branch. **The payment method comes back empty.** An `enum` constrains the output to the option names you declared, so a receipt paid a way you did not list has nothing to match. That is what stops "Visa", "credit card" and "CC" arriving as three different values. Add the missing option to `enum_options`, or use a `string` field when the set is open. **A crumpled photo drags every field down.** Extract reads the parse, so a shadowed or creased receipt costs you twice. Capture flat and lit, and prefer PNG over a heavily compressed JPG where you control the capture. For a batch that stays bad, move the Parse node to the [agentic tier](/guides/nodes/parse) (100 credits per page), which is the tier for low-quality inputs. **One threshold flags the wrong fields.** This receipt returned `total_amount` at 98% and `contains_alcohol` at 92%, and the two do not deserve the same gate. Set the threshold per field: the amount is worth a human look, the flag usually is not. ## Next steps The results envelope and the run status model Rate limits, idempotency and retries # Resume parsing Source: https://docs.anyformat.ai/examples/resume-parsing Pull contact details, skills, education and work history out of PDF and DOCX resumes This example turns a resume into candidate data you can filter on: name, contact details, years of experience, and three tables of rows for skills, education and work history. Each table is an `object` field, which is how a repeating list is modelled on the typed graph.
The resume
What comes back
candidate\_name98%
years\_of\_experience95%
skills
work\_history
NodesParse → Extract Credits60 per page You get
## The workflow ```json API theme={null} { "name": "Resume parsing", "description": "Extract candidate details from resumes", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "candidate_name", "description": "Full name of the candidate", "data_type": "string" }, { "name": "email", "description": "Email address", "data_type": "string" }, { "name": "phone", "description": "Phone number including country code if present", "data_type": "string" }, { "name": "years_of_experience", "description": "Total years of professional work experience", "data_type": "integer" }, { "name": "skills", "description": "Technical skills, programming languages and tools, one row per skill", "data_type": "object", "nested_fields": [ { "name": "skill", "description": "One skill, language or tool", "data_type": "string" } ] }, { "name": "education", "description": "Educational qualifications and degrees, one row per degree", "data_type": "object", "nested_fields": [ { "name": "institution", "description": "University or school name", "data_type": "string" }, { "name": "degree", "description": "Degree obtained (e.g. BSc CS, MBA)", "data_type": "string" }, { "name": "graduation_date", "description": "Date of graduation", "data_type": "date" } ] }, { "name": "work_history", "description": "Previous jobs and roles, most recent first, one row per job", "data_type": "object", "nested_fields": [ { "name": "company", "description": "Company name", "data_type": "string" }, { "name": "title", "description": "Job title", "data_type": "string" }, { "name": "start_date", "description": "Start date of employment", "data_type": "date" }, { "name": "end_date", "description": "End date of employment, or empty if this is the current role", "data_type": "date" } ] } ] } } ], "edges": [{ "source": "parse_1", "target": "extract_1" }] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Resume parsing", "Extract candidate details from resumes") .parse() .extract([ Schema.string("candidate_name", "Full name of the candidate"), Schema.string("email", "Email address"), Schema.string("phone", "Phone number including country code if present"), Schema.integer("years_of_experience", "Total years of professional work experience"), Schema.object("skills", "Technical skills, programming languages and tools, one row per skill", fields=[ Schema.string("skill", "One skill, language or tool"), ]), Schema.object("education", "Educational qualifications and degrees, one row per degree", fields=[ Schema.string("institution", "University or school name"), Schema.string("degree", "Degree obtained (e.g. BSc CS, MBA)"), Schema.date("graduation_date", "Date of graduation"), ]), Schema.object("work_history", "Previous jobs and roles, most recent first, one row per job", fields=[ Schema.string("company", "Company name"), Schema.string("title", "Job title"), Schema.date("start_date", "Start date of employment"), Schema.date("end_date", "End date of employment, or empty if this is the current role"), ]), ]) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af .workflow("Resume parsing", "Extract candidate details from resumes") .parse() .extract([ Schema.string("candidate_name", "Full name of the candidate"), Schema.string("email", "Email address"), Schema.string("phone", "Phone number including country code if present"), Schema.integer("years_of_experience", "Total years of professional work experience"), Schema.object("skills", "Technical skills, programming languages and tools, one row per skill", [ Schema.string("skill", "One skill, language or tool"), ]), Schema.object("education", "Educational qualifications and degrees, one row per degree", [ Schema.string("institution", "University or school name"), Schema.string("degree", "Degree obtained (e.g. BSc CS, MBA)"), Schema.date("graduation_date", "Date of graduation"), ]), Schema.object("work_history", "Previous jobs and roles, most recent first, one row per job", [ Schema.string("company", "Company name"), Schema.string("title", "Job title"), Schema.date("start_date", "Start date of employment"), Schema.date("end_date", "End date of employment, or empty if this is the current role"), ]), ]) .create(); ``` ## Run it and read the result The `files` field accepts PDF, DOCX and images. Poll the run until its `status` is `processed`. ```bash curl theme={null} # 1. Create the workflow with the JSON above curl -X POST 'https://api.anyformat.ai/v3/workflows/' \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -H 'Content-Type: application/json' \ -d @resume-workflow.json # → 201 { "id": "", ... } # 2. Upload the resume and start a run curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/upload/run/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -F 'files=@resume.docx' # → 202 { "run_id": "", "document_packet_id": "...", "status": "queued" } # 3. Poll until "status" is "processed" curl "https://api.anyformat.ai/v3/runs/$RUN_ID/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" ``` ```python Python theme={null} result = workflow.run("resume.docx").wait() print(result.fields["candidate_name"].value) print(result.fields["years_of_experience"].value) skills = [row["skill"].value for row in result.fields["skills"]] for job in result.fields["work_history"]: print(job["company"].value, job["title"].value, job["start_date"].value, job["end_date"].value) ``` ```typescript TypeScript theme={null} import { readFileSync } from "node:fs"; import type { ExtractedField, ExtractedRows } from "@anyformat/sdk"; const file = new File([readFileSync("resume.docx")], "resume.docx"); const result = await (await workflow.run(file)).wait(); console.log((result.field("candidate_name") as ExtractedField | undefined)?.value); console.log((result.field("years_of_experience") as ExtractedField | undefined)?.value); const skills = (result.field("skills") as ExtractedRows).map((row) => row.skill?.value); for (const job of result.field("work_history") as ExtractedRows) { console.log(job.company?.value, job.title?.value, job.start_date?.value, job.end_date?.value); } ``` ## Response The `results` envelope of a processed run, trimmed. A current role comes back with `end_date.value: null`. ```json Response highlight={6,8} theme={null} { "results": { "extractions": [{ "fields": { "candidate_name": { "value": "Maria Gonzalez", "confidence": 98.0, "evidence": [{ "text": "MARIA GONZALEZ", "page_number": 1 }] }, "years_of_experience": { "value": "9", "confidence": 95.0, "evidence": [{ "text": "9 years of professional experience", "page_number": 1 }] }, "skills": [{ "skill": { "value": "Python", "confidence": 98.0 } }, { "skill": { "value": "Go", "confidence": 98.0 } }], "work_history": [ { "company": { "value": "Fintonic", "confidence": 97.0 }, "title": { "value": "Staff Backend Engineer", "confidence": 97.0 }, "start_date": { "value": "2021-03-01", "confidence": 90.0 }, "end_date": { "value": null, "confidence": 90.0 } } ] } }] } } ``` Every cell also carries `verification_status` and `value_override`. [Runs and results](/concepts/runs-and-results) has the full envelope, section by section. ## When it goes wrong **A current job comes back with a made-up end date.** The model fills a `date` field it was asked for. Describe `end_date` as "empty if this is the current role", the way this schema does, and the current job returns `end_date.value: null` instead. **Your skills list arrives as one long string.** There is no `list` data type. A free-form list is an `object` field with one nested field, as `skills` does here, and each item comes back as its own row with its own confidence. [Field types](/concepts/field-types) has the shapes. **You cannot filter on years of experience.** Every value is a string on the wire, so `"9"` sorts next to `"10"` the wrong way. Declare `years_of_experience` as `integer` and cast it once on the way in, rather than parsing text at query time. **A scanned resume loses half the work history.** A DOCX or born-digital PDF has native text, so nothing is lost to OCR. A scan does not. Where you control the intake, ask for DOCX. Where you do not, move the Parse node to the [agentic tier](/guides/nodes/parse) (100 credits per page) for the scanned batch. **Dates come back on the first of the month.** Resumes write "March 2021", which names no day. A `date` field normalises that to `2021-03-01`. Compare on month, not on day, when you rank candidates by tenure. ## Next steps The multipart request, idempotency and filename rules Objects, enums and the other field shapes # Big invoices need a human Source: https://docs.anyformat.ai/examples/route-on-a-threshold Send the invoices over your approval limit down a second branch, and let the rest through untouched Most invoices should just go through. The ones over your approval limit should not. An [If/Else](/guides/nodes/if-else) node tests a field the [Extract](/guides/nodes/extract) node produced and sends the document down a **True** or a **False** branch, so the expensive work, and the person, only get involved when the number says so.
invoice\_number
total\_amount
bill\_to\_name
due\_date
invoice\_number
total\_amount
Nodes Credits60 per page You get
## The workflow The condition names a field from the Extract node above it, an operator, and a value. The edge that leaves the If/Else node carries `"branch": "true"` or `"branch": "false"`. ```json API theme={null} { "name": "Large invoices need a human", "description": "Route an invoice over the approval limit to a second look", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "invoice_number", "description": "The unique invoice identifier", "data_type": "string" }, { "name": "vendor_name", "description": "Name of the company that issued the invoice", "data_type": "string" }, { "name": "total_amount", "description": "Final total amount due including tax", "data_type": "float" } ] } }, { "id": "if_else_1", "type": "if_else", "condition": { "type": "comparison", "left": "total_amount", "op": ">", "right": { "source": "literal", "value": 4000 } } }, { "id": "extract_2", "type": "extract", "extraction_schema": { "fields": [ { "name": "bill_to_name", "description": "The party the invoice is billed to", "data_type": "string" }, { "name": "due_date", "description": "Date by which payment is due", "data_type": "date" } ] } } ], "edges": [ { "source": "parse_1", "target": "extract_1" }, { "source": "extract_1", "target": "if_else_1" }, { "source": "if_else_1", "target": "extract_2", "branch": "true" } ] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import ( ComparisonCheck, Edge, ExtractNode, ExtractionSchema, IfElseNode, ParseNode, Schema, WorkflowDefinition, ) client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = client.create_workflow(WorkflowDefinition( name="Large invoices need a human", nodes=[ ParseNode(id="parse_1", type="parse"), ExtractNode(id="extract_1", type="extract", extraction_schema=ExtractionSchema(fields=[ Schema.string("invoice_number", "The unique invoice identifier"), Schema.string("vendor_name", "Name of the company that issued the invoice"), Schema.float("total_amount", "Final total amount due including tax"), ])), IfElseNode(id="if_else_1", type="if_else", condition=ComparisonCheck( type="comparison", left="total_amount", op=">", right={"source": "literal", "value": 4000}, )), ExtractNode(id="extract_2", type="extract", extraction_schema=ExtractionSchema(fields=[ Schema.string("bill_to_name", "The party the invoice is billed to"), Schema.date("due_date", "Date by which payment is due"), ])), ], edges=[ Edge(source="parse_1", target="extract_1"), Edge(source="extract_1", target="if_else_1"), Edge(source="if_else_1", target="extract_2", branch="true"), ], )) ``` ```typescript TypeScript theme={null} // The fluent builder has no ifElse() verb yet. Send the API JSON on this page to // POST /v3/workflows/, or pass the same graph to af.updateWorkflow(workflowId, definition). ``` The fluent Python builder (`client.workflow(...).parse().extract(...)`) has no `if_else()` verb either. Build a `WorkflowDefinition` from the node classes, as above. ## Response **A run does not tell you which branch it took.** There is no branch field anywhere in the envelope. You read the outcome from what ran: an invoice over the limit comes back with two entries in `extractions`, one per Extract node; the same invoice under the limit comes back with one, because the node on the True branch never ran. ```json Over the limit theme={null} { "status": "processed", "results": { "extractions": [ { "split_name": null, "partition": null, "fields": { "invoice_number": { "value": "INV-2026-9001", "confidence": 96.0, "evidence": [{ "text": "Invoice #: INV-2026-9001", "page_number": 1 }] }, "vendor_name": { "value": "Wayne Enterprises", "confidence": 97.0, "evidence": [{ "text": "From: Wayne Enterprises", "page_number": 1 }] }, "total_amount": { "value": "4594.62", "confidence": 97.0, "evidence": [{ "text": "**TOTAL: $4,594.62**", "page_number": 1 }] } } }, { "split_name": null, "partition": null, "fields": { "bill_to_name": { "value": "Nguyen, Smith and Hickman", "confidence": 96.0, "evidence": [{ "text": "Bill to: Nguyen, Smith and Hickman", "page_number": 1 }] }, "due_date": { "value": "2026-03-22", "confidence": 97.0, "evidence": [{ "text": "Due date: 2026-03-22", "page_number": 1 }] } } } ] } } ``` ```json Under the limit theme={null} { "status": "processed", "results": { "extractions": [ { "split_name": null, "partition": null, "fields": { "invoice_number": { "value": "INV-2026-9001", "confidence": 96.0, "evidence": [{ "text": "Invoice #: INV-2026-9001", "page_number": 1 }] }, "vendor_name": { "value": "Wayne Enterprises", "confidence": 97.0, "evidence": [{ "text": "From: Wayne Enterprises", "page_number": 1 }] }, "total_amount": { "value": "4594.62", "confidence": 97.0, "evidence": [{ "text": "**TOTAL: $4,594.62**", "page_number": 1 }] } } } ] } } ``` ## Tell someone about it An extra Extract node is a placeholder for whatever the branch should do. The usual end of a True branch is a [Slack alert](/guides/nodes/slack-alert): a message in the channel that owns the decision, with the extracted values in it. ```json theme={null} { "id": "slack_1", "type": "slack_alert", "channel_id": "C0123ABC", "channel_name": "#ap-approvals", "message_template": "Invoice over the limit from ${field.vendor_name}: ${field.total_amount}. Due ${field.due_date}.", "severity": "warning" } ``` ```json theme={null} { "source": "if_else_1", "target": "slack_1", "branch": "true" } ``` The alert needs your Slack workspace [connected to the organization](/integrations/slack) first, by an admin, once. Until that is done the node saves fine and sends nothing. Severity sets the colour of the bar on the Slack card and nothing else: it does not decide whether the alert fires, which is what the If/Else above it is for. ## When it goes wrong **Every document takes the True branch.** The comparison reads the field as a number only when the field is a number. `total_amount` declared as a `string` compares as text, where `"900"` is greater than `"4000"`. Declare amounts as `float`. **Nothing takes either branch.** The condition names a field that the Extract node above it does not produce. `left` is the field **name**, spelled exactly as in the schema. **You cannot tell what happened.** Add the alert, or give the False branch its own node too, so each path leaves a trace. Reading the branch from the count of extractions is fine in a script and painful in an audit. **You want more than one test.** One If/Else node holds one condition. Chain a second node on the branch of the first for "over the limit **and** from a new supplier". ## Next steps Every operator, and conditions on a validation result The message template, its placeholders and the severity # The scan from accounting Source: https://docs.anyformat.ai/examples/split-a-mixed-scan One file that holds several documents: cut it into pieces by kind, then extract each kind with its own fields Somebody fed the whole tray into the scanner. What lands in your inbox is one PDF holding three supplier invoices, a shipping label and, for reasons nobody will explain, a boarding pass. A [Split](/guides/nodes/split) node cuts that file into pieces by kind, and each kind goes to the [Extract](/guides/nodes/extract) node that suits it.
One file, five pages
Five documents
Invoice98%
Shipping label
Boarding pass
Nodes Credits85 per page You get
## The workflow Each Split **rule** is one kind of document and one outgoing branch. The invoice rule also carries a `partition_key`: the field that tells one invoice from the next inside the same rule, so three invoices in a row become three documents rather than one three-page one. ```json API theme={null} { "name": "The scan from accounting", "description": "One scanned file that holds several documents", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "split_1", "type": "splitter", "rules": [ { "id": "INVOICE", "name": "Invoice", "description": "A supplier invoice: an invoice number, a bill-to party, line items and a total due.", "partition_key": "invoice_number" }, { "id": "SHIPPING_LABEL", "name": "Shipping label", "description": "A carrier shipping label: a tracking number, a sender and a recipient address, a weight." }, { "id": "BOARDING_PASS", "name": "Boarding pass", "description": "An airline boarding pass: a passenger name, a flight number, a seat, an origin and a destination airport." } ] }, { "id": "extract_invoice", "type": "extract", "extraction_schema": { "fields": [ { "name": "invoice_number", "description": "The unique invoice identifier", "data_type": "string" }, { "name": "vendor_name", "description": "Name of the company that issued the invoice", "data_type": "string" }, { "name": "total_amount", "description": "Final total amount due including tax", "data_type": "float" } ] } }, { "id": "extract_label", "type": "extract", "extraction_schema": { "fields": [ { "name": "tracking_number", "description": "The carrier tracking number", "data_type": "string" }, { "name": "carrier", "description": "The shipping carrier", "data_type": "string" }, { "name": "recipient_name", "description": "Name of the recipient the parcel ships to", "data_type": "string" } ] } }, { "id": "extract_pass", "type": "extract", "extraction_schema": { "fields": [ { "name": "passenger_name", "description": "Name of the passenger", "data_type": "string" }, { "name": "flight_number", "description": "The flight number", "data_type": "string" }, { "name": "seat", "description": "The seat assignment", "data_type": "string" } ] } } ], "edges": [ { "source": "parse_1", "target": "split_1" }, { "source": "split_1", "target": "extract_invoice", "branch": "INVOICE" }, { "source": "split_1", "target": "extract_label", "branch": "SHIPPING_LABEL" }, { "source": "split_1", "target": "extract_pass", "branch": "BOARDING_PASS" } ] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema, SplitterRule client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) invoice = SplitterRule( id="INVOICE", name="Invoice", description="A supplier invoice: an invoice number, a bill-to party, line items and a total due.", partition_key="invoice_number", ) label = SplitterRule( id="SHIPPING_LABEL", name="Shipping label", description="A carrier shipping label: a tracking number, a sender and a recipient address, a weight.", ) boarding_pass = SplitterRule( id="BOARDING_PASS", name="Boarding pass", description="An airline boarding pass: a passenger name, a flight number, a seat, an origin and a destination airport.", ) workflow = ( client.workflow("The scan from accounting", "One scanned file that holds several documents") .parse() .split(invoice, label, boarding_pass) .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.string("vendor_name", "Name of the company that issued the invoice"), Schema.float("total_amount", "Final total amount due including tax"), ], branch=invoice) .extract([ Schema.string("tracking_number", "The carrier tracking number"), Schema.string("carrier", "The shipping carrier"), Schema.string("recipient_name", "Name of the recipient the parcel ships to"), ], branch=label) .extract([ Schema.string("passenger_name", "Name of the passenger"), Schema.string("flight_number", "The flight number"), Schema.string("seat", "The seat assignment"), ], branch=boarding_pass) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const invoice = { id: "INVOICE", name: "Invoice", description: "A supplier invoice: an invoice number, a bill-to party, line items and a total due.", partition_key: "invoice_number", }; const label = { id: "SHIPPING_LABEL", name: "Shipping label", description: "A carrier shipping label: a tracking number, a sender and a recipient address, a weight.", }; const boardingPass = { id: "BOARDING_PASS", name: "Boarding pass", description: "An airline boarding pass: a passenger name, a flight number, a seat, an origin and a destination airport.", }; const workflow = await af .workflow("The scan from accounting", "One scanned file that holds several documents") .parse() .split([invoice, label, boardingPass]) .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.string("vendor_name", "Name of the company that issued the invoice"), Schema.float("total_amount", "Final total amount due including tax"), ], { branch: invoice }) .extract([ Schema.string("tracking_number", "The carrier tracking number"), Schema.string("carrier", "The shipping carrier"), Schema.string("recipient_name", "Name of the recipient the parcel ships to"), ], { branch: label }) .extract([ Schema.string("passenger_name", "Name of the passenger"), Schema.string("flight_number", "The flight number"), Schema.string("seat", "The seat assignment"), ], { branch: boardingPass }) .create(); ``` ## Run it and read the result Upload the file once. Split decides which pages belong to which rule, so you send nothing per document. ```bash curl theme={null} # Upload the whole scan and start a run curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/upload/run/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -F 'files=@scan.pdf' # → 202 { "run_id": "", "status": "queued" } curl "https://api.anyformat.ai/v3/runs/$RUN_ID/" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" ``` ```python Python theme={null} result = workflow.run("scan.pdf").wait() # One extraction per document, tagged with the rule and the partition it came from. for extraction in result.extractions: print(extraction.split_name, extraction.partition) ``` ```typescript TypeScript theme={null} import { readFileSync } from "node:fs"; const file = new File([readFileSync("scan.pdf")], "scan.pdf", { type: "application/pdf" }); const result = await (await workflow.run(file)).wait(); for (const extraction of result.extractions) { console.log(extraction.split_name, extraction.partition); } ``` ## Response `splits` says which pages became which document. `extractions` then carries one entry per document, each tagged with the `split_name` and the `partition` it came from, so you never have to work out which invoice a value belongs to. ```json theme={null} { "status": "processed", "results": { "splits": [ { "name": "Invoice", "files": [{ "file_name": "scan.pdf", "pages": [1, 2, 3] }], "confidence": 98, "partitions": [ { "name": "INV-2026-1000", "files": [{ "file_name": "scan.pdf", "pages": [1] }], "confidence": 98 }, { "name": "INV-2026-1001", "files": [{ "file_name": "scan.pdf", "pages": [2] }], "confidence": 98 }, { "name": "INV-2026-1002", "files": [{ "file_name": "scan.pdf", "pages": [3] }], "confidence": 98 } ] }, { "name": "Shipping label", "files": [{ "file_name": "scan.pdf", "pages": [4] }], "confidence": 98, "partitions": [] }, { "name": "Boarding pass", "files": [{ "file_name": "scan.pdf", "pages": [5] }], "confidence": 98, "partitions": [] }, { "name": "Other", "files": [], "confidence": null, "partitions": [] } ], "extractions": [ { "split_name": "Invoice", "partition": "INV-2026-1000", "fields": { "invoice_number": { "value": "INV-2026-1000", "confidence": 97.0, "evidence": [{ "text": "Invoice #: INV-2026-1000 \nIssue date: 2026-03-26", "page_number": 1 }], "verification_status": "not_verified", "value_override": null }, "vendor_name": { "value": "Acme Industries", "confidence": 97.0, "evidence": [{ "text": "**From: Acme Industries**", "page_number": 1 }] }, "total_amount": { "value": "1810.5", "confidence": 97.0, "evidence": [{ "text": "**TOTAL: $1,810.50**", "page_number": 1 }] } }, "validations": [] }, { "split_name": "Shipping label", "partition": null, "fields": { "carrier": { "value": "FedEx", "confidence": 99.0, "evidence": [{ "text": "Carrier: FedEx", "page_number": 4 }] }, "tracking_number": { "value": "RK51FQKH1DNN", "confidence": 99.0, "evidence": [{ "text": "TRACKING NUMBER RK51FQKH1DNN", "page_number": 4 }] } }, "validations": [] } ] } } ``` ## When it goes wrong **Everything lands in "Other".** Split always emits an `Other` group for pages that match no rule, and it is empty on a clean run. A full one means your rule descriptions read like labels rather than descriptions. Write what is printed on the page: "a tracking number, a sender and a recipient address, a weight" beats "shipping stuff". **Three invoices come back as one document.** That is the rule without a `partition_key`. The key names the field that changes between documents of the same kind, usually the identifier printed on each one, and it is what turns one three-page invoice group into three invoices. **A sparse document extracts with low confidence.** The boarding pass in this run returned its flight number at 80% and its seat at 78%, against 97-99% for the invoices. A card with six words on it gives the model little to cite. Treat a low confidence as "have someone look", not as a failure: see [verification and review](/guides/workflows/verification-review). **The pages are in the wrong order in the source file.** Split works by page, so a document interleaved with another cannot be recovered. Fix the scan, or split the file before you upload it. **The workflow is rejected when you save it.** Once a workflow has a Split node, every Extract needs a `branch`, and each `branch` must be a rule **id** (`INVOICE`), not the rule's display name (`Invoice`). ## Next steps Every rule option, and Split after a Classify node One document per file, routed by kind # Everyone spells your supplier differently Source: https://docs.anyformat.ai/examples/supplier-code-lookup Read the supplier name off the invoice, then resolve it to the code your ERP actually uses, with a reference CSV you upload once The invoice says "Wayne Enterprises". Your supplier master says "Wayne Enterprises, Inc." and your ERP only speaks `SUP-1042`. Next month the same supplier writes "WAYNE ENTERPRISES SA" and the whole thing starts again. [Smart lookup](/guides/nodes/smart-lookup) resolves the name on the page against a CSV you upload once, and returns the row your systems need.
On the invoice
INVOICE
In suppliers.csv
What comes back
vendor\_name97%
invoice\_number96%
supplier\_id
payment\_termsNET30
Nodes Credits135 per page You get
## The workflow Smart lookup is not a node of its own. It is a `source` on a field of the [Extract](/guides/nodes/extract) node, plus the reference CSV attached to that node. Here `vendor_name` is read off the page; `supplier_id` and `payment_terms` are resolved from the file. ```json API theme={null} { "name": "Invoices with supplier codes", "description": "Read the supplier name off the invoice, then resolve it to the code your ERP uses", "nodes": [ { "id": "parse_1", "type": "parse" }, { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "vendor_name", "description": "Supplier name exactly as printed on the invoice", "data_type": "string" }, { "name": "invoice_number", "description": "The unique invoice identifier", "data_type": "string" }, { "name": "supplier_id", "description": "Canonical supplier code from the supplier master, joined on the supplier name", "data_type": "string", "source": "smart_lookup" }, { "name": "payment_terms", "description": "Agreed payment terms for this supplier, from the supplier master", "data_type": "string", "source": "smart_lookup" } ] }, "lookup_file_uploads": [ { "filename": "suppliers.csv", "content": "" } ], "lookup_suggestion": "Match the supplier name against the supplier_name column, ignoring legal suffixes such as Inc., Ltd, LLC and S.A. Return the row's supplier_id and payment_terms." } ], "edges": [{ "source": "parse_1", "target": "extract_1" }] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Invoices with supplier codes", "Resolve the supplier to the code your ERP uses") .parse() .extract( [ Schema.string("vendor_name", "Supplier name exactly as printed on the invoice"), Schema.string("invoice_number", "The unique invoice identifier"), Schema.string( "supplier_id", "Canonical supplier code from the supplier master, joined on the supplier name", source="smart_lookup", ), Schema.string( "payment_terms", "Agreed payment terms for this supplier, from the supplier master", source="smart_lookup", ), ], lookup_files=["suppliers.csv"], lookup_suggestion=( "Match the supplier name against the supplier_name column, ignoring legal suffixes " "such as Inc., Ltd, LLC and S.A. Return the row's supplier_id and payment_terms." ), ) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af .workflow("Invoices with supplier codes", "Resolve the supplier to the code your ERP uses") .parse() .extract( [ Schema.string("vendor_name", "Supplier name exactly as printed on the invoice"), Schema.string("invoice_number", "The unique invoice identifier"), Schema.string("supplier_id", "Canonical supplier code from the supplier master, joined on the supplier name", { source: "smart_lookup", }), Schema.string("payment_terms", "Agreed payment terms for this supplier, from the supplier master", { source: "smart_lookup", }), ], { lookupFiles: ["suppliers.csv"], lookupSuggestion: "Match the supplier name against the supplier_name column, ignoring legal suffixes such as Inc., Ltd, LLC and S.A. Return the row's supplier_id and payment_terms.", }, ) .create(); ``` The SDKs read each path in `lookup_files` from disk and send it as `lookup_file_uploads`. Over the API you send the bytes inline, base64-encoded. ## The reference file Any CSV with a column to match on and the columns you want back. This one carries a near-miss on purpose: two suppliers whose names both start with "Wayne". ```csv suppliers.csv theme={null} supplier_id,supplier_name,city,payment_terms SUP-1042,"Wayne Enterprises, Inc.",Gotham,NET30 SUP-2013,"Wayne Medical Supplies LLC",Gotham,NET45 SUP-3390,"Acme Industries S.A.",Phoenix,NET15 SUP-4471,"Globex Corporation Ltd",Springfield,NET30 SUP-5528,"Initech LLC",Austin,NET60 ``` ## Response `vendor_name` and `invoice_number` were read off the page, so they carry a confidence and the phrase they came from. `supplier_id` and `payment_terms` came out of the CSV, so their `confidence` is `null` and their `evidence` is empty: there is no page they were read from. ```json theme={null} { "status": "processed", "results": { "extractions": [ { "split_name": null, "partition": null, "fields": { "vendor_name": { "value": "Wayne Enterprises", "confidence": 97.0, "evidence": [{ "text": "From: Wayne Enterprises\n148 Eric Track, New Stephanie, NC 00575", "page_number": 1 }], "verification_status": "not_verified", "value_override": null }, "invoice_number": { "value": "INV-2026-9001", "confidence": 96.0, "evidence": [{ "text": "Invoice #: INV-2026-9001\nIssue date: 2026-02-20", "page_number": 1 }], "verification_status": "not_verified", "value_override": null }, "supplier_id": { "value": "SUP-1042", "confidence": null, "evidence": [], "verification_status": "not_verified", "value_override": null }, "payment_terms": { "value": "NET30", "confidence": null, "evidence": [], "verification_status": "not_verified", "value_override": null } } } ] } } ``` ## When it goes wrong **The looked-up field has no confidence.** That is correct, not a bug. A looked-up value did not come off the page, so there is nothing to be confident about and nothing to cite. Judge the match by the extracted field beside it: if `vendor_name` is right, the join had the right input. **It matched the wrong row.** Two suppliers here start with "Wayne". The matcher works from the field name, the field description and `lookup_suggestion`, so add the signal that separates them: "match on supplier name **and** city". A description that names the column beats a clever hint. **Nothing comes back.** The lookup returns empty when no row is a defensible match, which is the honest answer for a supplier that is not in your file yet. Use `"source": "lookup_if_missing"` when you would rather keep whatever was printed on the page than get nothing. **You wanted to check a value against a fixed list.** Then you want an [enum field](/concepts/field-types), not a lookup. Smart lookup is for a list that lives in a file and returns a *different* value than the one it matched. **The workflow is rejected when you save it.** An Extract node with a lookup field and no reference file is refused at save time. Send `lookup_file_uploads` on create, or add the file to the node in Studio. ## Next steps Every option, including the matching effort When to use an enum instead # Using Ask annie Source: https://docs.anyformat.ai/guides/annie Build, run, and improve workflows by chatting with annie, anyformat's AI assistant. **[annie](/concepts/annie)** is anyformat's AI assistant, and the fastest way to get work done. Instead of assembling nodes or defining fields by hand, you tell her what you want in plain language. She builds it, runs your documents, and improves the results. This guide covers working with her day to day. *** ## Start a workflow On the **home** screen, type your request in the **Create with annie** chat: the document type and the data you want out of it. Attach a sample document by drag and drop, or with **Add document**, so annie sees the real layout. > *"Read supplier invoices and pull out the invoice number, date, supplier, and line items."* annie proposes a workflow and shows a preview to approve. Check the fields and types, then accept. She opens the new workflow in [Studio](/guides/studio/index). The conversation continues in the **Chat** tab, so you keep refining. Give annie both a **description and a sample document**. The sample lets her match your real field names and catch what a description alone would miss. *** ## Write good requests annie does best with specific, concrete asks: * **Name the document type:** "supplier invoices", "candidate resumes", "bank statements". * **List the data you want:** "invoice number, issue date, total including tax, and line items". * **Mention how it should behave:** "the total is a decimal", "there may be several invoices in one PDF", "flag documents that are expired". You do not need to know anyformat's node types. Describe the outcome, and annie chooses whether to sort by type, split a bundle, or add a check. *** ## Refine in Studio In Studio, keep working with annie in the **Chat** tab, or edit nodes by hand. Things to ask: "Add a due date field." · "Make total a decimal." · "Rename `ref` to `purchase_order`." "What else should I extract from these?" · "Write a better description for this field." Accept the ideas you like. "Classify these by document type." · "Split PDFs that contain several invoices." · "Add a rule that subtotal + tax equals total." "Match the supplier name against my vendor list and return the supplier code." See [Smart Lookup](/guides/nodes/smart-lookup). annie shows a preview before applying a structural change. Nothing changes until you approve it. *** ## Run documents and read results Ask annie to run documents and report back: * *"Run these three invoices and show me the totals."* * *"Which fields came back with low confidence?"* She runs the documents, returns the extracted values, and flags anything low-confidence so you know what to review. [Verification & review](/guides/workflows/verification-review) covers reviewing results at scale. *** ## Improve accuracy When accuracy matters, give annie examples of the correct answers and let her tune the workflow: Tell annie the right answers for a few documents, called ground truth, or mark them in the results. *"Improve this workflow's accuracy."* annie re-runs your examples, measures accuracy, and keeps only the changes that help. Running documents and measuring accuracy improvements use credits. annie tells you when an action has a cost. [How credits work](/concepts/how-credits-work) has the numbers. *** ## Get integration code When your workflow is ready to call from your own app, ask annie for the code: > *"Give me a Python script to run this workflow on a PDF."* She generates a ready-to-run **Python**, **JavaScript**, or **curl** script, wired to your real workflow and field names. The [SDKs](/api-reference-v3/sdks) hold the full reference. *** ## What's next? What annie is and everything she can do Edit the workflow annie builds, node by node The end-to-end creation walkthrough Review and verify results at scale # Cloud storage Source: https://docs.anyformat.ai/guides/cloud-storage Import documents into a workflow straight from Microsoft OneDrive or SharePoint. Add documents to a workflow directly from **Microsoft OneDrive** or **SharePoint**, without downloading them first. The import lives in the workflow itself, not on the organization's Connectors page. *** ## Import from OneDrive or SharePoint Open the workflow, click the **...** button in the toolbar, and choose **Connect Cloud**. You need the **Member** role or higher in the organization. Without it, the option is greyed out. Pick **Microsoft OneDrive / SharePoint**. A Microsoft sign-in window opens. Choose the account that can see the documents. A work account lands in SharePoint and OneDrive for Business. A personal account lands in personal OneDrive. Open **Connect Cloud** again and choose **Import from SharePoint / OneDrive**, or **Import from Personal OneDrive**. The Microsoft file picker opens. Select one or more files and confirm. anyformat copies the files into the workflow. They appear in the document table like any manual upload. Run them the usual way. See [Running workflows](/guides/workflows/running). The import copies the files once. It does not watch the folder. A file added to SharePoint later is not picked up until you import it too. The Microsoft connection lasts for the browser session. To switch accounts, choose **Connect Cloud > Sign out** and sign in again. *** ## What's next? Process the imported documents and review the results Send documents from your own systems instead # Coding assistant Source: https://docs.anyformat.ai/guides/coding-assistant Let Claude Code build and run anyformat workflows from your editor. The **anyformat Claude Code skill** is a published npm package, `@anyformat/skill`. It teaches Claude, or any compatible AI coding agent, the anyformat workflow API. Once installed, ask Claude to build typed-graph workflows, run documents, and read structured results without leaving your editor. ## Install ```bash theme={null} npx @anyformat/skill # global: installs to ~/.claude/skills/anyformat npx @anyformat/skill --project # project-local: installs to ./.claude/skills/anyformat ``` Set your API key in the environment and restart Claude Code so it picks up the new skill: ```bash theme={null} export ANYFORMAT_API_KEY="af_..." ``` The next time you launch Claude Code, it has the skill loaded. ## What Claude can do once installed * Build **linear parse → extract** workflows for a specific document type. * Build **branched** workflows: classify-then-extract, splitter-then-extract. * Run a document through an existing workflow and **poll the run** until its status is terminal, with the right backoff. * Read structured output: per-field confidence, evidence, and the deprecated `extraction` against the new `extractions[]` array. * Use **agentic parse mode** for documents with mixed tables / figures. * Avoid the common gotchas: rate-limited submission tier, polling without backoff, `extra_forbidden` errors when an unknown node-config key sneaks in. ## Example prompts Concrete asks to paste into Claude Code: > **"Extract invoice number, total, and issue date from invoice.pdf using anyformat."** > **"Parse contract.pdf to markdown with agentic mode using anyformat, then save the markdown to contract.md."** > **"Build an anyformat workflow that classifies documents as invoice or receipt and extracts the right fields for each. Wire it up so I can pipe new files into it from the command line."** > **"Run every PDF in ./inbox through my anyformat workflow, wait for each run to finish, and write the extracted fields to results.csv."** Claude also has the TypeScript and Python [SDKs](/api-reference-v3/sdks), so it picks the shape that fits your project: a fluent builder in TypeScript, a fluent builder in Python, or raw HTTP through curl. ## How it works under the hood The skill is a single SKILL.md plus reference docs. They load into Claude Code's context when it detects an anyformat-related task. It drives the public workflow API directly, so anything Claude does with the skill you reproduce with plain HTTP calls or either SDK. To inspect or hack on the skill itself, the source lives at [`@anyformat/skill` on npm](https://www.npmjs.com/package/@anyformat/skill). ## What's next? Copy-paste examples Claude can drive: invoices, resumes, contracts, and more Every endpoint Claude calls, with request/response schemas The fluent layer Claude prefers for TS projects The fluent layer Claude prefers for Python projects # Connect your agent Source: https://docs.anyformat.ai/guides/connect-your-agent Give Claude Code, Codex, Cursor, OpenCode or any MCP host a set of document tools, in one config block per client anyformat speaks [MCP](https://modelcontextprotocol.io), so the agent you already use can parse documents, build workflows and read results without you writing any code. One endpoint, one key, one config block per client. | | | | ------------- | ----------------------------------------- | | **URL** | `https://api.anyformat.ai/mcp` | | **Transport** | Streamable HTTP | | **Auth** | `Authorization: Bearer ` | Mint a key at [app.anyformat.ai/api-key](https://app.anyformat.ai/api-key) and export it before you start. The same key works on the REST API. ```bash theme={null} export ANYFORMAT_API_KEY="af_..." ``` ## Pick your client One command, and `--scope user` makes it available in every project: ```bash theme={null} claude mcp add --transport http anyformat https://api.anyformat.ai/mcp \ --header "Authorization: Bearer $ANYFORMAT_API_KEY" --scope user ``` Or commit it to the project by writing `.mcp.json` in the repository root. Claude Code expands `${VAR}` from the environment, so the key stays out of the file: ```json theme={null} { "mcpServers": { "anyformat": { "type": "http", "url": "https://api.anyformat.ai/mcp", "headers": { "Authorization": "Bearer ${ANYFORMAT_API_KEY}" } } } } ``` Confirm with `claude mcp list`. Add the server from the command line: ```bash theme={null} codex mcp add anyformat --url https://api.anyformat.ai/mcp ``` Then put the header in `~/.codex/config.toml`. `env_http_headers` reads the value from the named environment variable, so the key is never written to the file: ```toml theme={null} [mcp_servers.anyformat] url = "https://api.anyformat.ai/mcp" env_http_headers = { "Authorization" = "ANYFORMAT_MCP_AUTH" } ``` ```bash theme={null} export ANYFORMAT_MCP_AUTH="Bearer $ANYFORMAT_API_KEY" ``` Confirm with `codex mcp list`, then restart Codex. A project-scoped `.codex/config.toml` works the same way in a trusted project. Add the server to `~/.cursor/mcp.json` for every project, or `.cursor/mcp.json` for one. Cursor expands `${env:VAR}`: ```json theme={null} { "mcpServers": { "anyformat": { "url": "https://api.anyformat.ai/mcp", "headers": { "Authorization": "Bearer ${env:ANYFORMAT_API_KEY}" } } } } ``` Add it to `opencode.json` as a remote server. OpenCode interpolates `{env:VAR}`: ```json theme={null} { "$schema": "https://opencode.ai/config.json", "mcp": { "anyformat": { "type": "remote", "url": "https://api.anyformat.ai/mcp", "enabled": true, "headers": { "Authorization": "Bearer {env:ANYFORMAT_API_KEY}" } } } } ``` A host that can only launch a local stdio process reaches the endpoint through a bridge. [`mcp-remote`](https://github.com/geelen/mcp-remote) is the Node one. Put the header value in an environment variable so the space in `Bearer ...` survives argument parsing: ```json theme={null} { "mcpServers": { "anyformat": { "command": "npx", "args": [ "mcp-remote", "https://api.anyformat.ai/mcp", "--header", "Authorization:${AUTH_HEADER}" ], "env": { "AUTH_HEADER": "Bearer af_..." } } } } ``` [`fastmcp-remote`](https://pypi.org/project/fastmcp-remote/) is the Python equivalent, run with `uvx`. It takes the header as one argument, so it needs no environment variable: ```bash theme={null} uvx fastmcp-remote https://api.anyformat.ai/mcp \ --header "Authorization: Bearer $ANYFORMAT_API_KEY" ``` Both bridges skip their own OAuth flow when you give them an `Authorization` header. For Claude Desktop the file is `claude_desktop_config.json`, under **Settings → Developer**. Any MCP host that lets you set a header on a streamable HTTP server will work. This handshake is the whole contract: ```bash theme={null} curl -X POST https://api.anyformat.ai/mcp \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' ``` The **Custom connectors** panel in Claude on the web, in Cowork and in Claude Desktop expects a remote server that speaks OAuth. anyformat authenticates with an API key header and has no OAuth flow today, so connect it from a client that can set a header, or through one of the stdio bridges above. ## Check it worked Ask the agent something only anyformat can answer: > **"List my anyformat workflows."** If the key is wrong you get an authentication error rather than an empty list. If the tools are missing entirely, the host has not reloaded its config: restart it. ## The first three things to ask > **"Parse invoice.pdf with anyformat and save the markdown to invoice.md."** > **"Build an anyformat workflow that pulls the invoice number, the total and the due date, then run every PDF in ./inbox through it and write the results to a CSV."** > **"Which of my anyformat runs failed yesterday, and why?"** The agent stages the local file, creates the workflow, runs the documents and polls until each run is terminal. [Agents over MCP](/guides/mcp) walks one real session call by call. ## What the agent can reach Sixteen tools, split by what your key is allowed to do. A key with `read` scope only never sees the tools that write: they are absent from the tool list rather than failing when called. | Group | Tools | | --------- | ------------------------------------------------------------------------------- | | Workflows | list, get, create, update, delete, list versions | | Documents | stage files, upload documents, run a packet, get a packet, list packets, delete | | Runs | get a run, list runs | | Reading | parse a document, ask the knowledge base | The [MCP server reference](/api-reference-v3/mcp) has the full table, the scopes and the error shapes. ## Not using an agent? The [Claude Code skill](/guides/coding-assistant) teaches Claude the workflow API without MCP, and the [SDKs](/api-reference-v3/sdks) do the same job from your own code. ## Next steps One real session, call by call, with every payload Endpoint, auth, scopes and every tool # Building a dataset Source: https://docs.anyformat.ai/guides/health/datasets Add documents, author ground truth, and slice with sub-datasets A **dataset** is the fixed set of documents an evaluation scores against. Each workflow has one, managed under **Health → Datasets**. This guide covers filling it, keeping its ground truth correct, and slicing it with tags. [Datasets & evaluations](/concepts/evals) defines the terms. *** ## Adding documents Two ways put documents in a dataset. Use the one that matches where your labelled data already lives. ### From your processed results Promote a document you've already run through the workflow: 1. Open its results and confirm every value is correct. **Validate all of its datapoints.** The correct values become the file's [ground truth](/concepts/evals#ground-truth). 2. Choose **Add to dataset**. This **duplicates** the file into the dataset as an independent entity. From that point on, the dataset copy and the production file are separate. Editing one never changes the other. A file can be added this way only once every datapoint is validated. Until then, **Add to dataset** stays disabled. You are establishing the correct answer, so it has to be complete. ### By direct upload If you already have labelled data, upload the documents and their expected answers straight into the dataset. You do not run them through the workflow first. 1. On the **Datasets** subtab, choose **Upload files**. 2. Drop your documents. To import expected values, include a **`.json` ground-truth file named after each document**. `invoice.pdf` pairs with `invoice.json`. 3. Optionally **tag** the uploaded files to slot them straight into a [sub-dataset](#sub-datasets-slicing-with-tags). 4. Review the staged list, then choose **Upload**. Ground truth is **optional** on upload. Add documents alone and fill their values in later in the dataset table. anyformat pairs each `.json` to its document by filename, and checks its values against the workflow's current fields. The review step catches problems before anything is uploaded. It flags any file with a **duplicate name**, with **invalid JSON**, or that is an **orphan** ground-truth file matching no document. Dataset names must be unique, and anyformat never renames a file silently. A freshly added file, copied or uploaded, has ground truth but **no score yet**. It reads as ungraded until its first [evaluation](/guides/health/evaluations) produces the prediction its ground truth is scored against. *** ## Editing ground truth Every evaluation measures against the dataset's ground truth. Keeping it correct matters more than anywhere else. Edit expected values directly in **Health → Datasets**. You do not go back to the original run. The **Ground truth** grid shows the expected values for each file. An evaluation compares the workflow's output to them. Editing ground truth changes **future** evaluations, not past ones. An evaluation you already ran keeps the score it recorded. Run a new evaluation to measure the effect of your edits. *** ## Sub-datasets: slicing with tags One overall accuracy number hides where a workflow struggles. **Tag** files to create **sub-datasets**, and read accuracy for each slice separately. Tag by whatever distinction matters for your documents: * **Provider**: bank A against bank B * **Document type**: invoices against receipts * **Difficulty**: a "hard cases" tag for the documents that trip the workflow up Read results **overall** or **per sub-dataset**, such as *95% overall but 60% on hard cases*, and target the slice that needs work. Files with no tag make up the **Untagged** slice. When you [run an evaluation](/guides/health/evaluations), the **scope** you pick is either the full dataset or a single sub-dataset. *** ## Removing documents Select files in the **Datasets** grid and choose **Remove** to take them out of the dataset. Removal affects the dataset only. The production file is untouched. Removal is **final**. Add the file to the dataset again and you get a **brand-new dataset entry** with a clean slate. It is not linked to the removed one, and it carries none of the earlier evaluation history or ground-truth edits. Run an evaluation over the new entry to measure it. *** ## What's next? Score a workflow version against your dataset. How accuracy and confidence work across anyformat. # Running evaluations Source: https://docs.anyformat.ai/guides/health/evaluations Score a workflow version against your dataset and compare accuracy over time An **evaluation** scores one workflow version against your [dataset](/guides/health/datasets). It runs an extraction on every in-scope document, compares each result to that document's ground truth, and records the run with an accuracy you compare against other runs. Evaluations live under **Health → Evaluations**. *** ## Running an evaluation Choose **Run eval** and set two things: * **Scope**: the **full dataset**, or a single **sub-dataset**, including **Untagged**. anyformat scores this set of documents. * **Workflow version**: the version you measure. The evaluation runs an extraction for each in-scope file and scores it against ground truth. If the version has fields the dataset has no ground truth for, the run still proceeds. Those fields are **not scored**, and a warning names them. Missing ground truth never blocks a run. Running an evaluation **runs extractions**, which consume credits. Narrow the scope to a sub-dataset to measure one slice. *** ## Reading the run list Each evaluation appears as a numbered run, `#1`, `#2`, and so on, in a table with: | Column | What it shows | | ------------ | --------------------------------------------------- | | **Run** | The run number, e.g. `#3` | | **Dataset** | The scope evaluated: full dataset or sub-dataset | | **Version** | The workflow version scored | | **Date** | When the run happened | | **Status** | Whether the run is in progress, complete, or failed | | **Accuracy** | Share of graded fields that matched ground truth | | **Files** | How many documents were in scope | Each run is pinned to a version and a scope, so the list doubles as an **accuracy-over-time** history. It tells you whether the workflow is improving or regressing. *** ## Inside a single evaluation Open a run to debug where accuracy comes from: * **Accuracy**, **Documents**, and **Fields** as headline numbers. **Documents** counts how many passed. A **vs previous run** delta shows the change. * **By file**: per-document accuracy, to find the documents dragging the number down. * **By field**: per-field accuracy, to find the fields that fail consistently. From there, inspect a document's **expected vs predicted** values side by side to see what the workflow got wrong. *** ## Comparing versions The evaluation workflow is built for iteration: 1. Run an evaluation on the current version. That is your baseline. 2. Refine the workflow's fields, instructions, and nodes, then save a **new version**. 3. Run another evaluation on the same scope. 4. Compare the two runs' accuracy. Editing ground truth or refining the workflow **never** changes a past evaluation. Each run is immutable. That is what makes the difference between two runs real signal rather than noise. Always run a fresh evaluation to measure a change. *** ## What's next? Add documents, edit ground truth, and tag sub-datasets. How accuracy and confidence work across anyformat. # Health Source: https://docs.anyformat.ai/guides/health/index The per-workflow home for measuring and improving extraction quality Every workflow has a **Health** tab. It is where you measure how well the workflow performs and drive that up over time. It gathers quality metrics, dataset management, and evaluations in one place, so you measure quality without leaving the workflow. **The short version:** the **Overview** shows how the workflow is doing right now. To measure quality deliberately, build a **dataset** of documents with known-correct answers and run **evaluations** against it. The dataset stays fixed, so two workflow versions compare cleanly. *** ## What's in Health Health at a glance: accuracy and confidence, accuracy over time, and your weakest fields. Manage the workflow's dataset: add documents, edit ground truth, and tag files into sub-datasets. Run evaluations, browse the run list, and compare accuracy across workflow versions. Automatic, iterative improvement of a workflow's field descriptions against its dataset. Datasets and evaluations score a workflow's **extraction** output, so a workflow needs an **Extract** node. Parse-only and split workflows are excluded. Both are **decoupled from production**. Dataset edits and evaluation runs never touch your production data. *** ## The Overview The **Overview** gives you a high-level read on workflow health: * Average **confidence** and average **accuracy** * Accuracy and confidence **over time** * The **weakest fields**, where errors concentrate [Analytics & Quality](/guides/workflows/analytics-quality) explains what each number means and how to use it. *** ## The quality loop Health is built around a simple, repeatable loop: Add representative documents and confirm their correct values as **ground truth**. See [Building a dataset](/guides/health/datasets). Run a workflow version over the dataset and read its accuracy. See [Running evaluations](/guides/health/evaluations). Improve the fields, the instructions, or the nodes, guided by where the workflow fails. Or let the [Optimizer](/guides/health/optimizer) rewrite the field descriptions for you. Run again on the same dataset. Because the dataset is fixed and each evaluation is immutable, the difference between two runs is real signal, not noise. *** ## What's next? Add documents and author ground truth. Score a version and compare across versions. # Optimizing a workflow Source: https://docs.anyformat.ai/guides/health/optimizer Automatic, iterative improvement of a workflow's field descriptions, measured against your dataset The **Optimizer** rewrites a workflow's **field descriptions**, the instructions the extraction model reads for every field. It keeps a change only when that change **measurably improves accuracy** against your [dataset](/guides/health/datasets). It automates the refine step of the [quality loop](/guides/health#the-quality-loop): propose a change, evaluate it, keep it only if it helps. The Optimizer lives under **Health → Optimizer**. *** ## What a run does The Optimizer evaluates the current version against the dataset. This is **iteration 0**. Everything after is judged against it. The Optimizer studies where the previous results went wrong and writes improved field descriptions. The Optimizer runs the proposed descriptions over the dataset, like a regular [evaluation](/guides/health/evaluations). An iteration is **accepted** only if its accuracy beats the best result so far. Propose → evaluate repeats up to the number of iterations you chose. When the run finishes, the best iteration is **promoted automatically as a new workflow version**. Each iteration is also recorded as a patch version, so the full trail stays visible in the workflow's version history. **"No improvement found" is a valid outcome.** Your current descriptions already perform as well as the proposals. The promoted version matches your baseline, and nothing changes. *** ## What it needs * **An Extract node.** The Optimizer improves extraction field descriptions, so parse-only and split workflows are excluded. * **A dataset with ground truth.** The run scores every proposal against your documents' known-correct values, so a dataset without ground truth cannot start one. See [Building a dataset](/guides/health/datasets). Every iteration **runs extractions over the whole dataset**, which consumes credits. A run costs roughly *dataset size × (iterations + 1)* extractions, baseline included. Start with a small dataset and few iterations. *** ## Starting a run Choose **Run optimization** and set: * **Scope**: the full dataset. * **Metric**: overall accuracy. * **Iterations**: how many propose-and-evaluate rounds to attempt, from **1 to 10**. The default is **3**. A workflow allows **one active optimization at a time**. A second start is rejected until the current run finishes. *** ## Following a run Runs appear in the Optimizer table. Status, accuracy, and rounds update live as the run progresses: | Column | What it shows | | ------------ | ------------------------------- | | **Run** | The run's start time | | **Status** | In progress, done, or failed | | **Accuracy** | Baseline → best accuracy so far | | **Rounds** | Iterations completed vs planned | | **Date** | When the run started | Open a run for the full picture: an **accuracy-per-iteration chart**, the **timeline of iterations**, and each iteration's **proposed description changes**. Open a single iteration to see its **per-field accuracy**, which fields improved, which still mismatch, and the exact description edits it tried. The per-field view doubles as a work list. A field that stays inaccurate after an optimization needs more dataset examples, or more varied ones. *** ## Versions and rolling back The promoted result **goes live automatically**. It becomes the workflow's latest version, with no separate review step. Every change is a normal version-history entry. If you disagree with a rewrite, restore an earlier version. While a run is active, each iteration is appended as the workflow's latest version. Documents processed **during** the run may use in-progress descriptions. Optimize at a quiet moment for the workflow. *** ## Asking annie [annie](/concepts/annie) drives the Optimizer conversationally, which helps when you are already in a chat about the workflow's results. *"improve this workflow"* starts a run. *"how did the optimization go?"* reports accuracy movement and the changes she kept. *"list the optimizations"* shows the history. *** ## Current limits * A running optimization **cannot be cancelled**. It runs to completion. Size your first runs small. * The Optimizer changes **extraction field descriptions** only. It never touches Classify, Split, or Validate nodes. * Scope and metric are fixed for now: **full dataset**, **overall accuracy**. *** ## What's next? Add documents and author the ground truth an optimization measures against. Score a version by hand and compare accuracy across versions. # Agents over MCP Source: https://docs.anyformat.ai/guides/mcp A real end-to-end session against the anyformat MCP server: stage a local file, build a workflow, run it, and read the results. Every payload comes from a live run. This guide walks one real session against the [MCP server](/api-reference-v3/mcp). An agent takes an invoice PDF that exists only on the local machine, builds a parse → extract → validate workflow, runs the document through it, and reads the structured result. Every request and response below comes from a live production run, with identifiers shortened. The [MCP server reference](/api-reference-v3/mcp) covers connection setup and the full tool table. This page is the flow. The whole session is five tool calls: 1. `stage_files` turns a local file into a reference the API accepts. 2. `create_workflow` builds the typed graph. 3. `upload_documents` puts the file into the workflow. 4. `run_document_packet` starts the extraction, retry-safe. 5. `get_run` waits for the result and reads it. ## 1. Stage a local file An MCP tool call carries JSON, not bytes, so no tool takes a file directly. `stage_files` closes the gap: it returns an upload form for the bytes, plus a short `object_id` to refer to them by afterwards. ```json theme={null} // stage_files { "body": { "files": [ { "filename": "single_invoice.pdf", "declared_size": 2087, "content_type": "application/pdf" } ] } } ``` The response carries one slot per file: ```json theme={null} { "slots": [ { "object_id": "06a953d9792572568000f838b051ca9b", "upload": { "url": "https://s3.eu-west-1.amazonaws.com/...", "fields": { "...": "..." } }, "read_url": "https://s3.eu-west-1.amazonaws.com/...?X-Amz-...", "read_url_expires_in_seconds": 900 } ] } ``` The agent POSTs the bytes itself as multipart/form-data: every `upload.fields` entry verbatim, then the file. ```bash theme={null} curl -F 'Content-Type=application/pdf' -F 'key=...' -F '...' \ -F file=@single_invoice.pdf https://s3.eu-west-1.amazonaws.com/... # → 204 ``` From here on the file is the pair `{ "object_id": "06a953d9…", "filename": "single_invoice.pdf" }`. Prefer the id over `read_url` everywhere inside the API. The id is a few characters where the presigned URL is kilobytes, and it does not expire mid-conversation. `read_url` exists for handing the bytes to something *outside* anyformat. Staged bytes are scratch. They belong to no workflow, and storage deletes them within a day. Stage, then consume promptly. A file that must live on goes into a workflow, in step 3. ## 2. Create the workflow `create_workflow` takes the same typed graph as [`POST /v3/workflows/`](/api-reference-v3/workflows/create): exactly one `parse` node, an `extract` node with the field schema, and here a `validate` node with two deterministic rules. A deterministic rule is a check that runs in code, instantly and for free. ```json theme={null} { "body": { "name": "Invoice extraction", "nodes": [ { "id": "parse", "type": "parse", "mode": "standard" }, { "id": "extract", "type": "extract", "mode": "standard", "extraction_schema": { "fields": [ { "name": "invoice_number", "data_type": "string", "description": "Invoice number as printed on the document." }, { "name": "invoice_date", "data_type": "date", "description": "Date the invoice was issued." }, { "name": "total_amount", "data_type": "float", "description": "Invoice total in the document's currency." }, { "name": "line_items", "data_type": "object", "description": "One row per invoice line item.", "nested_fields": [ { "name": "description", "data_type": "string", "description": "Line item description." }, { "name": "amount", "data_type": "float", "description": "Line item amount." } ] } ] } }, { "id": "validate", "type": "validate", "rules": [ { "id": "invoice-number-present", "kind": "deterministic", "check": { "type": "required", "field": "invoice_number" } }, { "id": "total-positive", "kind": "deterministic", "check": { "type": "comparison", "left": "total_amount", "op": ">", "right": { "source": "literal", "value": 0 } } } ] } ], "edges": [ { "source": "parse", "target": "extract" }, { "source": "extract", "target": "validate" } ] } } ``` The response is the stored workflow, and every field now carries a server-assigned `persistent_id`: ```json theme={null} { "id": "06a953dd-84bf-7267-8000-f043cef61fdd", "nodes": [ { "id": "extract", "type": "extract", "extraction_schema": { "fields": [ { "persistent_id": "66bf650f1cc84788a7ec1a4500054dff", "name": "invoice_number", "data_type": "string", "..." : "..." } ] } } ] } ``` Keep those ids. When you later edit the workflow, **echo each existing field's `persistent_id` unchanged, including across renames**. That edit loop is `get_workflow`, change the graph, `update_workflow`. The id is the field's identity. It keeps quality metrics, ground truth and analytics attached. A field sent without one is always treated as new, and silently detaches all of that. If the graph is invalid, the error names every broken rule. This is a real rejection of a graph with no parse node: ```json theme={null} { "error": "The workflow topology is not valid.", "error_code": "TOPOLOGY_INVALID", "status": 400, "retryable": false, "detail": { "violations": [ { "rule": "exactly_one_parse_node", "message": "workflow must contain exactly one `parse` node, found 0", "node_ids": [] }, { "rule": "every_non_parse_node_has_inbound_edge", "message": "node 'extract' (extract) has no inbound edge", "node_ids": ["extract"] } ] }, "request_id": "6a953e3a…" } ``` Every failed tool call has this envelope: branch on `error_code`, and quote `request_id` in a support request. ## 3. Upload the document `upload_documents` puts files into the workflow as one document packet. It accepts fetchable HTTPS `urls`, staged `staged_files`, or a mix. The staged file from step 1 goes in by id: ```json theme={null} { "workflow_id": "06a953dd-84bf-7267-8000-f043cef61fdd", "body": { "staged_files": [ { "object_id": "06a953d9792572568000f838b051ca9b", "filename": "single_invoice.pdf" } ] } } ``` ```json theme={null} { "document_packet_id": "06a953de-ab9d-738f-8000-2d176673ece4", "files": [ { "id": "06a953de-a873-…", "name": "single_invoice.pdf" } ] } ``` The import is atomic: any fetch failure means nothing is persisted. Uploading runs nothing. That separation makes the run step retry-safe. ## 4. Run it, retry-safe ```json theme={null} { "document_packet_id": "06a953de-ab9d-738f-8000-2d176673ece4", "idempotency_key": "invoice-demo-2026-08-31-run1" } ``` ```json theme={null} { "run_id": "06a953df-2425-72c5-8000-c2bf4abac999", "status": "queued" } ``` Every call **without** a key starts a new billed run. That is the re-run affordance after you edit the workflow. With a key, a retried call replays the original run instead of billing a second extraction. The replay reports the run's current status. Retry after the run finished and the answer is the same `run_id` with `status: "processed"`, telling you the result is already there. ## 5. Read the result `get_run` long-polls server-side, so one call waits instead of the agent polling. `parse_output: "none"` drops the parsed markdown from the response when only the extracted fields matter. On a real document the parse section is about 90% of the payload. ```json theme={null} { "run_id": "06a953df-2425-72c5-8000-c2bf4abac999", "wait_seconds": 60, "parse_output": "none" } ``` Fifty seconds later, the run is `processed` and `results.extractions[0]` carries every field with its confidence and the evidence it was read from: ```json theme={null} { "fields": { "invoice_number": { "value": "INV-2026-9001", "confidence": 99, "evidence": [ { "text": "Invoice #: INV-2026-9001 Issue date: 2026-02-20 …", "page_number": 1 } ] }, "total_amount": { "value": "4594.62", "confidence": 97, "evidence": [ { "text": "Subtotal: $4,176.93 Tax (10%): $417.69 **TOTAL: $4,594.62**", "page_number": 1 } ] }, "line_items": [ { "description": { "value": "Matrix Extensible Action-Items", "confidence": 98 }, "amount": { "value": "1974.96", "confidence": 98 } } ] }, "validations": [ { "rule_id": "invoice-number-present", "status": "pass", "detail": "invoice_number is present." }, { "rule_id": "total-positive", "status": "pass", "detail": "4594.62 > 0.0 is true.", "compared_values": { "total_amount": 4594.62, "0.0": 0 } } ] } ``` Every extracted `value` is a JSON **string** on the wire, whatever the field's declared type. The `float` field above answers `"4594.62"`. Parse numbers before comparing or summing them. A field's `confidence` is a number from 0 to 100, such as `87.5`, or `null` when none was produced. ## Editing the workflow later The edit loop is fetch, edit, replace. `get_workflow` returns the `{name, description, nodes, edges}` shape that `update_workflow` accepts. Renaming `total_amount` to `grand_total` while echoing its `persistent_id` keeps the field's identity, so its metrics and history follow the rename: ```json theme={null} { "persistent_id": "c5cae54a0b144673951c49f098e443a3", "name": "grand_total", "data_type": "float" } ``` Runs always execute the latest version. Old versions stay readable for comparison and audit. Call `list_workflow_versions`, then `get_workflow(workflow_id, version=...)`. ## Quick parse, and what it is not `parse_document` is the one-call shortcut when you need only a single document's markdown. Pass `{object_id, filename}`, or an HTTPS `url`, then call `get_run`. The markdown is at `results.parse.markdown`. The invoice above parsed in about 13 seconds. It is a quick one-off, not a production pipeline: * Every call starts a new billed run. There is no idempotency key on this path. * It runs against an auto-provisioned system parse workflow, not one you manage. * **Save the markdown yourself.** anyformat does not retain quick-parse output. Treat the run as ephemeral. Anything recurring, retry-safe, or worth keeping belongs in a workflow: `upload_documents` → `run_document_packet` → `get_run`, as above. ## Asking questions across documents A workflow with a [Knowledge node](/guides/nodes/knowledge) ingests parsed documents from completed runs into a navigable corpus. `ask_knowledge` then answers questions about the content, with citations resolved to a page and region of the source PDF. Mint a `thread_id` with the prefix `kb-` to keep follow-ups in context. Every question is billed and can take a few minutes. `KNOWLEDGE_NOT_READY` means no usable snapshot is available. Wait only while indexing is in progress. For documents processed before Knowledge was added, a member starts **Index existing documents** in the app's Knowledge tab. MCP has no indexing action. Four more tools work the same corpus without a model and without a charge: `list_knowledge` returns the tree, `search_knowledge` ranks documents for a query, `read_knowledge` returns one document's text, and `download_knowledge` hands over the whole corpus as an archive. [Give your agent a knowledge base](/examples/knowledge-base-over-mcp) walks one real session through all of them. ## Related * [MCP server reference](/api-reference-v3/mcp): endpoint, auth, client setup, the full tool table. * [Node schemas](/api-reference-v3/node-schemas): every node type and field the typed graph accepts. * [Coding assistant](/guides/coding-assistant): the same know-how as an installable skill package. # Classify Source: https://docs.anyformat.ai/guides/nodes/classify Labels the document as one of your categories and routes it down that category's branch. **Classify** reads the parsed document and picks one of the categories you define. Each category is an outgoing branch, so a mixed inbox of invoices and receipts can flow to an [Extract](/guides/nodes/extract) node built for each kind. It lives in the **Intelligence** section of the Studio palette. A Classify node needs at least one **category**: an id, a name and a description the model reads. Every edge leaving the node names the category it fires for. ## The node ```json API theme={null} { "id": "classify_1", "type": "classify", "categories": [ { "id": "INVOICE", "name": "Invoice", "description": "A vendor invoice requesting payment." }, { "id": "RECEIPT", "name": "Receipt", "description": "A point-of-sale receipt for a completed payment." } ] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import ClassifyCategory, Schema client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) invoice = ClassifyCategory(id="INVOICE", name="Invoice", description="A vendor invoice requesting payment.") receipt = ClassifyCategory(id="RECEIPT", name="Receipt", description="A point-of-sale receipt for a completed payment.") workflow = ( client.workflow("Invoice or Receipt") .parse() .classify(invoice, receipt) .extract([Schema.string("vendor", "Vendor name")], branch=invoice) .extract([Schema.string("merchant", "Merchant name")], branch=receipt) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const invoice = { id: "INVOICE", name: "Invoice", description: "A vendor invoice requesting payment." }; const receipt = { id: "RECEIPT", name: "Receipt", description: "A point-of-sale receipt for a completed payment." }; const workflow = await af .workflow("Invoice or Receipt") .parse() .classify([invoice, receipt]) .extract([Schema.string("vendor", "Vendor name")], { branch: invoice }) .extract([Schema.string("merchant", "Merchant name")], { branch: receipt }) .create(); ``` In the API, routing lives on the edges. An edge that leaves a Classify node must set `branch` to a category **id**, not its name: ```json theme={null} "edges": [ { "source": "parse_1", "target": "classify_1" }, { "source": "classify_1", "target": "extract_1", "branch": "INVOICE" }, { "source": "classify_1", "target": "extract_2", "branch": "RECEIPT" } ] ``` The SDKs write these edges for you. `branch` accepts the category object or its id string. Once a workflow has a Classify node, every `extract()` call needs a `branch`. The same node with extra instructions for the classifier: ```json API theme={null} { "id": "classify_1", "type": "classify", "user_prompt": "Classify by the document title. A credit note counts as an invoice.", "categories": [ { "id": "INVOICE", "name": "Invoice", "description": "A vendor invoice requesting payment." }, { "id": "RECEIPT", "name": "Receipt", "description": "A point-of-sale receipt for a completed payment." } ] } ``` ```python Python theme={null} # The Python SDK's classify() takes categories only. Set user_prompt in the API JSON or in Studio. ``` ```typescript TypeScript theme={null} // The TypeScript SDK's classify() takes categories only. Set user_prompt in the API JSON or in Studio. ``` ## In Studio Click the Classify node on the canvas to open its panel. Add a category with its **Type** (the name) and **Description**. The **Describe classification process** box is the `user_prompt`. Each category appears as a port on the node; drag from a port to the node that handles that category. Configuring the Classify node in Studio ## Options `categories` is required. `user_prompt` is optional. | Field | Type | Default | What it does | | -------------------------- | ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `categories` | array of category | required | At least one. Each category has `id`, `name` and `description`, all non-empty strings. | | `categories[].id` | string | required | Stable id. Edges route on it: an outgoing edge's `branch` must equal one category's `id`. | | `categories[].name` | string | required | The name shown to the model. Classification keys off it, so it must be unique within the node. | | `categories[].description` | string | required | What the category covers, written for the model. The more distinct the descriptions, the better the split between categories. | | `user_prompt` | string | `null` | Extra instructions inserted between the classifier's system prompt and the document text, for example a tie-break rule between two similar categories. | The schema the API accepts is generated from the same source: [Classify node schema](/api-reference-v3/node-schemas#classify). ## Connects to
Each edge leaving Classify names one category id. A category can point at a Split node instead, and Classify also feeds Knowledge.
Each category id appears on one edge at most. Several categories may point at the same Extract node. A workflow has one Classify node at most in the SDK builders. To split a file after classifying it, connect one category to a Split node. In the SDKs, `split(..., route_from=)` names that category. ## What it returns Classify fills the `classifications` section of the [run results](/concepts/runs-and-results), one verdict per Classify node that ran. The verdict names the category and says how sure the classifier was. ```json highlight={4,5} theme={null} { "classifications": [ { "category": "Invoice", "confidence": 100.0, "evidence": "The document is explicitly titled \"INVOICE\" and includes invoice number INV-2026-9001." } ] } ``` `category` is the category **name**, not its id. `confidence` is on a 0 to 100 scale, and `null` when no score was measured. `evidence` is one free-form string the classifier wrote, not a list of snippets; it is `null` when none was captured. The Extract node on the chosen branch fills `extractions[]` as usual: one entry, with `split_name` and `partition` set to `null`, because Classify routes the whole document and does not split it. See [Extract](/guides/nodes/extract#what-it-returns) for that shape, and [Runs & results](/concepts/runs-and-results#what-the-results-contain) for the whole envelope. ## Billing Billed at 10 credits per page. Full price list: [How credits work](/concepts/how-credits-work). ## Examples * [Receipt scanning](/examples/receipt-scanning): invoices and receipts in one inbox, one Extract per kind. * [Email lead extraction](/examples/email-lead-extraction): sort incoming mail before extracting. * [Bank statement processing](/examples/bank-statement-processing): Classify feeding a Split node. # Edit Source: https://docs.anyformat.ai/guides/nodes/edit Fills the blank form it was given and returns the completed PDF. Reads the fields off the document itself; you map nothing by hand. **Edit** fills in a form. It reads the fillable gaps off the document [Parse](/guides/nodes/parse) produced, writes your values into them, and returns a filled PDF you can download. You do not map fields by hand: the node detects them. It lives in the **Intelligence** section of the Studio palette. Edit is in beta. The values come from two places: free-text **instructions** on the node, and **reference documents** you upload to the workflow once. A reference is your standing data, for example a company profile CSV or an employee handbook PDF. Every run fills from it without you retyping anything. ## The node ```json API theme={null} { "id": "edit_1", "type": "edit", "instructions": "Name: ACME SL; Date: 2026-01-01; I accept the terms: yes", "reference_document_ids": ["069dcc2c-e14c-7606-8000-2ee4fb17b4e2"], "font": "sans", "output_mode": "flattened" } ``` ```python Python theme={null} import os from anyformat.sdk import Client client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Fill onboarding form") .parse() .edit( instructions="Name: ACME SL; Date: 2026-01-01; I accept the terms: yes", reference_document_ids=["069dcc2c-e14c-7606-8000-2ee4fb17b4e2"], ) .create() ) ``` ```typescript TypeScript theme={null} // The TypeScript builder has no edit() method yet. Send the node in the // workflow's `nodes` array with an edge from the Parse node: const nodes = [ { id: "parse_1", type: "parse" }, { id: "edit_1", type: "edit", instructions: "Name: ACME SL; Date: 2026-01-01; I accept the terms: yes", reference_document_ids: ["069dcc2c-e14c-7606-8000-2ee4fb17b4e2"], }, ]; const edges = [{ source: "parse_1", target: "edit_1" }]; ``` The Python builder sets `instructions` and `reference_document_ids`. Set `font` and `output_mode` through the API JSON. ### Reference documents Upload references to the workflow first, then put the returned ids in `reference_document_ids`: ```bash curl theme={null} curl -X POST https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/edit-references/ \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -F "files=@company-profile.csv" ``` ```python Python theme={null} references = client.upload_edit_references(workflow.id, ["company-profile.csv"]) reference_ids = [r.id for r in references] ``` ```json theme={null} { "documents": [ { "id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e2", "name": "company-profile.csv", "status": "ready", "truncated": false, "error": null } ] } ``` One upload takes 1 to 10 files: `.csv`, `.txt`, `.md`, `.rst` or `.pdf`. A text file comes back `ready` at once. A PDF comes back `pending` while anyformat parses it once, at upload, never on a run. Poll `GET /v3/workflows/{workflow_id}/edit-references/` (Python: `client.list_edit_references(workflow_id)`) until it settles. A run whose Edit node names a reference that is not `ready` is refused, so the form is never filled from a partial reference. `DELETE /v3/workflows/{workflow_id}/edit-references/{document_id}/` removes a reference; remove its id from the node in the same change. ## In Studio Click the Edit node on the canvas to open its panel. The panel has a **Fill instructions** text box, a font picker, an output mode picker, and a reference files section where you upload and remove reference documents. The Edit node panel in Studio ## Options Every field is optional. Omit a field and the default applies. | Field | Type | Default | What it does | | ------------------------ | --------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `instructions` | string | `null` | Free-text values to write into the detected fields, for example `Name: ACME SL; Date: 2026-01-01; I accept the terms: yes`. Where an instruction and a reference address the same field, the instruction wins. | | `reference_document_ids` | string\[] | `[]` | Ids of reference documents uploaded to this workflow. Every id must belong to this workflow and be `ready`, or the run is refused. | | `font` | `sans` \| `serif` \| `mono` | `sans` | Typeface the filled values are written in. | | `output_mode` | `flattened` \| `editable` | `flattened` | `flattened` paints the values on and the PDF is final. `editable` leaves each value in a live form field a reviewer can correct in any PDF viewer. | The schema the API accepts is generated from the same source: [Edit node schema](/api-reference-v3/node-schemas#edit). ## Connects to
Only Parse feeds Edit, and nothing follows it.
Edit reads geometry straight from the parsed document, so it sits directly after Parse. A Classify or Extract in between adds nothing it can use. The Python builder accepts one Edit node per workflow. ## What it returns Edit fills the `edits` section of the [run results](/concepts/runs-and-results): one entry per file, with every form field the node detected, the value written into it, and a link to the filled PDF. ```json highlight={6,9} theme={null} { "edits": [ { "file_name": "onboarding-form.pdf", "fields": [ { "form_field_id": "p1_f0", "label": "Full name", "state": "empty", "value": "ACME SL", "confidence": 95 } ], "unmatched_instructions": [], "download_url": "https://storage.anyformat.ai/filled/onboarding-form.pdf?X-Amz-Signature=…" } ] } ``` `download_url` is valid for 15 minutes from the moment the response was built. Re-read the run for a fresh link instead of storing it. A field with `state: "prefilled"` is never overwritten and keeps `value: null`. `confidence` grades how surely an instruction addresses the field, not whether the written value is correct. `unmatched_instructions` lists the instruction fragments that matched no field. ```json theme={null} { "edits": [ { "file_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e3", "file_name": "onboarding-form.pdf", "fields": [ { "form_field_id": "p1_f0", "label": "Full name", "kind": "text", "state": "empty", "page": 1, "bbox": { "x0": 0.1, "y0": 0.1, "x1": 0.5, "y1": 0.16 }, "value": "ACME SL", "confidence": 95 } ], "unmatched_instructions": [], "download_url": "https://storage.anyformat.ai/filled/onboarding-form.pdf?X-Amz-Signature=…" } ] } ``` The full field reference is on [Get run](/api-reference-v3/runs/get#filled-forms-edits). In the Python SDK, `run.wait().edits` holds the same entries. ## Billing **35 credits per page** of the form the node fills. A PDF reference costs **25 credits per page, once, when you upload it**, because that upload is a parse. Runs that use the reference afterwards pay nothing for it. Text references (`.csv`, `.txt`, `.md`, `.rst`) are free. Full price list: [How credits work](/concepts/how-credits-work). ## Examples * Fill a supplier onboarding form from your company profile: upload `company-profile.csv` as a reference, build `Parse → Edit` with its id, run the blank form through the workflow, and download `edits[0].download_url`. * Let a reviewer correct the result: set `output_mode: "editable"` so every value stays in a live form field. * [Parse-only workflow](/examples/parse-only-workflow): the Parse node that Edit reads from. # Email alert Source: https://docs.anyformat.ai/guides/nodes/email-alert Sends an email when a document reaches it. Coming soon: the tile is in the palette, but it cannot be added to a workflow yet. **Email alert** sends an email for every document that reaches it, the way [Slack alert](/guides/nodes/slack-alert) posts a message. It lives in the **Alerts** section of the Studio palette, marked **soon**: the tile is visible but disabled, and it cannot be dragged onto the canvas today. Use [Slack alert](/guides/nodes/slack-alert) for alerts now. This page describes what Email alert will configure, so you can plan a workflow around it. ## The node The public API does not accept an `email_alert` node today. A workflow that carries one is rejected. There is no API JSON, and neither SDK builder has a method for it. ## In Studio The tile sits under **Alerts**, next to Slack alert, with a **soon** badge and greyed out. Once enabled, clicking the node on the canvas opens a panel with two inputs: **Email Address**, the recipient, and **Message**, the body to send. The Operators palette, with Email alert marked soon ## Options Studio only. Nothing here is sent to the API today. | Field | Type | Default | What it does | | ------------- | ------------- | ------- | ---------------------------------- | | Email Address | email address | empty | The single recipient of the alert. | | Message | text | empty | The body of the email. | ## Connects to
If/Else Email alert soon
The shape once the node is enabled. Extract and Validate will feed it directly too, on either If/Else branch. Nothing follows it.
## What it returns An email to the recipient, and nothing in the run results. Like Slack alert, it is a terminal node: it fires after the node before it finishes for the document, and only on the branch the document took. ## Billing Free. Email alert runs no model and bills no credits. Full price list: [How credits work](/concepts/how-credits-work). ## Examples None yet. [Slack alert](/guides/nodes/slack-alert) shows the same shape with a channel instead of an inbox. # Extract Source: https://docs.anyformat.ai/guides/nodes/extract Pulls the fields you define out of the parsed document, each with a value, a confidence and the evidence it came from. **Extract** reads what [Parse](/guides/nodes/parse) produced and fills in a schema: the fields you name, typed, with a confidence score and the source text for each. It is the node most workflows exist for. It lives in the **Intelligence** section of the Studio palette. The one thing an Extract node needs is its **schema**: the list of fields to pull out. Everything else is optional. The **tier** (`mode`) sets how much work to spend per page. ## The node ```json API theme={null} { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "invoice_number", "description": "The unique invoice identifier", "data_type": "string" }, { "name": "total_amount", "description": "Total invoice amount", "data_type": "float" }, { "name": "issue_date", "description": "Date when the invoice was issued", "data_type": "date" } ] } } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Invoice Processing") .parse() .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.float("total_amount", "Total invoice amount"), Schema.date("issue_date", "Date when the invoice was issued"), ]) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af .workflow("Invoice Processing") .parse() .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.float("total_amount", "Total invoice amount"), Schema.date("issue_date", "Date when the invoice was issued"), ]) .create(); ``` Each field has a name, a description the model reads, and a type. The types (`string`, `float`, `date`, `enum`, `object` and the rest) are on [Field types](/concepts/field-types). How to write a schema that extracts well is on [Schemas](/concepts/schemas). The same node on the agentic tier, with a [Smart lookup](/guides/nodes/smart-lookup) field that resolves the supplier name against a reference CSV: ```json API theme={null} { "id": "extract_1", "type": "extract", "mode": "agentic", "extraction_schema": { "fields": [ { "name": "supplier_name", "description": "Supplier name as printed", "data_type": "string" }, { "name": "supplier_code", "description": "Internal supplier code", "data_type": "string", "source": "smart_lookup" } ] }, "lookup_file_uploads": [{ "filename": "suppliers.csv", "content": "" }], "lookup_suggestion": "Match supplier names ignoring legal suffixes like GmbH or Ltd" } ``` ```python Python theme={null} client.workflow("Invoices with supplier codes").parse().extract( [ Schema.string("supplier_name", "Supplier name as printed"), Schema.string("supplier_code", "Internal supplier code", source="smart_lookup"), ], lookup_files=["suppliers.csv"], lookup_suggestion="Match supplier names ignoring legal suffixes like GmbH or Ltd", ) ``` ```typescript TypeScript theme={null} af.workflow("Invoices with supplier codes").parse().extract( [ Schema.string("supplier_name", "Supplier name as printed"), Schema.string("supplier_code", "Internal supplier code", { source: "smart_lookup" }), ], { mode: "agentic", lookupFiles: ["suppliers.csv"], lookupSuggestion: "Match supplier names ignoring legal suffixes like GmbH or Ltd", }, ) ``` The SDKs read each path in `lookup_files` from disk and send it as `lookup_file_uploads`. The API call sends the file content inline, base64-encoded. The Python SDK's `extract()` does not take `mode`, `use_images` or `lookup_reasoning_effort` yet. Set them in the API JSON, in Studio, or from the TypeScript SDK. A workflow can hold several Extract nodes. After a [Classify](/guides/nodes/classify) or [Split](/guides/nodes/split) node, each Extract sits on one branch and gets its own schema. See those pages for the `branch` argument. ## In Studio Click the Extract node on the canvas to open its panel. The **Schema** tab holds the fields. The **Config** tab holds the tier cards, the **Use Images** switch, and the **Lookup files**, **Lookup suggestion** and **Lookup matching effort** controls. Configuring the Extract node in Studio The **Config** tab holds the tier, **Use Images**, the lookup files and matcher settings, and the per-field **Lookup** toggles. Smart lookup settings on the Extract node ## Tiers | Tier | `mode` | What it does | When to use it | Credits / page | | --------------- | ---------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------- | | **Fast** (Beta) | `lite` | Standard extraction on a faster, cheaper model. | Simple documents at volume. Quality may vary on complex layouts. | 17 | | **Standard** | `standard` | Single-pass extraction. The default. | Most documents and layouts. Start here. | 35 | | **Agentic** | `agentic` | Multi-step extraction that maps every table to the schema and reasons across sections. | Dense or cross-page tables, values spread over several sections. | 150 | Start on Standard. Move down to Fast where it holds up, and up to Agentic only where Standard misses rows or mixes up columns. ## Options `extraction_schema` is required. Every other field is optional. Omit a field and the default applies. | Field | Type | Default | What it does | | ------------------------- | ---------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `extraction_schema` | object | required | `{ "fields": [...] }`. At least one field. Each field carries `name`, `description`, `data_type`, and optionally `source` and `persistent_id`. See [Field types](/concepts/field-types). | | `mode` | `standard` \| `agentic` \| `lite` | `standard` | The tier. `lite` is shown as **Fast** in the app. | | `use_images` | boolean | `false` | Send each PDF page's rendered image alongside its parsed text, so the model can read layout the text missed. Adds vision cost on every page. Standard and Fast only; no effect on Agentic or on non-PDF sources. | | `lookup_file_uploads` | array of `{ filename, content }` | `[]` | Reference files for [Smart lookup](/guides/nodes/smart-lookup), sent inline with `content` base64-encoded. Each one is uploaded and its URI is appended to `lookup_files`. Create-input only: it is not stored on the node. | | `lookup_files` | array of string | `[]` | URIs of the reference files already stored on the node. Read this back from `GET`; write `lookup_file_uploads` to add files. | | `lookup_suggestion` | string | `null` | Free-form hint shown to the lookup matcher, for example "Match supplier names ignoring legal suffixes like GmbH or Ltd". Applies to every lookup field in the node. | | `lookup_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high` | `null` | How hard the lookup matcher works on noisy or inexact keys. `null` is the model default. Higher effort improves match reliability at higher latency and cost. Only used when the node has at least one lookup field. | A field's `source` decides where its value comes from: `extraction` (the document, the default), `smart_lookup` (the reference file, always) or `lookup_if_missing` (the document first, the reference file when extraction left it blank). The schema the API accepts is generated from the same source: [Extract node schema](/api-reference-v3/node-schemas#extract). ## Connects to
A Classify or a Split node can feed Extract in place of Parse.
An Extract node has one outgoing edge at most. Send it to [Validate](/guides/nodes/validate) to check the values, [If/Else](/guides/nodes/if-else) to branch on them, [Slack alert](/guides/nodes/slack-alert) to announce them, or [Knowledge](/guides/nodes/knowledge) to index the document. ## What it returns Extract fills the `extractions` section of the [run results](/concepts/runs-and-results). A workflow with one Extract and no Split returns one entry, and every field in it carries a value, a confidence and the evidence it came from. ```json highlight={7,8} theme={null} { "extractions": [ { "split_name": null, "partition": null, "fields": { "invoice_number": { "value": "INV-2024-0847", "confidence": 97.0, "evidence": [{ "text": "Invoice #INV-2024-0847", "page_number": 1 }] }, "total_amount": { "value": "4087.50", "confidence": 96.0, "evidence": [{ "text": "Total: $4,087.50", "page_number": 2 }] } } } ] } ``` `split_name` and `partition` stay `null` unless a [Split](/guides/nodes/split) node ran. `fields` is keyed by the field name you gave on the node. `value` is a string on the wire whatever the field's `data_type`, and `null` when nothing was found. `confidence` is on a 0 to 100 scale. `evidence` lists the source snippets and the page each came from. ```json theme={null} { "invoice_number": { "value": "INV-2024-0847", "value_override": null, "verification_status": "not_verified", "confidence": 97.0, "evidence": [{ "text": "Invoice #INV-2024-0847", "page_number": 1 }] } } ``` `value_override` holds a human correction from review, or `null`. `verification_status` is the review state of the value, and starts at `not_verified`. Both come from [Verification and review](/guides/workflows/verification-review). **Value types on the wire.** Every `value` is a string or `null`, so parse it by the field's `data_type` on your side: * A `boolean` field returns the string `"True"` or `"False"`. * A `multi_select` field returns one comma-separated string, for example `"a, b, c"`, not a list. * A field the model could not find returns `{ "value": null, "value_override": null, "verification_status": "not_verified", "confidence": null, "evidence": [] }`. * An `object` field returns a list of rows. Each row is a dict from nested field name to the same `{ value, value_override, verification_status, confidence, evidence }` shape. After a Split node, `extractions` holds one entry per split and partition. See [Split](/guides/nodes/split#what-it-returns). The whole run envelope is on [Runs & results](/concepts/runs-and-results#what-the-results-contain). ## Billing Billed per page at the tier's rate (table above). A node with at least one lookup field adds 75 credits per page for Smart Lookup. Schema size and field count do not change the price. Full price list: [How credits work](/concepts/how-credits-work). ## Examples * [Invoice processing](/examples/invoice-processing): the usual Parse to Extract shape, with line items as an object field. * [Resume parsing](/examples/resume-parsing): nested fields and multi-select. * [Contract analysis](/examples/contract-analysis): long documents, dates and enums. * [Bank statement processing](/examples/bank-statement-processing): Split, then one Extract per statement. # If/Else Source: https://docs.anyformat.ai/guides/nodes/if-else Tests a condition on the extracted data and sends the document down a True or a False branch. Free. **If/Else** evaluates one condition against the fields an [Extract](/guides/nodes/extract) node produced, and routes the document to one of two branches: **True** when the condition holds, **False** when it does not. It transforms nothing. It lives in the **Logic** section of the Studio palette and is in **Beta**. Use it to handle matching and non-matching documents differently: send only invoices over a threshold to a [Slack alert](/guides/nodes/slack-alert), or only documents that failed a [Validate](/guides/nodes/validate) rule. ## The node ```json API theme={null} { "id": "if_else_1", "type": "if_else", "condition": { "type": "comparison", "left": "total", "op": ">", "right": { "source": "literal", "value": 10000 } } } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import ( WorkflowDefinition, Edge, ParseNode, ExtractNode, ExtractionSchema, Schema, IfElseNode, ComparisonCheck, ) from anyformat.workflow.nodes import SlackAlertNode client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = client.create_workflow(WorkflowDefinition( name="Large invoices", nodes=[ ParseNode(id="parse_1", type="parse"), ExtractNode(id="extract_1", type="extract", extraction_schema=ExtractionSchema(fields=[ Schema.string("vendor_name", "Vendor name."), Schema.float("total", "Grand total."), ])), IfElseNode(id="if_else_1", type="if_else", condition=ComparisonCheck( type="comparison", left="total", op=">", right={"source": "literal", "value": 10000}, )), SlackAlertNode(id="slack_1", type="slack_alert", channel_id="C0123ABC", channel_name="#finance", message_template="Large invoice from ${field.vendor_name}: ${field.total}", severity="warning"), ], edges=[ Edge(source="parse_1", target="extract_1"), Edge(source="extract_1", target="if_else_1"), Edge(source="if_else_1", target="slack_1", branch="true"), ], )) ``` ```typescript TypeScript theme={null} // The TypeScript builder has no ifElse() method yet. // Send the API JSON on this page to POST /v3/workflows/, or pass the same // graph to af.updateWorkflow(workflowId, definition) on an existing workflow. ``` The Python builder (`client.workflow(...).parse().extract(...)`) has no `if_else()` verb. Build a `WorkflowDefinition` from the node classes instead, as above, and pass it to `create_workflow`. The TypeScript builder has no method either; send the JSON. The edge that leaves If/Else names its branch: `"branch": "true"` or `"branch": "false"`. ```json theme={null} { "source": "extract_1", "target": "if_else_1" }, { "source": "if_else_1", "target": "slack_1", "branch": "true" } ``` A condition names a field by the **name** you gave it on the Extract node. Studio names fields by their persistent id; both forms resolve at run time. ## In Studio Click the If/Else node on the canvas to open its panel. The **Conditions** list holds one card per condition; with two or more, the **And** / **Or** toggle at the top decides how they combine. Each card has a subject (a field value, field math, a validation outcome, or an expression) and the assertion that completes "Check that the field…"; click **Save condition** when done. The node has two output handles on the canvas, **true** and **false**. The If/Else node panel in Studio ## Options | Field | Type | Default | What it does | | ----------- | ------------ | -------- | -------------------------------------------------------------------------------------- | | `condition` | check object | required | The condition to evaluate. One check, or an `all_of` / `any_of` combinator of several. | The schema the API accepts is generated from the same source: [If/Else node schema](/api-reference-v3/node-schemas#if_else). ### Conditions A condition is a **check**: the same closed set the [Validate](/guides/nodes/validate#checks) node uses, plus one kind that only exists here. | Check | `type` | What it tests | | ----------------------- | ------------------- | ----------------------------------------------------------------------------------------- | | **Required** | `required` | The field is present and not empty. | | **Number in range** | `range` | A numeric field is within `min` and `max`. | | **Date in range** | `date` | A date field is within `earliest` and `latest`. | | **Arithmetic** | `arithmetic` | A sum, difference, or product of fields equals another field. | | **Comparison** | `comparison` | A field compares to a fixed value or another field. | | **One of** | `one_of` | The field's value is in an allowed set. | | **Pattern** | `regex` | The field matches a regular expression. | | **Confidence** | `confidence` | The field's extraction confidence compares to a threshold. | | **Expression** | `expression` | A CEL expression over the whole extraction. | | **Validation outcome** | `validation` | An upstream Validate rule ended with `status` (`fail` by default). | | **All of** / **Any of** | `all_of` / `any_of` | Every / at least one nested check holds. This is what the **And** / **Or** toggle writes. | The wire shape of each check is on the [Validate](/guides/nodes/validate#check-payloads) page. The `validation` check has its own: ```jsonc theme={null} // Route on the outcome of the upstream rule "totals-add-up". status: "fail" | "pass" | "inconclusive" { "type": "validation", "rule_id": "totals-add-up", "status": "fail" } // Two conditions combined with And { "type": "all_of", "checks": [ { "type": "validation", "rule_id": "totals-add-up", "status": "fail" }, { "type": "range", "field": "total", "min": 1000, "max": null } ] } ``` A `validation` condition needs a [Validate](/guides/nodes/validate) node upstream that carries a rule with that `rule_id`. A rule id with no verdict for the document evaluates to inconclusive. **When a condition can't be evaluated, the document takes the False branch.** If a field the condition refers to is missing, or a confidence check has no confidence score, the condition counts as *not met*, so the document goes down **False**, never **True**. This keeps a True-only branch (like an alert) from firing on data anyformat couldn't actually check. To handle those documents on their own path instead, use a separate, preliminary If/Else node that checks whether the field is **required**, and place the value condition on its True branch. ## Connects to
A Validate node can feed If/Else in place of Extract. The false branch is optional. Email alert joins the targets once it ships.
An [Extract](/guides/nodes/extract) cannot sit after an If/Else today. The API rejects the edge (`an extract node cannot sit downstream of an if_else`) and Studio does not offer it. A second extraction pass on a branch is planned, not available. ## What it returns Nothing in the run results. If/Else adds no section and changes no field. It decides which downstream nodes run for the document: * The condition holds: the nodes on the **true** branch run. * The condition fails, or cannot be evaluated: the nodes on the **false** branch run. The **true** branch must have at least one node; the API rejects an If/Else with no `"branch": "true"` edge. The **false** branch may be left empty, and the document then stops there. On a workflow with a Split node, the condition is evaluated once per sub-document. ## Billing Free. If/Else runs no model and bills no credits. Full price list: [How credits work](/concepts/how-credits-work). ## Examples * [Invoice processing](/examples/invoice-processing): add the If/Else on this page after the Extract to flag invoices over 10,000. * [Contract analysis](/examples/contract-analysis): route contracts whose expiry date is on or before today to an alert with a `date` condition (`"latest": "today"`). # Knowledge Source: https://docs.anyformat.ai/guides/nodes/knowledge Indexes documents from completed workflow runs into a knowledge base you can browse, search, and ask questions of, with answers cited back to the page. **Knowledge** turns a workflow's parsed documents into a searchable knowledge base. Completed runs made while the node exists add their parsed documents to a per-workflow corpus. You browse and search the corpus in the app, and you ask questions in the app or through the API. Every answer cites the page and region it came from. Knowledge appears in the **Intelligence** section of the Studio palette when the feature is enabled for your organization. Knowledge is in alpha. The node has no options. Its presence opts the workflow into knowledge ingestion. ## The node ```json API theme={null} { "id": "knowledge_1", "type": "knowledge" } ``` ```python Python theme={null} import os from anyformat.sdk import Client, WorkflowDefinition from anyformat.workflow.definition import Edge from anyformat.workflow.nodes import KnowledgeNode, ParseNode client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = client.create_workflow( WorkflowDefinition( name="Contract library", nodes=[ ParseNode(id="parse_1", type="parse"), KnowledgeNode(id="knowledge_1", type="knowledge"), ], edges=[Edge(source="parse_1", target="knowledge_1")], ) ) ``` ```typescript TypeScript theme={null} // The TypeScript builder has no knowledge() method yet. Send the node in the // workflow's `nodes` array with an edge from the Parse node: const nodes = [ { id: "parse_1", type: "parse" }, { id: "knowledge_1", type: "knowledge" }, ]; const edges = [{ source: "parse_1", target: "knowledge_1" }]; ``` The Python builder (`client.workflow(...)`) has no `knowledge()` method, so the example builds the graph as a `WorkflowDefinition`. The API requires an inbound edge into the node, but the edge selects no input: indexing reads every parsed file in the completed run. ### Index existing documents Adding the node does not automatically index documents from runs that finished before the node existed. In the workflow's **Knowledge** tab, a member can choose **Index existing documents** for the first corpus, or **Retry indexing** after a failed attempt. While the action runs, the tab shows **Indexing documents**. Once a corpus exists, it never needs a manual update: new or changed documents are re-indexed in the background automatically, and the existing corpus keeps serving Ask and Browse throughout. The action reuses processed documents. It does not re-extract them. It is a maintenance action in the app, not an API or MCP workflow. ### Ask a question Once a usable index exists, ask the corpus through `POST /v3/workflows/{workflow_id}/knowledge/ask`: ```bash curl theme={null} curl -X POST "https://api.anyformat.ai/v3/workflows/$WORKFLOW_ID/knowledge/ask" \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"question": "Which contracts renew before March and what notice do they need?"}' ``` ```python Python theme={null} answer = workflow.ask("Which contracts renew before March and what notice do they need?") print(answer) for citation in answer.citations: print(f" {citation.path} p.{citation.page}: {citation.quote}") follow_up = workflow.ask("And which of those are with Meridian?", thread_id="kb-renewals") ``` Pass a `thread_id` that starts with `kb-` to ask a follow-up in the context of earlier questions in that thread. Omit it and each question stands alone. The full request and response, with the error codes, is on [Ask](/api-reference-v3/knowledge/ask). An AI agent asks the same question through the `ask_knowledge` tool of the [MCP server](/api-reference-v3/mcp). ## In Studio Drag Knowledge from the palette and it appears as a band around the whole graph, not as a card, because its scope is the whole workflow. Its panel has nothing to configure; it explains what the node does and points you to the workflow's **Knowledge** tab, where you browse the indexed documents and ask questions. The Knowledge base band around the graph ## Options The node has no fields. `id` and `type` are all it carries. The schema the API accepts is generated from the same source: [Knowledge node schema](/api-reference-v3/node-schemas#knowledge). ## Connects to
Classify, Extract and Validate can feed it instead. Nothing follows it.
Knowledge accepts one edge from Parse, Classify, Extract or Validate. It is terminal: nothing can follow it. Which accepted node feeds it makes no difference because indexing reads the completed run. A workflow has at most one Knowledge node; the API rejects a second one. ## What it returns Nothing in the [run results](/concepts/runs-and-results). Knowledge emits nothing downstream and adds no section to the run. It writes to the workflow's knowledge base instead. Completed runs ingest parsed files while Knowledge is present. The corpus keeps the current parsed content for each live file. The corpus is workflow-scoped, not version-scoped. It can contain parses from older workflow versions, but it is not a version history. You read the corpus in two places: * The **Knowledge** tab in the app: browse the documents, search them, and ask questions. * [`POST /v3/workflows/{workflow_id}/knowledge/ask`](/api-reference-v3/knowledge/ask): an answer plus citations, each resolved to a `file_id`, a `page` and a `bbox` in the source PDF. Without a Knowledge node the endpoint returns `409 KNOWLEDGE_NOT_ENABLED`. `409 KNOWLEDGE_NOT_READY` means no usable snapshot is available yet. ## Billing * **Indexing: 2 credits per page** whose source content is new or changed in a completed run. Re-running unchanged documents costs nothing. Backfill, repair and an unchanged reindex are maintenance and do not spend knowledge-index credits. * **Asking: 8 credits per 10,000 effective input tokens** the answering agent reads. A narrow question over a small corpus costs a fraction of a broad sweep over a large one. A question the organization cannot pay for is refused with `402` before it runs. Browsing and searching in the app are free. Full price list: [How credits work](/concepts/how-credits-work). ## Examples * [Contract analysis](/examples/contract-analysis): a contract workflow whose documents have lasting value. Add Knowledge and "which suppliers have a 90-day termination clause?" becomes answerable with citations. * [Invoice processing](/examples/invoice-processing): the usual Parse to Extract shape. Add Knowledge after Extract to keep every invoice searchable. # Nodes Source: https://docs.anyformat.ai/guides/nodes/overview The building blocks of every workflow: what each node does, what it returns, and what it costs. A [workflow](/concepts/workflows) is a graph of **nodes**. Each node does one job on the document: read it, pull fields out of it, sort it, split it, check it, fill it in, or tell someone about it. You connect nodes on the [Studio](/concepts/studio) canvas, or send the same graph to the API as `nodes` and `edges`. Most workflows are two nodes: **Parse** then **Extract**. The rest exist for documents that are more complicated, or for when you want something other than data back. ## The palette The Studio palette groups nodes the same way this table does. | Section | Node | What it does | Returns | Credits | | ------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------- | ------------------------ | | Intelligence | [Parse](/guides/nodes/parse) | Reads the document into text, tables and layout. Always first. | `parse` | 7 to 100 / page by tier | | Intelligence | [Extract](/guides/nodes/extract) | Pulls out the fields you define, with confidence and evidence. | `extractions[]` | 17 to 150 / page by tier | | Intelligence | [Classify](/guides/nodes/classify) | Labels the document as one of your categories and routes it. | `classifications[]` | 10 / page | | Intelligence | [Split](/guides/nodes/split) | Breaks one file that holds several documents into pieces. | `splits[]` | 25 / page | | Intelligence | [Knowledge](/guides/nodes/knowledge) Alpha | Indexes every parsed document into a searchable, askable corpus. | knowledge base | 2 / page indexed | | Intelligence | [Edit](/guides/nodes/edit) Beta | Fills the blank form it was given and returns the completed PDF. | `edits[]` | 35 / page | | Logic | [Validate](/guides/nodes/validate) | Checks extracted values against rules and flags failures. | validation results | 5 / AI rule | | Logic | [If/Else](/guides/nodes/if-else) Beta | Routes the document down a True or a False branch on its data. | branch | free | | Alerts | [Slack alert](/guides/nodes/slack-alert) Beta | Posts a message to a Slack channel when a document reaches it. | message | free | | Alerts | [Email alert](/guides/nodes/email-alert) Soon | Will send an email when a document reaches it. Shown in the palette, not yet enabled. | message | free | Knowledge appears in the palette once the feature is enabled for your organization. ## Three shapes you will build **Read only.** Parse alone. You get the document as markup, for search, your own model, or an archive. **Read, then pull out.** The default: "read this document and give me these fields". **Sort first, then pull out.** Classify decides the kind of document, then sends it to an Extract tailored to that kind. Split works the same way for files that hold several documents.
Each edge leaving Classify names one category id.
Add [Validate](/guides/nodes/validate) after an Extract to check the values, [If/Else](/guides/nodes/if-else) to branch on them, and an [alert](/guides/nodes/slack-alert) at the end of a branch to tell someone. ## Every node page reads the same way 1. What the node does and where it sits in the palette. 2. The node as code: API JSON, then the Python and TypeScript SDK call. 3. The Studio panel. 4. Every option, its type, default and what it does. 5. What can feed it and what it can feed. 6. What it costs. 7. Examples that use it. Start with [Parse](/guides/nodes/parse). # Parse Source: https://docs.anyformat.ai/guides/nodes/parse Reads the document and turns it into text, tables and layout that every other node works from. Every workflow starts with one. **Parse** reads a file (PDF, image, DOCX, XLSX, CSV and more) and turns it into structured text with layout: pages, blocks, tables, reading order, and a confidence per block. Every workflow starts with exactly one Parse node, and every other node reads what Parse produced. It lives in the **Intelligence** section of the Studio palette. Most workflows never configure it. The one choice that matters is the **tier** (`mode`): how much work to spend per page, from reading the PDF's own text layer to a multi-step pass over dense tables. ## The node ```json API theme={null} { "id": "parse_1", "type": "parse" } ``` ```python Python theme={null} import os from anyformat.sdk import Client client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Read only") .parse() # mode="standard" by default .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af .workflow("Read only") .parse() // mode: "standard" by default .create(); ``` A Parse node alone is a complete workflow: it returns the document as markdown and nothing else. See [Parse-only workflow](/examples/parse-only-workflow). The same node, on the agentic tier with the highest effort: ```json API theme={null} { "id": "parse_1", "type": "parse", "mode": "agentic", "effort": "accurate" } ``` ```python Python theme={null} client.workflow("Dense tables").parse(mode="agentic", effort="accurate") ``` ```typescript TypeScript theme={null} af.workflow("Dense tables").parse({ mode: "agentic", effort: "accurate" }) ``` ## In Studio Click the Parse node on the canvas to open its panel. The four tiers are the cards at the top; the advanced options sit below them. Configuring the Parse node in Studio ## Tiers | Tier | `mode` | What it does | When to use it | Credits / page | | ------------------ | ---------- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------- | | **Flash** | `flash` | Reads the PDF's own text layer plus layout grounding. No model call. A page without a text layer is OCR'd (see `scanned_pages`). | Born-digital PDFs, high volume, lowest cost. | 7 | | **Fast** | `lite` | One OCR pass on the strongest engine. No LLM correction. | Clean scans and simple layouts where OCR alone is enough. | 12 | | **Standard** | `standard` | Page-by-page parsing with reading-order correction. The default. | Most documents and tables. Start here. | 25 | | **Agentic** (Beta) | `agentic` | Adaptive, multi-step parsing that works hardest on dense tables (50+ rows, many columns). | Complex layouts where Standard output is not good enough. | 100 | Start on Standard. Move a workflow down to Fast or Flash for the document types where it holds up, and up to Agentic only where Standard underperforms. You rarely need Agentic everywhere. ## Options Every field is optional. Omit a field and the default applies. Fields that belong to another tier are accepted and ignored. | Field | Type | Default | Applies to | What it does | | -------------------- | -------------------------------------------- | ---------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mode` | `standard` \| `agentic` \| `lite` \| `flash` | `standard` | all | The tier. `lite` is shown as **Fast** in the app. | | `prompt_hint` | string | `null` | standard, agentic, flash | Free-form hint that biases the parse output, for example "the second column is a date". Ignored on Fast. | | `figure_enhancement` | boolean | `false` | standard | Extract structured data from charts and images. Extra processing per figure. | | `cache` | boolean | `true` | all | Reuse the parsed output when the same file was already parsed with the same tier and settings. A hit skips the whole parse and its cost. Set `false` to force a fresh parse. | | `effort` | `low` \| `mid` \| `accurate` | `mid` | agentic | Quality and cost preset. `low` is several times cheaper but makes more errors on dense or low-contrast tables; `accurate` is the highest fidelity. | | `ocr_effort` | `medium` \| `high` | `high` | lite | Retired. Accepted for compatibility and ignored; Fast always runs the strongest OCR. Do not set it. | | `scanned_pages` | `ocr` \| `skip` \| `fail` | `ocr` | flash | What to do with a page whose text layer has no words. `ocr` parses it like any other page; `skip` serves it blank and flags it; `fail` raises an error naming the page numbers. | The schema the API accepts is generated from the same source: [Parse node schema](/api-reference-v3/node-schemas#parse). ## Connects to
Parse also feeds Split, Edit and Knowledge.
Nothing feeds Parse. It is always the first node. A workflow has exactly one Parse node, and the app and the API both reject a second one. ## What it returns Parse fills the `parse` section of the [run results](/concepts/runs-and-results). Two keys carry the document: `markdown` holds it as one markup string, and `blocks` holds the same content block by block. ```json highlight={3,6} theme={null} { "parse": { "markdown": "…", "parse_confidence": 90.6, "layout_confidence": 0.7, "blocks": [ { "id": "p1_b1", "type": "text", "page": 1, "content": "Invoice #: INV-2026-9001" } ] } } ``` The `markdown` value is elided above, because it is the whole document and runs to thousands of lines. In it, each block opens with an `` anchor and every table is an HTML `
` element, so the text stays quotable and a citation can point at one block. `parse_confidence` is on a 0 to 100 scale; `layout_confidence` is on a 0 to 1 scale. Each entry in `blocks` repeats both confidences and adds the block's `id`, `type`, `page` and `content`. ```json theme={null} { "parse": { "markdown": "…", "text": "…", "parse_confidence": 90.6, "layout_confidence": 0.7, "blocks": [ { "id": "p1_b1", "type": "text", "page": 1, "bbox": { "x0": 0.114, "y0": 0.132, "x1": 0.322, "y1": 0.210 }, "parse_confidence": 96.5, "layout_confidence": 0.96, "content": "Invoice #: INV-2026-9001 \nIssue date: 2026-02-20", "hyperlinks": [], "rows": null, "image_base64": null } ] } } ``` `text` is the same document without the anchors or the table markup. `bbox` is in fractions of the page size. Every other section of the envelope is on [Runs & results](/concepts/runs-and-results#what-the-results-contain). See [Outputs](/concepts/outputs) for the CSV, JSON and Excel exports built on top of the parse. ## Billing Billed per page at the tier's rate (table above). A `cache` hit costs nothing. Full price list: [How credits work](/concepts/how-credits-work). ## Examples * [Parse-only workflow](/examples/parse-only-workflow): the document as markup, on each tier. * [POST /v3/parse/](/api-reference-v3/parse/parse): one document on the Fast tier in one call, with no workflow to create. * [Agentic parse to markdown](/examples/agentic-parse-to-markdown): dense tables on the agentic tier. * [Invoice processing](/examples/invoice-processing): the usual Parse to Extract shape. # Slack alert Source: https://docs.anyformat.ai/guides/nodes/slack-alert Posts a message to a Slack channel when a document reaches it, with extracted values and rule verdicts filled in. Free. **Slack alert** posts a message to a Slack channel for every document that reaches it. The message is a template: you write the text once, and placeholders fill in with the document's extracted values and its [Validate](/guides/nodes/validate) verdicts. It lives in the **Alerts** section of the Studio palette and is in **Beta**. It is a terminal node. Put it at the end of a branch, usually the **true** branch of an [If/Else](/guides/nodes/if-else), so it fires only on the documents you want to hear about. It needs the Slack connector. Connect your workspace once per organization: [Slack integration](/integrations/slack). ## The node ```json API theme={null} { "id": "slack_1", "type": "slack_alert", "channel_id": "C0123ABC", "channel_name": "#finance", "message_template": "Invoice from ${field.vendor_name}: ${field.total} ${field.currency}. Totals check: ${validation.totals-add-up.status}", "severity": "warning" } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import ( WorkflowDefinition, Edge, ParseNode, ExtractNode, ExtractionSchema, Schema, ValidateNode, ValidationRule, ArithmeticCheck, ) from anyformat.workflow.nodes import SlackAlertNode client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = client.create_workflow(WorkflowDefinition( name="Invoices to Slack", nodes=[ ParseNode(id="parse_1", type="parse"), ExtractNode(id="extract_1", type="extract", extraction_schema=ExtractionSchema(fields=[ Schema.string("vendor_name", "Vendor name."), Schema.float("subtotal", "Subtotal before tax."), Schema.float("tax", "Tax amount."), Schema.float("total", "Grand total."), Schema.string("currency", "ISO currency code."), ])), ValidateNode(id="validate_1", type="validate", rules=[ ValidationRule(id="totals-add-up", kind="deterministic", check=ArithmeticCheck(type="arithmetic", operands=["subtotal", "tax"], equals="total", tolerance=0.01)), ]), SlackAlertNode(id="slack_1", type="slack_alert", channel_id="C0123ABC", channel_name="#finance", message_template="Invoice from ${field.vendor_name}: ${field.total} ${field.currency}. " "Totals check: ${validation.totals-add-up.status}", severity="warning"), ], edges=[ Edge(source="parse_1", target="extract_1"), Edge(source="extract_1", target="validate_1"), Edge(source="validate_1", target="slack_1"), ], )) ``` ```typescript TypeScript theme={null} // The TypeScript builder has no slackAlert() method yet. // Send the API JSON on this page to POST /v3/workflows/, or pass the same // graph to af.updateWorkflow(workflowId, definition) on an existing workflow. ``` The Python builder (`client.workflow(...).parse().extract(...)`) has no `slack_alert()` verb. Build a `WorkflowDefinition` from the node classes instead, as above, and pass it to `create_workflow`. The TypeScript builder has no method either; send the JSON. `channel_id` is the Slack channel id (`C…`), which is stable across renames. Get it from the Studio channel picker, or from Slack: open the channel details, and the id is at the bottom. `channel_name` is the display name shown in the app and in logs; it is not re-resolved on every send. ## In Studio Click the Slack alert node on the canvas to open its panel. It has a **Slack Channel** picker that lists your public channels, a **Message** editor where `/` or the field picker inserts an extracted field as a chip, a **Severity** choice (Info, Warning, Critical), and a **Preview** of the rendered message with a **Send test** button that posts it to the chosen channel. If nobody has connected Slack for your organization yet, the panel shows a **Connect Slack** prompt instead. That is what a workspace sees until the [connector](/integrations/slack) is set up: The Slack alert panel before Slack is connected ## Options | Field | Type | Default | What it does | | ------------------ | --------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `channel_id` | string | required | Slack channel id, for example `C0123ABC`. anyformat joins the channel the first time it posts there. Public channels only. | | `channel_name` | string | required | Display name of the channel at save time, for example `#finance`. Shown in the app; never used to route. | | `message_template` | string | required | The message body. Plain text with the placeholders below. | | `severity` | `info` \| `warning` \| `critical` | `info` | Colour of the bar on the Slack card: blue, amber, red. Presentation only. It changes neither routing nor whether the alert fires; gate that with an [If/Else](/guides/nodes/if-else). | The schema the API accepts is generated from the same source: [Slack alert node schema](/api-reference-v3/node-schemas#slack_alert). ### Placeholders | Placeholder | Renders as | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `${field.}` | The extracted value of that field. Name the field as you did on the Extract node; Studio writes the field's persistent id instead, and both resolve. | | `${validation..status}` | The verdict of the upstream Validate rule: `pass`, `fail`, or `inconclusive`. | | `${validation..detail}` | The rule's one-line explanation. | | `${validation..severity}` | The rule's severity: `error` or `warning`. | A `validation` placeholder needs a [Validate](/guides/nodes/validate) node upstream that carries the rule. A placeholder that resolves to nothing, because the field was renamed, deleted, or not extracted for this document, or the rule did not run, renders as `` in the delivered message. Nothing is dropped silently. ## Connects to
Either If/Else branch works. Extract and Validate can feed it directly instead. Nothing follows it.
Parse, Classify, and Split cannot feed it: they carry no extracted fields for the template to fill. The API rejects those edges. ## What it returns A message in the channel, and nothing in the run results. Each message carries: * The rendered template, with the severity colour on the card. * An **Open document** button that opens the file in Studio at the workflow version the alert fired against. The alert fires after the node before it finishes for the document. Behind an If/Else, it fires only when the document took that branch. Deliveries are capped per organization at **60 per minute** and **500 per hour**; alerts over the cap are dropped for that window, not queued. Details and troubleshooting: [Slack integration](/integrations/slack#how-alerts-are-delivered). ## Billing Free. Slack alert runs no model and bills no credits. Test messages from Studio are free too. Full price list: [How credits work](/concepts/how-credits-work). ## Examples * [Invoice processing](/examples/invoice-processing): post every invoice over 10,000 to `#finance` with the [If/Else](/guides/nodes/if-else) on that page in front of this node. * [Contract analysis](/examples/contract-analysis): alert legal when a contract's key clause fails an AI validation rule, with `${validation..detail}` in the message. # Smart lookup Source: https://docs.anyformat.ai/guides/nodes/smart-lookup Resolves a field against a reference file you upload instead of reading it off the page, so a name on the document becomes the code your systems use. **Smart lookup** is a capability of the [Extract](/guides/nodes/extract) node, not a node of its own. A field with `"source": "smart_lookup"` or `"source": "lookup_if_missing"` is resolved by matching the document against a reference file (a CSV) you attach to the Extract node, instead of being read off the page. In the Studio palette it appears as part of Extract, under the **Intelligence** section; there is no separate chip. Use it when a value the document carries, a product name, a supplier name, a city, has to become an identifier defined outside the document: a SKU, a supplier code, a location ID. Extraction reads the raw value; the lookup returns the matching row's value from your file. **Example:** an invoice names the product "Blue Widget 500ml". Your master file maps product names to SKUs. The lookup matches "Blue Widget 500ml" to its row and returns `SKU-10234`. To check a value against a fixed list of options, use a [select field](/concepts/field-types) instead. Smart lookup is for a list that lives in an external file and returns a different value than the one being matched. ## The node An Extract node with one extracted field and one looked-up field, plus the reference file and a hint for the matcher: ```json API theme={null} { "id": "extract_1", "type": "extract", "extraction_schema": { "fields": [ { "name": "vendor_name", "description": "Vendor as printed on the document", "data_type": "string" }, { "name": "vendor_id", "description": "Canonical vendor code from the catalog, joined on vendor_name", "data_type": "string", "source": "smart_lookup" } ] }, "lookup_file_uploads": [ { "filename": "vendors.csv", "content": "" } ], "lookup_suggestion": "Match vendor_name against the vendor_name column ignoring legal suffixes like GmbH or Ltd; return vendor_id." } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Invoices with vendor codes") .parse() .extract( [ Schema.string("vendor_name", "Vendor as printed on the document"), Schema.string("vendor_id", "Canonical vendor code from the catalog, joined on vendor_name", source="smart_lookup"), ], lookup_files=["vendors.csv"], lookup_suggestion="Match vendor_name against the vendor_name column ignoring legal suffixes like GmbH or Ltd; return vendor_id.", ) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af .workflow("Invoices with vendor codes") .parse() .extract( [ Schema.string("vendor_name", "Vendor as printed on the document"), Schema.string("vendor_id", "Canonical vendor code from the catalog, joined on vendor_name", { source: "smart_lookup" }), ], { lookupFiles: ["vendors.csv"], lookupSuggestion: "Match vendor_name against the vendor_name column ignoring legal suffixes like GmbH or Ltd; return vendor_id.", }, ) .create(); ``` The looked-up field lives in `extraction_schema.fields` with the other fields; there is no separate list. The SDKs read each path in `lookup_files` from disk and send it as `lookup_file_uploads`. The API call sends the file content inline, base64-encoded. An Extract node with a lookup field but no reference file is rejected when the workflow is saved. Matching is done by the model. There is no step where you pick the match column and the output column. The matcher works from the field's name and description, plus the optional `lookup_suggestion`, so it can join on more than one signal at once, for example name **and** city when name alone is not unique. The extracted field and the looked-up field stay separate. A lookup does not rewrite the extraction schema; it marks one field as resolved from the reference file instead of the document, so you can always tell which value came from where. ## In Studio Reference files and matcher settings belong to the Extract node and are shared by every lookup field in it. Whether a given field uses them is set per field. Open the Extract node's **Config** tab and add one or more CSV files under **Lookup files**. Every lookup field in this node can draw on all of them. Open the field in the node's **Schema** tab and switch **Lookup** on. Choose **Always look up** or **Only if not extracted**. See `source` in the options table below. The **Lookup suggestion** box and the **Lookup matching effort** selector sit under the lookup files on the **Config** tab. Smart lookup settings on the Extract node ## Options Node-level options sit on the Extract node. The per-field switch is the field's `source`. | Field | Type | Default | What it does | | ------------------------- | ----------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lookup_file_uploads` | array of `{ filename, content }` | `[]` | Reference CSV files sent inline, `content` base64-encoded. Each one is uploaded and its URI is appended to `lookup_files`. Create-input only: it is not stored on the node. | | `lookup_files` | array of string | `[]` | URIs of the reference files already stored on the node. Read it back from `GET`; write `lookup_file_uploads` to add files. | | `lookup_suggestion` | string | `null` | Free-form hint shown to the matcher, for matching rules the data alone does not show: "Match supplier names ignoring legal suffixes like GmbH or Ltd". Applies to every lookup field in the node. | | `lookup_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high` | `null` | How hard the matcher works on noisy or inexact keys: typos, formatting differences, partial names. `null` is the model default. Higher effort improves match reliability at higher latency and cost. Shown as **Lookup matching effort** in Studio. | | `fields[].source` | `extraction` \| `smart_lookup` \| `lookup_if_missing` | `extraction` | Where the field's value comes from. See below. | `source` is the per-field switch. Its three values are the three positions of the **Lookup** control in Studio: | `source` | In Studio | What happens | | ------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `extraction` | Lookup off | The field returns only what extraction finds in the document. The default. | | `smart_lookup` | **Always look up** | The field is resolved entirely from the reference file. Extraction never reads it from the document. | | `lookup_if_missing` | **Only if not extracted** | Extraction runs first. The lookup fills the field only where extraction left it blank. A value extraction found is kept even when the lookup finds no match. | Start with the default matching effort. Raise it only when you see missed matches on messy source data. The schema the API accepts is generated from the same source: [Extract node schema](/api-reference-v3/node-schemas#extract). ## Connects to
Smart lookup runs inside the Extract node, so it has no edges of its own.
See [Extract](/guides/nodes/extract#connects-to) for what feeds the node and what it feeds. ## What it returns A lookup field comes back in `extractions[].fields` like any other field, keyed by its name, with the same `value`, `confidence` and `evidence` shape. There is no separate section for lookups. ```json highlight={6} theme={null} { "extractions": [ { "fields": { "vendor_name": { "value": "ACME Widgets GmbH", "confidence": 97.0, "evidence": [{ "text": "ACME Widgets GmbH", "page_number": 1 }] }, "vendor_id": { "value": "V-10234", "confidence": null, "evidence": [] } } } ] } ``` `vendor_name` was read off the page. `vendor_id` was resolved from the reference file, so its `confidence` is `null` and its `evidence` is empty: the lookup reports no confidence, and there is no page the value was read from. Both sit in the same `fields` object, keyed by the name you gave each one. * When a row matches, the field carries the matched row's value, subject to the lookup mode. * When no row matches, a `smart_lookup` field returns `null`; there is nothing to fall back on. A `lookup_if_missing` field keeps whatever extraction found and is `null` only when extraction found nothing either. The lookup never returns a partial or guessed match. In the app, lookup fields are marked apart from extraction fields so you can tell which values came from the document and which from your reference file. See [Extract](/guides/nodes/extract#what-it-returns) for every key on a field, and [Runs & results](/concepts/runs-and-results#what-the-results-contain) for the whole envelope. ## Billing 75 credits per page of the document, on top of the Extract tier, when the Extract node has at least one lookup field. The number of lookup fields and the number of rows matched do not change the price. Full price list: [How credits work](/concepts/how-credits-work). ## Current scope Supported today: * CSV reference files, several per Extract node. * Any number of lookup fields per node, each resolved on its own. * Matching on more than one field at once when that is what tells rows apart. * Per-field lookup mode, plus node-level matching effort and an optional hint. Not supported today: * XLS, XLSX and other binary spreadsheets. Convert them to CSV first. * Multi-step joins or transformations across more than one reference file. * Editing a lookup result after a run. If the reference file was wrong or incomplete, replace it and run again. ## FAQ An **Always look up** field returns `null`. An **Only if not extracted** field keeps its extracted value when it has one; only a field extraction also left blank ends up `null`. Nothing is guessed or partially filled in. Yes. Replace the CSV under the Extract node's **Lookup files**, or send a new `lookup_file_uploads` on update. Later runs use the new file. Yes. Files are uploaded once per Extract node, and every lookup field in that node can draw on all of them. No. The extracted fields are unchanged. The lookup marks one field as resolved from the reference file instead of the document; it does not merge into another field. ## Examples * [Invoice processing](/examples/invoice-processing): the usual Parse to Extract shape, where a vendor lookup slots in. # Split Source: https://docs.anyformat.ai/guides/nodes/split Breaks one file that holds several documents into pieces, and extracts each piece on its own. **Split** takes a file that holds more than one document, a scanned batch of invoices or a bundle of statements, and cuts it into pieces by page. Each piece is a document of one of your **rules**, and each rule is an outgoing branch to its own [Extract](/guides/nodes/extract) node. It lives in the **Intelligence** section of the Studio palette. A Split node needs at least one rule: an id, a name and a description the model reads. A rule can also carry a **partition key**, the field that separates one document from the next within the same rule. ## The node ```json API theme={null} { "id": "split_1", "type": "splitter", "rules": [ { "id": "STATEMENT", "name": "Statement", "description": "A bank account statement.", "partition_key": "account_number" }, { "id": "CHECK", "name": "Check", "description": "A scanned check." } ] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema, SplitterRule client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) statement = SplitterRule( id="STATEMENT", name="Statement", description="A bank account statement.", partition_key="account_number" ) check = SplitterRule(id="CHECK", name="Check", description="A scanned check.") workflow = ( client.workflow("Statement batch") .parse() .split(statement, check) .extract([Schema.float("closing_balance", "Closing balance")], branch=statement) .extract([Schema.float("amount", "Check amount")], branch=check) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const statement = { id: "STATEMENT", name: "Statement", description: "A bank account statement.", partition_key: "account_number" }; const check = { id: "CHECK", name: "Check", description: "A scanned check." }; const workflow = await af .workflow("Statement batch") .parse() .split([statement, check]) .extract([Schema.float("closing_balance", "Closing balance")], { branch: statement }) .extract([Schema.float("amount", "Check amount")], { branch: check }) .create(); ``` The node's `type` on the wire is `splitter`. In the API, routing lives on the edges. An edge that leaves a Split node must set `branch` to a rule **id**, not its name, and each rule needs its own target node: ```json theme={null} "edges": [ { "source": "parse_1", "target": "split_1" }, { "source": "split_1", "target": "extract_1", "branch": "STATEMENT" }, { "source": "split_1", "target": "extract_2", "branch": "CHECK" } ] ``` The SDKs write these edges for you. `branch` accepts the rule object or its id string. Once a workflow has a Split node, every `extract()` call needs a `branch`. The same node after a [Classify](/guides/nodes/classify) node. Only the documents in one category reach the Split node: ```json API theme={null} "edges": [ { "source": "parse_1", "target": "classify_1" }, { "source": "classify_1", "target": "split_1", "branch": "BATCH" }, { "source": "split_1", "target": "extract_1", "branch": "STATEMENT" } ] ``` ```python Python theme={null} client.workflow("Sorted batches").parse().classify(batch, single).split(statement, route_from=batch) ``` ```typescript TypeScript theme={null} af.workflow("Sorted batches").parse().classify([batch, single]).split([statement], { routeFrom: batch }) ``` ## In Studio Click the Split node on the canvas to open its panel. Add a rule with its **Type** (the name), **Description** and optional **Partition key**. Each rule appears as a port on the node; drag from a port to the Extract node that handles that rule. Configuring the Split node in Studio ## Options `rules` is required. `partition_key` is optional on each rule. | Field | Type | Default | What it does | | ----------------------- | ------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `rules` | array of rule | required | At least one. Each rule has `id`, `name` and `description`, all non-empty strings, and an optional `partition_key`. | | `rules[].id` | string | required | Stable id. Edges route on it: an outgoing edge's `branch` must equal one rule's `id`. | | `rules[].name` | string | required | The name shown to the model. It is also the `name` of the split and the `split_name` of its extractions in the results. | | `rules[].description` | string | required | What pages of this kind look like, written for the model. | | `rules[].partition_key` | string | `""` | The field that separates one document from the next within this rule. Each distinct value becomes its own split, extracted independently. Empty means the whole rule flows on as one document. At most 255 characters in Studio. | **Partition keys.** Without one, every page that matches a rule flows on as one document: a batch of ten statements from ten accounts reaches Extract as a single statement. Set `partition_key` to the name of the value that tells the documents apart, for example `account_number` or `invoice_number`. The model reads that value on each page. Pages that share one value form one split, and each split is extracted on its own. Ten accounts become ten extractions, each with its own `partition` value in the results. The key is a hint to the model, not a reference to the Extract schema; it does not have to match a field name downstream. The schema the API accepts is generated from the same source: [Splitter node schema](/api-reference-v3/node-schemas#splitter). ## Connects to
A Classify node can feed Split in place of Parse. Split feeds Extract and nothing else.
Each outgoing edge carries one rule id, and each rule routes to its own Extract node. Two rules cannot share a target. A workflow has one Split node at most in the SDK builders. ## What it returns Split fills the `splits` section of the [run results](/concepts/runs-and-results), one entry per rule. The entry names the rule and lists the pages that fell under it, and a rule with a partition key adds one partition per distinct value. ```json highlight={4,7} theme={null} { "splits": [ { "name": "Statement", "confidence": 91, "files": [{ "file_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e3", "file_name": "batch.pdf", "pages": [1, 2, 3, 4] }], "partitions": [ { "name": "1234-5678", "confidence": 94, "files": [{ "file_name": "batch.pdf", "pages": [1, 2] }] } ] } ] } ``` `name` is the rule's name. Page numbers are 1-indexed. `confidence` is on a 0 to 100 scale: a partition reports the minimum across its page ranges, and a rule the minimum across its partitions. It is `null` when no page matched. A rule's `files` is the union of its partitions, so the second partition of this rule is elided above. ```json theme={null} { "splits": [ { "name": "Statement", "confidence": 91, "files": [ { "file_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e3", "file_name": "batch.pdf", "pages": [1, 2, 3, 4] } ], "partitions": [ { "name": "1234-5678", "confidence": 94, "files": [{ "file_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e3", "file_name": "batch.pdf", "pages": [1, 2] }] }, { "name": "9876-5432", "confidence": 91, "files": [{ "file_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e3", "file_name": "batch.pdf", "pages": [3, 4] }] } ] } ] } ``` The extracted data lives in `extractions[]`, one entry per split and partition. Join on `split_name` (the rule name) and `partition` (the partition value, `null` when the rule has no partition key): ```json highlight={3,4} theme={null} { "extractions": [ { "split_name": "Statement", "partition": "1234-5678", "fields": { "closing_balance": { "value": "1520.40", "confidence": 96.0 } } }, { "split_name": "Statement", "partition": "9876-5432", "fields": { "closing_balance": { "value": "88.00", "confidence": 93.0 } } } ] } ``` Each field has the shape described on [Extract](/guides/nodes/extract#what-it-returns). The whole run envelope is on [Runs & results](/concepts/runs-and-results#what-the-results-contain). ## Billing Billed at 25 credits per page. Full price list: [How credits work](/concepts/how-credits-work). ## Examples * [Bank statement processing](/examples/bank-statement-processing): one file, many statements, a partition key per account. * [Invoice processing](/examples/invoice-processing): a scanned batch of invoices split by invoice number. # Validate Source: https://docs.anyformat.ai/guides/nodes/validate Checks the values an Extract node produced against your rules and records a verdict per rule. Deterministic checks are free; AI rules cost 5 credits each. **Validate** runs a list of rules over the fields an [Extract](/guides/nodes/extract) node produced and records a verdict for each rule: **pass**, **fail**, or **inconclusive**. It lives in the **Logic** section of the Studio palette. Every rule is one of two kinds, and one Validate node can mix both: | Rule kind | How it is checked | Best for | Cost | | ----------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | **Deterministic** | A structured check runs in code. No model. | Exact, testable conditions: a number in range, `a + b = c`, a value in a set, a pattern, a required field, a confidence threshold. | Free | | **AI** | A model reads your plain-language description and judges the data. | Fuzzy or semantic conditions: "the vendor looks like a real company", "the two names refer to the same person". | 5 credits per rule | Use a **deterministic** rule whenever a calculator or a lookup could settle the condition. It is instant, free, and never flakes. Use an **AI** rule when the condition needs judgement or language understanding. ## The node ```json API theme={null} { "id": "validate_1", "type": "validate", "rules": [ { "id": "totals-add-up", "kind": "deterministic", "severity": "error", "check": { "type": "arithmetic", "operands": ["subtotal", "tax"], "operator": "sum", "equals": "total", "tolerance": 0.01 } }, { "id": "vendor-legit", "kind": "ai", "severity": "warning", "description": "The vendor is a real, named company." } ] } ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema, ValidationRule, ArithmeticCheck client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Invoice with checks") .parse() .extract([ Schema.float("subtotal", "Subtotal before tax."), Schema.float("tax", "Tax amount."), Schema.float("total", "Grand total."), Schema.string("vendor_name", "Vendor name."), ]) .validate( ValidationRule(id="totals-add-up", kind="deterministic", severity="error", check=ArithmeticCheck(type="arithmetic", operands=["subtotal", "tax"], equals="total", tolerance=0.01)), ValidationRule(id="vendor-legit", description="The vendor is a real, named company."), ) .create() ) ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); const workflow = await af .workflow("Invoice with checks") .parse() .extract([ Schema.float("subtotal", "Subtotal before tax."), Schema.float("tax", "Tax amount."), Schema.float("total", "Grand total."), Schema.string("vendor_name", "Vendor name."), ]) .validate([ { id: "totals-add-up", kind: "deterministic", severity: "error", check: { type: "arithmetic", operands: ["subtotal", "tax"], operator: "sum", equals: "total", tolerance: 0.01 } }, { id: "vendor-legit", kind: "ai", description: "The vendor is a real, named company." }, ]) .create(); ``` `validate()` attaches the node to the last `extract()` you called. Pass `branch=` (Python) or `{ branch }` (TypeScript) to attach it to the Extract on one Classify category or Split rule instead. A check names a field by the **name** you gave it on the Extract node. Studio names fields by their persistent id instead; both forms resolve at run time. An **expression** check is the exception: it reads names only. ## In Studio Click the Validate node on the canvas to open its panel. Each rule is a card. Pick **AI validation** or **Deterministic validation** at the top of the card: an AI rule takes a sentence, a deterministic rule takes a subject (a field value, field math, or an expression) and the assertion that completes "Check that the field…". Set the severity and click **Save rule**. Configuring the Validate node in Studio ## Options | Field | Type | Default | What it does | | ------- | ------------- | ---------------------- | ---------------------------------------------------------------------------------------------- | | `rules` | list of rules | required, at least one | The rules to evaluate. Every rule runs on every extraction; a failed rule never stops the run. | Each rule: | Field | Type | Default | What it does | | --------------- | ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | required | Stable rule id. It names the rule in results, in an [If/Else](/guides/nodes/if-else) `validation` condition, and in a [Slack alert](/guides/nodes/slack-alert) placeholder. | | `name` | string | `null` | Human-readable name shown in the app. | | `kind` | `ai` \| `deterministic` | `ai` | How the rule is checked. | | `description` | string | `null` | The plain-language rule a model judges. Required when `kind` is `ai`; must be absent otherwise. | | `check` | check object | `null` | The structured check (table below). Required when `kind` is `deterministic`; must be absent otherwise. | | `severity` | `error` \| `warning` | `error` | How a failed rule is labelled in results. Display only: it never blocks the run. | | `source_fields` | list of strings | `[]` | Persistent ids of the fields the rule reads. Studio fills it in; the API does not need it. | The schema the API accepts is generated from the same source: [Validate node schema](/api-reference-v3/node-schemas#validate). ### Checks Every check kind below is deterministic and free. Two are combinators that nest other checks. | Check | `type` | What it asserts | Example | | ------------------- | ------------ | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------- | | **Required** | `required` | The field is present and not empty. | `vendor_name` is filled in | | **Number in range** | `range` | A numeric field falls within `min` and `max` (inclusive; either may be `null`). | `amount` between `0` and `1000000` | | **Date in range** | `date` | A date field falls within `earliest` and `latest`: each an ISO date, the literal `today`, or `null`. | `expiry_date` on or after `today` (not expired) | | **Arithmetic** | `arithmetic` | A `sum`, `subtract`, or `product` of fields equals another field, within `tolerance`. | `subtotal + tax = total` (±0.01) | | **Comparison** | `comparison` | A field compares (`==`, `!=`, `>`, `>=`, `<`, `<=`) to a fixed value or another field. | `end_date >= start_date` | | **One of** | `one_of` | The field's value is in an allowed set. `case_sensitive` defaults to `false`. | `currency` is one of `EUR`, `USD`, `GBP` | | **Pattern** | `regex` | The field matches a regular expression (RE2 syntax, linear time). | `iban` matches `^[A-Z]{2}\d{2}` | | **Confidence** | `confidence` | The field's extraction confidence (0 to 100) compares to `threshold`. | `total` has confidence `>= 80` | | **Expression** | `expression` | A [CEL](https://cel.dev/) expression over the whole extraction answers true or false. | the line items add up to the total | | **All of** | `all_of` | Every nested check passes. | required **and** in range | | **Any of** | `any_of` | At least one nested check passes. | matches pattern A **or** pattern B | Each check resolves to **pass**, **fail**, or **inconclusive**. A check is inconclusive when the field it names is missing, or cannot be read as the expected type. Numbers are read leniently: `"€1.234,56"` and `"1,234.56"` both parse. Dates accept the common written formats. `all_of` fails if any child fails, otherwise it is inconclusive if any child is inconclusive, otherwise it passes. `any_of` passes if any child passes, otherwise it is inconclusive if any child is inconclusive, otherwise it fails. A combinator holds up to 32 checks. Studio nests one level; the API accepts deeper nesting. A rule's check cannot contain a `validation` check (the outcome of another rule). The API rejects it. To act on a rule's verdict, route on it with an [If/Else](/guides/nodes/if-else) node instead. ### Check payloads ```jsonc theme={null} { "type": "required", "field": "vendor_name" } { "type": "range", "field": "amount", "min": 0, "max": null } // Date bounds: an ISO date "YYYY-MM-DD", "today" (relative), or null. { "type": "date", "field": "expiry_date", "earliest": "today", "latest": null } // not expired { "type": "date", "field": "invoice_date", "earliest": "2024-01-01", "latest": "2024-12-31" } { "type": "arithmetic", "operands": ["subtotal", "tax"], "operator": "sum", "equals": "total", "tolerance": 0.01 } { "type": "comparison", "left": "end_date", "op": ">=", "right": { "source": "field", "field": "start_date" } } { "type": "comparison", "left": "year", "op": ">=", "right": { "source": "literal", "value": 2000 } } { "type": "one_of", "field": "currency", "allowed": ["EUR", "USD", "GBP"], "case_sensitive": false } { "type": "regex", "field": "iban", "pattern": "^[A-Z]{2}\\d{2}" } { "type": "confidence", "field": "total", "op": ">=", "threshold": 80 } { "type": "expression", "expression": "sum(data.lines.map(l, num(l.amount))) == num(data.total)" } { "type": "all_of", "checks": [ { "type": "required", "field": "iban" }, { "type": "regex", "field": "iban", "pattern": "^[A-Z]{2}\\d{2}" } ] } { "type": "any_of", "checks": [ { "type": "one_of", "field": "currency", "allowed": ["EUR"] }, { "type": "range", "field": "total", "min": null, "max": 10000 } ] } ``` `operator` defaults to `sum`; `tolerance` to `0`; `case_sensitive` to `false`. Unset bounds are `null`. `threshold` is an integer from 0 to 100. ### Expressions An **expression** check runs a [CEL](https://cel.dev/) expression over the whole extraction. Use it for a condition the other checks cannot name: a total over a list of line items, a per-row comparison, a conditional. ``` abs(sum(data.lineas.map(l, l.importe)) - num(data.ibruto)) <= 1.0 ``` That is the rule this check was built for: the line items of an invoice must add up to the gross amount, within a tolerance of 1.0. The names are the ones the schema defines, in whatever language it uses. * The expression reads one root variable, `data`, which holds the whole extraction. * Fields use their **extracted name**: `data.ibruto`, `data.lineas`. An expression names no persistent id, so **renaming a field in the schema breaks every expression that names it**. The rule then reports **inconclusive**. * The [CEL builtins](https://github.com/google/cel-spec/blob/master/doc/langdef.md) all work: `map`, `filter`, `all`, `exists`, `size`, `has`, `startsWith`. * Three helpers come on top of them, because CEL coerces no number and folds no list, and an extracted value is frequently a string such as `"1.234,56"`: * `num(v)` reads one value as a number, with the same lenient parse the other checks use. * `sum(list)` adds a list, and reads each element the same way. Bare `+` does not: on two strings it joins them end to end. * `abs(x)`. * `has(data.x)` asks whether the key is there, not whether it holds a value. A field that was extracted as null answers `true`. A rule that means "was extracted" reads `data.x != null`. * Every evaluation error resolves to **inconclusive**: an expression that does not parse, that names a field the extraction does not carry, that divides by zero, or that answers with something other than a boolean. An expression never fails the run. ## Connects to
One Validate node reads exactly one Extract. Validate passes the extraction through unchanged, so a node after it sees the same fields the Extract produced. ## What it returns One verdict per rule per extraction. The run results carry them as `extractions[].validations[]`, on the entry whose fields the rules judged, so in a split workflow each piece carries its own. The key is always present: `[]` when the workflow has no Validate node. | Field | Meaning | | ----------------- | --------------------------------------------------------------------------------------- | | `rule_id` | The rule's `id`. | | `description` | The rule's description, as shown in the app. Empty for a deterministic rule. | | `severity` | `error` or `warning`, copied from the rule. | | `status` | `pass`, `fail`, or `inconclusive`. | | `detail` | A sentence that says why: the values compared, or the model's reasoning for an AI rule. | | `compared_values` | The values the check read. | | `source_fields` | The `persistent_id`s of the fields the rule read. | In the app, the verdicts appear in the **Validation** tab of the results, next to the extracted data. See [Verification and review](/guides/workflows/verification-review). A failed rule marks the document; it never stops the run or hides the extraction. Downstream nodes read the verdicts too. An [If/Else](/guides/nodes/if-else) node routes on a rule's outcome with a `validation` condition, and a [Slack alert](/guides/nodes/slack-alert) prints it with `${validation..status}`. On the public API the verdicts sit in each `extractions[]` entry of [`GET /v3/runs/{run_id}/`](/api-reference-v3/runs/get). The Python SDK exposes them as `result.validations` (per partition, `result.partitions[i].validations`); the TypeScript SDK as `extraction.validations` on each entry of `result.extractions`. ## Billing An **AI** rule costs **5 credits**, billed once per rule per extraction, not per page. A **deterministic** rule, including an expression, costs **0 credits**. A three-rule Validate with one AI rule costs 5 credits whether the document is 1 page or 100. Full price list: [How credits work](/concepts/how-credits-work). ## Examples * [Invoice processing](/examples/invoice-processing): the Extract the rules on this page check. Add the `totals-add-up` and `vendor-legit` rules after it. * [Bank statement processing](/examples/bank-statement-processing): an `expression` check that sums the transactions against the closing balance. # Quickstart Source: https://docs.anyformat.ai/guides/quickstart Build a workflow in the app and extract data from a document. No code. Build an invoice extractor at [app.anyformat.ai](https://app.anyformat.ai) and read the results. Nothing to install. **Working in code?** The [API quickstart](/guides/quickstart-api) builds the same workflow with curl, TypeScript, or Python. Even then, build your **first** workflow here. Fields are faster to iterate on visually. Once it works, copy the workflow ID and call it from code. Create an account at [app.anyformat.ai](https://app.anyformat.ai). From the home screen, type what you want to extract into the **Create with annie** chat. For example: *"Invoice processing: extract invoice number, total, and issue date."* Drag in a sample invoice PDF before you send. It is optional. [annie](/concepts/annie) uses it to pick better fields. annie builds the workflow and opens it in [Studio](/guides/studio/index), with the document alongside it. Review the fields she suggested. Ask her for changes in the **Chat** tab, or edit them yourself. Copy the workflow ID from the URL or from workflow settings. You need it to run this workflow through the API later. Drag the invoice onto the workflow, or upload it with the **Add document** button. Processing starts on its own. It usually finishes in 10 to 60 seconds. To run one file on its own, click its filename and press **Process document** on the right. Results appear as soon as processing finishes. No refresh needed. * **Table view** shows every document at once. The first tab lists the documents, one row each. The extra tabs show the output of each node in your workflow. * **Document view** opens one document on its own. Click a filename to get there. Each extracted value is highlighted on the page it came from. Click a field to see where it came from. Export the results as **CSV**, **Excel**, or **JSON** from the workflow view. [Outputs](/concepts/outputs) covers the differences. This workflow is a parse step feeding an extract step. In [Studio](/guides/studio/index) you can also build parse-only workflows, sort mixed document types with Classify, and split multi-document files. ## Where to go next The same workflow in curl, TypeScript, or Python A deeper walkthrough of building and refining a workflow with annie and Studio End-to-end examples: invoices, resumes, contracts, receipts, and more Let Claude Code build and run anyformat workflows from your editor # API quickstart Source: https://docs.anyformat.ai/guides/quickstart-api Create a workflow, run a document through it, and read the extracted values with curl, TypeScript, or Python. Three steps: create a workflow, run a document through it, read the values. The invoice extractor below pulls `invoice_number`, `total_amount`, and `issue_date`. Prefer to click? The [Quickstart](/guides/quickstart) builds the same workflow at [app.anyformat.ai](https://app.anyformat.ai) with no code. Building your first workflow there is faster, and you can call it from the API afterwards with its workflow ID. ## Before you start Get an API key from [app.anyformat.ai/api-key](https://app.anyformat.ai/api-key). The snippets below read it from the environment. Both SDKs also take the key as a constructor argument. The [Python SDK page](/api-reference-v3/sdks/python) carries the Python version pin. ```bash curl theme={null} export ANYFORMAT_API_KEY="your_api_key_here" ``` ```bash TypeScript theme={null} npm install @anyformat/sdk # Node 18+ export ANYFORMAT_API_KEY="your_api_key_here" ``` ```bash Python theme={null} pip install anyformat # Python 3.13 export ANYFORMAT_API_KEY="your_api_key_here" ``` ## 1. Create a workflow A workflow is a [typed graph](/concepts/workflows) of nodes. The smallest extraction shape is a `parse` node feeding an `extract` node: two nodes, one edge. Both SDKs expose a fluent builder over that same graph. [Nodes](/guides/nodes/overview) covers the other node types, including Classify and Split. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v3/workflows/' \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -d '{ "name": "Invoice Processing", "description": "Extract key data from invoices", "nodes": [ {"id": "parse_1", "type": "parse"}, { "id": "extract_1", "type": "extract", "extraction_schema": {"fields": [ {"name": "invoice_number", "data_type": "string", "description": "The unique invoice identifier"}, {"name": "total_amount", "data_type": "float", "description": "Total invoice amount"}, {"name": "issue_date", "data_type": "date", "description": "Date when the invoice was issued"} ]} } ], "edges": [{"source": "parse_1", "target": "extract_1"}] }' ``` ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); // .create() persists the workflow and returns its id. const workflowId = await af .workflow("Invoice Processing", "Extract key data from invoices") .parse() .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.float("total_amount", "Total invoice amount"), Schema.date("issue_date", "Date when the invoice was issued"), ]) .create(); console.log(`Created workflow: ${workflowId}`); ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Invoice Processing") .parse() .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.float("total_amount", "Total invoice amount"), Schema.date("issue_date", "Date when the invoice was issued"), ]) .create() # persists and returns a Workflow handle ) print(f"Created workflow: {workflow.id}") ``` Keep the workflow ID. Over HTTP it arrives as `id`, alongside the name, description, and timestamps: ```json highlight={2} theme={null} { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Invoice Processing", "…": "…" } ``` ## 2. Run a document [Upload and run](/api-reference-v3/workflows/upload-and-run) uploads the file and starts a run in one call. Replace `WORKFLOW_ID` with the ID from step 1. ```bash curl theme={null} curl -X POST 'https://api.anyformat.ai/v3/workflows/WORKFLOW_ID/upload/run/' \ -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ -F 'files=@invoice.pdf' ``` ```typescript TypeScript theme={null} // The TS SDK collapses submit and poll into one .run(file).wait() chain. // Build the same shape, or look the workflow up by id with the low-level client. // `file` must be a File with .name set. const file = new File([bytes], "invoice.pdf"); const result = await af .workflow("Invoice Processing", "Extract key data from invoices") .parse() .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.float("total_amount", "Total invoice amount"), Schema.date("issue_date", "Date when the invoice was issued"), ]) .run(file) .wait(); // continues into step 3 ``` ```python Python theme={null} run = workflow.run("invoice.pdf") # Path | str | bytes ``` The `202` response carries the `run_id`. Keep it to poll for results. `status: "queued"` means the run was accepted, not that extraction has finished. ```json highlight={2,5} theme={null} { "run_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4f9", "document_packet_id": "069dcc2c-e14c-7606-8000-2ee4fb17b4e1", "workflow_id": "550e8400-e29b-41d4-a716-446655440000", "status": "queued" } ``` ## 3. Read the values Poll [the run](/api-reference-v3/runs/get). It always returns **200**. `results` is `null` until `status` is `processed`, and then the results envelope arrives inline on the same read. Stop polling when `status` is `processed`, `error`, or `cancelled`. ```bash curl theme={null} curl -H "Authorization: Bearer $ANYFORMAT_API_KEY" \ "https://api.anyformat.ai/v3/runs/RUN_ID/" ``` ```typescript TypeScript theme={null} // `result` was awaited in step 2. console.log(result.field("invoice_number")?.value); console.log(result.field("total_amount")?.value); console.log(result.field("issue_date")?.value); ``` ```python Python theme={null} result = run.wait() print(result.fields["invoice_number"].value) print(result.fields["total_amount"].value) print(result.fields["issue_date"].value) ``` A finished run reports `status: "processed"`, and each extracted field carries a `value` and a `confidence`: ```json highlight={2,6-8} theme={null} { "status": "processed", "results": { "parse": {"…": "…"}, "extractions": [{"fields": { "invoice_number": {"value": "INV-2024-0847", "confidence": 97.0}, "total_amount": {"value": "4087.50", "confidence": 96.0}, "issue_date": {"value": "2024-03-15", "confidence": 93.0} }}] } } ``` * `results` holds one section per node type that ran. This workflow has a parse node and an extract node, so `parse` and `extractions` are filled. * `extractions` is a list. One entry per extraction the run produced. * `fields` is keyed by the field names you defined in step 1. * `value` is a string on the wire, including for `float` and `date` fields. `confidence` is a number from 0 to 100. * `parse` is trimmed above. It holds the parsed markdown, the plain text, per-page blocks, and their bounding boxes. Each field also carries `evidence`: the snippet and page number the value came from. [Runs & results](/concepts/runs-and-results) is the full envelope, section by section, and [Get run](/api-reference-v3/runs/get) lists every field. ```typescript TypeScript theme={null} import { Anyformat, Schema } from "@anyformat/sdk"; const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! }); // `file` must be a File with .name set. const file = new File([bytes], "invoice.pdf"); const result = await af .workflow("Invoice Processing", "Extract key data from invoices") .parse() .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.float("total_amount", "Total invoice amount"), Schema.date("issue_date", "Date when the invoice was issued"), ]) .run(file) .wait(); console.log(result.field("invoice_number")?.value); console.log(result.field("total_amount")?.value); console.log(result.field("issue_date")?.value); ``` ```python Python theme={null} import os from anyformat.sdk import Client from anyformat.workflow import Schema client = Client(api_key=os.environ["ANYFORMAT_API_KEY"]) workflow = ( client.workflow("Invoice Processing") .parse() .extract([ Schema.string("invoice_number", "The unique invoice identifier"), Schema.float("total_amount", "Total invoice amount"), Schema.date("issue_date", "Date when the invoice was issued"), ]) .create() ) result = workflow.run("invoice.pdf").wait() print(result.fields["invoice_number"].value) print(result.fields["total_amount"].value) print(result.fields["issue_date"].value) ``` ## Where to go next Every endpoint, every response, every error code The full results envelope, confidence, and evidence End-to-end examples: invoices, resumes, contracts, receipts, and more Let Claude Code build and run anyformat workflows from your editor # Using Studio Source: https://docs.anyformat.ai/guides/studio/index Open Studio, add nodes to the canvas, connect them, and build workflows that go beyond Parse → Extract. **[Studio](/concepts/studio)** is the visual editor for a workflow. You add nodes to a canvas, connect them, and configure each one. You reach Studio by [describing what you want to annie](/concepts/annie). She builds the workflow and opens it here. From there you take it further: **sort** documents by type, **split** a file that holds several documents, or **check** results against your own rules. Ask annie in the **Chat** tab, or edit the nodes yourself. *** ## Opening Studio * **Create a new workflow:** describe what you want to [annie](/concepts/annie) on the home screen. She builds the workflow and opens Studio. The conversation continues in the **Chat** tab. * **On an existing workflow:** open the workflow and click the **Studio** tab. *** ## The layout Studio canvas Studio has three areas: * **Operators**, on the left. The palette of nodes you drag onto the canvas, in four sections: Intelligence, Logic, Alerts, and Annotations. * **Canvas**, in the center. Your workflow: the nodes and the connections between them. * **Configuration panel**, on the right. It opens when you click a node. Set the node up there: fields, categories, rules. *** ## Working with nodes **Add a node.** Drag it from the **Operators** palette on the left onto the canvas. Every workflow needs a **Parse** node first. annie already puts one in the workflow she builds. **Open and configure a node.** Click a node on the canvas. Its configuration panel opens on the right: the fields for Extract, the categories for Classify, the rules for Split or Validate. Click empty canvas, or another node, to switch away. **Move a node.** Drag it around the canvas to keep things tidy. Position affects readability only. It does not change how the workflow runs. **Remove a node.** Hover over the node, click the **⋯** button in its corner, labelled *Node options*, then choose **Remove**. *** ## Working with connections Connections set the **order** the nodes run in. The output of one node flows into the next. **Make a connection.** Drag from the small handle on the right edge of a node to the handle on the left edge of the next node. Drag from **Parse** to **Extract**, and the text Parse produces flows into Extract. **Remove a connection.** Hover over the connecting line and click the **×** button that appears on it. ### What can connect to what Nodes connect only in ways that make sense, so you cannot build a broken workflow: * **Parse** is always the first node. Nothing connects into it, and a workflow holds **one** Parse. * **Parse** connects to **Extract**, **Classify**, **Split**, or **Edit**. * **Classify** and **Split** connect onward to **Extract**, one Extract per category or split rule, so each type is handled on its own. * **Extract** connects to **Validate**, **If/Else**, or an alert. * **Validate** connects onward to **If/Else** or an alert. * **Knowledge** covers the whole workflow and needs no connection. * **Edit** and the alerts are end nodes. Nothing connects out of them. Each output connects to one next node. To feed several others, such as a different Extract per document type, use **Classify**, **Split**, or **If/Else**. They branch into one output per category, rule, or condition. Each node's page lists what it connects to. See **[Nodes](/guides/nodes/overview)**. *** ## Run and refine Once your nodes are connected and configured, add a sample document and run it. Check the results and adjust until they look right. **[Nodes](/guides/nodes/overview)** covers what each node does. **[Validate](/guides/nodes/validate)** covers the rules you write. *** ## Common shapes The same nodes arranged differently cover most needs: Parse alone, Parse into Extract, and Classify or Split in front of Extract when the pile holds more than one kind of document. [Three shapes you will build](/guides/nodes/overview#three-shapes-you-will-build) draws each one. *** ## What's next? What each node does and how to set it up Review and verify the results your workflow produces # Analytics & Quality Source: https://docs.anyformat.ai/guides/workflows/analytics-quality Understanding and improving processing quality over time Every workflow answers one practical question: **can I trust these results before I rely on them?** Two numbers answer it, **confidence** and **accuracy**. They do different jobs. **The short version:** anyformat gives every value a **confidence** score, so you know where to look. Review the low-confidence values first. Your verifications then produce an **accuracy** number that tells you how often the workflow is right. Aim for high accuracy with light review on the low-confidence cases. You do not check everything. *** ## Confidence ### What is confidence? **Confidence** is how certain anyformat is about an extracted value. anyformat expresses it as a percentage: * **High confidence:** the model is very sure * **Low confidence:** the value may be ambiguous or unclear anyformat calculates confidence per: * Field * Document * Workflow, as an average Confidence score *** ### What confidence is (and isn't) * A **signal**, not a verdict * A way to prioritize human review * A guide for where to look first * A guarantee of correctness * A replacement for verifying results yourself * A measure of business accuracy A value can have: * High confidence and still be wrong * Low confidence and still be correct *** ### How to use confidence effectively Use confidence to: * Focus review on low-confidence fields * Skip reviewing obviously reliable values * Reduce overall human effort A good workflow does not eliminate low confidence. It **contains it**. *** ## Accuracy ### What is accuracy? **Accuracy** measures how often extracted values are **correct**, over the documents you verified. > Accuracy reflects confirmed correctness, not how sure the model felt. anyformat calculates accuracy from: * Fields you verified as correct * Fields you corrected *** ### Accuracy vs confidence | Confidence | Accuracy | | ------------------------------------------------ | --------------------------------- | | How sure the model is | How often it is confirmed right | | Available immediately, before you check anything | Builds up as you verify documents | | A guess about each value, up front | A track record, after the fact | | Helps you prioritize what to review | Measures real performance | You need **both**: * Confidence to guide review * Accuracy to judge quality *** ### What accuracy tells you Accuracy helps you answer: * Can I trust this workflow? * Is it ready to scale? * Which fields are fragile? Low accuracy usually points to: * Ambiguous instructions * Poor field definitions * Edge cases in documents *** ### Per field Both numbers are most useful **per field**. Sort fields by confidence or accuracy to surface: * Fields that consistently fail * Fields that no longer need review * Outliers dragging accuracy down *** ## Improving results ### How to improve confidence To improve confidence: * Make instructions more explicit * Clarify where information appears * Reduce ambiguity in field definitions * Split complex fields into simpler ones Confidence improves when field definitions become clearer. *** ### How to improve accuracy To improve accuracy: * Correct wrong values while verifying * Review low-confidence fields carefully * Refine the workflow when patterns appear * Adjust fields or instructions Accuracy improves through **human feedback loops**. Refinement improves **future documents**, not past ones. *** ## A realistic quality goal You do not need 100% confidence or 100% accuracy. A good goal is: > High accuracy with focused human review on low-confidence cases. That is how anyformat scales without burning time. *** ## What's next? Review and correct results: the loop that builds up accuracy. Measure quality deliberately with datasets and evaluations. # Creating Workflows Source: https://docs.anyformat.ai/guides/workflows/creating How to create and configure workflows in anyformat The fastest way to create a workflow is to **describe what you want to [annie](/concepts/annie)**, anyformat's AI assistant. She builds a Parse → Extract workflow and opens it in [Studio](/guides/studio/index). Refine it there by chatting with her, or by editing it yourself. **To build it by hand:** every new workflow starts with annie, but she does not have to build the fields. Ask for a blank workflow: *"Create an empty workflow, I'll add the fields myself"*. She hands you the same canvas in [Studio](/guides/studio/index) to arrange and configure yourself. *** ## Describe it (from the home screen) Start on the **home** screen, in the **Create with annie** chat box. Do three things there: * Describe the type of document you want to process * Explain what you want to extract * Upload a sample document by drag and drop, or click **Add document** Provide both a description and a sample document. That gives annie the best starting point. Send your message. annie proposes a workflow and shows you a preview to approve. Once you accept, she opens it in **Studio**, and the conversation continues in the **Chat** tab. *** ## Refining in Studio After annie builds the workflow, [Studio](/guides/studio/index) opens with the document alongside it. Refine it either way: Keep chatting in the **Chat** tab: "add a due date field", "make total a decimal", "split multi-invoice PDFs". Ask her for field suggestions or better descriptions, then accept the changes you like. Click any node to open its configuration panel and edit fields directly: add, rename, retype, duplicate, or delete. Each field has a name, a [type](/concepts/field-types), and an instruction. **[Nodes](/guides/nodes/overview)** lists every node and how to configure it. *** ## Reviewing results Add a sample document and run it. Processing starts automatically and takes seconds for a short document, a couple of minutes for a long one. Then check the results: * **The document view** shows each extracted value highlighted where it came from on the page. Click a field to jump to its source, or click the page to find the matching field. * **The Markdown view** shows whether parsing read the document correctly. Adjust your fields and instructions, then run again until the results look right. [Verification & review](/guides/workflows/verification-review) covers reviewing results at scale. *** ## Special field types A couple of field types have extra setup: * **Select / Multiselect**: define the option names and descriptions. * **Object (Subtable)**: define the subfields for each repeating row. [Field types](/concepts/field-types) has the full list and when to use each. *** ## What's next? How to run workflows on documents Review and verify extracted data at scale # Running Workflows Source: https://docs.anyformat.ai/guides/workflows/running How to run workflows on documents in anyformat Once a workflow is published, run it on documents to extract structured data. *** ## Adding documents Add documents to a workflow three ways: * **Manually:** upload through the interface * **Via API:** send documents programmatically * **Cloud storage:** import from Microsoft OneDrive or SharePoint. See [Cloud storage](/guides/cloud-storage). *** ## Document statuses Each document has a status that reflects its state: | Status | Description | | --------------- | ----------------------------------------------------------------------------- | | **Not started** | The workflow has not run on this document yet | | **Queued** | The document waits for an available processing slot | | **In progress** | anyformat is processing the document | | **Processed** | anyformat processed the document. Nobody has reviewed it. | | **Verified** | Someone reviewed the extracted data and confirmed it correct. Shown in green. | | **Error** | Processing failed. This document is not counted toward usage. | | **Cancelled** | Processing stopped before it finished | A document queued for processing A document being processed *** ## Running a workflow Two ways start processing: * **In bulk, from the table:** select the unprocessed documents and click **Run**. * **One at a time, from the document view:** open a document by clicking its filename in the table, then click **Process document** in the panel on the right. After an error, the same button reads **Process again**. Processing takes a few seconds for a short document, and a couple of minutes for a long one. Three things drive it: * Document size * Number of pages * Current system load If a document ends up in **Error**, open it and click **Process again**. Errors are usually temporary, or caused by an unreadable file. An errored document is not counted toward usage. If it keeps failing, check that the file is not corrupted or password-protected. *** ## Viewing results As soon as a document finishes processing, its results appear. You do not refresh. View them two ways: Review all your documents at once. The first tab shows one row per document and one column per field. Further tabs show each node's output: Parse, Classify, Split, Extraction, Validation. Click a filename to open one document on its own, with each value highlighted on the page it came from. It carries the same per-node tabs as the table view. Per-node tabs for a document *** ## Exporting results From the workflow view, download processed results in: * **CSV:** for spreadsheets and data analysis * **Excel:** for business users * **JSON:** for integrations and automation Export: * All documents at once * Selected documents * One document, as a row-level export Exporting results *** ## What's next? How to verify and correct extracted data Monitor and improve quality # Verification & Review Source: https://docs.anyformat.ai/guides/workflows/verification-review Reviewing and verifying extracted data at scale Once documents have been processed, **review and verify the extracted data**. Confirm what is correct, and fix what is not. This section is about: * Reviewing results * Verifying data * Correcting outputs * Preparing data for export or use *** ## Two things that sound alike These two are easy to confuse: You do this while building. It improves extraction **going forward**, on every future document. You do this after processing. It confirms whether the data from a **specific document** is correct. If results are not good enough, go back and **refine the workflow**. anyformat learns from your verifications and corrections over time, and improves future results. This section covers **verifying results**, not editing the workflow. *** ## Table view ### Reviewing data at scale The **table view** shows all extracted data across documents. The first tab shows your documents: * One row per document * One column per field in the schema * Metadata such as creation date, processed date, and who verified it Alongside it sits one tab per node: Parse, Classify, Split, Extraction, Validation. Review each node's output on its own. Table view *** ### Table actions From the table view: * Run the workflow on unprocessed documents * Download processed results as CSV, Excel, or JSON * Delete selected documents * Open a document in the document view by clicking its filename * Download results per document, a row-level export Select one document or many, then apply the action. *** ### Working with columns In the table view: * Reorder columns * Resize columns * Sort by any column * Filter by any column * Search by document or field value Use these to spot errors, find edge cases, and focus review where it matters. *** ### Objects in table view A field of type **Object (Subtable)**, such as invoice line items, appears as its own table. Expand the object to see every row laid out together, and review the repeated data on its own. Objects in table view *** ## Document view ### Reviewing one document at a time To open a single document, **click its filename** in the table. This opens the **document view**, designed for detailed, per-document review. The layout is similar to the workflow creation view: * Document viewer on one side * Extracted fields on the other It carries the same per-node tabs as the table view: Parse, Classify, Split, Extraction, Validation. Check each node for that document. One thing differs from the build screen: > Here, you are verifying final results, not changing the workflow. Document results view *** ### Visual grounding and evidence Each extracted value is visually linked to the document: * A single color highlights where the value appears in the document * Clicking a field highlights it in the document, and clicking an area in the document highlights the matching field This shows you why a value was extracted. *** ### Confidence indicators Each field shows a **confidence score**: a signal of how sure anyformat is about that value. * Shown as a percentage with a colored dot * Explained on hover * Low-confidence values are the ones worth checking first It reduces your effort by telling you where to look. [Confidence & accuracy](/guides/workflows/analytics-quality) covers what confidence does and does not mean. Confidence indicator *** ## Verifying results When a value is correct, mark it **verified** with the thumbs-up button. Verify: * Individual fields * Entire documents * Multiple documents at once A verified field turns green. Press the thumbs-up again to undo, or use **Undo** after an edit. As you verify, the document's status becomes **Verified**. Verified field *** ### Keyboard shortcuts To speed up review: | Shortcut | Action | | --------------- | ------------------------------------ | | `Shift + ← / →` | Navigate between documents | | `Ctrl + Enter` | Verify the current document | | `Shift + Enter` | Verify and move to the next document | *** ## Correcting outputs When a value is incorrect, edit it. anyformat saves the corrected value as the verified result, so your export carries the fix. For an **Object (Subtable)** field, verify individual rows or the whole object. Add new rows, or filter to show only the rows on the current page. *** ## What's next? Monitor and improve quality over time Export formats, and using data outside anyformat # Versions Source: https://docs.anyformat.ai/guides/workflows/versions How anyformat tracks workflow changes so you can always go back. anyformat records every change you make to a workflow, so you can always go back. *** ## How versioning works Every edit to a workflow creates a new version. This means: * anyformat keeps your previous configurations * You see the history of changes * Each version captures the complete workflow state ### What creates a new version? * Adding, removing, or modifying fields * Changing the schema * Updating workflow configuration ### Version history Versions form a chain. It lets you: * Track how your workflow evolved * Understand what changed between versions * Maintain a complete audit trail Version history *** ## Why this matters Versioning is your safety net. Say you edit an invoice workflow so the "total" field excludes VAT, and the next batch of results looks wrong. anyformat saved the previous version. Look back at the history and return to the version that worked, instead of rebuilding it from memory. Editing a workflow affects **future** runs only. A document you already processed keeps the results from the version it ran on. Changing the workflow never rewrites past data. *** ## What's next? The full workflow creation process Monitor and improve quality # Documents in. Data out. Source: https://docs.anyformat.ai/index Describe the fields you need once. anyformat reads every document you give it and returns structured data, with a confidence and a citation for each value.

Documents in. Data out.

Describe the fields you need once. anyformat reads every document you give it and returns structured data, with a confidence and a citation for each value.

INVOICE
Invoice #: INV-2026-9001
Issue date: 2026-02-20
Due date: 2026-03-22
Currency: USD
From: Wayne Enterprises
148 Eric Track, New Stephanie, NC 00575
Bill to: Nguyen, Smith and Hickman
Description
Qty
Unit price
Amount
Matrix Extensible Action-Items
8
Streamline Web-Enabled Networks
7
Evolve Strategic Models
5
Define
Run
Read
PythonTypeScriptcurlIn the app
fromimport
fromimport
POST201
POST202
GET200
GET200
GET200

Parse reads the document. Extract pulls out the fields you defined. Build the workflow once, in the app or in code, then run it on every document that follows.

Featured examples

All examples
# Slack Source: https://docs.anyformat.ai/integrations/slack Route workflow alerts to a Slack channel: an invoice over a threshold, a failed validation, a document classified as high-priority. The **[Slack alert](/guides/nodes/slack-alert)** node posts a message to a Slack channel whenever a document flows through it. Place it after an [If/Else](/guides/nodes/if-else) or [Validate](/guides/nodes/validate) node so it fires only on matches: "invoice total > \$10,000", "classification = escalate", or "a validation rule failed". This page covers the connection. The node's options and API shape live on the [node page](/guides/nodes/slack-alert). *** ## Connect your Slack workspace You set up the Slack connection once per organization. Every workflow in the org then sends alerts through it. 1. Open **Connectors** from the user menu under your avatar. 2. Find the **Slack** row and click **Connect**. 3. Approve the anyformat app in Slack's consent screen. 4. Slack returns you to the same page, with Slack showing as **Connected**. **Who can connect:** organization admins only. Non-admins see a note asking them to reach out to an admin instead of the **Connect** button. **What anyformat asks Slack for:** permission to post messages as the anyformat app, to list your public channels so you can pick one, and to join a channel the first time you send an alert to it. **Private channels are not supported** in the current release. The channel picker lists public channels only. For alerts in a private space, ask an admin to open a matching public channel, or [contact support][support]. **One workspace per organization.** To switch workspaces, use **Change account**, described in [Managing the connection](#managing-the-connection). To route alerts to more than one Slack workspace from the same org, [contact support][support]. *** ## Add a Slack Alert node In Studio, add a Slack Alert node one of two ways: * Drag **Alerts → Slack Alert** from the sidebar onto the canvas. * Or click the **+** chip on an upstream Extract, Validate, or If/Else handle and pick **Slack Alert**. Then configure it: ### Channel Pick from the dropdown of your public channels. anyformat joins the channel the first time it posts there. No manual invite is needed. ### Message template Compose the message with the built-in editor. Click the **picker** to drop a **field chip** into the template: ``` New invoice from [vendor_name]: total [invoice_total] ``` Chips resolve to the field's current value on delivery. Renaming the field in Studio keeps the chip working. Deleting the field breaks it, and the alert lands with a visible `` marker in that spot. **Validation chips.** With a [Validate](/guides/nodes/validate) node upstream, drop a rule-outcome chip too. For a rule whose id is `amt_positive`: ``` Rule "amount positive" [amt_positive.status]: [amt_positive.detail] ``` * Each rule exposes three attributes to insert: `status`, `detail`, `severity`. * `status` lands in Slack as one of `pass`, `fail`, or `inconclusive`. ### Severity The severity sets the color bar on the Slack card: | Severity | Color | | -------- | ----- | | Info | Blue | | Warning | Amber | | Critical | Red | ### Preview and test * The **Preview** panel shows the message with each chip replaced by the field name in brackets, such as `[vendor_name]`. Check the layout there before you save. If a chip cannot resolve, because its field was renamed or deleted, the preview shows `` in that spot. * Click **Send test** to post a rendered `[Test]`-prefixed message to the selected channel. It is the fastest way to confirm the bot has access and the layout looks right. **Test messages do not count against your alert budget.** *** ## How alerts are delivered * **Order of operations.** The alert fires after the upstream node (Extract, If/Else, or Validate) produces its result for the document. * **Open document button.** Every alert carries an **Open document** button. It opens the file in Studio at the exact workflow version the alert fired against, so recipients land on the schema the alert was built with. * **Missing values are visible.** If a chip's field or validation rule produced no value for a document, the delivered alert shows `` in that spot rather than dropping it silently. You see the broken template and fix it. * **Per-organization rate limits.** anyformat caps deliveries at **60 per minute** and **500 per hour** across the whole org, to protect your Slack workspace from a runaway workflow. Alerts over the cap are dropped for that window. They are not queued and not retried. *** ## Managing the connection Open **Connectors → Slack → ⋯** from the user menu for these controls: * **Change account** installs anyformat into a different Slack workspace. anyformat discards the credentials for the old workspace, **but the app stays installed in that workspace** until you remove it there. To clean it up fully, open Slack's **Apps → anyformat → Remove app** in the old workspace. * **Disconnect** stops all Slack Alert deliveries and asks Slack to uninstall the app from your workspace. anyformat disconnects immediately. If Slack rejects the uninstall, remove the app by hand from Slack's app directory. That rejection is rare, and usually means a network hiccup or an already-invalid token. Your Slack Alert nodes are not deleted. They pause until you reconnect. **Uninstalling from Slack directly**, through Slack's own **Apps → anyformat → Remove app**, does not notify anyformat. Deliveries start failing on the next attempt, but Studio still shows the integration as connected until you click **Disconnect** here. Prefer **Disconnect**. It flips both sides at once. *** ## Troubleshooting **"Slack isn't connected" appears when I open the node.** Nobody has connected Slack for your organization yet. If you are an admin, click **Connect Slack**. It runs the same flow as [Connect your Slack workspace](#connect-your-slack-workspace). If you are not an admin, ask one to set it up. **The channel picker won't load or says "Slack workspace unreachable".** Slack rejected the request. The usual causes are a revoked token, a rate limit, or a transient outage on Slack's side. Try again in a minute. If it persists, disconnect and reconnect from **Connectors**. **Message arrives with `${field.…}` shown literally.** Somebody typed the chip by hand, with characters the substitution cannot parse. Delete the raw text and re-insert the chip from the **picker**. **Message arrives with `` where a value should be.** Three causes. The referenced field was renamed or deleted, so re-insert the chip from the picker. Or the field was not extracted from this document. Or the referenced validation rule did not run for this document. **The alert didn't fire at all.** Slack Alert fires only when the upstream node produces a result for the document. Behind an If/Else, check that the branch condition was true for this input. **Deliveries stopped mid-run.** You may have hit the rate limit of 60 per minute or 500 per hour. Wait for the next window. Scope the alert with an If/Else so it fires only on the cases worth paging for. [support]: mailto:support@anyformat.ai # Introduction Source: https://docs.anyformat.ai/introduction What anyformat does, how a workflow is put together, and where to go next. anyformat reads documents and returns structured data. You describe the fields you need once. Every document you send after that comes back as data, with a confidence and a citation for each value. ## How it works A workflow is a small graph of nodes. [Parse](/guides/nodes/parse) reads the document. [Extract](/guides/nodes/extract) pulls out the fields you defined. Add [Classify](/guides/nodes/classify), [Split](/guides/nodes/split), [Validate](/guides/nodes/validate) or an [alert](/guides/nodes/slack-alert) when the documents ask for it. You build the workflow once, in the app or in code, and run it on every document that follows. The result is the same either way: one JSON object per document, with a value, a confidence and the evidence for each field. ## Two ways to build * **In the app.** [Studio](/concepts/studio) is the canvas at [app.anyformat.ai](https://app.anyformat.ai). Drag nodes, define fields, drop in a document, and read the result next to the page it came from. * **In code.** The [Python](/api-reference-v3/sdks/python) and [TypeScript](/api-reference-v3/sdks/typescript) SDKs, the [`afx` CLI](/api-reference-v3/cli) and the [REST API](/api-reference-v3/introduction) send the same graph of nodes and edges. An [MCP server](/api-reference-v3/mcp) exposes it to AI agents. ## Where to go next Your first workflow, end to end, in the app or in code. Every node: what it does, what it returns, what it costs. Complete workflows for invoices, contracts, bank statements and more. Endpoints, SDKs, CLI and MCP.