File transfers

Send large files to a tool, and fetch large files a tool returns

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 file50 MB, both directions
File reference lifetimeOne hour from the reserve call
Download URL lifetimeOne hour from the tool call
File reference reuseSingle use, spent by the first tool call that succeeds
Download URL reuseReusable until it expires

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 -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
> }'

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.

1{
2 "file_reference": "kP7nQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7iO2",
3 "upload": {
4 "url": "https://ah-api.merge.dev/api/v1/files/kP7nQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7iO2/upload/",
5 "method": "PUT",
6 "headers": { "Authorization": "Bearer <YOUR_ORGANIZATION_API_KEY>" }
7 },
8 "max_size_bytes": 52428800,
9 "expires_at": "2026-08-13T18:42:11Z"
10}

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 -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"

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

1{
2 "file_reference": "kP7nQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7iO2",
3 "size_bytes": 8340112,
4 "expires_at": "2026-08-13T18:42:11Z"
5}

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:

ToolPath to the argumentAlso required
sharepoint__files_upload_iteminput.file_referenceinput.drive_id, input.filename
google_drive__upload_fileinput.file_referenceinput.name
gmail__create_draftinput.file_referenceinput.file_reference_filename
outlook__create_draft_messagefile_reference, at the top levelfile_reference_filename

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

1{
2 "jsonrpc": "2.0",
3 "id": 1,
4 "method": "tools/call",
5 "params": {
6 "name": "sharepoint__files_upload_item",
7 "arguments": {
8 "input": {
9 "drive_id": "b!aQ2vXm4TzB8dLrW1yF6gH0",
10 "filename": "acme-msa-2026.pdf",
11 "parent_path": "Shared Documents/Contracts",
12 "file_reference": "kP7nQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7iO2"
13 }
14 }
15 }
16}

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 50 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:

ToolWorth knowing
gmail__get_attachmentThe URL result carries no extracted_text, so you lose the parsed PDF or DOCX text
google_drive__export_fileGoogle’s 10 MB export cap still applies to the export itself
google_drive__download_fileCannot be combined with range
outlook__get_message_attachmentFile attachments only; an itemAttachment or referenceAttachment returns unsupported_attachment_type
sharepoint__files_download_itemCaps an inline download at 10 MB
sharepoint_gcc__files_download_itemCaps 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.

1{
2 "jsonrpc": "2.0",
3 "id": 2,
4 "method": "tools/call",
5 "params": {
6 "name": "sharepoint__files_download_item",
7 "arguments": {
8 "input": {
9 "drive_id": "b!aQ2vXm4TzB8dLrW1yF6gH0",
10 "item_id": "01QRSTUVWXYZ2vXm4TzB8dLrW1yF6g",
11 "return_download_url": true
12 }
13 }
14 }
15}

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

1{
2 "download": {
3 "url": "https://ah-api.merge.dev/api/v1/files/tR4mK8pQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7/download/",
4 "method": "GET",
5 "headers": { "Authorization": "Bearer <YOUR_ORGANIZATION_API_KEY>" },
6 "expires_at": "2026-08-13T19:42:11Z",
7 "size_bytes": 8340112,
8 "content_type": "application/pdf",
9 "filename": "acme-msa-2026.pdf"
10 }
11}

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 -o acme-msa-2026.pdf \
> "https://ah-api.merge.dev/api/v1/files/tR4mK8pQ2vXm4TzB8dLrW1yF6gH0jS5cN9aE3uQ7/download/" \
> -H "Authorization: Bearer $MERGE_AH_API_KEY"

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:

$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.

WhereStatusWhat happenedWhat to do
Reserve400Missing filename, or size_bytes over 50 MBFix the request body
Upload400The request body was emptySend the file as the body, with no multipart wrapper
Upload404The reference is unknown, or belongs to another organizationReserve a new slot
Upload409This reservation was already uploadedReserve a new slot for a replacement
Upload410The reservation expired before the bytes arrivedReserve a new slot
Upload413The file is over 50 MBSplit the file, or use a connector’s own upload path
Download404The URL is unknown, or belongs to another organizationRun the tool again
Download410The URL expiredRun the tool again for a fresh URL
Download416The Range you asked for does not fit the fileRead the size from Content-Range and re-request
Tool callinvalid_argumentsThe reference is unknown, expired, already spent, or was never uploadedThe 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.