> ## Documentation Index
> Fetch the complete documentation index at: https://docs.waniwani.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Documents

> Read a PDF or an image into typed fields, from a URL or from a file the visitor attaches.

<Note>
  **Platform feature.** Requires `WANIWANI_API_KEY`. Works whether your MCP server is self-hosted or on Managed Hosting. [About the Platform](/sdk/platform/overview).
</Note>

Modules are capabilities you turn on per agent in the dashboard. The documents module reads a PDF or an image and gives you back the fields you asked for, shaped by a Zod schema.

A document reaches your agent one of two ways. Either you already have a URL and hand it over, or a visitor attaches a file in the chat widget and the platform stores it for you.

## Setup

The documents client sits on the Waniwani client instance:

```ts theme={null}
import { waniwani } from "@waniwani/sdk";

const wani = waniwani(); // reads WANIWANI_API_KEY from env
```

## Read a document you have a URL for

Describe what you want with a Zod schema and pass the URL:

```ts theme={null}
import { z } from "zod";

const invoice = z.object({
  invoiceNumber: z.string().nullable(),
  total: z.number().nullable(),
  dueDate: z.string().nullable(),
});

const { fields, pageCount } = await wani.documents.extract({
  url: "https://example.com/invoice.pdf",
  filename: "invoice.pdf",
  schema: invoice,
});

console.log(fields.invoiceNumber, pageCount);
```

`filename` is not decoration. It is how an unsupported type gets refused before anything is fetched.

<Warning>
  Extraction runs in strict mode, so mark every field the document might not answer `.nullable()`. A field that is not nullable and not present makes the whole extraction fail rather than coming back empty.
</Warning>

The URL has to be publicly fetchable. Private and loopback addresses are refused.

## What you get back

```ts theme={null}
{
  fields,          // your schema's shape, parsed. A null field is one the document did not legibly answer.
  pageCount,       // pages processed and billed
  pageConfidence,  // mean per-page OCR confidence, or null when none was reported
  documentId,      // handle for this extraction, valid for the 7-day retention window
}
```

## Read a file the visitor attached

Turn **Modules → Documents** on for the agent first. While it is off the widget shows no paperclip and the upload endpoint answers 403, so nothing arrives.

With it on, the visitor gets a paperclip, a drop target and paste. The bytes go straight from the browser to storage, never through your server, and your tool call arrives with a handle instead of a file.

Read it off the scoped client:

```ts theme={null}
import { extractScopedClient } from "@waniwani/sdk/mcp";
import { z } from "zod";

const summary = z.object({
  title: z.string().nullable(),
  summary: z.string().nullable(),
});

server.registerTool(
  "read_attachment",
  {
    title: "Read an attached file",
    description:
      "Read a document or image the visitor attached to this conversation. Takes no arguments: the attachment is already on the request.",
    inputSchema: {},
  },
  async (_args, extra) => {
    const wani = extractScopedClient(extra);
    const document = wani?.attachedDocuments[0];

    if (!wani || !document) {
      return { content: [{ type: "text", text: "Nothing is attached to this message." }] };
    }

    const { fields } = await wani.documents.extract({
      documentId: document.documentId,
      schema: summary,
    });

    return { content: [{ type: "text", text: `${document.filename}: ${fields.summary}` }] };
  },
);
```

Each entry carries `documentId`, `filename` and `mediaType`. Only the id is load-bearing, and there is no filename to pass to `extract()` because the platform kept it.

<Note>
  Your agent needs a tool like this before the toggle does anything visible. The platform stores the file and hands over an id; reading it is the agent's job.
</Note>

Give the tool a description that names the case plainly, including screenshots and photos. A model that does not believe it can read images will refuse before it ever calls your tool.

### On your own chat route

If you run the chat backend yourself rather than using the hosted widget, pull the same handles out of the request body:

```ts theme={null}
import { readAttachedDocuments } from "@waniwani/sdk";

const documents = readAttachedDocuments(body);
```

It accepts the whole body, reads the `documents` array when the widget sent one, and otherwise falls back to the latest user turn in `messages`.

## Read only some pages

Billing is per page processed, so narrow the range when you know where the answer is. Indexes are zero-based, and the option is ignored for images.

```ts theme={null}
await wani.documents.extract({
  documentId,
  schema: invoice,
  pages: [0, 1], // the first two pages
});
```

## Limits

|                   |                                      |
| ----------------- | ------------------------------------ |
| File size         | 50 MB                                |
| PDF length        | 1,000 pages                          |
| Types             | PDF, PNG, JPEG, TIFF, BMP, GIF, WEBP |
| Files per message | 10                                   |
| Retention         | 7 days                               |

Both the size and page caps are the OCR vendor's. A PDF is counted before the vendor is called, so an oversized document is refused rather than billed.

After 7 days the stored file is gone and its `documentId` no longer resolves. Extract what you need and keep the fields, not the id.

## Widget configuration

The hosted widget picks the module state up on its own, so `WaniwaniChat` and the script embed need no code for any of this.

`ChatEmbed`, the bring-your-own-backend primitive, takes it as a prop instead, and can send the upload calls under a different credential than the chat. See [Chat in React](/sdk/chat/react).
