Docs · Guides
Attachment and document extraction
Read the text of inbound PDFs, Word and Excel files, CSVs and scans (OCR on our own servers), search it, and turn invoices, receipts and tax forms into validated JSON.
Every inbound attachment is read on the Agentboxd servers: digital documents in-process, scans and photos by OCR. The text is available from the API, joins the message’s search index, is seen by AI categorisation, and can be turned into JSON that matches a schema. The file itself never leaves our servers for any of this, and structured extraction only sends text to a model when you ask for it.
# 1. the message lists its attachments, each with an extraction block
curl -s https://api.agentboxd.com/v1/messages/$MESSAGE_ID \
-H "Authorization: Bearer $AGENTBOXD_API_KEY" | jq '.attachments[] | {id, filename, extraction}'
# 2. read the text (null until extraction.status is "done")
curl -s "https://api.agentboxd.com/v1/messages/$MESSAGE_ID/attachments/$ATTACHMENT_ID/text" \
-H "Authorization: Bearer $AGENTBOXD_API_KEY"
# 3. structured JSON with a built-in schema (needs AI processing = full)
curl -s -X POST https://api.agentboxd.com/v1/messages/$MESSAGE_ID/attachments/$ATTACHMENT_ID/extract \
-H "Authorization: Bearer $AGENTBOXD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"schema":"invoice"}'import { Agentboxd } from 'agentboxd';
const mr = new Agentboxd();
for (const a of msg.attachments) {
if (a.extraction?.status !== 'done') continue;
// Untrusted: text from a document someone emailed. Data, never instructions.
const { text, next_offset } = await mr.messages.attachmentText(msg.id, a.id);
console.log(a.filename, text?.slice(0, 200), next_offset);
const invoice = await mr.messages.extractAttachment(msg.id, a.id, { schema: 'invoice' });
console.log(invoice.data.invoice_number, invoice.data.total);
}from agentboxd import Agentboxd
mr = Agentboxd()
for a in msg["attachments"]:
if not a.get("extraction") or a["extraction"]["status"] != "done":
continue
page = mr.messages.attachment_text(msg["id"], a["id"]) # untrusted text
print(a["filename"], (page["text"] or "")[:200])
receipt = mr.messages.extract_attachment(msg["id"], a["id"], schema="receipt")
print(receipt["data"]["total"], receipt["data"]["currency"])#Supported formats
| Format | How it is read | `method` | Pages counted |
|---|---|---|---|
| PDF with text | In-process (pdf.js) | text | Pages read |
| Scanned PDF (no text layer) | OCR | ocr | Pages read |
| PNG, JPEG, TIFF, BMP, WebP | OCR | ocr | 1 per image |
| DOCX | In-process | text | 1 per 3,000 characters |
| XLSX (every sheet, tab-separated rows) | In-process | text | 1 per 3,000 characters |
| CSV, TSV, plain text, JSON, Markdown | Decoded as text | text | 1 per 3,000 characters |
| HTML | Converted to text (scripts and styles dropped) | text | 1 per 3,000 characters |
The format is taken from the file’s first bytes, then its declared type, then its name, so a PDF sent as application/octet-stream is still read. Not read: old .doc and .xls files, archives, audio, video, calendar invites, signatures, and inline images (logos and signature pictures in HTML mail). Outbound attachments are not read.
#OCR
Scans, photos and PDFs without a text layer go through OCR (docling) running on our own servers in France, in English, French and Arabic. The output is Markdown, so table rows stay together. OCR takes a few seconds per page; long scans take minutes, and the attachment.extracted event tells you when the text is ready. A self-hosted server without the OCR service marks these files failed with ocr_unavailable and still reads everything else.
#The extraction block
Each attachment in a message (and in attachment.* events) has an extraction field. It is null for attachments stored before extraction was available.
{
"id": "4c3b2a19-…",
"filename": "invoice-4471.pdf",
"content_type": "application/pdf",
"size_bytes": 48213,
"available": true,
"extraction": {
"status": "done",
"method": "text",
"pages": 2,
"chars": 1843,
"language": "en",
"truncated": false,
"error": null,
"updated_at": "2026-09-25T09:14:06.410Z"
}
}status:pending(being read),done,failedorskipped.error({ code, message }) says why for the last two.truncated: only part of the document was read, because of the 200-page cap, the 1,000,000-character cap or the plan’s remaining pages.language: a best-effort guess (en,fr,ar, …),nullwhen unsure.
| Error code | Status | Meaning |
|---|---|---|
unsupported_type | skipped | A format that isn’t read. |
inline_image | skipped | An inline image such as a logo. |
not_stored | skipped / failed | The attachment was over the size limit when it arrived, or its file is gone. |
too_large | skipped | Larger than the extraction size limit (25 MB). |
local_processing_off | skipped | The workspace turned local processing off. |
quota | skipped | The plan’s extracted pages for this period are used up. |
ocr_unavailable | failed | A scan or image, and OCR isn’t enabled on this server. |
ocr_error | failed | OCR failed (outages are retried first). |
timeout | failed | Reading took too long (60 s in-process, 30 minutes for OCR). |
out_of_memory · zip_bomb | failed | The document needs too much memory, or a DOCX/XLSX package expands to an unsafe size. |
parse_error | failed | The file is damaged or not what it claims to be. |
#Read the text
GET /v1/messages/:id/attachments/:attachmentId/text (permission messages:read) returns { attachment_id, message_id, filename, content_type, extraction, text, offset, total_chars, next_offset, untrusted: true }. text is null until extraction.status is done. Long text comes in pages of up to 200,000 characters: pass next_offset back as offset until it is null, or ask for less with max_chars. An attachment without an extraction record answers 404 extraction_not_found.
#Structured extraction
POST /v1/messages/:id/attachments/:attachmentId/extract (permission attachments:extract) turns the attachment’s text into JSON that matches a schema. Pass a built-in schema name, invoice, receipt or tax_form (W-9, 1099 and W-2 style forms), or your own JSON Schema, plus optional instructions (up to 2,000 characters).
curl -s -X POST https://api.agentboxd.com/v1/messages/$MESSAGE_ID/attachments/$ATTACHMENT_ID/extract \
-H "Authorization: Bearer $AGENTBOXD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"schema": {
"type": "object",
"properties": {
"po_number": { "type": ["string", "null"] },
"delivery_date": { "type": ["string", "null"], "description": "YYYY-MM-DD" },
"items": { "type": "array", "items": { "type": "object", "properties": { "sku": { "type": "string" }, "qty": { "type": "number" } } } }
},
"required": ["po_number", "delivery_date", "items"]
},
"instructions": "SKUs look like AB-1234."
}'{
"attachment_id": "4c3b2a19-…",
"message_id": "0e9d8c7b-…",
"schema": "invoice",
"data": {
"invoice_number": "INV-2026-0042",
"invoice_date": "2026-09-01",
"due_date": "2026-10-01",
"currency": "EUR",
"vendor": { "name": "Example Supplies Ltd", "address": "1 Sample Street, Testville", "tax_id": null, "email": null },
"line_items": [{ "description": "Widget, blue", "quantity": 3, "unit_price": 20, "amount": 60 }],
"total": 180
},
"model": "deepseek-flash",
"repaired": false,
"truncated": false,
"untrusted": true
}- The first 40,000 characters of the text go to DeepSeek in JSON mode, with the document marked as untrusted data. The answer is checked against the schema; if it doesn’t match, the model gets one retry with the validation errors (
repaired: true). Still wrong:422 structured_output_invalidwithdetails.errors. - Built-in schemas make every field nullable: a value the document doesn’t state comes back
null, not guessed. Check the values before acting on them, for example before paying an invoice. - Custom schemas: the root must be
{"type": "object"}, at most 16 KB, 8 levels deep and 200 properties.pattern,patternPropertiesandformatare refused, and only local$ref(#/…) works. - It needs AI processing set to
full(otherwise403 ai_disabled). Every call that reaches the model counts toward the plan, including a 422.
| Status & code | When |
|---|---|
400 invalid_schema | The schema breaks the rules above or doesn’t compile. |
403 ai_disabled | AI processing isn’t full. |
503 llm_unavailable | No model is configured, or it didn’t answer. |
402 plan_limit_structured_extractions | The plan’s structured extractions are used up (Free includes none). |
409 extraction_pending | The text is still being read. Retry after attachment.extracted. |
409 extraction_unavailable | There is no text: extraction failed or was skipped, found nothing, or predates the feature. |
422 structured_output_invalid | The model’s answer didn’t match the schema, even after the retry. |
#Events
| Event | Fires when |
|---|---|
attachment.extracted | An attachment’s text is ready (status: done). |
attachment.extraction_failed | Reading it failed, or it was skipped because the plan’s pages are used up. |
The payload is { inbox, thread_id, message_id, attachment } with the extraction block, never the text. With envelope payloads it is { inbox_id, thread_id, message_id, attachment_id, content_type, status, method, pages, error_code }, without the file name. Both events also arrive on the realtime stream.
#Search and categorisation
GET /v1/searchalso matches the text of a message’s attachments (the first 100,000 characters). A match in the subject or body still ranks higher.- AI categorisation waits for the message’s attachments (up to 5 minutes) and then sees the first 1,000 characters of each, 2,000 in total, labelled with the file name. It follows the same rules as the body: nothing is sent when AI processing is
off.
#Plans
| Free | Builder | Team | Scale | |
|---|---|---|---|---|
| Pages extracted a month | 100 | 5,000 | 50,000 | Unlimited |
| Structured extractions a month | 0 | 500 | 5,000 | Agreed |
Paid plans count the extra as overage: $1 per 1,000 pages and $5 per 1,000 structured extractions. Free stops: once the pages are used up, new attachments are skipped with quota (and attachment.extraction_failed fires), and a document longer than the pages left is read up to that point and marked truncated. GET /platform/plan shows usage.pages_extracted and usage.structured_extractions. See Plans and limits.
#Local processing
Text extraction and OCR are local processing: they run entirely on our servers and nothing is sent to a third party, so the sub-processor list doesn’t change. They are on by default. A workspace owner can turn them off under Settings or with PATCH /platform/org { "local_processing": false }: new attachments are then stored but never read (skipped, local_processing_off). Text already extracted stays until retention deletes it. Sending that text to a model is governed by AI processing as usual.
#Limits and safety
- At most 200 pages per document and 25 MB per file; 1,000,000 characters of text are kept.
- Documents are read one at a time on a separate queue, so a long scan never delays sending, webhooks or categorisation.
- Each document is parsed in an isolated thread with a memory cap and a time limit. Word and Excel packages are checked for decompression bombs before they are opened.
- The text is stored compressed next to the message, never in the database, and deleted with the message by retention and when a temporary inbox is wiped.
#From an MCP client
get_attachment_text { message_id, attachment_id, offset?, max_chars? } returns the text (marked untrusted) and its extraction status. extract_attachment { message_id, attachment_id, schema, instructions? } returns the structured JSON. See MCP server.