For agents

Files & media

One upload API — reserve a presigned PUT, send the bytes, poll readiness. URLs are references on posts; files are a first-class resource with get, list, and delete.

Media in Inklate follows three rules:

  1. There is exactly one upload API. Reserve a presigned upload for a local file, PUT the raw bytes to the returned URL, then poll the file until it’s ready. There is no inline base64 upload and no “complete” call — a server-side watcher finalizes the moment the bytes land.
  2. Files already hosted at a public URL are referenced, not uploaded. Put the https:// URL straight into a post’s media list; the server fetches, verifies, and stores it, and the response tells you the file id to poll.
  3. Uploaded objects are a files resource. Read one at GET /files/{fileId}, list them at GET /files, delete at DELETE /files/{fileId}. Upload is the action; files are the nouns.

Supported types and limits

KindMIME typesMax size
Imageimage/jpeg, image/png, image/gif, image/webp25 MB
Videovideo/mp4, video/quicktime512 MB

A declared type outside this table fails the upload call immediately with error.reason: "unsupported_type". Bytes that sniff to a different type than declared fail with content_mismatch — the declared MIME is never trusted. WebM is not accepted (its container can’t be probed for the facts per-channel checks need).

Time-to-live values: the presigned PUT URL lasts 3600 s, a signed download URL lasts 600 s, and an unfilled reservation expires after 2 hours (then it’s garbage-collected).

Upload flow (local files)

media.upload → PUT each file’s bytes → poll files.get until readiness leaves "processing". The same contract on REST, MCP, and tRPC.

# 1. Reserve — 1–10 files per call, all-or-nothing.
curl -X POST https://api.inklate.com/api/v1/media/upload \
  -H "Authorization: Bearer inklate_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: launch-carousel-1" \
  -d '{ "files": [
        { "mime_type": "image/jpeg", "bytes": 812340 },
        { "mime_type": "video/mp4",  "bytes": 31457280 }
      ] }'
# → { "files": [ { "file": { "id": "file_…", "readiness": "processing", … },
#                  "upload": { "url": "https://…", "headers": { "Content-Type": "image/jpeg" },
#                              "expires_in_seconds": 3600 } }, … ],
#     "replayed": false }

# 2. PUT the raw bytes with the returned Content-Type. No complete call.
curl -T photo.jpg -H "Content-Type: image/jpeg" "https://…presigned…"

# 3. Poll until readiness leaves "processing".
curl -H "Authorization: Bearer inklate_XXXXXXXX" \
  https://api.inklate.com/api/v1/files/file_abc

Batch semantics: an invalid entry fails the whole call with the offending index ({ "error": { "code": "BAD_REQUEST", "reason": "unsupported_type", "index": 1, … } }) and nothing is created. After creation the files are fully independent — each has its own PUT URL and its own finalize watcher; one failed PUT never affects its siblings.

Send an Idempotency-Key header (or idempotency_key in the body): a retried call with the same key returns the original reservations, with fresh PUT URLs for files whose bytes haven’t landed yet ("replayed": true).

Poll a whole batch in one round-trip with GET /files?ids=file_a,file_b,file_c.

URL references (files hosted elsewhere)

Don’t download-and-reupload a file that already has a public URL — reference it on the post:

curl -X POST https://api.inklate.com/api/v1/posts \
  -H "Authorization: Bearer inklate_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{
    "body": "Launch day.",
    "channels": ["chn_…"],
    "media": ["file_9f2…", "https://cdn.example.com/launch.mp4"]
  }'
# → { …post…, "media": [
#      { "fileId": "file_9f2…", "sourceUrl": null },
#      { "fileId": "file_new…", "sourceUrl": "https://cdn.example.com/launch.mp4" } ] }

The media list mixes file ids and https:// URLs in render order. Each URL is ingested server-side (https-only, public hosts only, verified against the same type/size table) and becomes a normal file; the response echoes the url → fileId mapping so you can poll each one to readiness. This is how chat-hosted agents — which can’t PUT raw bytes — attach video.

The state machine

The raw status walks pending_upload → uploaded → processing → ready | failed | rejected, with a parallel scan verdict. Act on readiness, the server-folded 3-bucket field:

  • "processing" — keep polling.
  • "ready" — publishable; include_url=true now returns a signed download URL.
  • "failed" — terminal; read error.reason, fix, re-upload.

A post can be created while an attachment is still processing; scheduling/publishing waits for ready.

Error reasons

Failures carry a machine slug beside the human message — in the file’s error.reason, the REST error envelope, and the MCP error text ([reason:…]).

ReasonMeaningDo
too_largeBytes exceed the per-kind ceilingCompress or trim, re-upload
unsupported_typeType not in the supported tableConvert (e.g. WebM → MP4)
content_mismatchBytes sniff to a different type than declaredDeclare the real type
emptyZero bytes landedRe-upload the real file
upload_incompleteThe PUT never finished before the reservation expiredReserve again, PUT again
fetch_failedA URL reference couldn’t be fetched (or was refused)Check the URL is public https
decode_failedThe bytes aren’t a decodable image/videoRe-encode the file
infectedFailed the virus scanDon’t attach it

The files resource

  • GET /files/{fileId}?include_url=true&variant=thumbnail — one file; include_url adds { url, url_expires_in_seconds } once ready (variant signs a named derivative instead of the original).
  • GET /files?limit=20&cursor=…&readiness=ready&kind=image&ids=… — newest first, keyset-paginated via next_cursor.
  • DELETE /files/{fileId} — permanent and irreversible; refused with 409 while the file is attached to a post (update the post’s media list first).

The MCP tools mirror this one-to-one: files_upload, files_get, files_list, files_delete — with the caveat that files_upload needs a runtime that can PUT raw bytes (CLI agents); chat-hosted agents use URL references on posts_create instead.