
# Passing & receiving files

Every file-taking or file-producing op on Relaystation uses the **same I/O model**. You pick how to pass (or receive) the bytes per call — there is no setup step and no required storage product. Compute is priced per op on the metered input **regardless of where the bytes came from**.

## Passing a file in

Three ways to hand an op its input, picked per call:

- **Inline** — up to **4 MiB** (decoded), pass `{ "inline": "<base64>" }` as the op's file field. Nothing is stored; nothing to clean up.
- **Scratch** — for files over 4 MiB, or whenever you'd rather upload once and reuse:
  1. `POST /v1/cputools/upload-url` (free) returns a presigned upload form and an `inputKey` like `scratch/<your-customer-id>/<uuid>`.
  2. Upload your file (up to **50 MB**) with one multipart POST to that URL.
  3. Pass `{ "inputKey": "..." }` as any op's file field — as many ops, as many times as you like, for **24 hours**.
  Scratch keys are namespaced to your customer identity: another customer's key — even if leaked — resolves to `404` for you, and yours for them. The same gate admits all auth paths (API key, wallet-JWT, or a signed x402 payment), so an account-less agent can use scratch too.
- **A baton** — pass `{ "batonId": "bat_…" }` (optionally `{ "batonId": "bat_…", "entryRef": { "sequenceNumber": N } }` to select one append entry) to read a [baton](https://relaystation.ai/docs/batons) as the op's input. The op's **compute charge** applies exactly as for any other input; the baton's **storage/egress** draw down the **prepaid quota** you bought at create and appear as byte-denominated quota events, never as a second money charge.

> **Baton input works on HTTP and MCP.** `{ "batonId": … }` is a first-class input source on both the HTTP API and the MCP cputools tools (and `deliver` lands an op's output into a baton on both). Ownership is enforced identically on either surface — a baton you don't own resolves to `404`.

This same input model — inline, `inputKey`, or `{ "batonId": … }` — is how you pass the document to the **[Document AI](https://relaystation.ai/docs/docai)** ops (`/v1/doc/*`, including the async multi-page `/v1/doc/analyze-async`) and the image to the **[Vision](https://relaystation.ai/docs/vision)** ops (`/v1/vision/*`). (The [web-render](https://relaystation.ai/docs/web-render) ops are the exception — they take a public `url`, not a file.)

## Getting results back

Every binary-producing op returns the same envelope. Small results arrive **inline** (the gate is the base64-encoded size against a 4 MiB threshold — raw outputs up to ~3 MiB, since base64 inflates ~1.37×); larger ones land in your free 24-hour scratch and arrive as a key + a link:

```json
{ "output": { "inline": "<base64>", "sizeBytes": 51234, "contentType": "application/pdf", "filename": "converted.pdf" } }
{ "output": { "outputKey": "scratch/<you>/<uuid>", "outputUrl": "https://…presigned, valid 1 hour…", "sizeBytes": 12023329, "contentType": "image/png" } }
```

Handle both shapes and you handle every op. Recipes per client:

### curl / shell — to disk

```bash
RESP=$(curl -s -X POST https://api.relaystation.ai/v1/pdf/merge \
  -H "Authorization: Bearer $KEY" -H "Idempotency-Key: $(uuidgen)" \
  -d @payload.json)

if echo "$RESP" | jq -e '.output.inline' > /dev/null; then
  echo "$RESP" | jq -r '.output.inline' | base64 -d > result.pdf
else
  curl -s -o result.pdf "$(echo "$RESP" | jq -r '.output.outputUrl')"
fi
```

The `outputUrl` is presigned — no auth header on that GET (and don't send one; it's a direct storage URL, not an API route).

### Code (fetch) — in an agent sandbox

```js
const { output } = await (await fetch(url, opts)).json();
const bytes = output.inline
  ? Buffer.from(output.inline, 'base64')
  : Buffer.from(await (await fetch(output.outputUrl)).arrayBuffer());
```

If your sandbox blocks outbound domains, allow the API host **and** the storage host the `outputUrl` points at (it is a different domain).

### Chat-based clients (MCP) — present the link

In chat clients the tool result is JSON in the conversation; nobody wants 12 MB of base64 there. For large outputs, surface `outputUrl` to the human as a download link — it works in a browser for one hour. If the session outlives the link, re-fetch a fresh one (below) or chain the `outputKey` into a durable [baton](https://relaystation.ai/docs/batons) when the result must persist.

MCP tool results for a stored binary output also carry a **`resource_link`** content block alongside the JSON — `{ "type": "resource_link", "uri": "<presigned URL>", "name": "<filename>", "mimeType": "<contentType>" }` (MCP spec 2025-06). A client that understands resource links can offer the download directly; one that doesn't simply ignores the extra block — the JSON (and `structuredContent`) still carry `outputKey` + `outputUrl`, so nothing is lost.

### Re-fetching a stored output

The `outputUrl` an op returns expires in an hour. To get a **fresh** link for the same object without re-running the op, call:

```bash
curl -s https://api.relaystation.ai/v1/outputs/scratch/<you>/<uuid> \
  -H "Authorization: Bearer $KEY"
# → { "outputKey", "url": "<fresh presigned GET, 1 hour>", "sizeBytes", "contentType", "expiresAt" }
```

This works for as long as the scratch object lives (24 hours), then returns `404`. It is **free**, and ownership-gated: you can only re-fetch keys under your own `scratch/<you>/` namespace — anyone else's key (or an aged-out one) returns `404`, never a hint that it exists. The `url` it returns is the immediate download; `outputUrl` from the original response stays valid for its hour too.

## Chaining: outputKey is an inputKey

An op's `outputKey` lives in the same namespace as your uploads, so you can feed it straight into the next op's `inputKey` — no download, no re-upload, no pipeline product:

```bash
# 1. Upload once (12 MB scan) → inputKey
curl -s -X POST https://api.relaystation.ai/v1/cputools/upload-url \
  -H "Authorization: Bearer $KEY" -d '{"ext":"png"}'
# → { "inputKey": "scratch/<you>/aaaa.png", "url": ..., "fields": {...} }  (multipart-POST the file to url+fields)

# 2. First op — output exceeds 4 MiB, so it lands in scratch
curl -s -X POST https://api.relaystation.ai/v1/image/convert \
  -H "Authorization: Bearer $KEY" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"file":{"inputKey":"scratch/<you>/aaaa.png"},"format":"png"}'
# → { "output": { "outputKey": "scratch/<you>/bbbb", ... } }

# 3. Chain — the outputKey IS the next inputKey
curl -s -X POST https://api.relaystation.ai/v1/image/metadata \
  -H "Authorization: Bearer $KEY" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"file":{"inputKey":"scratch/<you>/bbbb"}}'
```

Each step is its own pay-per-call op with its own receipt. The bytes never leave the platform between steps, and the 24-hour scratch window comfortably covers a working session. (For chaining the *tabular* ops — filter, sort, join, SQL — in a single call, see [`POST /v1/pipeline`](https://cputools.relaystation.ai/docs/pipeline), which threads bytes step-to-step and bills the sum.)

## Delivering an output into a baton

Instead of receiving the bytes, you can have an op **deliver** its output straight into a [baton](https://relaystation.ai/docs/batons) — durable, shareable, witnessed:

- `"deliver": { "batonId": "bat_…" }` lands the output in an **existing** baton. That's **one** money charge (the op's compute) plus quota events for the storage drawn down — never a second charge for the same byte. The async **[`/v1/doc/analyze-async`](https://relaystation.ai/docs/docai)** op uses this too — pass `deliver: { batonId }` at submit and the parsed multi-page result lands in your baton when the job completes.
- `"deliver": { "new": { "preset": "drop", "tier": "nano" } }` creates a **fresh** baton (quoted exactly like [`POST /v1/baton`](https://relaystation.ai/docs/batons)) and lands the output in it. That's **two ledger lines** — the op's compute charge *and* the baton-create charge — both real, shown distinctly. The whole thing is one idempotent unit: a retried call with the same `Idempotency-Key` returns the original result *and* the original baton id, never a second baton or a second charge.

## Lifetimes

How long each thing lives:

- **`outputUrl`** (a presigned download link) — valid **1 hour** from the response.
- **`outputKey` / `inputKey`** (the scratch object behind it) — lives **24 hours**, then auto-deletes. Reusable across as many ops as you like inside that window.
- **Inline** bytes — never stored; they exist only for the one call.
- **A baton** — lives for the duration you configured at create. When a result must outlive the day — be **durable**, **shareable** (handed to another agent or a human via a token), or **witnessed** (tamper-evident, provable) — that's the baton tier, a paid product with its own quoted price, lifecycle, and trust options. Batons are optional, always: no op requires one, and the [lodestone path](https://relaystation.ai/docs/lodestone) never gains a mandatory storage step.
