WebsiteGet Ultimate VJ
DocumentationControl & integrations

Local control API

On this page

Ultimate VJ exposes a localhost-only control surface: HTTP REST, OSC, WebSocket state events and an MCP endpoint. Nothing listens until you start it.

Before reaching for any of this, note that there are two simpler options depending on your goal:

Starting it

Settings → Connections → Start local control.

  • Nothing binds at launch. No launch agent, no autostart - the service exists only while you leave it started.
  • Both listeners bind IPv4 loopback only (127.0.0.1). There is no Bonjour service, no wildcard bind and no LAN listener.
  • Default port for both REST and OSC is 0, meaning choose an available port automatically. Set a specific port if a client needs a fixed one.
  • Both listeners must become ready within 3 seconds or startup fails with an error.

Once running, the panel shows four lines you can copy:

text
REST · http://127.0.0.1:PORT/api/v1
MCP · http://127.0.0.1:PORT/mcp
OSC · 127.0.0.1:PORT UDP
Events · ws://127.0.0.1:PORT/api/v1/events

The access key

Copy access key puts a 64-character hex string on the pasteboard. It is 32 random bytes generated at launch, held in memory only, deliberately kept out of saved sets, and stable for the life of the app launch.

Every request authenticates with Authorization: Bearer <key>, read from headers only - never from a URL. Failure returns 401 with WWW-Authenticate: Bearer realm="ULTIMATEVJ".

Copy control target conveniently produces the JSON ControlTarget for Composition, Selected layer, Selected clip or Selected column.

Clients identify themselves

Every envelope carries an origin string (1–32 characters: letters, digits, ., _, -). Capacity is 64 clients, and each has a 5-second lease renewed by any request - heartbeat, commands, receipts. Idle clients are expired.

REST

Base path /api/v1. Only GET and POST are allowed.

A bare /v1 prefix is also routed, and most behaviour is identical, but not all of it - on the canonical prefix only, error codes are remapped from the registry's internal values (409 with reason deadlineOutOfRange becomes 422; queueCapacity becomes 429) and POST /commands answers 202 with a Location header. On the legacy prefix you get the raw registry codes and a plain 200 with no Location. If you are writing a client, use /api/v1.

Route Method
/discovery GET - same document as /capabilities
/capabilities GET
/openapi.json GET
/events GET - 426 unless upgraded to WebSocket
/state GET
/targets GET, optional cursor
/targets/state POST
/commands POST
/heartbeat POST
/disconnect POST
/receipts/{requestUUID} GET, with origin query

Anything else is 404.

Responses are always JSON, always close the connection, always send Cache-Control: no-store and X-Content-Type-Options: nosniff.

Commands and receipts

A command envelope is {requestID, origin, sessionEpoch, deadline?, nextGrid, commands} with 1 to 64 commands. Unknown fields anywhere in the envelope or in targets are rejected, not ignored - the API would rather fail than guess.

POST /commands returns 202 with a Location header pointing at the receipt. A receipt is {requestID, status: queued|applied|rejected, revision, reason?, lateness}. Replaying the identical envelope returns the stored receipt; a changed envelope under the same ID is a conflict. Completed receipts are retained 10 minutes for remote clients.

Transport/parser failures can use {"error": <reason>}. Rejected command results can instead return a ControlReceipt, with status: rejected and a reason. Inspect both HTTP status and body shape: canonical routes use 401 for authorization, 409 for conflicts, 422 for mapped validation failures and 429 for mapped capacity failures.

Targeting

Targets are addressed by persistent UUIDs, never by index or selection:

json
{"document": "<UUID>", "entity": {"layer": {"layer": "<UUID>"}}}

Entities are composition, layer, clip (bank + clip UUIDs) and column (bank + column UUIDs). UUIDs are minted once when a set is loaded or imported; duplicates would make control refuse outright.

Start with GET /api/v1/targets without a cursor. Follow nextCursor as the cursor query value for subsequent pages; an empty cursor is invalid. Pages contain at most 16 entries, with cursors of the form <sha256>:<offset>. If the set changes between pages, the fingerprint changes and you get 409 staleCursor rather than a wrong result. POST /targets/state reads up to 8 targets in one call, returning rows in request order without recording an undo step or changing selection.

Parameters you can command come from a catalogue of 14 parameters: layer opacity, master opacity, crossfader position, crossfader curve, layer crossfader side, layer blend, BPM, quantization, meter, paused, speed, seek, cue and playback - thirteen of which are readable, since cue is an action rather than a value. There are also 9 action descriptors. Anything outside that catalogue - including effect internals and surface geometry - has no command today. clip.cue is deliberately not readable, because cue jumps are actions.

Now you can also discover everything programmatically: GET /openapi.json returns a full OpenAPI 3.1.1 description derived from the live command registry, including limits and per-command metadata.

OSC

UDP on loopback, up to 16 peers. Messages are OSC 1.0 with type tags i, f, s and b; the mandatory ,-tag preamble and 4-byte alignment are enforced, and anything else is rejected before dispatch. Limits: 16 KiB packets, 4 KiB strings, 64 arguments, 64 elements, nesting depth 8.

Three address profiles exist:

Profile Shape
envelope-json-v1 /uvj/v1/command with two arguments: key string, then a blob holding the envelope JSON
uuid-typed-v1 /uvj/v1/documents/<DOC>/composition/…, /layers/<LAYER>/…, `/banks//clips
uuid-pattern-v1 Wildcards (*, ?, character classes, brace alternatives) expanding to concrete UUID addresses

Typed messages always carry five leading arguments: key, origin, request UUID, session epoch and next-grid flag. Typed set addresses end in /set/<parameter id>. /trigger, /restart and /disconnect take no further values; /press takes one press UUID; /tap takes one number.

Literal endpoints exist for targets, target state, subscribe, unsubscribe, discovery, state, heartbeat and receipt lookup - and those are never expanded by wildcards.

Bundles group a bundle's direct messages into one shared transaction. Nested bundles keep their own independent NTP timetags, and future timetags are capped at 2 seconds. The command budget is 64 per packet, shared across everything expanded from it.

Feedback: subscribe at /uvj/v1/subscribe and receive changed state snapshots on /uvj/v1/state/event at up to 4 Hz, addressed to whichever peer subscribed - no destination address is accepted. Maximum 8 subscribers, each with a 5-second renewal lease; a ninth receives oscFeedbackCapacity. Note that any response status ≥ 400 other than 409 or 429 tears the peer's whole UDP connection down, and therefore its subscription.

WebSocket events

Upgrade at /api/v1/events (a plain GET returns 426). You receive {"type":"state","sequence":N,"state":{…}}

  • the same payload as /api/v1/state - only when something changed, coalesced at up to 4 Hz. Maximum 8 subscribers; a ninth gets 429 webSocketCapacity.

Each client holds one pending frame, which the next publication replaces rather than queues, so a slow client falls behind gracefully instead of backing up the server. The stream is read-only: sending any text or binary frame closes the connection with 1003. Max frame 4 KiB, max message 256 KiB.

MCP

Single POST endpoint /mcp on the same port, protocol version 2026-07-28, stateless, no sessions. Note it is path-strict: /api/v1/mcp returns 404.

Eight tools: uvj_capabilities, uvj_state, uvj_targets, uvj_target_state, uvj_apply, uvj_receipt, uvj_heartbeat, uvj_disconnect.

Each maps to the identical REST handler. Tool schemas are derived locally from the same OpenAPI models, so they cannot drift from the REST surface. tools/list accepts no cursor - there are eight tools, always listed in full. uvj_targets still paginates as described above.

Requests need _meta carrying io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities, plus the mcp-protocol-version and mcp-method headers, and an Accept header listing both application/json and text/event-stream. For tools/call, also send mcp-name exactly matching the requested tool name. Body limit 65,536 bytes.

Bounded by design

Every resource is deliberately capped so a misbehaving client is refused rather than served. These are resource bounds, not a real-time reliability guarantee - the app claims no sustained performance under load:

Resource Cap
Clients / OSC peers / HTTP connections 64 / 16 / 16
Commands per envelope 64
Queued groups / queued commands 64 / 1,024
Held presses / receipts 256 / 1,024
Future scheduling horizon 2 seconds
Receipt retention (remote) 600 seconds
Handoff queue 16 pending items
HTTP body / total wire bytes 64 KiB / 112 KiB
Request timeout / processing timeout 2 s / 2 s
Targets page 16 entries, 12 KB per page, 16 KB response cap
Targets per state read 8

Dispatch executes at most 64 commands per drain pass with a 2 ms budget, ordered by deadline then arrival, never splitting a group. Command envelopes and targeted-state reads carry the current sessionEpoch; this is not required on every discovery, snapshot, receipt or heartbeat request. Session invalidation rejects stale pending work and releases holds.

Native UI vs remote clients

One asymmetry worth knowing: commands dispatched from the app's own UI do not retain completed receipts, so the 600-second replay window applies only to remote submissions.

Not verified

  • Sustained performance and real-time behaviour under load for any of these limits.
  • Whether descriptions in the OpenAPI document match runtime at every code path.
  • The full displayName list for blend cases outside the 14 layer modes.
  • Behaviour of lease and timing with a real clock under heavy load, or while paused.