@laplace.live/persona-sdk is the typed client for the Plugin API, and the source of truth for its wire schema. This page is the type reference; the Plugin API page covers enabling the server, keys and the method surface
Isomorphic — Node 22+, Bun, browsers and OBS browser sources — with zod as its only runtime dependency, backing the wire schemas
npm install @laplace.live/persona-sdkThe tables below mirror the SDK's own declarations. Everything is exported from
the package root, so a single import type { … } from '@laplace.live/persona-sdk'
reaches all of it
What You Can Build
The Persona Console is the official reference implementation for this SDK. It is a whole remote control for the application — scenes, layers and the Inventory that fills them, expressions, motions, parameter bindings, hotkeys, tracking and settings — in a browser tab, laid out to be usable from a phone mid-stream
The console does not import desktop code. The plugin API is its entire contract, which means everything it does is available to anything else you write against the same SDK
Client implementations can follow these patterns:
- Gate on capabilities, not on format. Every per-instance section reads
InstanceRuntime.capabilitiesand disables itself, rather than calling and handlingunsupported-for-format - Treat a reconnect as a cache flush. Events missed while the socket was down are gone, so cached data may no longer match the server — invalidate caches and fetch fresh data on reconnect
- Handle the terminal close codes.
4001and4002mean stop reconnecting; everything else is worth retrying - Let the SDK own subscriptions.
persona.on(…)re-subscribes after a reconnect, so no bookkeeping is needed at the call site
For an unencrypted ws:// connection from an HTTPS page, use 127.0.0.1 to
reach Persona on the same machine. To connect to another machine, serve your
page over HTTP or connect through a TLS proxy in front of Persona — see the
console notes
LLM Prompt
Paste the prompt below into your AI assistant, or keep it in a rules file such as CLAUDE.md or AGENTS.md, and it has the background it needs to write plugins for you. The documentation URLs it cites are all fetchable
You are helping me build a plugin for LAPLACE Persona, a desktop VTuber app
(https://laplace.live/persona). Persona exposes a local WebSocket server called the
Plugin API, and `@laplace.live/persona-sdk` is its typed client.
Before writing any code, fetch the reference documents listed at the end — they are
the single source of truth for method names, events, error codes and types. Do not
rely on memory and do not invent names.
## Setup
- Install `@laplace.live/persona-sdk`. Isomorphic: Node 22+, Bun, browsers and OBS
browser sources. Its only runtime dependency is zod.
- The user enables the server in Persona under Settings → Plugin API and creates an
API key there. Tokens look like `sk-lp-v1-…`. Default endpoint: `ws://127.0.0.1:25034`.
- Everything — values and types — is exported from the package root; there is no
other entry point.
## Client
```ts
import { PersonaClient } from "@laplace.live/persona-sdk";
const persona = new PersonaClient({
token: "sk-lp-v1-…", // required; everything else is optional
clientInfo: { name: "My Plugin", version: "1.0.0" }, // display-only identity
});
await persona.connect();
const { scenes, activeSceneId } = await persona.call("scene.list");
await persona.call("scene.patch", {
background: { mode: "color", color: "#00ff00", imageAssetId: null },
});
persona.on("motion.started", (m) => console.log("playing", m.group));
const mouth = persona.driveParameter("MouthOpen", 0.8); // parameter lease
mouth.set(0.3);
mouth.release();
```
`call(method, params?)` is fully typed — the method name determines the request and
response shapes. Methods cover scenes, model instances, stage objects, expressions,
motions, speech, hotkeys, automations, model and asset registration, settings,
tracking and pose input, per-key storage and parameter injection; events notify you
of scene, selection, expression, motion, speech, tracking and registry changes. The
exact method and event names, parameter shapes, error codes and limits live in the
reference below — look them up instead of guessing. Gate features on the
capabilities reported by `app.info` and `instance.list`, never on version strings.
## Reference
Fetch these before writing code. The documentation is served as raw Markdown —
append `.mdx` to any page URL (`.en.mdx` for English):
- https://laplace.live/persona/plugin-api.en.mdx — server setup, API keys, and every
method, event, error code and limit
- https://laplace.live/persona/sdk.en.mdx — the SDK client: options, auth modes and
every exported typeClient
import { PersonaClient } from "@laplace.live/persona-sdk";
const persona = new PersonaClient({ token: "sk-lp-v1-…" });
await persona.connect();
const { scenes, activeSceneId } = await persona.call("scene.list");
await persona.call("scene.patch", {
background: { mode: "color", color: "#00ff00", imageAssetId: null },
});
persona.on("motion.started", (m) => console.log("playing", m.group));PersonaClient is constructed with these options. Only token is required
Prop
Type
ClientInfo
clientInfo is what puts your application's name under Connected Clients in Persona's settings. It is optional, self-declared and display-only — never authorisation — and the SDK re-declares it on every reconnect
const persona = new PersonaClient({
token,
clientInfo: { name: "My Overlay", version: "1.2.0", developer: "You" },
});Prop
Type
Authentication
By default the token travels as a ?token= query parameter, which is what a browser can do. From Node you can send it as an Authorization: Bearer header instead by supplying a socket that sets headers — the ws package, installed separately:
import WebSocket from "ws";
const persona = new PersonaClient({
token,
auth: "header",
createWebSocket: (url, headers) => new WebSocket(url, { headers }),
});WebSocketLike is the socket surface createWebSocket must return. The global WebSocket and the ws client both satisfy it structurally, so no casts are needed
Prop
Type
Parameter Leases
driveParameter holds a value on a model parameter and returns an InjectionHandle. The SDK owns the heartbeat, and the lease expires about a second after the last write — so if your process dies, the parameter reverts instead of sticking
const mouth = persona.driveParameter("MouthOpen", 0.8);
mouth.set(0.3);
mouth.release();Only one session can hold a given parameter at a time; a second one gets a conflict error
The third argument also takes a weight and an onError — the callback a heartbeat failing in the background reports through, so the caller never has to await each renewal
Prop
Type
Closing
onClose reports every server- or network-initiated close with the code and reason the raw socket carried, plus terminal — the mark on the codes the client does not reconnect after. It fires after the client has settled its own handling (state, pending calls), so calling close() inside it is safe; a close() you issued does not report. Setting it makes terminal closes skip onWarning
Prop
Type
PersonaClientState
type PersonaClientState = "closed" | "connecting" | "open" | "reconnecting";Constants
| Export | Value | Meaning |
|---|---|---|
PROTOCOL_VERSION | 4 | Bumped on breaking wire changes; the server reports its own in hello |
DEFAULT_API_HOST | 127.0.0.1 | All the server binds unless the user allows local network access |
DEFAULT_API_PORT | 25034 | What the app listens on unless you change it |
SCENE_ACTIVATION_TIMEOUT_MS | 120000 | How long the SDK waits on scene.activate unless requestTimeoutMs is set |
CLOSE_KEY_REVOKED | 4001 | Terminal close code — the key was revoked |
CLOSE_FORCE_DISCONNECTED | 4002 | Terminal close code — the user disconnected the session |
CLOSE_SERVER_STOPPING | 1001 | The server is stopping. Not terminal — reconnect once it returns |
INJECT_LEASE_TTL_MS | 1000 | How long after its last write an injected parameter reverts |
INJECT_HEARTBEAT_MS | 100 | How often the SDK re-sends held leases |
STORAGE_KEY_MAX_LENGTH | 128 | Longest storage key |
STORAGE_VALUE_MAX_LENGTH | 65536 | Ceiling on one value's serialized length |
STORAGE_KEYS_MAX | 256 | Keys one API key may hold |
SPEECH_URL_MAX_LENGTH | 8000000 | speech.play URL ceiling, sized for a ~40 s WAV as base64 |
API_ERROR_CODES, EVENT_NAMES, INPUT_NAMES, APP_CAPABILITIES, FPS_LIMIT_PRESETS and EFFECTS_QUALITY_LEVELS ship as as const arrays, so a picker can offer exactly the values the app accepts. isApiErrorCode, isEventName and isAppCapability are their guards, and injectTargetKey builds the canonical key for an injection target
personaWsUrl assembles the socket URL from a host, a port and a secure flag — or from one line of user-typed address: host, host:port, bare or bracketed IPv6, and a full ws(s):// URL taken verbatim. The token does not belong in it; PersonaClient appends its own. parsePort and isValidPort validate the input that feeds it
Models and Assets
Models and assets share one entry shape. kind is always what the item is and origin always where it came from — bundled shipped with the application, user registered by the user — alongside the creator credits the app's content manifest declares
Prop
Type
type ModelFormat = "live2d" | "vrm";
type ContentOrigin = "bundled" | "user";
type AssetKind =
| "image"
| "video"
| "audio"
| "prop"
| "ibl"
| "lut"
| "animation"
| "cameraMotion";
type InventoryKind = ModelFormat | "pngtuber" | AssetKind | "effect";effect is the one kind that names no file: the Inventory uses it to list the built-in effects
model.list returns ModelRef, a ContentRef whose kind narrows to a ModelFormat. asset.list returns AssetRef, which narrows kind to an AssetKind and adds exists — false once the file is gone from disk
Prop
Type
Prop
Type
CatalogItem
CatalogItem is the transport-independent metadata type used by content pickers. Both local registry entries and remote catalog entries map to it. It extends ContentRef with catalog information and omits origin, which is unknown before installation. download describes how to fetch an item that is not yet installed; its absence means the item is already local
Prop
Type
Placement
Screen space is pixels from the stage centre; world space is metres. Rotations are radians throughout the wire format, even though the panel shows degrees
Prop
Type
Prop
Type
Place2D and Place3D are the object variants — the same fields plus opacity
Scene Items
A scene's items array holds both models and objects, in z-order
type SceneItem = SceneModelItem | SceneObjectItem;Prop
Type
Prop
Type
Prop
Type
ObjectContent
What an object renders, discriminated on kind. Everything but capture is creatable from the interface today — see Layers
type ObjectContent =
| { kind: "image"; assetId: string }
| {
kind: "video";
assetId: string;
loop: boolean;
muted: boolean;
volume: number;
}
| { kind: "prop"; assetId: string }
| {
kind: "web";
url: string;
width: number;
height: number;
fps: number;
transparent: boolean;
css: string;
layer: WebLayer;
shutdownWhenHidden: boolean;
}
| { kind: "capture"; source: CaptureKind; sourceId: string; label: string };
type ObjectSpace = "2d" | "3d";
type WebLayer = "behind" | "front";
type CaptureKind = "display" | "window";Prop
Type
Pinning
Prop
Type
AttachAnchor
type AttachAnchor =
| { kind: "root" }
| { kind: "bone"; bone: string }
| {
kind: "artMesh";
id: string;
verts: [number, number, number];
weights: [number, number, number];
};object.anchors returns the anchors a parent offers as AnchorOption — an anchor plus its display label. A VRM offers the model root plus its humanoid bones; a Live2D model offers the model root plus every ArtMesh it has, each ArtMesh row with its order, the paint order when the list was taken
AttachDepth
type AttachDepth =
{ kind: "front" } | { kind: "behind" } | { kind: "artMesh"; id: string };depth is where a Live2D item paints inside the model it rides — behind it, in front, or just above one of its ArtMeshes. Only a Live2D instance riding a Live2D model uses it; instance.attach sets it
Prop
Type
depthStops(meshes) lays a depth control's stops out back to front — behind, above each ArtMesh in paint order, in front — and depthStopIndex(meshes, depth) finds the stop a depth sits at, reading an ArtMesh the parent no longer has as the frontmost. pinRiders(items, instanceId) returns every instance riding instanceId, directly or through a chain of pins — the ones it can never pin to — and pinnableParents(models, items, instanceId) builds the pin picker's rows from that, with ridesThis marking the ones to disable. defaultAttach(parentInstanceId) is a fresh pin: root anchor, in front, with no split and no tuning
Prop
Type
Prop
Type
Scene
Prop
Type
Prop
Type
Prop
Type
Background
Prop
Type
type BackgroundMode = "transparent" | "color" | "image";Camera
Prop
Type
Prop
Type
cameraDistanceLimits(environment, radius) returns the distance bounds mouse navigation keeps to: DISTANCE_MIN and DISTANCE_MAX, 0.35 and 20, widened in proportion when the scene's 3D set is placed larger or smaller than a room about 10 m across. radius is the set's own bounding radius, from environment.radius in scene.inspect — null until the set loads, and omitted by older builds — and null gives the plain bounds
Lighting
Prop
Type
type SceneLightType = "directional" | "point" | "ambient" | "area" | "spot";
type ShadowQuality = "off" | "low" | "medium" | "high" | "ultra";
type ShadowFilter = "pcf" | "pcss";SCENE_LIGHT_TYPES, SHADOW_QUALITY_LEVELS and SHADOW_FILTERS ship the types, tiers and filters as as const arrays, in the order the panel offers them
followCamera makes a light's placement and angles relative to the camera, followCameraOptions picks the camera movements it follows — DEFAULT_LIGHT_FOLLOW_CAMERA_OPTIONS has all three on — and followCameraReference holds what a switched-off option freezes. To switch a light without moving it, call scene.setLightFollowCamera instead of patching the field; see Lighting
Prop
Type
Prop
Type
iblAssetId and modelAssetId select an environment map and a 3D set, and builds with environment-map-model accept both at once. Write them through environmentWithAsset(), which sets the one you pick, keeps the other, and drops the light overrides that belonged to an outgoing set. A picked map switches bakeFromModel off; a set switches it on only where there was neither, and one set replacing another keeps it. null clears both. environmentClearPatch(environment, slot) builds the edit that removes 'model', 'map' or, for null, both: the model takes bakeFromModel and its light overrides with it, and removing only the map hands lighting to a remaining set. Two more helpers back the panel's buttons: applyEnvironmentLighting() switches both light routes on and reduces the scene's own light intensities, and applyEnvironmentLook() applies an EnvironmentLook to the scene
Prop
Type
Prop
Type
Prop
Type
Prop
Type
SCENE_VOLUMETRIC_LIGHTING_SPECS holds the haze's slider bounds, defaultSceneVolumetricLighting() returns the haze settings a scene starts with, and healSceneVolumetricLighting() brings any value into range. Haze is switched on per light, through SceneLight.volumetric: sceneLightCanHaze(type) says whether a type can light it, and sceneLightHazes(light) whether a light does now
Effects
SceneEffects is deliberately flat: every toggle-plus-numbers effect sits at the top level, so tooling can walk them generically. Where an effect appears in the panel is a presentation choice, not a shape
Prop
Type
Which effects hold a row in the layer list is tracked separately, in environment.effectLayers — a list of ToggleEffectKey, which is every toggle-plus-numbers key in this registry. It is not the same thing as enabled: enabled is whether the effect is on, effectLayers is whether it has a row. Patching an effect on lands its row automatically, so writing enabled is enough
type SceneToneMapping = "none" | "neutral" | "aces" | "agx";Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Gradient and Rim Light share one set of 24 blend modes, EffectBlendMode; EFFECT_BLEND_MODES lists them in the order the panel offers
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
rain and snow are shaped like every other effect but do not behave like one: they are geometry in the scene rather than links in the post chain. See Rain and Snow
Models and objects carry a stack of their own — effects and effectLayers on SceneModelItem and SceneObjectItem, applied before the scene's and edited through instance.setEffects. It holds only the LayerEffectKey subset: color, levels, colorWheels, colorShift, selectColors, gradient, blur, bloom, diffusion, rim, outline and dropShadow. Bloom's colour selection fields only take effect there. See Layer Effects
Transition
transition is the effect a scene plays when it is entered — see Scene Transition. Hosts without the scene-transitions capability omit it. SCENE_TRANSITION_TYPES lists the types, defaultSceneTransition() returns the cut every scene starts with, and healSceneTransition() brings any value into range
Prop
Type
environment.itemTransition is how the scene's models, objects and spawned bodies appear and disappear — see Show and Hide. Older builds omit it. ITEM_TRANSITION_STYLES lists the styles, defaultItemTransition() returns the 300 ms glitch every scene starts with, and ITEM_TRANSITION_MAX_MS is the 5000 ms ceiling
Prop
Type
Runtime State
instance.list reports what each item on stage can actually do, so a client can disable a control rather than discover it through an error
Prop
Type
type InstanceCapability =
| "motions"
| "expressions"
| "placement-2d"
| "placement-3d"
| "mtoon"
| "idle-clips"
| "idle-motions"
| "pose"
| "live2d-params"
| "speech";speech is reported only by a loaded Live2D model, so it only appears in this runtime list — format cannot tell you. The application's own capabilities are a separate list, reported in hello and by app.info: gate features on it rather than comparing version
type AppCapability =
| "storage"
| "speech"
| "automations"
| "controllers"
| "model-editing"
| "asset-inspection"
| "layer-effects"
| "scene-transitions"
| "area-lights"
| "spot-lights"
| "camera-follow-lights"
| "shadow-filters"
| "environment-map-model"
| "spawn"
| "tracking-lost"
| "motion-stop"
| "stage-capture";stage.capture (the stage-capture capability) answers { dataUrl, width, height } — the stage window as a PNG with alpha, its longest edge maxEdge at most, 64 – 2048 and 1024 by default. It is what a client that cannot see the screen looks at; the Plugin API has the rest of the contract
Prop
Type
files keeps every motion at its authored index: one whose file is missing stays as an empty string, so the motions after it keep their indices. motionSlots(files) returns the playable entries along with those indices. motion.play on an empty slot fails with not-found, and a group with no file at all is left out. A Live2D model also lists every .motion3.json in its folder that its model3.json does not declare, each as a group named by the file
motion.stop (the motion-stop app capability) fades the running motion out and lets the idle resume; it answers { stopped: false } when only the idle is playing. motion.playing's oneShot — also on motion.started — says whether that motion is the kind a stop would end
Prop
Type
Prop
Type
Prop
Type
Hotkeys
Prop
Type
Prop
Type
HotkeyConfig is the same shape without registered — it is what hotkey.set accepts
Automations
Automations belong to the application. Each runs a sequence of actions triggered by a shortcut, an event or an API call. automation.list returns them, automation.run starts one, and automation.state pushes the whole list again whenever it changes. It needs the automations capability; the Plugin API covers the usage
Prop
Type
type AutomationActionKind =
| "effect-toggle"
| "effect-params"
| "effect-clip"
| "camera-pose"
| "reset-camera"
| "play-camera-motion"
| "stop-camera-motion"
| "layer-visibility"
| "stream-mode"
| "switch-scene"
| "toggle-expression"
| "play-motion"
| "play-audio"
| "audio-control"
| "remove-all-expressions"
| "load-model"
| "model-position";actionKinds and triggerKinds are typed string[] rather than unions on purpose: a server may send kinds newer than the SDK. automationActionLabel gives one action kind its English label, using a generic label for unknown kinds, and automationLabel gives a whole automation its label — the title when it has one, otherwise the action labels joined with · in list order, which is not playing order. AUTOMATION_BOUNDARY_KINDS and isAutomationBoundary name the setup kinds, switch-scene and load-model, which finish before the timeline starts
Settings
Prop
Type
SettingsPatch is the writable subset: lipSync, controller (its enabled switch only), window, ui and performance, all optional. Tracking and pose are changed through tracking.* and pose.* instead. Both types are inferred from runtime Zod schemas the SDK exports too, SettingsSchema and SettingsPatchSchema: parsing a patch drops unknown and read-only keys, while parsing a response lets unknown fields through. PersonaClient does not validate settings for you
type TrackingSourceId = "persona-ios" | "ifacialmocap" | "vts-ios";
type PoseSourceId = "vmc" | "mocopi";
type TrackingSourceKind = TrackingSourceId | PoseSourceId | "mediapipe";
type TrackingStatus = "off" | "waiting" | "tracking" | "no-face";
type PoseStatus = "off" | "waiting" | "tracking";
type EffectsQuality = "low" | "medium" | "high";Tracking Sources
tracking.sources is the list of configured sources — network face and body sources, and the webcam. Several run at once, and model instances bind to them by id through faceSourceId, poseSourceId and handSourceId on SceneModelItem
Prop
Type
tracking.source, pose.source and pose.port predate multiple sources and now only mirror the first network source on each channel, kept for older clients. New code reads sources
The mediapipe webcam source carries its own options, with their defaults in DEFAULT_MEDIAPIPE_CONFIG
Prop
Type
type HandTrackingMode = "arms" | "fingers";A source kind no longer maps to a single channel — the webcam can feed all three. sourceSupportsChannel(source, channel) tells whether a source can feed face, hands or pose with its current task switches, and sourceChannelEnabled(source, channel, masters) also folds in the source's own switch and, for network sources, the face and pose master switches
Lip Sync
settings.lipSync and lipSync.configure share one LipSyncConfig for the desktop's microphone. Hosts without microphone lip sync omit it from Settings
Prop
Type
lipSync.state and lipSync.calibrate return a LipSyncState. The calibration steps are covered under Microphone Lip Sync
Prop
Type
lipSyncMode on SceneModelItem decides per model whether the microphone drives it:
type LipSyncMode = "off" | "always" | "when-untracked";Storage
storage.* values are arbitrary JSON. The limits are in Constants, the semantics in Plugin Storage
type JsonValue =
string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };Injection
Prop
Type
type InjectTargetType = "input" | "live2d-param" | "vrm-expression";InjectTarget is the same without value and weight. For input targets, id is one of INPUT_NAMES — VTube Studio's own vocabulary plus the 52 raw ARKit channels, and the names a model's .vtube.json references
The 21 from VTS's vocabulary:
FaceAngleX FaceAngleY FaceAngleZ FacePositionX FacePositionY FacePositionZ EyeOpenLeft EyeOpenRight EyeLeftX EyeLeftY EyeRightX EyeRightY Brows BrowLeftY BrowRightY MouthSmile MouthOpen MouthX CheekPuff JawOpen TongueOut
Then 52 ARKit-prefixed channels, each named for its ARKit blendshape with the first letter capitalized — ARKitJawOpen, ARKitMouthSmileLeft, ARKitEyeBlinkRight and so on. The prefix is there because VTS's vocabulary already holds JawOpen, CheekPuff and TongueOut, and .vtube.json input names are matched case-insensitively
Those three names are exactly ARKIT_TWINS: each is the same float as its ARKit channel, both spellings are accepted, and both are read as the ARKit one. INPUT_RANGES gives every input's span — head and hand angles are in degrees, ARKit channels are 0..1, the rest unitless
input targets also accept controller inputs. Profile #1 keeps the 19 VTS names (ControllerStickLeftX/Y, ControllerStickRightX/Y, stick clicks, the D-pad, face buttons, shoulders, triggers, options and home); profiles #2 onward insert their number after Controller, as in Controller2StickLeftX and Controller12Cross, with no fixed profile cap. BASE_CONTROLLER_INPUT_NAMES lists the first profile's default controls, controllerInputName builds the numbered ones, and getInputRange resolves their spans — stick and D-pad Y is positive upward
Webcam hands and the microphone add two more families. HAND_INPUT_NAMES holds VTS's 26 hand inputs plus HandLeftAngleY and HandRightAngleY: detection, position, angle and openness for each hand, one value per finger such as HandLeftFinger_1_Thumb, and BothHandsFound and HandDistance. VOICE_INPUT_NAMES holds VTS's VoiceVolume, VoiceFrequency, VoiceVolumePlusMouthOpen, VoiceFrequencyPlusMouthSmile, VoiceA to VoiceO and VoiceSilence, plus VoiceMouthOpen and signed VoiceMouthSpread. Injecting either stays Live2D-only, like every input target
The OS cursor is a third family: MOUSE_INPUT_NAMES holds MousePositionX and MousePositionY, running −1..1 across the display the cursor is on, growing rightward and upward like FacePositionY. Both are independent of face tracking and of the tracking switch
For how these inputs reach a Live2D model's parameters, see Parameter Bindings
Message Format
You do not need these to use the SDK — they are here for anyone speaking the protocol directly
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
ApiErrorCode is the closed set of code values an ErrorMessage carries — see Errors for what each one means. PersonaApiError is the class call throws, carrying that code
Last updated on September 20, 2026