> This page is for Agent Handler.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.merge.dev/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.merge.dev/_mcp/server.

# File transfers

> How to stage a file with the reserve and upload endpoints and pass it to a tool, and how to fetch a file a tool hands back as a download URL.

Tool arguments and tool results are JSON that passes through your agent's context, which makes them the wrong place for an 8 MB PDF. File transfers route the bytes around the model: you stage a file in Agent Handler and give the tool a reference to it, or a tool stages its output and gives you a download URL. The file content never enters the conversation, so it costs no tokens and is never at risk of being truncated to fit a context window.

Reach for this whenever a tool takes or returns real file content: attaching a signed contract to a draft email, uploading a scanned document to a library, pulling an invoice out of a mailbox.

## Limits

|                         |                                                        |
| ----------------------- | ------------------------------------------------------ |
| Largest file            | 100 MB, both directions                                |
| File reference lifetime | One hour from the reserve call                         |
| Download URL lifetime   | One hour from the tool call                            |
| File reference reuse    | Single use, spent by the first tool call that succeeds |
| Download URL reuse      | Reusable until it expires                              |

### Where a connector caps lower

A few connectors have their own limits below 100 MB, set by the third party rather than by Agent Handler. The tool's error tells you when you have hit one.

| Connector           | Limit              |
| ------------------- | ------------------ |
| HiBob               | 5 MB               |
| Google Drive export | 10 MB              |
| Gmail upload        | 15 MB              |
| Gmail download      | 25 MB              |
| DocuSign            | 25 MB per document |
| Box upload          | 50 MB              |

Every request on this page authenticates with the same Access Key that authorizes your MCP calls:

```
Authorization: Bearer <YOUR_API_KEY>
```

## Sending a file to a tool

Reserve a slot, send the bytes, then name the reference in the tool call. The three steps are separate requests because the middle one can take a while and you want the other two to be cheap.

The first two steps belong in your application code, not in the agent loop. Your backend already has the file on disk or in a bucket; the agent does not, and cannot make an HTTP PUT. By the time the model chooses a tool, all it needs is the `file_reference` string.

### 1. Reserve a slot

`POST /api/v1/files/` records the file's metadata and tells you where to send the bytes. Nothing moves yet.

#### cURL

```bash
curl -X POST https://ah-api.merge.dev/api/v1/files/ \
  -H "Authorization: Bearer $MERGE_AH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "acme-msa-2026.pdf",
    "content_type": "application/pdf",
    "size_bytes": 8340112
  }'
```

#### Python

```python
import os
import requests

BASE = "https://ah-api.merge.dev/api/v1"
AUTH = {"Authorization": f"Bearer {os.environ['MERGE_AH_API_KEY']}"}
path = "acme-msa-2026.pdf"

reservation = requests.post(
    f"{BASE}/files/",
    headers=AUTH,
    json={
        "filename": os.path.basename(path),
        "content_type": "application/pdf",
        "size_bytes": os.path.getsize(path),
    },
    timeout=30,
).json()
```

`filename` is the name the file carries into the connector, and it is the only required field. `content_type` defaults to `application/octet-stream`. `size_bytes` is optional and buys you one thing: a `400` on a file that is over the limit, before you spend bandwidth sending it. The upload enforces the limit either way.

```json
{
  "file_reference": "kP7nQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7iO2",
  "upload": {
    "url": "https://ah-api.merge.dev/api/v1/files/kP7nQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7iO2/upload/",
    "method": "PUT",
    "headers": { "Authorization": "Bearer <YOUR_ORGANIZATION_API_KEY>" }
  },
  "max_size_bytes": 104857600,
  "expires_at": "2026-08-13T18:42:11Z"
}
```

The `Authorization` value under `upload.headers` is a placeholder, not a key. Agent Handler never echoes your Access Key back to you; send your own.

### 2. Send the bytes

PUT the raw file to `upload.url`. There is no multipart wrapper and no form encoding: the request body is the file itself, streamed.

#### cURL

```bash
curl -X PUT --upload-file acme-msa-2026.pdf \
  "https://ah-api.merge.dev/api/v1/files/kP7nQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7iO2/upload/" \
  -H "Authorization: Bearer $MERGE_AH_API_KEY" \
  -H "Content-Type: application/pdf"
```

#### Python

```python
with open(path, "rb") as body:
    uploaded = requests.put(
        reservation["upload"]["url"],
        headers={**AUTH, "Content-Type": "application/pdf"},
        data=body,
        timeout=300,
    ).json()
```

Passing an open file object as `data` streams it, so a 100 MB upload does not need 100 MB of memory. The response confirms the byte count that actually arrived:

```json
{
  "file_reference": "kP7nQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7iO2",
  "size_bytes": 8340112,
  "expires_at": "2026-08-13T18:42:11Z"
}
```

One reservation accepts one successful upload. To replace a file, reserve a new slot.

### 3. Call the tool

Pass the `file_reference` value as the tool's `file_reference` argument. Every upload tool also accepts inline `content` for small files, and the two are mutually exclusive: provide one or the other, never both.

Where the argument sits depends on the tool's own input schema, which you can read from `tools/list`:

| Tool                            | Path to the argument               | Also required                      |
| ------------------------------- | ---------------------------------- | ---------------------------------- |
| `sharepoint__files_upload_item` | `input.file_reference`             | `input.drive_id`, `input.filename` |
| `google_drive__upload_file`     | `input.file_reference`             | `input.name`                       |
| `gmail__create_draft`           | `input.file_reference`             | `input.file_reference_filename`    |
| `outlook__create_draft_message` | `file_reference`, at the top level | `file_reference_filename`          |

A `tools/call` uploading the reserved PDF into a SharePoint library:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "sharepoint__files_upload_item",
    "arguments": {
      "input": {
        "drive_id": "b!aQ2vXm4TzB8dLrW1yF6gH0",
        "filename": "acme-msa-2026.pdf",
        "parent_path": "Shared Documents/Contracts",
        "file_reference": "kP7nQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7iO2"
      }
    }
  }
}
```

Agent Handler validates the reference, streams the staged bytes into SharePoint, and returns the created item. Your agent sees the item's ID and metadata, never the file content. The reference is spent once the call succeeds, and the staged copy is deleted.

A call that fails leaves the reference usable, so you can retry it without uploading again.

## Getting a file back from a tool

File-returning tools answer inline by default, encoded as text or base64. Set `return_download_url` to `true` and you get a URL instead, which handles the full 100 MB and keeps the bytes out of the agent's context.

Inline is not a uniform ceiling. Some tools reject an oversized file outright rather than truncating it: both SharePoint tools cap an inline download at 10 MB, and `google_drive__export_file` inherits Google's own 10 MB export cap. Others stream whatever the provider returns. Where a cap exists, the tool's error tells you to retry with `return_download_url`, so treat the URL as the default for anything you did not author yourself.

When you do take content inline, read the `encoding` field next to it instead of assuming a format. Both SharePoint tools decide per file rather than per file type: the connector tries to decode the bytes as UTF-8 and falls back to base64 only when that fails. A CSV comes back as `utf-8`, and a PDF fetched through the same call comes back as `base64`.

Branch on `encoding`, because the wrong guess fails quietly. Base64-decoding a `utf-8` payload does not raise; it skips every character that is not valid base64 and hands you a shorter blob. A 9,438,000 byte CSV decodes that way into 6,259,500 bytes of noise, which reads like a truncated download rather than a decoding mistake.

### 1. Ask for a URL

Ten tools support this today. Each takes the argument at `input.return_download_url`, nested one level, because each declares a single input model. That is more consistent than the upload side, where the depth varies per tool.

Four of them set the flag and hand back a URL, with nothing else to watch for: `docusign__get_document`, `docusign__download_all_documents`, `onedrive__download_item`, and `onedrive_gcc__download_item`.

The other six change behavior in a way worth knowing before you call them:

| Tool                                  | Worth knowing                                                                                         |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `gmail__get_attachment`               | The URL result carries no `extracted_text`, so you lose the parsed PDF or DOCX text                   |
| `google_drive__export_file`           | Google's 10 MB export cap still applies to the export itself                                          |
| `google_drive__download_file`         | Cannot be combined with `range`                                                                       |
| `outlook__get_message_attachment`     | File attachments only; an itemAttachment or referenceAttachment returns `unsupported_attachment_type` |
| `sharepoint__files_download_item`     | Caps an inline download at 10 MB                                                                      |
| `sharepoint_gcc__files_download_item` | Caps an inline download at 10 MB                                                                      |

Google Drive spells the argument `returnDownloadUrl` in its schema and accepts either spelling. The rest use `return_download_url`.

> **Where a Gmail attachment ID comes from**
>
> `gmail__get_attachment` takes an `attachment_id` that you read off the message part, and Gmail payloads return that field as `attachmentId` in camelCase while the fields beside it (`part_id`, `mime_type`, `label_ids`) stay snake\_case. Reading `attachment_id` gives you nothing, and the call then fails on an ID you never picked up.
>
> A draft is readable the same way a sent message is. Pass the `message_id` that `list_drafts` and `get_draft` return, not the `draft_id`.

```json
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "sharepoint__files_download_item",
    "arguments": {
      "input": {
        "drive_id": "b!aQ2vXm4TzB8dLrW1yF6gH0",
        "item_id": "01QRSTUVWXYZ2vXm4TzB8dLrW1yF6g",
        "return_download_url": true
      }
    }
  }
}
```

Agent Handler fetches the file from the connector, stores it, and puts a `download` block in the tool result in place of the content:

```json
{
  "download": {
    "url": "https://ah-api.merge.dev/api/v1/files/tR4mK8pQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7/download/",
    "method": "GET",
    "headers": { "Authorization": "Bearer <YOUR_ORGANIZATION_API_KEY>" },
    "expires_at": "2026-08-13T19:42:11Z",
    "size_bytes": 8340112,
    "content_type": "application/pdf",
    "filename": "acme-msa-2026.pdf"
  }
}
```

As on the upload side, the `Authorization` header here is a placeholder. The URL alone is not enough to fetch the file: a request needs the token in the URL *and* your Access Key, so a leaked URL on its own is useless.

### 2. Fetch the file

#### cURL

```bash
curl -o acme-msa-2026.pdf \
  "https://ah-api.merge.dev/api/v1/files/tR4mK8pQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7/download/" \
  -H "Authorization: Bearer $MERGE_AH_API_KEY"
```

#### Python

```python
download = tool_result["download"]

with requests.get(download["url"], headers=AUTH, stream=True, timeout=300) as resp:
    resp.raise_for_status()
    with open(download["filename"], "wb") as out:
        for chunk in resp.iter_content(chunk_size=64 * 1024):
            out.write(chunk)
```

The response streams, and carries `Content-Type`, `Content-Length`, and `Content-Disposition` from the stored file.

Two extras are worth knowing about. The endpoint accepts a `Range` header, so you can resume an interrupted download or read part of a large file:

```bash
curl "$DOWNLOAD_URL" \
  -H "Authorization: Bearer $MERGE_AH_API_KEY" \
  -H "Range: bytes=1048576-2097151"
```

That returns `206` with a `Content-Range` header, or `416` if the range does not fit the file. And a `HEAD` request to the same URL returns the size, type and filename with no body, which is enough to decide whether you want the file at all.

Unlike a file reference, a download URL is reusable. Fetch it as many times as you like within the hour.

## Errors worth handling

These are the failures your code can do something about. The three endpoints return standard HTTP statuses; a failure inside a tool call comes back as a tool error carrying `error_reason` and `error_code`.

| Where     | Status              | What happened                                                           | What to do                                           |
| --------- | ------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------- |
| Reserve   | `400`               | Missing `filename`, or `size_bytes` over 100 MB                         | Fix the request body                                 |
| Upload    | `400`               | The request body was empty                                              | Send the file as the body, with no multipart wrapper |
| Upload    | `404`               | The reference is unknown, or belongs to another organization            | Reserve a new slot                                   |
| Upload    | `409`               | This reservation was already uploaded                                   | Reserve a new slot for a replacement                 |
| Upload    | `410`               | The reservation expired before the bytes arrived                        | Reserve a new slot                                   |
| Upload    | `413`               | The file is over 100 MB                                                 | Split the file, or use a connector's own upload path |
| Download  | `404`               | The URL is unknown, or belongs to another organization                  | Run the tool again                                   |
| Download  | `410`               | The URL expired                                                         | Run the tool again for a fresh URL                   |
| Download  | `416`               | The `Range` you asked for does not fit the file                         | Read the size from `Content-Range` and re-request    |
| Tool call | `invalid_arguments` | The reference is unknown, expired, already spent, or was never uploaded | The message says which; reserve and upload again     |

A reserved slot whose bytes never arrive is not an error until you use it. It expires quietly after an hour and is cleaned up.

## Next

Read the request and response schemas for both endpoints in the [Files API reference](/merge-agent-handler/agent-handler/files).