Object storage

Your episode artifacts (video, sensors, actions) live in Cloudflare R2, an S3-compatible object store. Heavy bytes never touch the RoboTrace origin server - the SDK uploads them directly to R2 using short-lived signed PUT URLs, and the portal video player streams them back through short-lived signed GET URLs.

Why R2

R2 has zero egress fees, so you can scrub through hour-long episode replays in the portal - or pull a full dataset down to a training cluster - without anyone metering the bytes. R2 also speaks the S3 wire protocol, so if you ever want to point your own S3-compatible tooling (aws-cli, rclone, boto3, …) at a bucket directly (e.g. for self-hosting), you can - just swap the endpoint.

Privacy: who can read your bytes

The bucket is private. There is no public URL for an episode artifact - even if someone guesses the object key, they get a 403. Reads always go through /api/episodes/[id]/artifact/[kind], which:

  1. Authenticates the request - admin or a member of the org_members row for the episode's client.
  2. Mints a short-lived signed GET URL (valid 1 hour).
  3. Redirects the browser to it.

In practice, the in-portal video player follows the redirect and streams byte-ranges directly from R2 over your authenticated session. The signed URL stays in the browser process - it isn't fronted by a CDN, isn't logged, and stops working an hour after it was minted.

Signed URL TTL

  • Uploads (PUT) - 30 minutes after they're minted. Long enough to push a multi-GB video over a slow uplink, short enough that a leaked URL isn't a long-lived credential. If your upload takes longer, the SDK currently re-calls POST /api/ingest/episode to mint fresh URLs (which creates a new episode row, today). A "regenerate URLs for an existing episode" endpoint is on the 0.2 roadmap.
  • Reads (GET) - 1 hour. Re-minted fresh on every page load by the auth-gated route above. If a player sits idle past expiry it gets a 403 on the next byte-range request - page refresh recovers.

Content-Type matters

Each PUT URL is signed with a specific Content-Type. The PUT must match or R2 returns 403. The Python SDK handles this for you; for raw HTTP clients see Ingest API → §2.

In-browser uploads (Phase 3+)

Phase 1 uploads come from the Python SDK, which doesn't need CORS. When the in-browser upload UI lands, the bucket gets a CORS rule allowing PUT / GET from the portal origin - nothing for SDK users to configure.