VMC output
Prefix: /api/vmc
N.E.K.O. can publish the active VRM avatar's humanoid bones and expressions to a VMC-compatible receiver over OSC/UDP. The sender starts disabled, defaults to 127.0.0.1:39539 at 60 Hz, and only produces frames while a VRM model is active.
The preferred first-party integration is window.vrmVmcSender. The raw REST and WebSocket contracts below are implementation-facing and may evolve with the VRM runtime.
The browser initially loads only a lightweight API facade. Until a control method such as enable() or syncStatusFromBackend() is called, it does not load the full sender, poll status, create VMC timers, enter the per-frame sampling path, or change the existing VRM frame limiter.
Quick start
- Start a VMC receiver such as VSeeFace, Warudo, or a Unity/Unreal VMC integration.
- Configure the receiver to listen on UDP port
39539. - Load a VRM character in N.E.K.O.
- Enable output from the main page:
await window.vrmVmcSender.enable('127.0.0.1', 39539, 60)Optional controls:
await window.vrmVmcSender.requestTPose(2)
await window.vrmVmcSender.disable()The endpoint and send rate are persisted, but output intentionally starts disabled after a backend restart.
Output contract
The backend converts Three.js right-handed transforms to Unity/VMC coordinates and emits:
/VMC/Ext/OK/VMC/Ext/T/VMC/Ext/Root/Pos/VMC/Ext/Bone/Pos/VMC/Ext/Blend/Val/VMC/Ext/Blend/Apply/VMC/Ext/VRM(low-frequency: sent once per model change to identify the character)
Each frame carries at most 64 bones and 256 expressions. The two caps apply independently: a frame with 65 bones is truncated regardless of how many expressions it carries, and vice versa. The first-party sampler walks a fixed list of 55 humanoid bones, so only a third-party publisher posting directly to /api/vmc/ws can reach the bone cap; the expression cap is reachable from the browser with a model that ships hundreds of custom expressions.
Extras are dropped and a warning is logged once per cap. The sampler truncates before sending, so for browser publishers the expression warning appears in the browser console; the backend's own warnings cover third-party publishers.
The webpage's display position, scale, and rotation are not used as the VMC root. VMC owns an independent identity root so dragging or resizing the desktop avatar does not move the receiver's world origin.
When output is disabled, the destination changes, or the active VRM is released, N.E.K.O. sends zero values for active expressions followed by /VMC/Ext/OK 0. Model-release frames are acknowledged before the browser closes its dedicated socket.
An unexpected browser publisher disconnect has a 2-second grace period. A replacement publisher that authenticates during that window continues without a terminal transition; otherwise the backend clears active expressions and sends /VMC/Ext/OK 0.
REST control plane
Mutation routes require N.E.K.O.'s same-origin CSRF headers. First-party code should call window.vrmVmcSender instead of constructing those headers manually.
GET /api/vmc/status
Returns the effective runtime state:
{
"success": true,
"enabled": false,
"host": "127.0.0.1",
"port": 39539,
"send_rate_hz": 60,
"config_path": ".../vmc_config.json",
"t_pose_requested": false,
"t_pose_duration_sec": 2.0,
"t_pose_generation": 0
}POST /api/vmc/enable
All JSON fields are optional:
{
"host": "127.0.0.1",
"port": 39539,
"send_rate_hz": 60
}host accepts an ASCII hostname or IPv4 address, port must be an integer from 1..65535, and send_rate_hz must be an integer from 1..120.
Frames are sampled by the browser, so on a disabled-to-enabled transition the backend broadcasts {"type": "vmc_state_changed", "enabled": true} over the main chat WebSocket. The page then loads the full sender and starts per-frame sampling. Non-browser clients such as plugins can therefore enable output with this endpoint alone, without a manual enable() call in the page console. If no page is connected, the UDP sender still opens but emits no frames until one connects.
Repeat calls (for example to retune port) do not re-broadcast, because sampling is already running.
POST /api/vmc/disable
Sends the terminal VMC state, closes the UDP client, and reports the disabled runtime status.
POST /api/vmc/t_pose
Requests raw-rest-pose output for the active VRM:
{
"duration_sec": 2
}The duration must be positive and finite and is capped at 10 seconds.
WebSocket data plane
/api/vmc/ws is a dedicated first-party data channel; it is not the main chat WebSocket.
The browser must:
- Connect from an allowed local HTTP(S) origin.
- Send an
authmessage containing the current CSRF token within 5 seconds. - Wait for
{"type":"ready"}. - Send sequenced
frameorreleaseenvelopes.
Only one publisher may hold the process-wide lease. The server keeps one newest pending normal frame, serializes release after any in-flight frame, and expires a publisher after 10 seconds without a valid frame. Release and expression-retirement frames use frame_ack messages so state is not discarded before OSC transmission succeeds.
The browser opens this socket with a constructor borrowed from a hidden same-origin iframe rather than with window.WebSocket. The desktop build's preload replaces the top-level constructor and registers the most recently created socket as the chat IPC proxy target without discriminating by URL, so a VMC socket built from it would take over the chat channel. If the probe iframe cannot be created (CSP frame-src, sandbox) and the top-level constructor does not stringify as [native code], VMC refuses to connect, logs why, and stops sampling instead of reconnecting: a dead motion output is recoverable, a hijacked chat channel is not.
That second test is a heuristic, not a fact about the host. An ordinary browser's constructor is normally a native binding, so a blocked iframe there costs nothing — but a host whose WebSocket is implemented in JavaScript rather than by the engine exposes readable source and reads as non-native (Node's undici WebSocket stringifies as class _WebSocket extends EventTarget). If both conditions land, the symptom is a silent dead output, recoverable by allowing same-origin frames (frame-src 'self'), which restores the borrow and skips the check entirely.
Close codes:
| Code | Meaning |
|---|---|
4403 | Origin or authentication rejected |
4409 | Message exceeds 256 KiB |
4428 | Publisher idle timeout |
4429 | Another VMC publisher is active |
Security and deployment
- Do not expose the main server port
48911to an untrusted LAN or the public Internet. - Use
127.0.0.1unless the receiver intentionally runs on another trusted machine. - OSC uses UDP and has no transport-level acknowledgement; the browser acknowledgements only confirm that N.E.K.O. handed the frame to its UDP sender.
- Development installs need the locked
python-oscdependency (uv sync).
Troubleshooting
- No motion: confirm that VMC is enabled and a VRM, not Live2D/MMD/PNGTuber, is active.
- No receiver data: verify the destination host/port, receiver listen port, and local firewall.
- Unexpected send rate: while the full-rate render loop is active, cumulative scheduling averages approximately the configured rate. A VRM that is otherwise idle is intentionally capped at about 30 Hz until animation or interaction resumes.
- Publisher busy: close the other N.E.K.O. page or wait for its 10-second lease timeout.
- Console error about hijacking the desktop chat channel: the page could not obtain a native WebSocket constructor: the same-origin probe iframe was blocked and
window.WebSocketdid not stringify as[native code]. That second test is a heuristic, so a browser with no preload at all can in principle land here. Either way, no frames are published until same-origin frames are allowed (frame-src 'self'), which restores the iframe borrow and skips the check entirely. - Sampling error: sampling is suspended to protect rendering and retried after a later backend status poll.
