Skip to main content

Agentic Table Extraction API

Submit a document, get back the tables inside it as structured rows. One POST to start a job, then poll for the result or have it pushed to a webhook.

This is the same multi-agent, LLM-powered agentic table extractor available in Prompt Studio, exposed as a standalone API — so you can run it from your own code without building a workflow around it.

The extraction runs on your own adapters — the LLM and OCR connections already configured in your organization — so model choice and spend stay on your account.

Scope

This API serves the table extractor. Other extractor types are not yet available.


Authentication​

Every request carries an Agent-KV API key as a bearer token:

Authorization: Bearer <your-key>

The key identifies your organization. It is not a user session, so these endpoints work from a server, a scheduled job, or a CI pipeline with no login flow.

Creating a key​

In the web app, go to Settings → Agent-KV API Keys and create one. An organization admin can create, rename, rotate and revoke keys there.

caution

The key value is shown once, when you create it. Copy it then. If you lose it, rotate the key to get a new value — rotating replaces the old value immediately, so any client still using it will start getting 403.


Choosing your adapters​

Every submit names three adapters by id. They must belong to your organization, and each must be of the right type:

RoleTypeWhat it does
llmLLMTable structure and content extraction
lite_llmLLMPer-page detection — finds which pages hold your table
x2textLLMWhispererOCR; turns the document into text

Find the ids in Settings → Adapters — each card has a Copy ID button.

llm and lite_llm may be the same adapter. They are separate so you can put a cheaper, faster model on per-page detection, which runs on every page and is the highest-volume stage. Using one adapter for both works and simply costs more.


Endpoints​

MethodPathPurpose
POST/api/v1/agent-kv/Submit a document
GET/api/v1/agent-kv/{job_id}Job status
GET/api/v1/agent-kv/{job_id}/resultExtracted tables
POST/api/v1/agent-kv/{job_id}/cancelCancel a running job
DELETE/api/v1/agent-kv/{job_id}Delete the stored files (the job record is kept)
Trailing slashes are significant

POST /api/v1/agent-kv/ requires the trailing slash. The job routes (/{job_id}, /{job_id}/result, /{job_id}/cancel) must have no trailing slash. Getting this wrong returns 405 or 404, not a redirect.


Submit a document​

POST /api/v1/agent-kv/ — multipart/form-data.

FieldRequiredDescription
fileyesThe document. PDF or .xlsx.
extractorsyesA JSON string describing what to extract (below).
page_startnoFirst page to consider (1-based).
page_endnoLast page to consider.
timeoutnoSeconds to wait inline for the result. See Waiting inline.
webhook_urlnoHTTPS URL to notify when the job finishes.
tagsnoYour own labels, echoed back on the job.
custom_datanoArbitrary JSON you want carried with the job.

The extractors field​

A JSON string — it is one multipart field, not a nested object:

[
{
"name": "table",
"keys": { "target_table": "Rent roll" },
"options": {},
"adapters": {
"llm": "<llm-adapter-id>",
"lite_llm": "<llm-adapter-id>",
"x2text": "<llmwhisperer-adapter-id>"
}
}
]

keys.target_table is how you name the table you want, in your own words — "Rent roll", "Line items", "Schedule of charges". It is required.

Options​

All optional, inside options:

OptionDefaultDescription
instructions""Free-text guidance for the extraction.
json_structure""A JSON shape describing the columns you expect.
enable_header_mappingfalseMap varied source headers onto consistent names. Changes the result shape — see below.
correct_number_separatorsfalseNormalise thousands/decimal separators.
number_format"US""US" or "EU" — which separator convention the source uses.
enable_header_mapping changes the result shape

With it off, tables is a flat list of row objects. With it on, tables becomes {"header_mapping": {...}, "rows": [...]} — read .rows.

Example​

curl -X POST https://<your-host>/api/v1/agent-kv/ \
-H "Authorization: Bearer $AGENT_KV_KEY" \
-F "file=@rent_roll.pdf" \
-F 'extractors=[{"name":"table","keys":{"target_table":"Rent roll"},"options":{},"adapters":{"llm":"'$LLM'","lite_llm":"'$LLM'","x2text":"'$X2TEXT'"}}]'

Response — 202 Accepted​

{
"job_id": "5b6e9b0a-...",
"status": "dispatched",
"status_url": "/api/v1/agent-kv/5b6e9b0a-...",
"created_at": "2026-10-21T06:00:00Z"
}

Check status​

GET /api/v1/agent-kv/{job_id}

{
"job_id": "5b6e9b0a-...",
"status": "running",
"extractors": {
"table": {
"stage": "table_extraction",
"stages": [{ "name": "table_extraction", "status": "running" }]
}
},
"pages_total": 2,
"created_at": "...",
"started_at": "...",
"completed_at": null
}

status is one of pending, dispatched, running, completed, failed, cancelled. The last three are terminal.

A reasonable poll is every 2–5 seconds. Extraction time scales with page count.


Get the result​

GET /api/v1/agent-kv/{job_id}/result

{
"success": true,
"status": "completed",
"extractors": {
"table": {
"tables": [{ "values": { "Unit": "101", "Tenant": "A. Smith" } }],
"headers": ["Unit", "Tenant"],
"row_count": 6,
"page_count": 2,
"table_pages": [1, 2],
"detected": true,
"reason": ""
}
},
"usage_summary": {
"total": {
"pages": 2,
"total_cost": 0.128,
"input_tokens": 35836,
"output_tokens": 1379
}
}
}
Where to read success

success and status are top level — they describe the job. The extractor's own block holds the extracted data and carries no success of its own.

When no table is found​

A successful job can still find nothing. That is not an error, and the result says so:

{
"detected": false,
"reason": "no table matching 'Rent roll' was found on any page",
"tables": []
}

Check detected before treating an empty tables as a failure, and read reason — it distinguishes two different outcomes:

  • No match. No page contained a table matching your target_table. The document was read; it simply does not have that table.
  • Detection did not complete. Page detection failed on one or more pages, and the reason names them. The answer covers only the pages that were checked, so an empty result here does not prove the table is absent — it is worth retrying.

Failures​

{ "success": false, "status": "failed", "error": "..." }

Waiting inline​

Pass timeout=<seconds> on submit and the API holds the request open. If the job finishes within that window you get 200 with the full result immediately, instead of 202 and a poll loop. If it does not, you get the normal 202 and poll as usual.

Useful for small documents and interactive callers. For anything large, prefer a webhook.


Webhooks​

Pass webhook_url on submit and you get one POST when the job reaches a terminal state:

{ "job_id": "5b6e9b0a-...", "status": "completed" }

status is completed, failed or cancelled. The payload carries no extracted data — fetch the result endpoint when you receive it.

Requirements: the URL must be HTTPS and resolve to a public host. Private, loopback and link-local addresses are refused at submit.

Delivery: a failed delivery (connection error or non-2xx) is retried with backoff. A URL that can never work — wrong scheme, non-public host — is not retried.

One extra case. If you cancel a job whose extraction had already finished, the result is kept rather than discarded, and a follow-up POST tells you it is there:

{ "job_id": "...", "status": "cancelled", "result_available": true }

Treat result_available as sticky — nothing orders it against the original cancellation POST, so it may arrive first.


Cancel and delete​

POST /api/v1/agent-kv/{job_id}/cancel stops a job that is still running. Cancelling a job that already finished returns 409 with its current status; it is not an error to try.

DELETE /api/v1/agent-kv/{job_id} removes the stored input and result. The job record is kept as history, so the status endpoint still returns 200 afterwards — that is not a failed delete. Fetching the result returns 404 once the files are gone.


Errors​

CodeMeaning
400Validation failed. The body names the offending field.
402Subscription expired or inactive.
403Key missing, unknown or revoked.
404No such job for this organization.
409The job is not in a state that allows this (e.g. cancelling a finished job).
429Rate limit or concurrency ceiling reached. Retry later.

A 400 on submit most often means one of:

  • an adapter id that is not yours, is the wrong type, is deprecated, or whose free trial allowance is exhausted — the error names which role;
  • a document over the page or size cap;
  • a webhook_url that is not HTTPS-and-public;
  • a missing target_table.

Limits and retention​

LimitDefault
Maximum file size50 MB
Maximum pages per document100
Concurrent jobs per organization5
Result retention7 days
The page cap counts the whole document

page_start / page_end narrow what is extracted, not what is read. The whole document is OCR'd and billed regardless, so a page range does not bring an oversized document under the cap.

Uploaded documents are deleted as soon as a job completes or fails. A cancelled job's upload is the exception: it is removed by scheduled cleanup rather than immediately, so call DELETE if you need it gone sooner. Results are deleted after the retention window, or immediately on DELETE.


Troubleshooting​

I got a 200 with an HTML page instead of JSON. The request did not reach the API. Check the URL prefix — the endpoints live under /api/v1/agent-kv/. A path the platform does not route is served by the web app, which answers with a 200 and an HTML page rather than a 404.

405 Method Not Allowed on submit. The trailing slash is missing. Submit is POST /api/v1/agent-kv/.

404 on status or result for a job that exists. A trailing slash was added. The job routes take none.

The job completed but tables is empty. Check detected and reason in the extractor block. A reason beginning "detection did not complete" means some pages were never checked — retry rather than concluding the table is absent. Any other reason means the document was read and has no table matching your target_table.

Headers look wrong, or figures are parsed oddly. Try enable_header_mapping for inconsistent source headers, and number_format if figures use a different thousands/decimal convention than the default.