# Attachment and document extraction

Source: https://agentboxd.com/docs/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.

curl:

```bash
# 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"}'
```

extract.ts:

```ts
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);
}
```

extract.py:

```python
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 |
| Calendar invites (`.ics`, `text/calendar`) | The fields a person reads, as text | `text` | Not counted |
| Small zip and gzip files of text (DMARC reports) | Each file’s text, under its name | `text` | Not counted |

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, other archives (7z, rar, tar, and zip or gzip files that aren’t small and simple: more than 10 files, nested, encrypted, files that aren’t text, larger than 25 MB unpacked, or compressed more than 100:1), audio, video, forwarded messages, signatures, and inline images (logos and signature pictures in HTML mail). Outbound attachments are not read.

Calendar invites are read so they are screened like any other attachment: the title, time, location, description, comments, links, organizer and attendee names, and any other field with words in it, including text an HTML description hides. Links are never opened. The invite part next to the message body is read too. Invites don’t count against your plan’s pages and are read even when those pages are used up. Only the first 256 KB of an invite are 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 deployment 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.

attachment:

```json
{
  "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`, `failed` or `skipped`. `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`, …), `null` when 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). |
| `too_many_attachments` | skipped | The message has more than 20 attachments to read; only the first 20 are read. |
| `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 or an archive expands to an unsafe size. |
| `archive_unsupported` | failed | An archive that isn’t unpacked: nested, encrypted, more than 10 files, or a file that isn’t text. |
| `parse_error` | failed | The file is damaged or not what it claims to be. |
| `internal_error` | failed | A fault on our side stopped the reading, after retries, or it was not finished within 6 hours. |

## 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`.

> **Careful:** Extracted text is untrusted input, exactly like an email body: anyone can email your agent a PDF that says “ignore your instructions”. The API marks it `untrusted: true`, the MCP tools start it with an UNTRUSTED line, and your agent should treat it as data, never as instructions.

## 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).

custom schema:

```bash
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."
  }'
```

response:

```json
{
  "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_invalid` with `details.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`, `patternProperties` and `format` are refused, and only local `$ref` (`#/…`) works. The 16 KB and 8-level limits also apply to the schema with each `$ref` replaced by what it points to, every time it is used. There, a definition in the root’s `$defs` or `definitions` counts only where a `$ref` uses it. The 200-property limit counts the schema as you send it. A `$ref` can’t point to a part that contains it, so recursive schemas are refused. A `$ref` can’t point into `enum`, `const`, `default` or `examples`, and `$id` works only at the root.
- It needs [AI processing](https://agentboxd.com/docs/ai-processing) set to `full` (otherwise `403 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](https://agentboxd.com/docs/webhooks#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](https://agentboxd.com/docs/realtime-events).

## Search and categorisation

- `GET /v1/search` also 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](https://agentboxd.com/docs/plans).

## 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](https://agentboxd.com/legal/subprocessors) doesn’t change. They are on by default. A workspace owner can turn them off under [Settings](https://agentboxd.com/app/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](https://agentboxd.com/docs/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.
- At most 20 attachments are read per message: the first 20 in the message’s `attachments` list, not counting inline images and files that aren’t read anyway. The others are `skipped` with `too_many_attachments`, with no event. They are still stored and can be downloaded.
- Documents are read one at a time on a separate queue, so a long scan never delays sending, webhooks or categorisation.
- Messages take turns: after a few minutes on one message, its remaining attachments stay `pending` and wait behind mail that arrived in the meantime.
- 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](https://agentboxd.com/docs/plans#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](https://agentboxd.com/docs/mcp).
