00:00 / 00:00

Persona

SDK

@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-sdk

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.capabilities and disables itself, rather than calling and handling unsupported-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. 4001 and 4002 mean 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

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 type

Client

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

ExportValueMeaning
PROTOCOL_VERSION4Bumped on breaking wire changes; the server reports its own in hello
DEFAULT_API_HOST127.0.0.1All the server binds unless the user allows local network access
DEFAULT_API_PORT25034What the app listens on unless you change it
SCENE_ACTIVATION_TIMEOUT_MS120000How long the SDK waits on scene.activate unless requestTimeoutMs is set
CLOSE_KEY_REVOKED4001Terminal close code — the key was revoked
CLOSE_FORCE_DISCONNECTED4002Terminal close code — the user disconnected the session
CLOSE_SERVER_STOPPING1001The server is stopping. Not terminal — reconnect once it returns
INJECT_LEASE_TTL_MS1000How long after its last write an injected parameter reverts
INJECT_HEARTBEAT_MS100How often the SDK re-sends held leases
STORAGE_KEY_MAX_LENGTH128Longest storage key
STORAGE_VALUE_MAX_LENGTH65536Ceiling on one value's serialized length
STORAGE_KEYS_MAX256Keys one API key may hold
SPEECH_URL_MAX_LENGTH8000000speech.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 frombundled 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

Tech otakus destroy the world