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:
- Authenticates the request - admin or a member of the
org_membersrow for the episode's client. - Mints a short-lived signed
GETURL (valid 1 hour). - 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-callsPOST /api/ingest/episodeto mint fresh URLs (which creates a new episode row, today). A "regenerate URLs for an existing episode" endpoint is on the0.2roadmap. - 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 a403on 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.