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
{
"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 }]
}| Key | Type | What it is |
|---|---|---|
version | number | The schema era you wrote against. Required to store, see below |
duration | number | Seconds in one cycle. Required |
camera | object | perspective, orthographic or fullscreen. Required |
size | object | { width, height } in pixels: the frame the program is made for, at any shape. See below |
createContent | string | Builds the scene and returns objects and refs. Required |
createTimeline | string | Returns the timeline, GSAP syntax on a deterministic sampler. Required |
onFrame | string | Per-frame update. The efficient path for reading data |
setup | string | Async asset loading, runs before createContent |
scene | object | Background and fog |
elements | array | 2D elements rendered as textured planes |
objects | array | World-space 3D primitives and GLB assets |
postprocessing | array | Global effects |
fonts | array | Webfont faces registered and awaited before the first frame |
data | object | Arbitrary values exposed as ctx.data |
assets | object | The files the program uses, by name, exposed as ctx.assets. See below |
params | array | Remix knobs: which data keys the program exposes, and how. A knob of kind asset swaps a declared file instead |
presets | array | Named 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:
"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:
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.
| Name | What it counts | Where you meet it |
|---|---|---|
config.version | The dialect era a config was written against | Inside the config, currently 2 |
| A vos version | One entry in a vos's history chain | currentVersionId, baseVersionId, and v3 in the studio |
| The engine version | The @vosjs/core npm release | Your 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
"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:
{
"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
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.
| Stage | Checks | Reports as |
|---|---|---|
schema | The stored-config rules above, version resolution included | Error |
syntax | Every function string parsed as JavaScript | Error |
compile | compileVosConfig does not throw | Error |
determinism | No wall clock or randomness in frame paths | Error or warning |
dialect | Timeline syntax the sampler can run | Error or warning |
fonts | Every non-generic family has a matching fonts entry | Warning |
color-trap | A colour knob feeding new THREE.Color | Error when direct, warning when it reaches one through a helper |
materials | The two limits the render fleet cannot survive | Error |
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.
Related
- Voses and versions for the endpoints that store a config.
- Versions and conflicts for the chain a push joins.
/llms-remix.txtfor this contract in its canonical terse form, with the knob, font and 3D recipes.