Build

Files and versions

Spaces, paths and nodes; uploading and downloading through the block gateway; version history, names and comparisons.

Spaces and paths

Shell
curl -H "Authorization: Bearer $SIDESPACE_TOKEN" https://side.example.com/v1/spaces

Each space has a root_node_id. Below it, every file and folder is a node with an id (nod_…). You can work by id or by path:

ToCall
Find the node at a pathGET /v1/spaces/{sid}/paths/Ep01/Shots/A001_C003.mov
List a folder (by name, paged)GET /v1/spaces/{sid}/nodes/{nid}/children?limit=200&cursor=…
Create a folder or an empty filePOST /v1/spaces/{sid}/nodes with parent_id, name, kind (dir or file)
Rename or movePATCH /v1/spaces/{sid}/nodes/{nid} with name and/or parent_id
Delete (to the trash, 30 days)DELETE /v1/spaces/{sid}/nodes/{nid}
Restore from the trashPOST /v1/spaces/{sid}/nodes/{nid}/restore

Names are unique in a folder, ignoring case. When you create a node whose name is taken, the default if_exists: "rename" makes A001_C003 (2).mov; if_exists: "reuse" returns the existing node instead, so two programs saving the same path end up with one file and two versions.

PATCH takes an optional If-Match: <meta_rev> header. If someone renamed or moved the node since you read it, you get 412 instead of overwriting their change.

The SDKs and the CLI take paths everywhere and resolve them for you:

Shell
sidespace ls "Film A" Ep01/Shots
sidespace mv "Film A" Ep01/old.mov Ep01/Archive/old.mov
sidespace rm "Film A" Ep01/tmp

How bytes move

The API server never carries file content. It hands out short tickets for the block gateway, which stores content as encrypted, deduplicated chunks (content-defined, BLAKE3-hashed) and streams it.

  • Uploading the same footage twice, or a re-export that changed only the metadata, sends only the chunks the gateway doesn't have.
  • Reads support HTTP Range, and the gateway reads only the chunks a range touches. A player can start in the middle of a 200 GB file at once.
  • Tickets last 15 minutes, or 6 hours with "stream": true for a player.

Upload

The SDKs and the CLI do all of this in one call (side.upload(space, path, data)). Over plain HTTP, an upload is three steps.

1. Get the node (create it, or reuse the file already at that name):

Shell
curl -X POST https://side.example.com/v1/spaces/$SPACE/nodes \
  -H "Authorization: Bearer $SIDESPACE_TOKEN" -H "Content-Type: application/json" \
  -d '{"parent_id": "'$FOLDER'", "name": "A001_C003.mov", "kind": "file", "if_exists": "reuse"}'

2. Send the bytes. Open an upload session to get a put ticket, then stream the whole file to the gateway. It chunks and stores it and answers with the manifest.

Shell
curl -X POST https://side.example.com/v1/spaces/$SPACE/upload-sessions \
  -H "Authorization: Bearer $SIDESPACE_TOKEN" -H "Content-Type: application/json" -d '{"hashes": []}'
# {"missing": [], "ticket": "…", "blockgw_url": "https://blocks.side.example.com", "expires_at": "…"}

curl -X POST "https://blocks.side.example.com/stream-upload?t=$TICKET" --data-binary @A001_C003.mov
# {"algo": "fastcdc-v1", "size": 734003200, "chunks": [{"hash": "9f2c…", "offset": 0, "len": 1048576}, …]}

Clients that chunk locally (the Mac app and the CLI do) send their chunk hashes in hashes, upload only the missing ones with PUT /chunks/{hash}?t=…, and build the manifest themselves.

3. Commit the version:

Shell
curl -X POST https://side.example.com/v1/spaces/$SPACE/nodes/$NODE/versions \
  -H "Authorization: Bearer $SIDESPACE_TOKEN" -H "Content-Type: application/json" \
  -d '{"base_version_id": "ver_…", "manifest": {…}, "mime": "video/quicktime", "client_mtime": "2026-11-14T09:30:00Z"}'

base_version_id is the version you started from (null for a new file). If someone else saved in between, your version still becomes current (last save wins), the answer's superseded names the version you replaced, and its author gets a notification. Nothing is lost: both are in the history. A missing chunk answers 409 missing_chunks.

Download

Ask for a ticket, then read from the gateway:

Shell
curl -X POST https://side.example.com/v1/spaces/$SPACE/download-tickets \
  -H "Authorization: Bearer $SIDESPACE_TOKEN" -H "Content-Type: application/json" \
  -d '{"version_id": "ver_…"}'
# {"ticket": "…", "manifest": {…}, "blockgw_url": "https://blocks.side.example.com", "expires_at": "…"}

curl -H "Range: bytes=0-1048575" "https://blocks.side.example.com/files/ver_…?t=$TICKET" -o head.bin

Add download=<file name> to the URL to get a Content-Disposition: attachment header, which is handy for links in a browser. The gateway answers CORS for /files/*, so a web page can play or fetch a file directly.

const bytes = await side.download("Film A", "/Ep01/notes.md");
const res = await side.open("Film A", "/Ep01/Shots/A001_C003.mov", { range: [0, 1_048_575] }); // a fetch Response

Renditions

The server makes a thumb (poster image) for images and videos, and a proxy for videos: a 720p H.264 stream that plays at once in any browser, whatever the original's codec or size. Ask for one with "rendition": "thumb" or "proxy" in the ticket request. Your own tools can attach renditions with PUT /v1/spaces/{sid}/versions/{vid}/renditions/{kind} (scope renditions:write).

Versions

Shell
sidespace versions "Film A" Ep01/cut.mp4
sidespace get "Film A" Ep01/cut.mp4 cut_v3.mp4 --version 3
sidespace label "Film A" Ep01/cut.mp4 3 "Client cut"
sidespace compare "Film A" Ep01/cut.mp4 "Client cut"     # against the current version
sidespace restore "Film A" ver_01JD…
ToCall
History, newest firstGET /v1/spaces/{sid}/nodes/{nid}/versions
Make an old version current again (a copy, history kept)POST /v1/spaces/{sid}/versions/{vid}/restore
Name a version ("Client cut"), or clear it with ""PUT /v1/spaces/{sid}/versions/{vid}/label
What changed between two versionsGET /v1/spaces/{sid}/versions/{a}/compare/{b}

A name belongs to one version of a file at a time, ignoring case: naming another version "Client cut" moves the name. Anywhere the CLI and MCP take a version you can give an id, a number (3 or v3) or a name.

Compare answers without downloading anything, from the manifests and sidecars:

JSON
{
  "same_content": false,
  "size_delta": -1048576,
  "bytes": { "shared": 690000000, "only_a": 44003200, "only_b": 42954624, "shared_ratio": 0.94 },
  "media": [{ "field": "duration_s", "a": 92.4, "b": 88.0 }],
  "sidecars": [{ "namespace": "ai.tags", "change": "changed", "fields": ["tags"] }],
  "renditions": { "ver_A…": ["thumb", "proxy"], "ver_B…": ["thumb"] }
}

shared_ratio close to 1 means a metadata-only re-save or a trim; close to 0 means new footage.

To see a whole folder as it was at a moment, or put it back, see History and rewind.

Change part of a file

Change a few bytes in the middle, insert, cut a range, append or truncate without sending the whole file. The result is saved as a new version. Only the chunks near your changes are new, so this is quick even on large files.

curl -X POST -H "Authorization: Bearer $SIDESPACE_TOKEN" -H 'If-Match: "ver_…"' -H "Content-Type: application/json" \
  "$SIDESPACE_URL/v1/spaces/$SPACE/nodes/$NODE/edits" \
  -d '{"ops": [{"at": 120, "delete": 4, "text": "night"}, {"at": 2048, "text": "\nINT. HARBOR"}]}'
  • Each op replaces delete bytes starting at at with text (or base64 bytes). delete: 0 inserts, no data deletes, and at equal to the file's size appends. truncate cuts the result to that many bytes last.
  • Offsets count in the version you edit, before any of your changes, and ranges must not overlap. A request takes at most 10,000 ops and 4 MiB of new data.
  • If-Match (or if_version_id in the body) names the version you read. If someone saved since, the answer is 412 and nothing changes, so you never edit on top of a save you haven't seen. Without it, the edit applies to the current version, and a save that lands while it runs still makes it fail.
  • Over MCP, edit_text replaces an exact piece of text and append_text adds to the end; see MCP.

Sync and presence

  • GET /v1/spaces/{sid}/snapshot pages through every live node for a first sync. Then follow the journal from the snapshot's at_seq.
  • POST /v1/spaces/{sid}/presence with {"node_id", "state": "viewing" | "editing" | "none"} tells others what you have open. Reports last 90 seconds unless renewed. GET lists them, and the realtime socket pushes them.