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.
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.
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:
| Role | Type | What it does |
|---|---|---|
llm | LLM | Table structure and content extraction |
lite_llm | LLM | Per-page detection — finds which pages hold your table |
x2text | LLMWhisperer | OCR; 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
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/agent-kv/ | Submit a document |
GET | /api/v1/agent-kv/{job_id} | Job status |
GET | /api/v1/agent-kv/{job_id}/result | Extracted tables |
POST | /api/v1/agent-kv/{job_id}/cancel | Cancel a running job |
DELETE | /api/v1/agent-kv/{job_id} | Delete the stored files (the job record is kept) |
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.
| Field | Required | Description |
|---|---|---|
file | yes | The document. PDF or .xlsx. |
extractors | yes | A JSON string describing what to extract (below). |
page_start | no | First page to consider (1-based). |
page_end | no | Last page to consider. |
timeout | no | Seconds to wait inline for the result. See Waiting inline. |
webhook_url | no | HTTPS URL to notify when the job finishes. |
tags | no | Your own labels, echoed back on the job. |
custom_data | no | Arbitrary 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:
| Option | Default | Description |
|---|---|---|
instructions | "" | Free-text guidance for the extraction. |
json_structure | "" | A JSON shape describing the columns you expect. |
enable_header_mapping | false | Map varied source headers onto consistent names. Changes the result shape — see below. |
correct_number_separators | false | Normalise thousands/decimal separators. |
number_format | "US" | "US" or "EU" — which separator convention the source uses. |
enable_header_mapping changes the result shapeWith 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
}
}
}
successsuccess 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
reasonnames 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
| Code | Meaning |
|---|---|
400 | Validation failed. The body names the offending field. |
402 | Subscription expired or inactive. |
403 | Key missing, unknown or revoked. |
404 | No such job for this organization. |
409 | The job is not in a state that allows this (e.g. cancelling a finished job). |
429 | Rate 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_urlthat is not HTTPS-and-public; - a missing
target_table.
Limits and retention
| Limit | Default |
|---|---|
| Maximum file size | 50 MB |
| Maximum pages per document | 100 |
| Concurrent jobs per organization | 5 |
| Result retention | 7 days |
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.