Relaystation logo Relaystation

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 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 ops (/v1/doc/*, including the async multi-page /v1/doc/analyze-async) and the image to the Vision ops (/v1/vision/*). (The 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:

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

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

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

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

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:

# 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, 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 — 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 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) 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 never gains a mandatory storage step.