API › Programs

The config format

What a VosConfigJson is, why a stored one must declare its version, and how to validate before you push.

A program is a VosConfigJson: a JSON object whose animation code travels as strings. The engine defines that dialect and documents it at github.com/vosjs/vos. This page is the part vos.so itself enforces, the rules a config meets at the moment it stops being transient and becomes stored work.

The shape

JSON
{
  "version": 2,
  "duration": 8,
  "camera": { "preset": "fullscreen" },
  "createContent": "(ctx) => { ... }",
  "createTimeline": "(ctx, content, duration) => { ... }",
  "onFrame": "(ctx, content, dt) => { ... }",
  "data": { "hue": 210 },
  "params": [{ "key": "hue", "kind": "number", "min": 0, "max": 360, "default": 210 }]
}
KeyTypeWhat it is
versionnumberThe schema era you wrote against. Required to store, see below
durationnumberSeconds in one cycle. Required
cameraobjectperspective, orthographic or fullscreen. Required
sizeobject{ width, height } in pixels: the frame the program is made for, at any shape. See below
createContentstringBuilds the scene and returns objects and refs. Required
createTimelinestringReturns the timeline, GSAP syntax on a deterministic sampler. Required
onFramestringPer-frame update. The efficient path for reading data
setupstringAsync asset loading, runs before createContent
sceneobjectBackground and fog
elementsarray2D elements rendered as textured planes
objectsarrayWorld-space 3D primitives and GLB assets
postprocessingarrayGlobal effects
fontsarrayWebfont faces registered and awaited before the first frame
dataobjectArbitrary values exposed as ctx.data
assetsobjectThe files the program uses, by name, exposed as ctx.assets. See below
paramsarrayRemix knobs: which data keys the program exposes, and how. A knob of kind asset swaps a declared file instead
presetsarrayNamed param value sets, rendered as Looks

Everything under elements, objects, camera and postprocessing is the engine's contract, and the engine repository is its canonical description. params and presets are vos.so fields the engine schema does not know about, which matters when you read what the schema keeps, below.

Functions are strings

setup, createContent, createTimeline and onFrame are JavaScript source inside a JSON string. Plain JavaScript, never TypeScript: ctx as Foo is not a type annotation here, it is a syntax error at run time. Backticks and ${...} need escaping when they must survive into the compiled output.

The compiler is a template generator and the lints are regular expressions, so neither one catches a syntax error by itself. That is why the validate endpoint parses every function string with a real JavaScript parser before it tries anything else.

Files are declared

A picture, a video, a model or a font file a program uses is named in assets, and the program reads the name:

JSON
"assets": {
  "logo":  { "ref": "asset:7d1e2f3a-0b9c-4d5e-8f6a-1b2c3d4e5f60", "kind": "image" },
  "shots": { "ref": ["asset:…", "asset:…"], "kind": "image" }
},
"elements": [{ "id": "mark", "type": "image", "src": "$assets.logo" }]

A function reads ctx.assets.logo (a URL) or ctx.assets.shots (a list of URLs). An element, object or font names one as the string "$assets.logo", or "$assets.shots[1]" for one of a list.

A stored ref is asset:<id>, the id an upload returned, or a URL. It is never a path: vos push uploads each local file a config.json names and writes the id, and a config that still names a path is refused with the entry and the fix in words.

The reason to declare a file instead of typing its URL into a function is that a declared file gets the right URL on every surface. In the studio and on the watch page it is read with your session. On a server render it is read with the job's own token, so a private picture is in the still of a private program. A URL inside a function string cannot be given either, and a render of a private program draws without it. A declared file is also kept for as long as any version names it.

The validate endpoint reports an element whose "$assets.name" names nothing as an error, and a hosted file typed in code as a warning that prints the assets line to write.

version is required to store

Nothing in the compiler or the runtime reads config.version. It exists for the one thing structure cannot recover later: a config whose meaning changed while its shape did not, a unit, a default, a field that got reinterpreted.

So the two altitudes disagree on purpose. The compiler stays tolerant, because a config being played is transient and watched, and a wrong reading corrects itself. A config that is about to become durable is neither, so every route that stores one refuses an absent version in plain words:

Text
POST /api/vos    { "config": { "duration": 8, ... } }
→ 400 version: Missing "version". A stored config must say which schema it
      was written against. Add "version": 2.

Reading an absent version as "the current one" would be a guess about time. Nothing in a payload says when it was authored, and a config written today can be pushed years from now, out of a repository or by a model whose training stopped this year. Guessing that wrong is silent and permanent, which is exactly the failure the field exists to prevent. Emitters should state the version so no human has to type it, and no tool should ever fill it in on someone's behalf.

Version 2 is the floor and the current era. There is no version 1 in circulation, so write 2 and expect to keep writing it until this page says otherwise. Anything the engine cannot resolve is refused rather than stored half-understood: a newer era it has never seen (Config version 3 was made by a newer vos), and equally a retired one it can no longer read. A refusal is the whole answer there, never a partial read.

Three things called version

They appear within a few lines of each other in the same payloads, so it is worth keeping them apart.

NameWhat it countsWhere you meet it
config.versionThe dialect era a config was written againstInside the config, currently 2
A vos versionOne entry in a vos's history chaincurrentVersionId, baseVersionId, and v3 in the studio
The engine versionThe @vosjs/core npm releaseYour package.json

Only the middle one moves when you push. Editing a program does not change config.version, and a new engine release does not either.

size is the program's frame

JSON
"size": { "width": 1080, "height": 1920 }

A program lays itself out in whatever frame it gets, so it says which one it was made for. Its ratio is the program's aspect, at any shape (9:16, 4:5, 21:9, any width and height from 16 to 8192); its pixels are the default output. The watch page plays it in that frame, the studio stages it there, its still and preview are rendered at that aspect, and an export's resolution is the short edge at that ratio. Design numbers (font sizes, element offsets) stay 1080-high at every size, so a size changes the frame, never what a design number means. A program without size plays at 16:9.

The CLI reads it too: vos render and vos still output it with no flags, and one of --width/--height keeps its aspect.

What the schema keeps

Parsing strips keys the schema does not know. params and presets are re-attached from your raw body by every route that stores a config, so send them in the same object and they survive. Anything else you invent does not: it parses away quietly, and the stored config comes back without it.

An invalid params or presets entry is dropped rather than failing the write, because knobs are progressive enhancement and never a save blocker. Validate first if you want to be told.

A knob of kind asset is a file knob. Its key is a name in assets, it has no default, and its value is that file's ref, so nothing is written to data:

JSON
{
  "assets": { "logo": { "ref": "asset:{id}", "kind": "image" } },
  "params": [{ "key": "logo", "label": "Logo", "kind": "asset" }]
}

accept lists the kinds of file it takes (image, video, audio, model, font, hdr); without it the knob takes the declared file's own kind. A file knob that names no declared file, or a file the program never reads, is kept and answered with a warning.

Validate before you push

Text
POST /api/vos/validate
Authorization: Bearer vos_sk_...
{ "config": { ... } }
→ 200 { "ok": false, "errors": [ { "stage": "compile", "message": "...", "hint": "..." } ],
        "warnings": [] }

Nothing is written, no quota is spent, no media is queued. The answer is always 200: an invalid config is the answer, not a failed request.

The ladder runs in the order a real push would fail it.

StageChecksReports as
schemaThe stored-config rules above, version resolution includedError
syntaxEvery function string parsed as JavaScriptError
compilecompileVosConfig does not throwError
determinismNo wall clock or randomness in frame pathsError or warning
dialectTimeline syntax the sampler can runError or warning
fontsEvery non-generic family has a matching fonts entryWarning
color-trapA colour knob feeding new THREE.ColorError when direct, warning when it reaches one through a helper
materialsThe two limits the render fleet cannot surviveError

On success the report carries compiled, with the byte size of the generated program and the number of knobs it found.

A push is a compile

Every write that stores a config compiles it server-side, so pushing is itself a validation: a config that does not compile answers 400 with the compile error in the body. That makes /api/vos/validate a convenience rather than a gate, useful when you would rather not spend a create against your quota to find out.

One case deserves attention. A PATCH /api/vos/{id} carrying config recompiles against the current addon registry, so a program that has only ever run as an older compiled artifact can fail on the way through, on a loader or an addon that has since changed name. The stored artifact was fine; the source no longer is. Read the compile error rather than assuming the push was rejected for its content.

Determinism

Frame paths derive everything from ctx.time and ctx.data. No Date.now, no Math.random, nothing that reads the wall clock. The same frame index must paint the same pixels every time it is asked, because export shards the timeline across parallel browser sessions and each one cold-seeks into the middle of it. A program that drifts renders visible seams. The determinism lint is the stage that catches it.