Local control API
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:
- wanting hardware knobs → see Keyboard and MIDI
- wanting tempo sync with other apps → see Tempo, Link and quantization
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:
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:
{"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/ |
| 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
displayNamelist for blend cases outside the 14 layer modes. - Behaviour of lease and timing with a real clock under heavy load, or while paused.