# pdf

> Read PDFs locally — classify text-based vs scanned, convert to markdown, extract positioned text and tables, and report which pages still need OCR.

| field | value |
|-------|-------|
| version | 0.1.13 |
| type | binary |
| license | Apache-2.0 |
| repo | https://github.com/iii-hq/workers |
| supported_targets | x86_64-apple-darwin, aarch64-apple-darwin, x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-unknown-linux-musl, armv7-unknown-linux-gnueabihf |
| author | iii |

## installation

```sh
iii trigger compose::add worker=pdf@0.1.13
```

## dependencies

- `configuration` @ `latest`

## readme

# pdf

Read PDFs on the machine, with no OCR service and no API key. This worker
classifies a document in about twenty milliseconds — is this real text, or a
photograph of a page? — converts text-based documents to markdown that keeps
their headings, lists, links and tables, and reports exactly which pages still
need OCR and why. It also exposes the layout underneath: where every run of
characters sits, and what the text is inside a given box on a page. Nothing is
uploaded, and a long document is capped rather than dumped, so a report does not
swallow the context an agent needed for the answer.

It ships a console page too. Drop a PDF in and see exactly what the agent sees:
the verdict, the per-page OCR decision, and the extracted markdown.

## Install

```bash
iii trigger compose::add worker=pdf
```

## Quickstart

Classify first. It is cheap, and it decides whether anything else is worth
doing: extraction on a scan returns nothing, and without the verdict an empty
result is indistinguishable from an empty document.

```rust
use iii_sdk::{register_worker, InitOptions};
use iii_sdk::protocol::TriggerRequest;
use serde_json::json;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let iii = register_worker("ws://localhost:49134", InitOptions::default());

    let verdict = iii.trigger(TriggerRequest {
        function_id: "pdf::classify".into(),
        payload: json!({ "path": "/tmp/report.pdf" }),
        action: None,
        timeout_ms: Some(30_000),
    }).await?;
    // { "document_type": "text_based", "confidence": 1.0, "page_count": 40,
    //   "pages_needing_ocr": [], "ocr_reasons": [], "elapsed_ms": 18, … }

    let markdown = iii.trigger(TriggerRequest {
        function_id: "pdf::to-markdown".into(),
        payload: json!({ "path": "/tmp/report.pdf", "pages": [1, 2, 3] }),
        action: None,
        timeout_ms: Some(60_000),
    }).await?;
    // { "body": { "text": "# Quarterly Report\n…", "chars": 5693,
    //             "total_chars": 5693, "truncated": false }, … }

    println!("{markdown:#?}");
    Ok(())
}
```

A document with no path goes in as `bytes_base64` instead. An encrypted one
takes a `password` on `pdf::classify` and `pdf::to-markdown`.

### Reading the verdict

`document_type` is `text_based`, `scanned`, `image_based` or `mixed`. The
document-level answer is not the whole story: a two-hundred-page report with a
scanned cover is not a scanned document, and treating it as one sends the whole
thing to an OCR service for the sake of one page. `pages_needing_ocr` and
`ocr_reasons` carry the per-page decision:

| Reason | What it means |
|---|---|
| `scanned` | A raster page. It needs a vision model. |
| `no_text` | Nothing extractable and nothing to OCR. Often a blank page. |
| `vector_text` | Characters drawn as outlines rather than text. Unreadable as characters. |
| `suspected_garbled_text` | A text layer that decodes to nonsense. Do not trust it, whatever the document type says. |

### Response caps

Every text-bearing response is capped and says so. `truncated: true` with a
`total_chars` far above `chars` means you are holding a fragment.

The cheap fix is `pages`, not a bigger cap: conversion cost scales with the
document, so narrowing to the pages you need is faster as well as smaller. A
four-hundred-page report takes tens of seconds to convert whole and
milliseconds a page at a time.

`max_chars: 0` lifts the cap entirely. That belongs in a pipeline moving a
document to storage, not in a call whose result lands in a conversation.

### Reading a box on a page

When a vision model has located a region and you want the real characters
rather than its transcription:

```json
{
  "path": "/tmp/invoice.pdf",
  "regions": [{ "page": 1, "boxes": [[320.0, 640.0, 560.0, 700.0]] }],
  "mode": "text"
}
```

`mode: "table"` runs table detection over the same box and returns a markdown
table instead.

### Two conventions worth knowing

Page numbers are 1-indexed everywhere on this surface, in requests and
responses.

Coordinates are not uniform, and each response states which it used.
`pdf::extract-items` reports PDF points from the **bottom** left, the PDF
convention. `pdf::extract-regions` takes boxes in PDF points from the **top**
left, which is what a layout model produces. Getting this wrong is silent: text
comes back, just from the wrong end of the page.

## Configuration

Configuration lives in the `configuration` worker under the id `pdf` and every
field hot-reloads. Nothing here needs a restart.

```yaml
max_input_bytes: 268435456   # largest document accepted, before parsing
max_chars: 40000             # default cap on returned text or markdown
preview_chars: 600           # leading characters shown alongside a capped body
max_items: 5000              # default cap on positioned items in one response
classify_sample_pages: 8     # pages sampled to classify; 0 scans everything
min_text_ops_per_page: 3     # text operators before a page counts as text
text_page_ratio_threshold: 0.6  # share of text pages to call a document text-based
```

The three detection fields are the ones worth understanding. Sampling is what
keeps classification at tens of milliseconds on a four-hundred-page file; it
also means the verdict comes from part of the document, which is why every
response reports `pages_sampled`. Raise `classify_sample_pages`, or set it to
`0`, when a borderline mixed document needs settling.

Defaults live in [`src/config.rs`](src/config.rs).

## Called on demand

This worker registers no harness hook and injects nothing into any prompt. A
conversation that never touches a document never pays for it, and there is no
per-turn cost to having it installed. An agent finds it the ordinary way,
through the function registry and [`skills/SKILL.md`](skills/SKILL.md); a
person finds it through the console page.

## What this worker does not do

It does not rasterize pages, so it cannot OCR anything. Scanned and image-based
documents get classified and routed, not read. Image content is reported as a
placeholder with a real bounding box and no pixels.

Routed where, in practice: [`document::ocr`](https://github.com/iii-hq/workers/tree/main/document) renders those pages
through the `browser` worker and reads them with a vision model. It costs money
per page, which is exactly why `pdf::classify` exists — pass it the
`pages_needing_ocr` named here rather than the whole document.

It is a parser, not a renderer: it walks the document's content streams and
reconstructs the geometry, which is why it is fast and why it needs no service
behind it.

## skills

This version ships 1 skill file for agents, also browsable on the worker page:

- [`SKILL.md`](https://workers.iii.dev/workers/pdf?tab=skills&skill=SKILL.md&version=0.1.13)

### SKILL.md

# pdf

The pdf worker parses PDF documents on the machine. A PDF is not text: reading
one with a file-reading function returns binary noise and spends the context on
it, so every PDF goes through `pdf::*` instead. Parsing is local, needs no
credential, and sends nothing anywhere.

Its first job is a routing decision. `pdf::classify` samples the document's
content streams and answers whether the pages hold real characters or are
photographs of pages, plus which individual pages cannot be read without a
vision model and why. That verdict decides whether the rest of the work is
worth doing at all, and it is what separates "this document is empty" from
"this document is a scan".

Its second job is reading. Text-based documents convert to markdown that keeps
the shape of the original, because headings, lists and tables are recovered
from font sizes and page geometry rather than from any structure the file
promises. Underneath that sit the positions themselves, for callers that need
to know where text is and not only what it says.

This worker is called on demand. It registers no harness hook and injects
nothing into any prompt, so a conversation that never touches a document never
pays for it. Reach for it when one appears.

## When to Use

- A conversation names a PDF path or hands one over: call `pdf::classify`
  before anything else. Never read a PDF with a file-reading function; it
  returns binary noise and spends the context on it.
- Read a document: `pdf::to-markdown`, narrowed with `pages` when it is long.
- Search or embed a document rather than read it: `pdf::extract-text`.
- Decide whether a document is worth sending to a vision model, and which of
  its pages: `pdf::classify`, then read `pages_needing_ocr` and `ocr_reasons`.
- A vision model located a region and you want the real characters rather than
  its transcription: `pdf::extract-regions`.
- Reason about layout, headings by size, or where a value sits on the page:
  `pdf::extract-items`.

## Boundaries

- Nothing here rasterizes a page, so nothing here can OCR. Scanned and
  image-based documents are classified and routed, never read. Image content
  is reported as a placeholder with a real box and no pixels. `document::ocr`
  is where routed pages go when the `document` worker is installed: it renders
  them and reads them with a vision model, so it costs money per page. Hand it
  the `pages_needing_ocr` this worker named rather than the whole file.
- `suspected_garbled_text` in `ocr_reasons` means the text layer decodes to
  nonsense. Do not trust the extraction, whatever `document_type` says.
- Responses are capped. `truncated: true` with a much larger `total_chars`
  means you hold a fragment and must not answer from it. Narrow with `pages`
  rather than raising the cap: conversion cost scales with the document, so a
  page filter is faster as well as smaller. `max_chars: 0` lifts the cap and
  belongs in a pipeline moving a document to storage, not in a call whose
  result lands in the conversation.
- Encrypted documents take a `password` on `pdf::classify` and
  `pdf::to-markdown` only. The other three cannot decrypt and say so.
- Page numbers are 1-indexed everywhere, in requests and responses.
- Coordinates differ by function and every response states which it used:
  `pdf::extract-items` reports PDF points from the bottom left,
  `pdf::extract-regions` takes boxes in PDF points from the top left. Assuming
  the wrong one returns text from the wrong end of the page with no error.

## Functions

- `pdf::classify` — the routing call. Document type, confidence, page count,
  the 1-indexed pages needing OCR, and a machine-readable reason per page.
- `pdf::to-markdown` — markdown with headings, lists, links and tables
  recovered; optional page filter, per-page output, and a fidelity or compact
  profile.
- `pdf::extract-text` — plain text, no structure recovery. Cheaper than
  markdown when the result will be searched or embedded.
- `pdf::extract-items` — every positioned run of characters with its box,
  font, size, and recovered bold, italic, underline and strikeout.
- `pdf::extract-regions` — the text, or a markdown table, inside given boxes
  on given pages.

## api reference

```json
{
  "functions": [
    {
      "description": "Classify a PDF as text-based, scanned, image-based or mixed, and report which pages need OCR and why. Samples content streams rather than extracting text, so it answers in tens of milliseconds. Call this before any other pdf function.",
      "metadata": {},
      "name": "pdf::classify",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "FsScope": {
            "description": "The filesystem jail a call runs under.\n\nThe harness stamps this onto every function it dispatches, so a `path` an agent supplies has to be checked against it. Without the check these functions would read any document on the machine and hand back its text, which is a way around the scope the session was granted. Mirrors the shape the shell worker takes.",
            "properties": {
              "grants": {
                "default": [],
                "description": "Additional directories or files explicitly granted to this session.",
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              "root": {
                "description": "The session's working directory.",
                "type": "string"
              }
            },
            "required": [
              "root"
            ],
            "type": "object"
          }
        },
        "description": "Where the PDF comes from. Exactly one of the two fields must be set.",
        "properties": {
          "bytes_base64": {
            "default": null,
            "description": "Base64-encoded PDF bytes, for a document with no path. Mutually exclusive with `path`.",
            "type": [
              "string",
              "null"
            ]
          },
          "fs_scope": {
            "anyOf": [
              {
                "$ref": "#/definitions/FsScope"
              },
              {
                "type": "null"
              }
            ],
            "description": "Filesystem jail this call runs under; stamped by the harness on agent calls, absent on operator or console calls."
          },
          "password": {
            "default": null,
            "description": "Password for an encrypted document. Never logged or echoed back.",
            "type": [
              "string",
              "null"
            ]
          },
          "path": {
            "default": null,
            "description": "Filesystem path to the PDF. Mutually exclusive with `bytes_base64`.",
            "type": [
              "string",
              "null"
            ]
          },
          "sample_pages": {
            "default": null,
            "description": "Pages sampled for the verdict, overriding the worker default; `0` scans every page (slower, settles a borderline mixed document).",
            "format": "uint",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          }
        },
        "title": "Request",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "DocumentType": {
            "description": "What a document is made of.",
            "oneOf": [
              {
                "description": "Real text throughout. Extract locally.",
                "enum": [
                  "text_based"
                ],
                "type": "string"
              },
              {
                "description": "Pictures of pages. Every page needs OCR.",
                "enum": [
                  "scanned"
                ],
                "type": "string"
              },
              {
                "description": "Images with little or no text layer.",
                "enum": [
                  "image_based"
                ],
                "type": "string"
              },
              {
                "description": "Some pages carry text, others do not. Read `pages_needing_ocr`.",
                "enum": [
                  "mixed"
                ],
                "type": "string"
              }
            ]
          },
          "PageOcrReason": {
            "description": "Why one page cannot be read without OCR.",
            "properties": {
              "page": {
                "description": "1-indexed page number.",
                "format": "uint32",
                "minimum": 0,
                "type": "integer"
              },
              "reasons": {
                "description": "Machine-readable reasons: `scanned` (a raster page), `no_text` (nothing extractable and nothing to OCR), `vector_text` (characters drawn as outlines rather than text) or `suspected_garbled_text` (a text layer that decodes to nonsense).",
                "items": {
                  "type": "string"
                },
                "type": "array"
              }
            },
            "required": [
              "page",
              "reasons"
            ],
            "type": "object"
          }
        },
        "properties": {
          "confidence": {
            "description": "How much to trust the verdict, from 0.0 to 1.0.",
            "format": "float",
            "type": "number"
          },
          "document_type": {
            "allOf": [
              {
                "$ref": "#/definitions/DocumentType"
              }
            ],
            "description": "The document-level verdict."
          },
          "elapsed_ms": {
            "description": "Wall-clock time for the classification.",
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "has_encoding_issues": {
            "description": "`true` when font encodings decoded badly. Only known on the encrypted path, which extracts far enough to notice; absent otherwise, where `suspected_garbled_text` in `ocr_reasons` carries the same signal.",
            "type": [
              "boolean",
              "null"
            ]
          },
          "ocr_reasons": {
            "description": "Per-page explanation for `pages_needing_ocr`.",
            "items": {
              "$ref": "#/definitions/PageOcrReason"
            },
            "type": "array"
          },
          "ocr_recommended": {
            "description": "`true` when the images carry meaning the text layer does not, so OCR adds something even on a text-based document. Absent for an encrypted document, for the same reason as `pages_sampled`.",
            "type": [
              "boolean",
              "null"
            ]
          },
          "page_count": {
            "description": "Pages in the document.",
            "format": "uint32",
            "minimum": 0,
            "type": "integer"
          },
          "pages_needing_ocr": {
            "description": "1-indexed pages that cannot be read without OCR. Empty for a clean text-based document.",
            "items": {
              "format": "uint32",
              "minimum": 0,
              "type": "integer"
            },
            "type": "array"
          },
          "pages_sampled": {
            "description": "Pages actually inspected. Lower than `page_count` when sampling, so a verdict from a sample can be told apart from one that read everything. Absent for an encrypted document, which takes a decryption path that does not report the counters.",
            "format": "uint32",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "pages_with_text": {
            "description": "Inspected pages that carry text operators. Absent for an encrypted document, for the same reason as `pages_sampled`.",
            "format": "uint32",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "source": {
            "description": "Source label: the file name, or `<inline>` for an in-memory document.",
            "type": "string"
          },
          "title": {
            "description": "Document title from the PDF metadata, when it has one.",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "confidence",
          "document_type",
          "elapsed_ms",
          "ocr_reasons",
          "page_count",
          "pages_needing_ocr",
          "source"
        ],
        "title": "Response",
        "type": "object"
      }
    },
    {
      "description": "Extract positioned text items: the box, font, size and styling of every run of characters on a page. Coordinates are PDF points with a bottom-left origin. Use this for layout-aware reading; use pdf::to-markdown to just read the document.",
      "metadata": {},
      "name": "pdf::extract-items",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "FsScope": {
            "description": "The filesystem jail a call runs under.\n\nThe harness stamps this onto every function it dispatches, so a `path` an agent supplies has to be checked against it. Without the check these functions would read any document on the machine and hand back its text, which is a way around the scope the session was granted. Mirrors the shape the shell worker takes.",
            "properties": {
              "grants": {
                "default": [],
                "description": "Additional directories or files explicitly granted to this session.",
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              "root": {
                "description": "The session's working directory.",
                "type": "string"
              }
            },
            "required": [
              "root"
            ],
            "type": "object"
          }
        },
        "description": "Where the PDF comes from. Exactly one of the two fields must be set.",
        "properties": {
          "bytes_base64": {
            "default": null,
            "description": "Base64-encoded PDF bytes, for a document with no path. Mutually exclusive with `path`.",
            "type": [
              "string",
              "null"
            ]
          },
          "fs_scope": {
            "anyOf": [
              {
                "$ref": "#/definitions/FsScope"
              },
              {
                "type": "null"
              }
            ],
            "description": "Filesystem jail this call runs under; stamped by the harness on agent calls, absent on operator or console calls."
          },
          "max_items": {
            "default": null,
            "description": "Items to return before truncating; omit for the worker default, `0` returns every item (very large on a dense document).",
            "format": "uint",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "pages": {
            "default": null,
            "description": "1-indexed pages to read. Omit for the whole document.",
            "items": {
              "format": "uint32",
              "minimum": 0,
              "type": "integer"
            },
            "type": [
              "array",
              "null"
            ]
          },
          "path": {
            "default": null,
            "description": "Filesystem path to the PDF. Mutually exclusive with `bytes_base64`.",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "Request",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "Item": {
            "description": "One positioned run of characters.",
            "properties": {
              "bold": {
                "type": "boolean"
              },
              "font": {
                "description": "Font name as the document names it.",
                "type": "string"
              },
              "font_size": {
                "description": "Font size in points.",
                "format": "float",
                "type": "number"
              },
              "height": {
                "description": "Height in PDF points, approximated from the font size.",
                "format": "float",
                "type": "number"
              },
              "italic": {
                "type": "boolean"
              },
              "kind": {
                "$ref": "#/definitions/ItemKind"
              },
              "link": {
                "description": "Link target, for a `link` item.",
                "type": [
                  "string",
                  "null"
                ]
              },
              "mcid": {
                "description": "Marked-content id tying this item to the document's tagged structure tree, when the document has one.",
                "format": "int64",
                "type": [
                  "integer",
                  "null"
                ]
              },
              "page": {
                "description": "1-indexed page number.",
                "format": "uint32",
                "minimum": 0,
                "type": "integer"
              },
              "strikeout": {
                "description": "Recovered from vector lines through the text, not from a flag.",
                "type": "boolean"
              },
              "text": {
                "description": "The characters.",
                "type": "string"
              },
              "underline": {
                "description": "Recovered from vector lines near the baseline, not from a flag.",
                "type": "boolean"
              },
              "width": {
                "description": "Width in PDF points.",
                "format": "float",
                "type": "number"
              },
              "x": {
                "description": "Left edge, PDF points from the left of the page.",
                "format": "float",
                "type": "number"
              },
              "y": {
                "description": "Baseline, PDF points from the **bottom** of the page.",
                "format": "float",
                "type": "number"
              }
            },
            "required": [
              "bold",
              "font",
              "font_size",
              "height",
              "italic",
              "kind",
              "page",
              "strikeout",
              "text",
              "underline",
              "width",
              "x",
              "y"
            ],
            "type": "object"
          },
          "ItemKind": {
            "description": "What one item is.",
            "oneOf": [
              {
                "description": "Ordinary text.",
                "enum": [
                  "text"
                ],
                "type": "string"
              },
              {
                "description": "An image placeholder. The box is real; no pixels are decoded.",
                "enum": [
                  "image"
                ],
                "type": "string"
              },
              {
                "description": "Text carrying a hyperlink; the target is in `link`.",
                "enum": [
                  "link"
                ],
                "type": "string"
              },
              {
                "description": "A filled-in form field value.",
                "enum": [
                  "form_field"
                ],
                "type": "string"
              }
            ]
          }
        },
        "properties": {
          "coordinate_origin": {
            "description": "Origin convention for `x` and `y`, always `pdf-points, bottom-left`. Stated on every response because a caller that assumed the other convention reads the wrong end of the page with no error. Note `pdf::extract-regions` takes boxes with a top-left origin instead.",
            "type": "string"
          },
          "count": {
            "description": "Items returned.",
            "format": "uint",
            "minimum": 0,
            "type": "integer"
          },
          "elapsed_ms": {
            "description": "Wall-clock time for the extraction.",
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "items": {
            "description": "The items, in document order, capped per `max_items`.",
            "items": {
              "$ref": "#/definitions/Item"
            },
            "type": "array"
          },
          "source": {
            "description": "Source label: the file name, or `<inline>` for an in-memory document.",
            "type": "string"
          },
          "total_count": {
            "description": "Items the document holds for the requested pages. Equal to `count` when nothing was dropped.",
            "format": "uint",
            "minimum": 0,
            "type": "integer"
          },
          "truncated": {
            "description": "`true` when `items` stops short. Narrow `pages`, or pass `max_items: 0`.",
            "type": "boolean"
          }
        },
        "required": [
          "coordinate_origin",
          "count",
          "elapsed_ms",
          "items",
          "source",
          "total_count",
          "truncated"
        ],
        "title": "Response",
        "type": "object"
      }
    },
    {
      "description": "Extract the text inside bounding boxes on given pages, as text or a markdown table. Built for the hybrid path where a vision model locates a region and the exact characters come from the document rather than from a transcription. Coordinates are PDF points with a top-left origin.",
      "metadata": {},
      "name": "pdf::extract-regions",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "FsScope": {
            "description": "The filesystem jail a call runs under.\n\nThe harness stamps this onto every function it dispatches, so a `path` an agent supplies has to be checked against it. Without the check these functions would read any document on the machine and hand back its text, which is a way around the scope the session was granted. Mirrors the shape the shell worker takes.",
            "properties": {
              "grants": {
                "default": [],
                "description": "Additional directories or files explicitly granted to this session.",
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              "root": {
                "description": "The session's working directory.",
                "type": "string"
              }
            },
            "required": [
              "root"
            ],
            "type": "object"
          },
          "Mode": {
            "description": "What to pull out of each box.",
            "oneOf": [
              {
                "description": "The characters inside the box, as flat text.",
                "enum": [
                  "text"
                ],
                "type": "string"
              },
              {
                "description": "A markdown table, when the items inside the box form one.",
                "enum": [
                  "table"
                ],
                "type": "string"
              }
            ]
          },
          "PageRegions": {
            "description": "Boxes to read on one page.",
            "properties": {
              "boxes": {
                "description": "Boxes as `[x1, y1, x2, y2]` in PDF points, origin at the top left.",
                "items": {
                  "items": {
                    "format": "float",
                    "type": "number"
                  },
                  "maxItems": 4,
                  "minItems": 4,
                  "type": "array"
                },
                "type": "array"
              },
              "page": {
                "description": "1-indexed page number.",
                "format": "uint32",
                "minimum": 0,
                "type": "integer"
              }
            },
            "required": [
              "boxes",
              "page"
            ],
            "type": "object"
          }
        },
        "description": "Where the PDF comes from. Exactly one of the two fields must be set.",
        "properties": {
          "bytes_base64": {
            "default": null,
            "description": "Base64-encoded PDF bytes, for a document with no path. Mutually exclusive with `path`.",
            "type": [
              "string",
              "null"
            ]
          },
          "fs_scope": {
            "anyOf": [
              {
                "$ref": "#/definitions/FsScope"
              },
              {
                "type": "null"
              }
            ],
            "description": "Filesystem jail this call runs under; stamped by the harness on agent calls, absent on operator or console calls."
          },
          "mode": {
            "allOf": [
              {
                "$ref": "#/definitions/Mode"
              }
            ],
            "default": "text",
            "description": "Flat text, or a markdown table."
          },
          "path": {
            "default": null,
            "description": "Filesystem path to the PDF. Mutually exclusive with `bytes_base64`.",
            "type": [
              "string",
              "null"
            ]
          },
          "regions": {
            "description": "One entry per page, each carrying the boxes to read on it.",
            "items": {
              "$ref": "#/definitions/PageRegions"
            },
            "type": "array"
          }
        },
        "required": [
          "regions"
        ],
        "title": "Request",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "PageResult": {
            "description": "Results for one page, parallel to that page's requested boxes.",
            "properties": {
              "page": {
                "description": "1-indexed page number.",
                "format": "uint32",
                "minimum": 0,
                "type": "integer"
              },
              "regions": {
                "description": "One result per requested box, in the order they were given.",
                "items": {
                  "$ref": "#/definitions/RegionResult"
                },
                "type": "array"
              }
            },
            "required": [
              "page",
              "regions"
            ],
            "type": "object"
          },
          "RegionResult": {
            "description": "What one box held.",
            "properties": {
              "needs_ocr": {
                "description": "`true` when the extraction is not trustworthy: an empty box, a font the parser cannot decode, or text that decodes to nonsense. In `table` mode it also means no table structure was found.",
                "type": "boolean"
              },
              "ocr_reason": {
                "description": "Machine-readable reason, when the cause is known.",
                "type": [
                  "string",
                  "null"
                ]
              },
              "text": {
                "description": "The text, or the markdown table in `table` mode.",
                "type": "string"
              }
            },
            "required": [
              "needs_ocr",
              "text"
            ],
            "type": "object"
          }
        },
        "properties": {
          "coordinate_origin": {
            "description": "Origin convention the requested boxes were read under, always `pdf-points, top-left`. Stated on every response because a caller that assumed the other convention gets text from the wrong end of the page with no error. Note `pdf::extract-items` reports bottom-left instead.",
            "type": "string"
          },
          "elapsed_ms": {
            "description": "Wall-clock time for the extraction.",
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "pages": {
            "description": "One entry per requested page, in the order they were given.",
            "items": {
              "$ref": "#/definitions/PageResult"
            },
            "type": "array"
          },
          "region_count": {
            "description": "Boxes read across every page.",
            "format": "uint",
            "minimum": 0,
            "type": "integer"
          },
          "regions_needing_ocr": {
            "description": "Boxes whose result should not be trusted.",
            "format": "uint",
            "minimum": 0,
            "type": "integer"
          },
          "source": {
            "description": "Source label: the file name, or `<inline>` for an in-memory document.",
            "type": "string"
          }
        },
        "required": [
          "coordinate_origin",
          "elapsed_ms",
          "pages",
          "region_count",
          "regions_needing_ocr",
          "source"
        ],
        "title": "Response",
        "type": "object"
      }
    },
    {
      "description": "Extract a PDF as plain text, with no structure recovery. Cheaper than pdf::to-markdown and the right call when the text will be searched or embedded rather than read.",
      "metadata": {},
      "name": "pdf::extract-text",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "FsScope": {
            "description": "The filesystem jail a call runs under.\n\nThe harness stamps this onto every function it dispatches, so a `path` an agent supplies has to be checked against it. Without the check these functions would read any document on the machine and hand back its text, which is a way around the scope the session was granted. Mirrors the shape the shell worker takes.",
            "properties": {
              "grants": {
                "default": [],
                "description": "Additional directories or files explicitly granted to this session.",
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              "root": {
                "description": "The session's working directory.",
                "type": "string"
              }
            },
            "required": [
              "root"
            ],
            "type": "object"
          }
        },
        "description": "Where the PDF comes from. Exactly one of the two fields must be set.",
        "properties": {
          "bytes_base64": {
            "default": null,
            "description": "Base64-encoded PDF bytes, for a document with no path. Mutually exclusive with `path`.",
            "type": [
              "string",
              "null"
            ]
          },
          "fs_scope": {
            "anyOf": [
              {
                "$ref": "#/definitions/FsScope"
              },
              {
                "type": "null"
              }
            ],
            "description": "Filesystem jail this call runs under; stamped by the harness on agent calls, absent on operator or console calls."
          },
          "max_chars": {
            "default": null,
            "description": "Characters to return before truncating. Omit for the configured default; `0` returns the whole document.",
            "format": "uint",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "path": {
            "default": null,
            "description": "Filesystem path to the PDF. Mutually exclusive with `bytes_base64`.",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "Request",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "Body": {
            "description": "A body that may have been shortened to fit one response, and the numbers a caller needs to decide what to do about it.\n\nThe cap is what keeps a long document from flooding a model's context. A caller that genuinely wants the whole thing asks for `max_chars: 0`, which is the shape a worker-to-worker pipeline uses to move a document without it passing through anyone's context.",
            "properties": {
              "chars": {
                "description": "Characters returned in `text`.",
                "format": "uint",
                "minimum": 0,
                "type": "integer"
              },
              "preview": {
                "description": "Leading characters of the content. Present only when the body was truncated, so a caller can see the shape of what it did not get without re-reading the start of `text`.",
                "type": [
                  "string",
                  "null"
                ]
              },
              "text": {
                "description": "The content, shortened to the effective character cap.",
                "type": "string"
              },
              "total_chars": {
                "description": "Characters the document actually holds. Equal to `chars` when nothing was dropped.",
                "format": "uint",
                "minimum": 0,
                "type": "integer"
              },
              "truncated": {
                "description": "`true` when `text` stops short of the document. Ask again with `max_chars: 0` to take everything, or on the functions that accept one, narrow with a `pages` filter.",
                "type": "boolean"
              }
            },
            "required": [
              "chars",
              "text",
              "total_chars",
              "truncated"
            ],
            "type": "object"
          }
        },
        "properties": {
          "body": {
            "allOf": [
              {
                "$ref": "#/definitions/Body"
              }
            ],
            "description": "The text, capped per `max_chars`."
          },
          "elapsed_ms": {
            "description": "Wall-clock time for the extraction.",
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "source": {
            "description": "Source label: the file name, or `<inline>` for an in-memory document.",
            "type": "string"
          }
        },
        "required": [
          "body",
          "elapsed_ms",
          "source"
        ],
        "title": "Response",
        "type": "object"
      }
    },
    {
      "description": "Internal: hot-reload the pdf worker from the authoritative configuration when it changes, swapping the per-call snapshot.",
      "metadata": {
        "internal": true
      },
      "name": "pdf::on-config-change",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Payload of the internal config-change handler. The handler re-fetches the authoritative value, so this carries only the advisory id; a struct rather than a `Value` keeps the request schema concrete.",
        "properties": {
          "id": {
            "default": null,
            "description": "Configuration id that changed (advisory; the handler re-fetches).",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "OnConfigChangeEvent",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Ack returned by the internal config-change handler.",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "OnConfigChangeResponse",
        "type": "object"
      }
    },
    {
      "description": "Convert a text-based PDF to markdown, preserving headings, lists, links and tables. Returns nothing for a scanned document — call pdf::classify first. Responses are capped; pass max_chars 0 to take the whole document, or pages to take a slice of it.",
      "metadata": {},
      "name": "pdf::to-markdown",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "FsScope": {
            "description": "The filesystem jail a call runs under.\n\nThe harness stamps this onto every function it dispatches, so a `path` an agent supplies has to be checked against it. Without the check these functions would read any document on the machine and hand back its text, which is a way around the scope the session was granted. Mirrors the shape the shell worker takes.",
            "properties": {
              "grants": {
                "default": [],
                "description": "Additional directories or files explicitly granted to this session.",
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              "root": {
                "description": "The session's working directory.",
                "type": "string"
              }
            },
            "required": [
              "root"
            ],
            "type": "object"
          },
          "Profile": {
            "description": "How faithful the markdown should be to the source characters.",
            "oneOf": [
              {
                "description": "Preserve the source text as written.",
                "enum": [
                  "fidelity"
                ],
                "type": "string"
              },
              {
                "description": "Prefer shorter output, collapsing runs like the dot leaders in a table of contents. Not character-faithful to the source.",
                "enum": [
                  "compact"
                ],
                "type": "string"
              }
            ]
          }
        },
        "description": "Where the PDF comes from. Exactly one of the two fields must be set.",
        "properties": {
          "bytes_base64": {
            "default": null,
            "description": "Base64-encoded PDF bytes, for a document with no path. Mutually exclusive with `path`.",
            "type": [
              "string",
              "null"
            ]
          },
          "fs_scope": {
            "anyOf": [
              {
                "$ref": "#/definitions/FsScope"
              },
              {
                "type": "null"
              }
            ],
            "description": "Filesystem jail this call runs under; stamped by the harness on agent calls, absent on operator or console calls."
          },
          "include_images": {
            "default": false,
            "description": "Include `[Image: …]` placeholders. Off by default: nothing here decodes pixels, so a placeholder adds noise without adding information.",
            "type": "boolean"
          },
          "max_chars": {
            "default": null,
            "description": "Characters to return before truncating. Omit for the configured default; `0` returns the whole document.",
            "format": "uint",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "pages": {
            "default": null,
            "description": "1-indexed pages to convert; omit for the whole document (a page filter is the cheap way to read a long report).",
            "items": {
              "format": "uint32",
              "minimum": 0,
              "type": "integer"
            },
            "type": [
              "array",
              "null"
            ]
          },
          "password": {
            "default": null,
            "description": "Password for an encrypted document. Never logged or echoed back.",
            "type": [
              "string",
              "null"
            ]
          },
          "path": {
            "default": null,
            "description": "Filesystem path to the PDF. Mutually exclusive with `bytes_base64`.",
            "type": [
              "string",
              "null"
            ]
          },
          "per_page": {
            "default": false,
            "description": "Return markdown per page as well as the joined document. Useful when a caller wants to route some pages to OCR and keep the rest.",
            "type": "boolean"
          },
          "profile": {
            "allOf": [
              {
                "$ref": "#/definitions/Profile"
              }
            ],
            "default": "fidelity",
            "description": "Source fidelity versus token efficiency."
          },
          "strip_headers_footers": {
            "default": true,
            "description": "Strip repeated running headers and footers.",
            "type": "boolean"
          }
        },
        "title": "Request",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "Body": {
            "description": "A body that may have been shortened to fit one response, and the numbers a caller needs to decide what to do about it.\n\nThe cap is what keeps a long document from flooding a model's context. A caller that genuinely wants the whole thing asks for `max_chars: 0`, which is the shape a worker-to-worker pipeline uses to move a document without it passing through anyone's context.",
            "properties": {
              "chars": {
                "description": "Characters returned in `text`.",
                "format": "uint",
                "minimum": 0,
                "type": "integer"
              },
              "preview": {
                "description": "Leading characters of the content. Present only when the body was truncated, so a caller can see the shape of what it did not get without re-reading the start of `text`.",
                "type": [
                  "string",
                  "null"
                ]
              },
              "text": {
                "description": "The content, shortened to the effective character cap.",
                "type": "string"
              },
              "total_chars": {
                "description": "Characters the document actually holds. Equal to `chars` when nothing was dropped.",
                "format": "uint",
                "minimum": 0,
                "type": "integer"
              },
              "truncated": {
                "description": "`true` when `text` stops short of the document. Ask again with `max_chars: 0` to take everything, or on the functions that accept one, narrow with a `pages` filter.",
                "type": "boolean"
              }
            },
            "required": [
              "chars",
              "text",
              "total_chars",
              "truncated"
            ],
            "type": "object"
          },
          "DocumentType": {
            "description": "What a document is made of.",
            "oneOf": [
              {
                "description": "Real text throughout. Extract locally.",
                "enum": [
                  "text_based"
                ],
                "type": "string"
              },
              {
                "description": "Pictures of pages. Every page needs OCR.",
                "enum": [
                  "scanned"
                ],
                "type": "string"
              },
              {
                "description": "Images with little or no text layer.",
                "enum": [
                  "image_based"
                ],
                "type": "string"
              },
              {
                "description": "Some pages carry text, others do not. Read `pages_needing_ocr`.",
                "enum": [
                  "mixed"
                ],
                "type": "string"
              }
            ]
          },
          "PageOcrReason": {
            "description": "Why one page cannot be read without OCR.",
            "properties": {
              "page": {
                "description": "1-indexed page number.",
                "format": "uint32",
                "minimum": 0,
                "type": "integer"
              },
              "reasons": {
                "description": "Machine-readable reasons: `scanned` (a raster page), `no_text` (nothing extractable and nothing to OCR), `vector_text` (characters drawn as outlines rather than text) or `suspected_garbled_text` (a text layer that decodes to nonsense).",
                "items": {
                  "type": "string"
                },
                "type": "array"
              }
            },
            "required": [
              "page",
              "reasons"
            ],
            "type": "object"
          },
          "PageResult": {
            "description": "One page of markdown, with its own OCR verdict.",
            "properties": {
              "markdown": {
                "description": "Markdown for this page.",
                "type": "string"
              },
              "needs_ocr": {
                "description": "`true` when this page's text is not trustworthy and OCR would do better.",
                "type": "boolean"
              },
              "ocr_reason": {
                "description": "Machine-readable reason, when the cause is known.",
                "type": [
                  "string",
                  "null"
                ]
              },
              "page": {
                "description": "1-indexed page number.",
                "format": "uint32",
                "minimum": 0,
                "type": "integer"
              }
            },
            "required": [
              "markdown",
              "needs_ocr",
              "page"
            ],
            "type": "object"
          }
        },
        "properties": {
          "body": {
            "allOf": [
              {
                "$ref": "#/definitions/Body"
              }
            ],
            "description": "The markdown, capped per `max_chars`."
          },
          "document_type": {
            "allOf": [
              {
                "$ref": "#/definitions/DocumentType"
              }
            ],
            "description": "The document-level verdict, so a caller that skipped `pdf::classify` still learns it got nothing because the document is a scan."
          },
          "elapsed_ms": {
            "description": "Wall-clock time for the conversion.",
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "has_encoding_issues": {
            "description": "`true` when font encodings decoded badly. The markdown, if any, is not to be trusted.",
            "type": "boolean"
          },
          "ocr_reasons": {
            "description": "Per-page explanation for `pages_needing_ocr`.",
            "items": {
              "$ref": "#/definitions/PageOcrReason"
            },
            "type": "array"
          },
          "page_count": {
            "description": "Pages in the document.",
            "format": "uint32",
            "minimum": 0,
            "type": "integer"
          },
          "pages": {
            "description": "Per-page markdown, when `per_page` was requested.",
            "items": {
              "$ref": "#/definitions/PageResult"
            },
            "type": [
              "array",
              "null"
            ]
          },
          "pages_converted": {
            "description": "Pages actually converted. Equal to `page_count` unless `pages` was set.",
            "format": "uint32",
            "minimum": 0,
            "type": "integer"
          },
          "pages_needing_ocr": {
            "description": "1-indexed pages that need OCR.",
            "items": {
              "format": "uint32",
              "minimum": 0,
              "type": "integer"
            },
            "type": "array"
          },
          "pages_with_columns": {
            "description": "1-indexed pages laid out in multiple columns.",
            "items": {
              "format": "uint32",
              "minimum": 0,
              "type": "integer"
            },
            "type": "array"
          },
          "pages_with_tables": {
            "description": "1-indexed pages holding a detected table.",
            "items": {
              "format": "uint32",
              "minimum": 0,
              "type": "integer"
            },
            "type": "array"
          },
          "source": {
            "description": "Source label: the file name, or `<inline>` for an in-memory document.",
            "type": "string"
          }
        },
        "required": [
          "body",
          "document_type",
          "elapsed_ms",
          "has_encoding_issues",
          "ocr_reasons",
          "page_count",
          "pages_converted",
          "pages_needing_ocr",
          "pages_with_columns",
          "pages_with_tables",
          "source"
        ],
        "title": "Response",
        "type": "object"
      }
    },
    {
      "description": "Serve the pdf worker's injected console UI assets (content function for its console:script / console:style / console:module triggers).",
      "metadata": {
        "internal": true
      },
      "name": "pdf::ui-content",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Input of the content function: the console asks for one asset by path.",
        "properties": {
          "path": {
            "description": "The asset path from the trigger config (e.g. `state/page.js`).",
            "type": "string"
          }
        },
        "required": [
          "path"
        ],
        "title": "UiContentInput",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Output of the content function.",
        "properties": {
          "content": {
            "description": "The asset source, verbatim.",
            "type": "string"
          },
          "content_type": {
            "description": "MIME type the console should serve the asset with.",
            "type": "string"
          }
        },
        "required": [
          "content",
          "content_type"
        ],
        "title": "UiContentResult",
        "type": "object"
      }
    }
  ],
  "triggers": []
}
```
