# 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](https://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 }]
}
```

| 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:

```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.

| 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

```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.

| 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.

<Note>
The colour trap is worth reading twice if your program has colour knobs. A hex
fed through `new THREE.Color` is linearized from sRGB and renders darker than
the swatch a person picked. Parse the hex into a raw vector uniform instead.
</Note>

## 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](/docs/api/voses-and-versions) for the endpoints that store a config.
- [Versions and conflicts](/docs/agents/versions-and-conflicts) for the chain a push joins.
- [`/llms-remix.txt`](/llms-remix.txt) for this contract in its canonical terse form, with the knob, font and 3D recipes.
