Skip to content

Uploads ​

Status: live contract.

Member plane. POST /agent_api/v1/uploads stages bytes; a later InferenceRequest names them by public_id in upload_public_ids, a conversation's or a run's input in attachments beside its text (conversations.md), a run's model step in its attachments (runs.md). Conversation inputs and model steps accept ordinary files as well as images. Assembly places supported images and PDF documents natively; unsupported PDFs and other files become an index with nexus://uploads/<public_id> for attachment tools. Text-generation InferenceRequests also accept PDFs on models that declare the file input modality. Other workloads keep their own media allowlists. Nothing else in this family accepts binary input; a member read serves bytes back out, and two named reads serve a thumbnail and a preview of them (below).

An upload is staged against the (Account, creator) pair — the creator a member on this door and the session door, an executor on the executor plane's (executor.md, "Captures"); exactly one of the two — which is why it is a sibling of /profile rather than a child of a Workspace. It becomes a Workspace's content when an input, model step, or tool result there references it. New input bindings resolve the submitting member's own staged uploads; executor result captures resolve that executor's uploads. The member descriptor read below remains creator-scoped, while bytes and representations additionally follow access to the content that names the upload.

Create ​

POST /agent_api/v1/uploads — multipart/form-data, one part named upload[file].

POST /agent_api/v1/uploads
Authorization: Bearer sk-cybros-api-v1-...
Content-Type: multipart/form-data; boundary=...

--...
Content-Disposition: form-data; name="upload[file]"; filename="clip.m4a"
Content-Type: audio/mp4

<bytes>
--...--

201 with the staged descriptor:

json
{
  "upload": {
    "public_id": "01a0...",
    "filename": "clip.m4a",
    "content_type": "audio/mp4",
    "byte_size": 20480,
    "created_at": "2026-08-27T00:00:00Z"
  }
}

The bytes decide what they are. content_type is detected from the content itself, never from the part's declared type or the filename you sent. Bytes nothing recognizes are stored as application/octet-stream rather than borrowing your claim, and a later request that needs a modality this lane does not admit is refused then.

Media extensions follow the detection. filename is returned because it is part of the wire: a transcription provider reads the extension to decide how to decode the audio, and the same bytes sent as clip.mp3 are refused where clip.wav is accepted. Your extension is kept when it is one the detected type allows — audio/mp4 permits m4a, mp4a and m4b, and only you know which you meant — and replaced when it is not. This correction applies to detected audio, image, video and PDF types. Other files preserve their sanitized original filename, including code, Markdown and Office extensions; the extension never changes their detected content_type. A claimant reads bound input files through the executor attachment resources.

Native PDF input uses the original document bytes, never its first-page preview. The selected model must declare file input and its wire must admit application/pdf. PDF bytes use the existing 8 MiB inline media bound; staging a larger file succeeds within upload_bound, but a native model request rejects bytes above the inline bound. Nexus does not split documents, extract their text or render their pages for inference. Other file types, including Office documents and plain text files, continue through attachment tools.

There is no idempotency key. A repeated POST is a second upload, which is what sending the bytes twice asked for.

StatusCodeMeaning
400parameter_missingthe upload root or its file member is absent
400parameter_invalidupload[file] is present but is not a file part
413content_too_largethe bytes exceed upload_bound (see the size-bound contract)
404representation_unavailablea thumbnail or preview read of an upload that has no representation of that kind (below)

The shipped container also sets Thruster's pre-Rack request-body limit to 110 MiB. That leaves multipart envelope headroom above the application's 100 MiB file bound while refusing much larger bodies before Rack parses or spools them. A 413 generated by that proxy may not use the JSON error envelope above. Deployments that replace Thruster should configure an equivalent front-proxy cap; the application-level upload_bound remains authoritative for the uploaded file itself.

Read ​

GET /agent_api/v1/uploads/{public_id}
Authorization: Bearer sk-cybros-api-v1-...

Returns the same descriptor at 200. Only the creator's own uploads are visible; anything else is 404, which is also what an id that never existed answers.

Staged bytes a request never binds are reclaimed on their own — a member's staged upload and an executor's capture alike, after a day.

Bytes ​

GET /agent_api/v1/uploads/{public_id}/bytes
Authorization: Bearer sk-cybros-api-v1-...
Range: bytes=0-1023

The bytes, streamed (Accept-Ranges: bytes; a Range header answers 206 and the slice), Content-Type the detected type, as an attachment. ONE rule: the row is readable by its creator, or by a principal who can read a row that NAMES it — a conversation input's or a run step's attachment, a tool result's capture, a sealed request's placed picture, a InferenceRequest's input — judged through the funnel that row's own read uses. Absent, foreign and unreadable answer 404 alike. There is no index and no destroy.

A fork reader can read pictures bound to its inherited variants through the child's current conversation ACL, without access to the source conversation. Only turns within the child's closure count; a concealed inherited turn grants no read, and workspace access remains required. A plain fork retains this access after its source is tombstoned.

Thumbnail and preview ​

GET /agent_api/v1/uploads/{public_id}/thumbnail
GET /agent_api/v1/uploads/{public_id}/preview
Authorization: Bearer sk-cybros-api-v1-...

Two NAMED reads of the upload's representation — a picture of it a UI can show — under the same rule as bytes, whole (no Range: a representation is small), served inline with the representation's own Content-Type. The presets are named in code and never supplied by the client (there is no ?preset=): the thumbnail is bounded at 256 px on its longest edge, the preview at 1600 px (representation_bounds in the contract pack). One mechanism draws every kind — Active Storage's own representation: a variant for an image, a previewer's render for a PDF's first page (poppler's pdftoppm) or a video's frame (ffmpeg) — and the model-facing prepared variant an image lane is handed is the same table's third entry, reached by the request assembly, never by a route. A representation is rendered on its first read and found on every later one. The Content-Type is the representation's: a PNG stays PNG, a JPEG stays JPEG, a PDF's page or a video's frame is PNG.

An upload that has no representation of that kind — a text file, an audio clip, a PDF or a video on a host without the tool (the previewer is what decides; install/README.md names the packages) — answers 404 representation_unavailable, never a failure. Absent, foreign and unreadable are the plain 404 first, exactly as bytes.

Conditional GET ​

An upload is immutable, so every attachment read — bytes, thumbnail, preview — is a conditional GET:

HTTP/1.1 200 OK
ETag: "3f1c…"
Cache-Control: private, max-age=31556952

The ETag is strong (never W/), derived from the blob's checksum and the read's kind — one tag per kind, stable across reads, and another kind's tag never matches. Cache-Control is private because the read is ACL'd, with a year's max-age. A read carrying If-None-Match with the matching tag answers 304 Not Modified with no body, before a byte is streamed or a representation is rendered — on a Range read too. The SDK reads the tag back (uploads.bytes(id, io).etag) and passes it in (etag:), answering the 304 as a typed result rather than an exception.

The session door ​

The console has the same ingest behind its own authentication: POST /uploads on the session plane (the browser's signed-in cookie), multipart/form-data with the same upload[file] part, answering the same 201 descriptor, the same 400s and the same 413. It calls the one ingest — the bytes decide, the size bound is checked at the door — and stages the upload under the signed-in user as creator, so an input of theirs later names it exactly as the member door's would. A signed-out caller is redirected to sign in, as on every console door. There are no framework direct-upload endpoints (no /rails/active_storage/direct_uploads, no disk PUT, no blob URLs): a direct upload would create the blob from the client's declared type and size, against the contract above.

Nexus is licensed O’Saasy. The rest of the repository defaults to MIT.