# remix a vos program with your own agent (vos.so) > This is vosso's remix contract (the gallery at vos.so/gallery is the > catalog; every program opens in the browser editor). Every vos is a > program: inputs + a deterministic function → video. Remixing is editing > that program's JSON config and pushing the result back to vos.so as a > PRIVATE vos on the user's account (with lineage). You — the agent — do > the editing locally; the platform validates, compiles, and renders a > preview. This HTTP contract is the shipping surface. ## Auth Three credential shapes; NEVER print any of them. 0. HANDOFF LINK (vos.so/h/vos_ho_...) — the owner pasted one line: "Read https://vos.so/h/ and do what it says." Fetch it: the response is the prompt itself, an Auth paragraph, and the project package inline (recipes with bodies, members with contentUrls, the seed's config and doc). The URL's last segment is your bearer for 24 hours: a FOLDER GRANT that reads that one project and pushes private work into it (folderId forced, at most 20 creates, versions on its own pushes, recipe replace in that folder only). It cannot publish, touch other folders, upload, or list assets; GET /folders returns only its folder. Expired or revoked ⇒ 404 at read, 401 on use. 1. REMIX GRANT (vos_rg_...) — ephemeral, zero-setup. The watch page's "remix with your agent" prompt embeds one; if your prompt contains a vos_rg_ token, just use it as the bearer. Grants expire in 24h and are bound to ONE source vos: they can only POST remixes of it (remixOfId required, max 5 CREATED remixes), iterate the voses they created, and read those. Versions of a remix you already created do not count toward the 5 — iterating your own remix is the loop. 2. DURABLE CONTENT KEY (vos_sk_...) — minted at https://vos.so/app/api. Resolution order BEFORE asking the user: a. the VOS_API_KEY environment variable b. ~/.config/vos/credentials (first line) c. run `vos login` (the CLI's browser device flow) and let the user finish the sign-in — it stores the key at (b) An empty (a) is a step, not a stop. If the user pastes a key into the chat instead, use it — but suggest rotating it afterward (chat transcripts persist). Authorization: Bearer $VOS_API_KEY # or the vos_rg_ grant token Both shapes can create/read/iterate voses. They can NEVER publish (visibility 'public' is clamped/rejected — publishing stays a human act on vos.so) and never touch the render API. Durable-key creates are quota'd (50/24h) and version pushes at 200/24h across every vos (see Limits below). ## The loop CLI shortcut (npm i -D @vosjs/cli): `vos fetch ` → edit config.json → `vos check` → `vos push` → `vos pull` runs this whole loop with the same rules (private, lineage, params preserved, tracked base). pull brings down the attributed changelog of studio edits since your base — human-edited nodes are protected on your next push unless the user explicitly asked you to change them (`--overrides`). The raw HTTP below is the contract either way; the changelog endpoint is GET /api/vos/{id}/changes?since={yourBaseVersionId} (owner-only). 1. FETCH the source config (public voses need no auth): GET https://vos.so/api/vos/{id}/config → { "config": VosConfigJson } GET https://vos.so/api/vos/{id} → metadata (title, params, …) 2. REMIX locally. The config's function fields (setup, createContent, createTimeline, onFrame) are JS FUNCTIONS AS STRINGS. Rules: - No TypeScript syntax inside function strings; escape backticks/${} that must survive into output. - createContent returns { objects, refs, dispose }; createTimeline reaches custom handles via content.refs. (content-contract). - Change 1–2 axes decisively (palette, motion grammar, density) — never a 10%-nudge duplicate. - DECLARE 2–4 REMIX KNOBS via config.params — the editor renders them as live knobs. Each param documents a ctx.data key the program reads: "params": [{ "key": "hue", "label": "Hue", "kind": "number", "min": 0, "max": 1, "default": 0.6, "hint": "Shifts every color around the wheel", "unit": "°", "group": "Color", "order": 1 }], // kinds: number (min/max/step) | color | select (options) | toggle // | text (free string ≤280 chars — a bound headline or a URL; // "multiline": true renders a taller editor for content) // | font (value = a catalog family name; options OPTIONAL — // a curated subset, absent = the whole hosted catalog. // Faces travel with the value: knob edits write data.fonts // automatically, no config.fonts declaration needed) // | asset (a FILE knob: swaps a declared file, see FILE // KNOBS below; no default, no data key) // hint: ONE sentence on what changes (shown under the knob — write it) // unit: shown inside the number field (px % s × °) // group/order: optional — grouped knobs get their own panel card // the program reads it in onFrame, EVERY FRAME. The editor delivers a // knob edit as SET_DATA: a program with onFrame reads the new ctx.data // next frame (cheap); one without onFrame gets its content rebuilt on // every edit (live, but a full createContent per slider tick). Never // snapshot ctx.data inside createContent of an onFrame program, and // GSAP onUpdate is no substitute (it does not fire while paused): // "onFrame": "(ctx, content) => { // const d = ctx.data || {} // if (typeof d.hue === 'number') content.refs.uniforms.uHue.value = d.hue // }" - COLOR KNOBS: never feed the hex through new THREE.Color() into a shader uniform — color management linearizes sRGB, so the render lands darker and more saturated than the declared hex. Parse it to a raw Vector3 instead: const c = parseInt(hex.slice(1), 16) u.uColor.value = new THREE.Vector3((c>>16&255)/255, (c>>8&255)/255, (c&255)/255) - SHIP LOOKS via config.presets — named param-value sets rendered as tiles above the knobs (tap a look, then fine-tune). Aim for 2–3 when the program has 4+ params; applying one is a single undoable edit: "presets": [{ "name": "Vivid", "values": { "hue": 0.9, "speed": 2 } }, { "name": "Calm", "values": { "hue": 0.3, "speed": 0.6 } }] // values must reference declared param keys with matching types; // anything else is dropped on save (max 8 looks, name ≤ 24 chars) - TEXT ELEMENTS need declared webfonts (@vosjs/core ≥0.13): headless renders have no system fonts, so any non-generic font.family must have a matching config.fonts entry — the engine registers and awaits them (fail-open) before first render: "fonts": [{ "family": "Playfair Display", "weight": 700, "url": "https://assets.vos.so/fonts/playfair-display/700.woff2" }] // one entry per weight used; canvas cannot synthesize weights // the hosted catalog (42 families, weights, URLs): // GET https://vos.so/api/fonts // `vos check` warns on undeclared families - FILES ARE DECLARED, NEVER TYPED INTO A FUNCTION (@vosjs/core ≥0.27). A picture, a video, a model, a font file: name each one in config.assets and read the name. This is the ONE rule for a program's own files: "assets": { "logo": { "ref": "./logo.png", "kind": "image" }, "shots": { "ref": ["./shots/1.png", "./shots/2.png"] } } // in a function: ctx.assets.logo (a URL) ctx.assets.shots (URLs) // in an element, object or font: "src": "$assets.logo" // "src": "$assets.shots[1]" // kind is a hint: image | video | audio | model | font | hdr On disk a ref is a path in config.json's own directory or below (a path that climbs out, ../ or absolute, is refused: copy the file in). `vos push` uploads each local file once (a re-push sends nothing; pictures and SVG, video, sound and .glb models go, and since 0.61 fonts and HDR maps) and what is stored names it "asset:", the spelling the platform resolves for every surface: the studio, the watch page, and a server render of a PRIVATE vos, which a URL inside a function string can never reach. A declared file is also kept for as long as a version names it. Over raw HTTP, upload first (below) and write "ref": "asset:" yourself; a path in a stored manifest is refused in words. A URL on assets.vos.so stays a URL: "ref": "https://assets.vos.so/…". - FILE KNOBS: A FILE A PERSON SWAPS IS A KNOB OVER A DECLARED FILE (@vosjs/shared ≥0.7, @vosjs/cli ≥0.65). The logo, the product shot, the model: declare the file, then declare a knob whose key IS that name. The editor shows the file with a Replace control (the person's own files, or one from their device), and the swap rewrites the manifest entry, so the new file is a declared file like the old one: hosted, kept, and reachable by a server render of a private vos. "assets": { "logo": { "ref": "./logo.png", "kind": "image" } }, "params": [{ "key": "logo", "label": "Logo", "kind": "asset", "hint": "The mark in the corner" }] // key: a name in config.assets (ONE file, never a list) // accept: optional, the kinds it takes: ["image"], ["image","video"] // (image | video | audio | model | font | hdr). Absent, the // declared file's own kind, so write "kind" on the file. // no "default" and nothing in data: the value IS the file's ref. // a Look may swap it too: "values": { "logo": "asset:" } The program reads it like any declared file (ctx.assets.logo, or "src": "$assets.logo") and needs no onFrame: a swap is a recompile, one reload, never a SET_DATA. `vos check` warns when a file knob names no declared file, a list, a file nothing reads, or no kind; the platform keeps such a knob and answers the same warning. To swap it yourself, change the file's "ref" and push. - Server-render constraints: no THREE.DoubleSide on transmission materials, no dispersion, third-party files only from assets.vos.so (your own uploads go in config.assets as above). 3. VALIDATE before you push. Three rungs, pick the one you have: vos check (CLI: schema, syntax, compile, lints) POST https://vos.so/api/vos/validate (HTTP, key or session; a DRY RUN of { "config": { ...VosConfigJson... } } the push ladder, nothing written) → 200 { "ok", "errors": [{ stage, message, hint? }], "warnings": [...], "compiled": { bytes, params } } stages: schema → syntax (every function string parsed as JavaScript, so `ctx as Foo` fails HERE, not in the browser) → compile → determinism → dialect → fonts (warn) → color-trap (a colour knob fed through new THREE.Color) → materials (dispersion; DoubleSide on transmission) → assets (a "$assets.name" that names nothing is an error; a hosted file typed in code is a warning that says the manifest line to write). An invalid config is the ANSWER (200 with ok:false), never a failed request. npm i @vosjs/core; compileVosConfig(config) (local, no network: compile only) A push runs the same ladder and 400s on a failure, so pushing is a validation too, but a failed push is a turn the human sees. 4. PUSH as a private vos with lineage: POST https://vos.so/api/vos Authorization: Bearer vos_sk_... Content-Type: application/json { "title": "Aurora Ribbons — dusk", "slug": "aurora-ribbons-dusk", // [a-z0-9-], unique per user "visibility": "private", "config": { ...VosConfigJson with params... }, "remixOfId": "{source vos id}", // → 'remixed from' credit "label": "what you did", // ≤60 — v1's message "note": "why: the user's ask" // ≤2000 — what they read first } → 201 { "vos": { "id", ... } } // preview render auto-queues A create mints v1, and v1 takes the same label/note as every later version: an unlabelled first push is a history that starts mid-sentence. THE SOURCE'S FILES BECOME YOURS: a file the config names that belongs to the source's owner (a picture, a model, a recording) is copied into your account at the push, and what is stored names the copy. So the remix keeps playing when its source goes private or is deleted, and the copies count against your storage (a push they do not fit in is refused 409, whole). Leave the source's file URLs as you fetched them: there is nothing to download and upload again. A file you could not have opened is not copied: it stays as written and answers 404. NO ACCOUNT? THE CLAIMABLE PUSH (programs, and the files they draw with): vos push --claimable (CLI shortcut) POST https://vos.so/api/claim (no auth) { "title": "...", "slug"?: "...", "config": { ...VosConfigJson... }, "prompt"?: "...", "share"?: true, "uploadToken"?: "vos_cu_…" } → 201 { "claimUrl", "expiresAt", "vos": { "id" }, "files"? } The program's files first: POST https://vos.so/api/claim/uploads (no auth) → { "uploadToken" }; each file through POST /api/claim/uploads/files with that token as the bearer (begin, PUT /:id/parts/:n, POST /:id/complete, the account upload door's protocol); name each one in the config by the asset.ref it answers ("asset:") and send "uploadToken" with the claim, which spends it. At most 12 files and 50 MB, sound up to 5 min, video up to 60 s. vos push --claimable (0.63+) does all of this. "prompt" is the prompt the human chose to show on the watch page, in their words (ask first; ≤4000). "share": true makes the claim page lead with "Claim and share", which claims it and gives it a link anyone can watch in the human's one press (https://vos.so/host.md is the door). Rules: config ≤200KB, never a recording take, 5 pushes/24h per network. Hand claimUrl to the human and NOWHERE else — it is the only reference and the only credential. Unclaimed work is deleted after 72h (this is deliberate cleanup, not data loss — re-push if it lapses). Claiming moves the vos into the human's projects and starts the hosted preview render; iteration after claim rides their content key (the loop below). 5. ITERATE — pull before you edit, push against the head you read: GET https://vos.so/api/vos/{id} → note vos.currentVersionId GET https://vos.so/api/vos/{id}/changes?since= → { yourBase, head, protected: ["speed", ...], changes: [{ versionId, versionNumber, origin, note, ops: [{ op, track, id, props: { k: [from, to] } }], summary: "knob speed: 1→1.5" }] } The typed changelog of what the human changed since you last worked — read the summaries, they are the human's half of the conversation. POST https://vos.so/api/vos/{id}/versions { "config": { ... }, "baseVersionId": "", // ALWAYS send it "label": "tighter ring pulse", // ≤60 chars "note": "0.4s felt slow; 0.3s reads snappier", // handoff note "client": "claude-code/2.1" } // your tool name/version (≤60) ALWAYS stamp label AND note on every push: label = what you did in imperative one-line form, note = why (the user's ask, one sentence). The version history reads as a conversation — an unlabelled push is a turn the human cannot read. `client` is display-only attribution (User-Agent grammar; the vos CLI names your agent itself, VOS_CLIENT overrides) — authorship trust always comes from your key, never this string. GET https://vos.so/api/vos/{id}/versions → { currentVersionId, versions: [{ id, versionNumber, origin, label, note, summary, client, ... }] } Every version carries a server-stamped `summary` — the semantic diff vs its parent ("zoom z3: level 2.2→1.8") — so the history reads as a changelog without calling /changes. PATCH https://vos.so/api/vos/{id} { "title" | "config" (send baseVersionId too) | "tags" | "visibility": "private"|"unlisted" } A PATCH carrying "config" creates a version, so it takes the SAME "label"/"note"/"client" attribution as the versions POST — stamp them there too; a labelless push is a turn the human cannot read. GET https://vos.so/api/vos/{id}/config (your private voses work with the key) THE 409 RITUAL — a stale baseVersionId returns 409 { "error": "stale_base", "currentVersion": {...}, "changes": [...], "protected": [...] } — the rejection CARRIES the changelog you should have pulled. Read it, re-apply your edit ON TOP of the new head's config, push again with the fresh baseVersionId. baseVersionId is REQUIRED on key pushes (absent → 400): the studio autosaves, so head moves routinely — a baseless push would throw away the human's work. (restoreOf is exempt; it names its target.) PROTECTED NODES — humans win by default. A node (param key, data key, code function) a studio version touched since YOUR last push rejects your writes: 409 { "error": "protected_conflict", "nodes": ["speed"], ... } Keep the human's values and re-push without touching those nodes. Include "overrides": ["speed"] ONLY when the user's instruction targets that exact node ("make it faster" → yes; general polish → never). Protection expires once you push — it means "don't undo what the human just did", not "frozen forever". RESTORE an earlier version (copy-forward, history never mutates): POST https://vos.so/api/vos/{id}/versions { "restoreOf": "" } NAME a version after the fact (label/note are the ONLY mutable fields — commentary about a version, never the version; null clears): PATCH https://vos.so/api/vos/{id}/versions/{versionId} { "label": "calmer intro", "note": "..." } 6. HAND BACK: the user reviews at https://vos.so/vos/{id} (watch page: preview, params, function) and remixes further in the studio at https://vos.so/studio?vos={id} — or publishes from the dashboard. Tell them both URLs. (Older links to /stage?vos={id} keep working: they 301 to /studio with the query intact.) ## Folders & recipes (create in the owner's style) Folders are the user's private shelves (the workspace presents a folder as a PROJECT — same thing, "folder" on the wire); a folder can hold voses, assets, RECIPES — personal style documents (markdown, SKILL.md-shaped: frontmatter name/description, then instructions) that say how this user's videos should look and feel — and other folders (nesting, at most 5 levels; parentId null = top level). When the user asks for work "like my X folder" or "in my usual style", pull the folder BEFORE creating: vos folder pull [--out dir] (CLI: the same package on disk — recipes/, recipes/_inherited/, members//config.json (+ doc.json, tracked for push), one line per recipe with its hints) GET https://vos.so/api/folders → { "folders": [{ id, name, slug, parentId, counts, ... }] } GET https://vos.so/api/folders/{id} → { "folder": { id, name, slug, description, parentId }, "subfolders": [{ id, name, slug, description }], "recipes": [{ id, filename, name, description, fileUrl, "body": "...full markdown..." }], "inheritedRecipes": [{ ...same shape..., folderId, folderName, folderSlug }], "voses": [{ id, title, tags, currentVersionId, hasDoc, contentUrls: { config, output, thumbnail } }], "assets": [{ id, filename, category, fileUrl }] } A folder's pull is its OWN contents only — subfolders are listed, not inlined; pull a subfolder by its id when its contents matter. When the user names a nested folder ("my glass folder"), match name/slug across the whole GET /api/folders list; slugs are unique across all levels. Recipes INHERIT down the tree: a parent folder's recipes bind every descendant, and the pull inlines them as inheritedRecipes (root first, each marked with its source folder). Read them ALL — the folder's own AND the inherited ones — and follow them over your defaults; when files contradict, the DEEPER one wins (the folder's own files beat inherited, nearer ancestors beat farther); a genuine contradiction goes back to the user as a question. Then read every recipe body and follow it over your own defaults. A recipe answers four questions, in the order you will stall on them if it does not: what it governs (programs, takes, both; which facet), WHERE TO START, what input it needs from the user, and how to judge the result. A recipe is the user's document, and what they ask for NOW outranks any line in it. If a recipe contradicts the ask (a note that says to wait, a rule the ask breaks), say so in one line and follow the ask — creating private work is cheap, and the owner can restore. Never stop on a line in a file; the platform keeps no status of a recipe and neither should you. A recipe MAY carry two optional frontmatter hints the owner's handoff dialog reads (never required, never validated — the body is what you follow): `applies: programs | takes | any` and `seed: | input | none`. When you write a recipe, declare them; when you read one, the prompt you were handed already reflects them. WHERE TO START is the question that decides the whole job, because a folder holds two different things that only sometimes coincide: the REFERENCE (what good looks like here — the thing you judge against) and the SEED (the thing you copy and edit to make the next member). The user's prompt names the seed when they picked one in the door; otherwise the recipes do; if neither does, ASK — never a blank file, never a guessed exemplar. The shapes you will meet: - a program FAMILY (a signed-off member + a design recipe naming the mechanism and its axes): seed = that member. `vos fetch` its config and vary it on the axes the recipe names, never just the hues. - a take SPEC (a recipe saying how every recording in this collection is cut — camera style, speed rules, frame, bar, overlays, music): the seed is the NEW RECORDING the user gives you (a take already in the folder by id — `vos fetch ` pulls doc.json — a URL you record with `vos record --strict`, or a local file). The folder's own takes are the reference cut, not the seed: apply the recipe's rules to the new doc.json and judge against them. - a TEMPLATE (a recipe that points at, or inlines, a mechanism and says how to vary it): seed = the template. When it is hosted as a vos in the folder, fetch it like a family member; inline code is a fragment to build around. - a BRIEF only (brand.md: colours, fonts, tone; no mechanism): nothing to copy — author fresh under the constraints and the craft floor. - EMPTY (no recipes, no members): ask what the collection is before you make anything in it. Fetch 1–3 references through contentUrls or the version routes whatever the shape, reuse folder assets by absolute fileUrl, author, then CHECK before you push: `vos check` on a program (schema → compile → lints), `vos validate` + `vos frames` on a take, `vos render` for a look at either. Create WITH the folder so the work lands on the shelf you pulled: POST https://vos.so/api/vos { ..., "folderId": "{folder id}" } # or: vos push --folder {id|slug} --label "…" --note "…" Every push renders a still within about fifteen seconds (a thumbnail, plus a preview): GET /api/vos/{id}, follow contentUrls.thumbnail, and LOOK at it before you report done — it is the owner's first sight of the work. Iterate via the versioned loop above (step 5) — folder placement changes nothing about versions, 409s, or protected nodes. Organizing your own output (when the user asks — e.g. "put these in a subfolder"): your key may ADD a folder and file work into it. The CLI verbs are "vos folder list|create|move|pull", and "vos push --folder " files a NEW vos at create; the HTTP contract underneath: # create a folder (nesting via parentId, ≤5 levels, slug derived; # 50 folders per account, 50 subfolders and 500 items per folder) POST https://vos.so/api/folders { "name", "description"?, "parentId"? } → 201 { "folder": { id, name, slug, parentId } } # move work between the owner's folders (null unfiles) PATCH https://vos.so/api/vos/{id} { "folderId": "{id}" | null } PATCH https://vos.so/api/assets/{id} { "folderId": "{id}" | null } # rename one of your assets in place (recipes included) — bytes and # fileUrl untouched; the extension must keep matching the asset's # category (a recipe stays .md, and a key renaming one gets CAPS). # CLI: vos asset rename PATCH https://vos.so/api/assets/{id} { "filename": "DESIGN.md" } A recipe IS a plain markdown FILE (SKILL.md-shaped frontmatter with name and description, then instructions) in the asset pipeline. The folder is the collection; its .md files are FACETS named by role — TASTE.md (the judging bar), DESIGN.md (the technique spec), more by intent (MOTION.md, COPY.md, BRAND.md...). NAME A RECIPE IN CAPS: it is the file read before anything is made, the role CLAUDE.md and AGENTS.md play in a repo, and the name is what says so. Your key is held to it — a recipe you upload or rename is stored with its stem uppercased (cut.md lands as CUT.md) — so write it that way and the terminal and the shelf agree. A person's own upload keeps whatever they typed: lowercase on the shelf is theirs, never a thing to tidy. Role names are convention, never validation: any .md is a recipe, and you read them ALL. Writable with your content key, for when the user asks you to write down or update a style ("distill what we converged on"): # create: the upload door (UPLOADING A FILE, below) with the .md as # the file, "intent": "library" and "folderId" the folder you pulled. # ≤64KB; 20 creates/24h. The answer's asset.metadata carries the # frontmatter's name and description, and asset.filename the name it # was stored under (a recipe a key files is named in CAPS). # CLI: vos recipe push --folder # replace IN PLACE (recipes only; raw markdown body; same id/fileUrl; # frontmatter summary re-extracted server-side — uncapped). The body # you displace is KEPT as the one prior version, unless yours is # byte-identical (a no-op save never overwrites the real prior). # CLI: vos recipe push --asset PUT https://vos.so/api/assets/{id}/file Content-Type: text/markdown # the prior body, and the way back: restore SWAPS live and prior, so # a restore is undoable by restoring again (404 when none is kept) GET https://vos.so/api/assets/{id}/file?prev=1 POST https://vos.so/api/assets/{id}/file/restore # locate recipe files outside a folder pull GET https://vos.so/api/assets?category=recipe[&folder={id}] Rules: - The shelf is the human's: your key may ADD organization (create a folder, move YOUR work into it) but only when the user asks, and it can never rename, delete or reorder folders (session-only endpoints; your key gets 401/403). You fill the shelf, you don't reshape it. - Write what you learned back into a recipe when the user's prompt or the user asks you to — and then APPEND it under a dated "## Agent notes" heading; never rewrite the owner's rules. Send the whole file back with your section added and nothing else moved; the body you replace is kept, so a mistake is one restore away. - When a recipe and the folder's recent members disagree, prefer the members and tell the user the recipe looks stale. - Everything here is owner-only: grants get 403, and another user's folder ids simply 404. ## The 3D recipe (showcase a model) The 3D showcase programs (gallery tag "3d": Studio Turntable, Dolly Reveal) take a model swap with ZERO function-string edits — they declare a modelUrl knob (kind "text"): 1. UPLOAD the model — UPLOADING A FILE. One door takes every kind, and your content key works on it: POST https://vos.so/api/assets/uploads Authorization: Bearer vos_sk_... { "filename": "chair.glb", "size": 482113, "sha256": "", (optional: see below) "intent": "library", (optional) "folderId": "{folder id}" } (optional: files it) → 201 { "uploadId", "partBytes", "partCount", "expiresAt" } → 200 { "asset": {...}, "reused": true } these bytes are already yours: send nothing PUT https://vos.so/api/assets/uploads/{uploadId}/parts/{n} the raw bytes of part n (1-based): exactly partBytes each, the last one the remainder. Content-Length must be exact. → 200 { "partNumber", "etag" } POST https://vos.so/api/assets/uploads/{uploadId}/complete { "parts": [{ "partNumber": 1, "etag": "..." }] } → 201 { "asset": { "id", "kind", "filename", "contentType", "size", "duration", "metadata", "fileUrl": "/api/assets/{id}/file", "ref": "asset:{id}" }, "notes": [] } ("reused": true beside them when your account already held these exact bytes: the asset is the one you had, and nothing new is kept) A small file is still one part. DELETE .../uploads/{uploadId} gives up. What a file IS comes from its BYTES at complete, never from its name or a declared type: asset.kind is one of video, audio, image, vector (svg), model (glb), font, hdr, recipe, captions, and asset.filename takes the extension the bytes earned. GET https://vos.so/api/limits lists every kind's formats, size cap and daily rate. "notes" are sentences to pass on to the person (an SVG is stored as a picture only, so its scripts and handlers are removed; an HEVC clip may not play in Chrome). A refusal is { "error", "kind" } with kind one of type, bomb, codec, size, duration, storage, rate, removed: at the first call for what you declared, at complete for what the bytes turned out to be. "removed" (410) is a file vos.so took down: it cannot be stored again, and GET on a removed file's URL answers 410 with the reason. Tell the person; do not retry or re-encode it. Send "sha256" and the same bytes are never uploaded twice, and never count against a rate (the server hashes what it stores either way). "intent": "library" is a file added to keep; the default ("attached") is a file a document is about to name, and one that no document names and no folder holds is removed after 7 days. WHAT YOU ALREADY HOLD: look before uploading a file again. GET https://vos.so/api/assets?limit=50&kind=image&q=logo → { "assets": [{ "id", "kind", "filename", "size", "uses", "intent", "fileUrl" }], "total", "kinds" } ("uses" = how many live programs use it; sort=size puts what holds the storage first; GET /api/assets/{id} adds "usedIn", every program that uses the file.) GET https://vos.so/api/user/usage → { "usage": { "assetBytes", "trashBytes", "uploadsToday" }, "limits": { "storageBytes", "uploadKinds" } } A file from somewhere else: say where. The complete call takes "provenance": { "sourceUrl", "license", "attribution" } beside "parts"; it is kept with the file and shown on its page. It is your statement, not a check: only bring a file its license lets the person use. A file is private to you until a vos uses it: the push links it, and it is then readable by whoever can open that vos (public or unlisted means anyone, private means you). A file the PROGRAM reads is declared: "assets": { "product": { "ref": "asset:{id}" } }, read as ctx.assets.product (the rule above), and a file a person should be able to swap gets a FILE KNOB over that name (FILE KNOBS, above). The older spelling still plays and these 3D templates use it: a text knob holding a URL in the config's data, as a value or inside a list, relative (fileUrl) or absolute (https://vos.so + fileUrl); server renders resolve both. Write new programs with a file knob. A URL typed inside a function string is linked but NOT resolved, so a server render of a private vos cannot fetch it. SOUND goes through the same door (it lands in Sound > Uploads). Put that url in doc.json as an audio clip's key; the push composes it into the program. Never embed a sound as a data: URI in an audio element: it is not a track, renders do not mix it, and the config outgrows a claimable push. 2. FETCH a 3d-tagged program's config (step 1 above) and set the knob — both the data key and the matching params default: "data": { ..., "modelUrl": "https://vos.so/api/assets/{id}/file" } Empty string keeps the built-in product. The load FAILS OPEN: a bad URL silently renders the built-in — check a frame before handing back. 3. Knob honesty: template material knobs (hue/finish, ring) act on the BUILT-IN product's materials, never on a GLB's own. The stage knobs (backdrop, light mood) always act. 4. Advanced (custom stage): edit the async "setup" loader and the buildProduct() body — commented as THE SWAP POINT — yourself (bbox-normalize to ~1.7 units, ground at y=0). 5. Push as usual (step 5). Humans do the same flow UI-side by dropping a GLB on https://vos.so/gallery (it lands in the browser editor). ## The text recipe (headline + font knobs) Text element props can BIND to ctx.data (@vosjs/core ≥0.15): set content, font.family or font.color to { "$data": "key" } and the engine resolves the value from data at render time, re-resolving live on every data edit — so a headline swap is a pure data edit, no function-string changes, no recompile: "elements": [{ "id": "title", "type": "text", "content": { "$data": "headline" }, "font": { "family": { "$data": "font" }, "size": 96, "color": { "$data": "ink" } }, "position": "center" }], "data": { "headline": "Launch day", "font": "Inter", "ink": "#fff" }, "params": [ { "key": "headline", "kind": "text", "multiline": true, "default": "Launch day", "hint": "The line everyone reads" }, { "key": "font", "kind": "font", "options": ["Inter", "Playfair Display", "Space Mono"], "default": "Inter", "hint": "Voice of the headline" }, { "key": "ink", "kind": "color", "default": "#ffffff" }] Rules that keep it honest: - Fonts load from DATA too (core ≥0.17): data.fonts takes the same { family, url, weight? } entries as config.fonts, registered at boot and on every data edit. The remix UI writes it for you on font-knob edits; if you hand-edit data.font yourself, set data.fonts to the family's hosted faces (GET /api/fonts) in the same push, or the fleet falls back. - A missing/empty data key falls back to defaults (content goes empty, family falls to the preset stack) — always ship a default in data. - split-text elements resolve bindings at every fresh render (exports and server renders are always correct), but a LIVE editor session reloads once per edit there — per-unit meshes are structural. Fine for knobs. - Verify like every knob: render a frame per option/value and diff (vos still / the render API); knob honesty covers this on submit. ## The split entrance recipes (per-unit text animation) Split text elements (split: { "type": "chars"|"words"|"lines" }) expose per-unit handles: createTimeline reads ctx.elements.get(id).segments — an array of animatable props (x/y/opacity/scale/rotation) in reading order. Entrances are plain tweens over that array. Three canonical shapes, each compile- and dialect-verified (deterministic: literal delays, no Math.random, no modifiers/onUpdate): // staggered rise — chars float up as they fade in "createTimeline": "(ctx, content, duration) => { const { gsap, elements } = ctx const tl = gsap.timeline() const segs = (elements.get('title') || {}).segments || [] segs.forEach((seg, i) => { tl.fromTo(seg, { opacity: 0, y: seg.y + 28 }, { opacity: 1, y: seg.y, duration: 0.5, ease: 'power3.out' }, 0.3 + i * 0.05) }) tl.to({}, { duration: 0.01 }, duration) return tl }" // typewriter — step reveal, one unit per beat tl.fromTo(seg, { opacity: 0 }, { opacity: 1, duration: 0.001 }, 0.3 + i * 0.06) // pop — scale overshoot per word (split "words", ~0.07 stagger) tl.fromTo(seg, { opacity: 0, scale: 0.4 }, { opacity: 1, scale: 1, duration: 0.45, ease: 'back.out(1.7)' }, 0.3 + i * 0.07) Rules: - segments exist ONLY on split elements; guard with "|| []" — a non-split element animates whole via elements.get(id).props instead. - y is CENTER-relative design px: tween FROM seg.y + offset TO seg.y (the segment's own baked position), never to absolute numbers. - keep a trailing tl.to({}, { duration: 0.01 }, duration) so the timeline spans the whole vos (a shorter timeline loops early). - reverse/center orders are index math: i → segs.length - 1 - i, or Math.abs(i - (segs.length - 1) / 2). - declare the family in config.fonts (or data.fonts) — split units raster per glyph, so a fallback face is extra visible. - take-mode overlays get the same vocabulary declaratively via overlay fx (see llms-full) — these recipes are the PROGRAM-mode equivalent. ## Errors you may see - 400 Invalid input / Failed to compile config — fix and re-push - 401 Invalid or revoked API key - 403 lacks the 'content' scope | cannot publish | cannot set isOfficial - 409 stale_base — HEAD moved; the body carries the changelog, follow the ritual - 409 protected_conflict — a human touched those nodes; keep their values unless the user explicitly asked (then overrides) - 429 daily create quota (50/24h), version push quota (200/24h) or key limit - 409 storage ceiling / folder full — see Limits; never retry hot, and never delete to make room (Trash still counts; tell the person) - 403 delete_scope — this key was not given the delete scope; its owner can grant it at vos.so/app/api, or delete it themselves - 429 delete_rate — a key moves at most 20 things to Trash an hour - 413 recording too long (>30 min) or too large (>1.5GB) - 409 slug exists — pick another slug ## Limits (every account has the same limits; the numbers below are them) - Creates: 50/24h per key. Version pushes: 200/24h per key across every vos (429; iterate locally with vos render / vos frames, push when a round is done). A session's own checkpoints: 100 per vos per 24h. - Uploads: each kind has its own size cap and daily rate, served by GET /limits (limits.uploadKinds; a video or a sound is up to 30 min, a recording up to 1.5GB, 20 recordings/24h). A refusal is 413 or 429 in words. Same-bytes re-uploads (sha256) never count. - Storage: 5 GB of assets per account (recordings + uploaded files; versions never count). A write past it is 409, and nothing already saved is ever deleted for it. Files in Trash still count, so deleting does not make room: only the person emptying Trash does. - Deleting: DELETE /api/vos/{id} moves it to Trash, restorable for 30 days (POST /api/vos/{id}/restore; GET /api/trash lists it). Nothing a key can do is final. Full rule in /llms-full.txt, "Taking a vos down". - Folders: 50 per account, depth 5, 50 subfolders and 500 items (voses + assets + recipes) per folder (409). Recipes: 200 per account, ≤64KB. - Previews and thumbnails: free and unmetered, rate-limited to 20 per vos per hour and 300 per account per day — past that the version still lands and its media renders on the next watchdog pass (~20 min). Never refused. - Cleanup: an uploaded recording that no vos uses and no folder holds is deleted 7 days after it was uploaded, or after it last lost the vos that used it. Saved content never expires; Trash is emptied at its date. - GET https://vos.so/api/user/usage (session only) → { plan, usage, limits }. ## More - https://vos.so/llms.txt — the tool index (engine, CLI, recorder) - https://vos.so/llms-full.txt — the take/capture agent reference - https://github.com/vosjs/vos — the engine (schema source of truth)