Make 2 ai vtubers talk to eachother in vtube studio With functioning token System For mouths (working code example for everyone cause i could not find this anywhere)

ali:
VTS_PORT = 8001

MOUTH_PARAM = “MouthOpen” # VTuber mouth parameter

TOKEN_FILE = “vts_token.json”

class VTubeStudioClient:

def \__init_\_(self):

    self.url = f"ws://localhost:{VTS_PORT}"

    self.authenticated = False

    self.token = self.load_token()

    self.\_auth_requested = False  # Prevent repeated auth requests



    self.\_connect()



def \_connect(self):

    clean_print(f"\[cyan\]Connecting to VTube Studio on {self.url}...\[/cyan\]")

    self.ws = websocket.WebSocketApp(

        self.url,

        on_open=self.on_open,

        on_message=self.on_message,

        on_error=self.on_error,

        on_close=self.on_close

    )

    self.thread = threading.Thread(target=self.ws.run_forever, daemon=True)

    self.thread.start()



def on_open(self, ws):

    clean_print("\[green\]Connected to VTube Studio API\[/green\]")

    time.sleep(1)

    self.\_auth_requested = False  # Reset on new connection



    if self.token:

        self.send({

            "apiName": "VTubeStudioPublicAPI",

            "apiVersion": "1.0",

            "requestID": "auth",

            "messageType": "AuthenticationRequest",

            "data": {

                "pluginName": "Local AI VTuber",

                "pluginDeveloper": "Bro77xp",

                "authenticationToken": self.token

            }

        })

    elif not self.\_auth_requested:

        self.\_auth_requested = True

        self.send({

            "apiName": "VTubeStudioPublicAPI",

            "apiVersion": "1.0",

            "requestID": "auth_token",

            "messageType": "AuthenticationTokenRequest",

            "data": {

                "pluginName": "Local AI VTuber",

                "pluginDeveloper": "Bro77xp",

                "pluginIcon": ""

            }

        })



def on_message(self, ws, message):

    try:

        msg = json.loads(message)

        mtype = msg.get("messageType", "")

        if mtype == "AuthenticationTokenResponse":

            token = msg\["data"\]\["authenticationToken"\]

            self.save_token(token)

            clean_print("\[green\]✅ Token received and saved. Please restart the script.\[/green\]")

            self.authenticated = True

        elif mtype == "AuthenticationResponse":

            self.authenticated = True

            clean_print("\[green\]Authenticated with VTube Studio!\[/green\]")

    except Exception as e:

        clean_print(f"\[red\]Error parsing message:\[/red\] {e}")



def on_error(self, ws, error):

    clean_print(f"\[red\]VTS WebSocket error:\[/red\] {error}")



def on_close(self, ws, close_status_code, close_msg):

    now = time.time()

    if not hasattr(self, '\_last_close_log') or now - self.\_last_close_log > 30:

        self.\_last_close_log = now

        clean_print("\[yellow\]VTS connection closed. Retrying in 5 seconds...\[/yellow\]")

    time.sleep(5)

    self.\_connect()



def send(self, payload):

    try:

        if self.ws and self.ws.sock and self.ws.sock.connected:

            self.ws.send(json.dumps(payload))

        else:

            now = time.time()

            if not hasattr(self, '\_last_not_connected_log') or now - self.\_last_not_connected_log > 30:

                self.\_last_not_connected_log = now

                clean_print("\[yellow\]VTS not connected; could not send message (suppressing for 30s)\[/yellow\]")

    except Exception as e:

        clean_print(f"\[red\]Error sending to VTS:\[/red\] {e}")



def trigger_hotkey(self, hotkey_id, transition_duration: float = 2.0):

    """Trigger a hotkey with optional transition duration (seconds) for smooth animation fade."""

    self.send({

        "apiName": "VTubeStudioPublicAPI",

        "apiVersion": "1.0",

        "requestID": f"hotkey\_{hotkey_id}",

        "messageType": "HotkeyTriggerRequest",

        "data": {

            "hotkeyID": hotkey_id,

            "transitionDuration": transition_duration

        }

    })





def inject_parameters(self, params: dict, request_id: str = "inject"):

    """Inject multiple Live2D parameters in one request.

    params: {param_id: value, ...}  e.g. {"FaceAngleX": 15.0, "EyeOpenLeft": 0.5}

    """

    values = \[{"id": k, "value": float(v)} for k, v in params.items()\]

    self.send({

        "apiName": "VTubeStudioPublicAPI",

        "apiVersion": "1.0",

        "requestID": request_id,

        "messageType": "InjectParameterDataRequest",

        "data": {"parameterValues": values}

    })



def set_mouth(self, value):

    self.send({

        "apiName": "VTubeStudioPublicAPI",

        "apiVersion": "1.0",

        "requestID": "mouth",

        "messageType": "InjectParameterDataRequest",

        "data": {

            "parameterValues": \[{

                "id": MOUTH_PARAM,

                "value": float(value)

            }\]

        }

    })



def save_token(self, token):

    with open(TOKEN_FILE, "w") as f:

        json.dump({"token": token}, f)



def load_token(self):

    if os.path.exists(TOKEN_FILE):

        with open(TOKEN_FILE, "r") as f:

            return json.load(f).get("token")

    return None

# Initialize VTS client

vtube = VTubeStudioClient()

# Idle animation engine - autonomous head/eye/brow movement when not speaking

idle_anim = IdleAnimationEngine(vtube, lambda: tts_active or tts_generating or tts_playing)

hara:

VTS_PORT = 8003

MOUTH_PARAM = “MouthOpen”

TOKEN_FILE = “vts_token1.json”

class VTubeStudioClient:

API_NAME = "VTubeStudioPublicAPI"

API_VERSION = "1.0"



def \__init_\_(self):

    self.url = f"ws://localhost:{VTS_PORT}"



    self.ws = None

    self.thread = None



    self.authenticated = False

    self.token = self.load_token()



    self.hotkeys = {}



    self.\_last_mouth = -1.0

    self.\_last_mouth_time = 0.0



    self.\_connect()



    try:

        print("Authenticated:", self.authenticated)

    except Exception:

        pass



def \_connect(self):

    console.print(f"\[cyan\]Connecting to VTube Studio on {self.url}...\[/cyan\]")

    self.ws = websocket.WebSocketApp(

        self.url,

        on_open=self.on_open,

        on_message=self.on_message,

        on_error=self.on_error,

        on_close=self.on_close

    )

    self.thread = threading.Thread(target=self.ws.run_forever, daemon=True)

    self.thread.start()



def on_open(self, ws):

    console.print("\[green\]Connected to VTube Studio API\[/green\]")

    time.sleep(1)



    if self.token:

        console.print("\[cyan\]Attempting to authenticate with stored token...\[/cyan\]")

        self.send_request(

            "auth",

            "AuthenticationRequest",

            {

                "pluginName": "Local AI VT3.0",

                "pluginDeveloper": "Bro77xp",

                "authenticationToken": self.token

            }

        )

    else:

        console.print("\[cyan\]No stored token found. Requesting authentication token...\[/cyan\]")

        self.send_request(

            "auth_token",

            "AuthenticationTokenRequest",

            {

                "pluginName": "Local AI VT3.0",

                "pluginDeveloper": "Bro77xp",

                "pluginIcon": ""

            }

        )



def on_message(self, ws, message):

    try:

        msg = json.loads(message)

        mtype = msg.get("messageType", "")

        if mtype == "AuthenticationTokenResponse":

            token = msg\["data"\]\["authenticationToken"\]

            self.save_token(token)

            console.print("\[green\]✅ Token received and saved. Authenticating...\[/green\]")

            self.send_request(

                "auth",

                "AuthenticationRequest",

                {

                    "pluginName": "Local AI VT3.0",

                    "pluginDeveloper": "Bro77xp",

                    "authenticationToken": token

                }

            )

        elif mtype == "AuthenticationResponse":

            self.authenticated = True

            console.print("\[green\]Authenticated with VTube Studio!\[/green\]")

    except Exception as e:

        console.print(f"\[red\]Error parsing message:\[/red\] {e}")



def on_error(self, ws, error):

    console.print(f"\[red\]VTS WebSocket error:\[/red\] {error}")



def on_close(self, ws, close_status_code, close_msg):

    console.print("\[yellow\]VTS connection closed. Retrying in 5 seconds...\[/yellow\]")

    time.sleep(5)

    self.\_connect()



def save_token(self, token):

    with open(TOKEN_FILE, "w") as f:

        json.dump({"token": token}, f)



def send_request(

    self,

    request_id: str,

    message_type: str,

    data: dict | None = None

):

    if not self.is_connected():

        return False



    payload = {

        "apiName": self.API_NAME,

        "apiVersion": self.API_VERSION,

        "requestID": request_id,

        "messageType": message_type,

    }



    if data:

        payload\["data"\] = data



    try:

        self.ws.send(json.dumps(payload))

        return True

    except Exception as e:

        console.print(f"\[red\]VTS Send Error:\[/red\] {e}")

        return False



def is_connected(self):

    return (

        self.ws

        and self.ws.sock

        and self.ws.sock.connected

    )




def load_token(self):

    try:

        if not os.path.exists(TOKEN_FILE):

            return None



        with open(TOKEN_FILE, "r", encoding="utf-8") as f:

            data = json.load(f)



        return data.get("token") or data.get("authenticationToken")

    except Exception as e:

        console.print(f"\[yellow\]Could not load token: {e}\[/yellow\]")

        return None

Oh. After trying a few things in Colab, I think I may have identified at least some of the weak points in the current implementation:


The overall architecture you ended up with looks like the right one:

Ali TTS
→ Ali mouth-analysis loop
→ Ali VTS client
→ Ali VTube Studio port/session
→ Ali model

Hara TTS
→ Hara mouth-analysis loop
→ Hara VTS client
→ Hara VTube Studio port/session
→ Hara model

In other words:

  • one WebSocket client per VTube Studio instance;
  • one configured port per client;
  • separate token storage so the clients cannot accidentally overwrite each other’s local token state;
  • each TTS stream sends MouthOpen only through its own VTS client.

Both models can use the parameter name MouthOpen. Because they are running in separate VTube Studio instances, those are separate API contexts. The VTS rule that only one plugin can normally "set" a parameter applies within one VTS instance, not across two separate instances.

I do not think the remaining weak points are mainly about needing another mouth parameter. They look more like authentication-session state, reconnection state, and cleanup state.

The four changes I would make first are:

  1. Read the actual AuthenticationResponse.data.authenticated value.
  2. Store a newly received token in self.token as well as in the token file.
  3. Clear the local authenticated/ready state as soon as the socket disconnects.
  4. Put TTS/mouth cleanup in a finally block.

Recommended default lifecycle

For each character, I would use this sequence:

Connect to the configured VTS port
→ optionally request APIStateRequest for diagnostics
→ load an existing token, or request a new token
→ store a new token in memory and on disk
→ send AuthenticationRequest
→ check data.authenticated and data.reason
→ request CurrentModelRequest
→ verify that the expected model is loaded
→ mark this client READY
→ allow mouth/hotkey/parameter requests

After a disconnection:

Socket closes
→ immediately clear READY/authenticated
→ stop or ignore outgoing parameter updates
→ create a new connection
→ authenticate the new session with the stored token
→ verify the model again
→ return to READY

This follows the separation already described in the official VTube Studio API authentication documentation: obtaining a token and authenticating the current WebSocket session are two different steps.

A token normally only needs to be obtained once. The same stored token can be used to authenticate later sessions, such as after VTube Studio restarts or the plugin reconnects.

Minimal changes

1. Treat the response data as the authentication result

Receiving a message whose type is AuthenticationResponse does not by itself mean authentication succeeded.

The official API returns the same response type when a token is invalid or has been revoked, but sets:

{
  "authenticated": false,
  "reason": "..."
}

A minimal pattern would be:

elif mtype == "AuthenticationResponse":
    data = msg.get("data", {})

    self.authenticated = bool(
        data.get("authenticated", False)
    )

    if self.authenticated:
        print("Authenticated with VTube Studio.")
    else:
        reason = data.get(
            "reason",
            "Unknown authentication failure"
        )
        print(f"VTube Studio authentication rejected: {reason}")

This is probably the most important small change.

At the moment, both client variants set:

self.authenticated = True

whenever an AuthenticationResponse arrives, without checking whether VTS actually returned true or false.

2. Keep the newly issued token in memory

When the token response arrives, update both the file and the current client object:

elif mtype == "AuthenticationTokenResponse":
    data = msg.get("data", {})
    token = data.get("authenticationToken")

    if token:
        self.token = token
        self.save_token(token)

        # Send AuthenticationRequest here using this token.
        self.authenticate(token)

The important line is:

self.token = token

Without that, the token may be correctly written to disk and used in the immediate authentication request, while the current object still thinks it has no token.

That becomes relevant if the socket disconnects and reconnects during the same Python process. The reconnection code may enter the “no token” branch again even though a valid token was already obtained and saved.

For Ali, there are two reasonable options:

  • keep the “restart after receiving the token” flow, but do not set authenticated=True before the next session is authenticated; or
  • use the same immediate authentication flow as Hara.

The second option makes both clients follow the same lifecycle and is probably easier to maintain.

3. Clear session state on disconnect

A valid token can survive reconnects, but the authenticated state belongs to the current WebSocket session.

At minimum:

def on_close(
    self,
    ws,
    close_status_code,
    close_msg
):
    self.authenticated = False

    print(
        "VTS connection closed. "
        "Retrying in 5 seconds..."
    )

    time.sleep(5)
    self._connect()

It may be even clearer to use a separate state such as:

DISCONNECTED
CONNECTING
CONNECTED
AUTHENTICATING
READY
RECONNECTING

Then mouth and hotkey requests can be allowed only in READY.

A small guard is already useful even without a full state machine:

def set_mouth(self, value):
    if not self.authenticated:
        return False

    # Send InjectParameterDataRequest here.

That prevents a stale local True value from allowing parameter requests before the new session has authenticated.

4. Make TTS cleanup exception-safe

The mouth-reset and mute-reset logic should run even if audio playback, WebSocket sending, interruption handling, or sd.wait() fails.

Conceptually:

tts_muted = True

try:
    sd.play(audio, sample_rate)

    # Analyse audio blocks.
    # Send MouthOpen values.
    # Wait for playback completion.

finally:
    try:
        vtube.set_mouth(0.0)
    except Exception as cleanup_error:
        print(
            "Could not reset mouth during cleanup:",
            cleanup_error
        )

    tts_muted = False

The inner try around set_mouth(0.0) is useful because the cleanup send can also fail if the socket disconnected. It should not hide the original playback exception.

A small verification matrix

These checks would cover most of the practical separation contract:

Test Expected result
Run Ali only Only Ali’s mouth moves
Run Hara only Only Hara’s mouth moves
Run both, play Ali TTS only Hara remains closed
Run both, play Hara TTS only Ali remains closed
Alternate Ali and Hara speech The active speaker alone receives mouth updates
Restart only the Python scripts Stored tokens authenticate the new sessions
Restart only one VTS instance Only that client loses READY and re-authenticates
Revoke permission for one plugin The other client continues working
Reverse the VTS startup order Model verification catches an incorrect port/model pairing
Disconnect during TTS Mouth and local mute/playback flags return to their safe state

For useful logs, I would include:

character
port
absolute token-file path
connection generation
requestID
messageType
authenticated
authentication reason
modelName
modelID
API error ID
API error message

Do not log the token itself.

Using an absolute token path in the log is helpful because a relative filename such as vts_token.json is resolved from the process’s current working directory. Two terminals may therefore read different files—or unexpectedly the same file—depending on how they were launched.

What I tested in Colab, and what the tests do not prove

I ran several small probes against the published code paths:

  • direct callback tests using mocked VTS responses;
  • an authenticated: false response;
  • token receipt followed by reconnect;
  • disconnect while the local authenticated flag was true;
  • delayed responses associated with an older mock socket;
  • repeated/overlapping close events;
  • exceptions during audio start, mouth streaming, and audio wait;
  • seeded randomized event-order traces;
  • the same local regression matrix before and after a candidate hardening patch.

The public probes are here:

The first notebook checks whether the published state transitions can enter problematic local states when authentication, disconnect, reconnect, and callback events are reordered.

The second notebook generates a candidate hardening patch and applies the same regression checks before and after the candidate changes.

Within that local harness, the candidate changes moved the regression matrix from 5/15 passing cases to 15/15. That result is useful as a consistency check for the proposed state-management changes, but it is not a measurement of real-world failure probability.

The tests reproduced these reachable states:

  • AuthenticationResponse with authenticated: false being treated locally as success;
  • a newly issued token being written to disk but not assigned to self.token;
  • the local authenticated flag remaining true after disconnect;
  • mouth requests being locally allowed before a new session had authenticated;
  • cleanup not being reached after an injected TTS/WebSocket exception.

However, these probes do not reproduce the full VTube Studio environment.

They do not prove:

  • the historical root cause of the original instability;
  • that old socket callbacks actually arrive in the normal setup;
  • that duplicate close callbacks occur during normal operation;
  • the real port/model pairing on the machine;
  • the real Live2D parameter mappings;
  • the real audio-device timing;
  • that the candidate patch is production-ready.

I would therefore describe the results as:

reproducible state-management edge cases and hardening opportunities

rather than:

proof of the original root cause.

Authentication, tokens, and reconnect lifecycle

There are several related but different states:

WebSocket object exists
WebSocket transport is connected
A token is available
AuthenticationRequest was sent
The current session is authenticated
The expected model is loaded
The client is ready to inject parameters

Those should not all be represented by one early authenticated=True.

Token acquisition versus session authentication

The VTube Studio authentication documentation describes two operations:

  1. AuthenticationTokenRequest
  2. AuthenticationRequest

The first obtains a reusable token after the user approves the plugin.

The second authenticates the current WebSocket session with that token.

The official documentation specifically says that:

  • a token usually only needs to be obtained once;
  • it can be reused when VTS restarts or the plugin reconnects;
  • pluginName and pluginDeveloper used during authentication must match the values used when the token was requested;
  • an invalid or revoked token can produce an AuthenticationResponse whose authenticated field is false.

Separate pluginName values for Ali and Hara are useful for human-readable permissions and logs, but they are not inherently required merely because there are two VTS instances.

The hard requirements are:

  • keep each client’s connection/session state separate;
  • keep the token accepted for that client paired with its authentication identity;
  • use matching pluginName and pluginDeveloper values when reusing a token;
  • do not let one client’s mutable token state overwrite the other’s.

Checking the current session

The official API provides APIStateRequest, whose response includes:

{
  "active": true,
  "currentSessionAuthenticated": false
}

This is useful because it asks VTube Studio about the current session instead of relying only on a local Python boolean.

The API documentation also recommends assigning a requestID. VTS returns it with the response and includes it in its logs when errors occur.

For two clients, request IDs such as these are easier to trace:

ali-g3-auth
ali-g3-model
ali-g3-mouth-0042

hara-g2-auth
hara-g2-model
hara-g2-mouth-0017

Here, g3 could mean “connection generation 3.”

Verifying the loaded model

After authentication, CurrentModelRequest can return:

modelLoaded
modelName
modelID
timeSinceModelLoaded

See Getting the currently loaded model.

This provides a stronger check than assuming:

port 8001 always means Ali
port 8003 always means Hara

A better rule is:

The Ali client is READY only if:
- its session authenticated, and
- the expected Ali modelID is loaded.

The same applies to Hara.

VTS also has UDP API discovery, which broadcasts the active port, instanceID, and window title.

One limitation is that instanceID is generated when a VTS instance starts. It remains stable while that instance is running, but it should not be treated as a permanent character ID across restarts.

For persistent character routing, the loaded modelID is the more useful final check.

Old callbacks and connection generations

A defensive reconnect design can assign each new connection a generation number:

self.connection_generation += 1
generation = self.connection_generation

Callbacks capture that generation and ignore messages if they do not belong to the current socket.

Conceptually:

def is_current_connection(self, ws, generation):
    return (
        ws is self.ws
        and generation == self.connection_generation
    )

This can prevent an old callback from modifying the state of a newer session.

I could reproduce this problem only by deliberately reordering mock events. I do not know whether that exact event order occurs in the normal setup, so I would treat generation checks as defensive hardening, not as a confirmed fix for the original problem.

The same applies to a reconnect guard. It is reasonable to prevent two close paths from scheduling two reconnects, but the tests do not prove that normal websocket-client operation is currently creating duplicate connections.

The websocket-client long-lived connection examples also show dispatcher-based reconnection using the reconnect argument. That is another design reference, not necessarily a requirement to replace the current implementation.

Diagnostics if one mouth still does not behave correctly

A useful diagnostic order is:

Can the WebSocket connect?
├─ No
│  ├─ verify the configured port
│  ├─ verify "Allow Plugin API access"
│  └─ check firewall/antivirus restrictions
│
└─ Yes
   └─ Is the current session authenticated?
      ├─ No
      │  ├─ check stored token
      │  ├─ check pluginName/pluginDeveloper pairing
      │  ├─ check whether permission was revoked
      │  └─ inspect AuthenticationResponse.reason
      │
      └─ Yes
         └─ Is the expected modelID loaded?
            ├─ No
            │  └─ wrong VTS instance, wrong startup order,
            │     or model still loading
            │
            └─ Yes
               └─ Does InjectParameterDataRequest succeed?
                  ├─ APIError 453
                  │  └─ parameter does not exist
                  │
                  ├─ APIError 454
                  │  └─ another plugin controls the parameter
                  │     in this VTS instance
                  │
                  └─ InjectParameterDataResponse
                     └─ Does the VTS input value move?
                        ├─ No
                        │  └─ inspect the injection/update path
                        │
                        └─ Yes, but the visible mouth does not move
                           └─ inspect model mapping, smoothing,
                              expressions, animations, and physics

API errors worth logging

The official VTube Studio error list includes:

Error Meaning
8 Request requires authentication
50 User denied the token request
453 Parameter name does not exist
454 Parameter is controlled by another plugin
455 Unknown injection mode

A simple handler would make troubleshooting much easier:

elif mtype == "APIError":
    data = msg.get("data", {})

    print(
        "VTS API error:",
        data.get("errorID"),
        data.get("message"),
        "requestID=",
        msg.get("requestID"),
    )

Parameter refresh timing

The parameter injection documentation says that a plugin must resend a parameter it wants to control at least once every second.

If it stops sending for longer than that, VTS considers the parameter “lost” and returns control to the previous source or the default value.

The existing mouth loop appears comfortably above that documented minimum.

I did not find an official maximum injection frequency in the API documentation, so I would not treat the current observed send rate as the root cause without measuring behavior against a real VTS instance.

Parameter ownership

For the normal "set" mode, only one plugin can control a given parameter at a time inside one VTS instance.

VTS also has an "add" mode that can combine inputs from multiple plugins, but I do not think that is needed for this case. A direct mouth controller is easier to reason about in "set" mode.

Because Ali and Hara use separate VTS instances, both instances can independently have a MouthOpen parameter controlled by their corresponding client.

If VTS accepts the parameter but the visible mouth does not move

At that point the problem is probably no longer token routing.

The next layer is the model configuration:

  • Is MouthOpen mapped to the correct Live2D output parameter?
  • Is smoothing delaying the visible response?
  • Is an active expression overriding the output?
  • Is an animation overriding it?
  • Is physics affecting the same output?
  • Is VTS’s built-in microphone lip-sync also active?

The VTube Studio model settings guide is the relevant reference for that branch.

TTS interruption and mouth cleanup

The playback path has several operations that can fail independently:

TTS generation
audio buffer conversion
sd.play()
mouth-analysis loop
WebSocket parameter send
sd.wait()
user interruption
device disconnect

If cleanup only exists at the normal end of the function, an exception can skip it.

Possible leftover state includes:

tts_muted = True
tts_playing = True
the final local mouth value is nonzero
the speech lock remains held longer than expected

A safer structure is:

def play_tts_with_mouth(audio, sample_rate):
    global tts_muted

    tts_muted = True

    try:
        sd.play(audio, sample_rate)

        # Process blocks and send mouth values.
        # Wait for playback completion.

    finally:
        try:
            vtube.set_mouth(0.0)
        except Exception as cleanup_error:
            print(
                "Mouth cleanup failed:",
                cleanup_error
            )

        tts_muted = False

If there are more flags, reset all of them in the same cleanup section:

finally:
    best_effort_reset_mouth()
    tts_muted = False
    tts_playing = False
    tts_generating = False

If an audio_lock is acquired manually, it should also be released reliably. Using:

with audio_lock:
    ...

is safer than separate acquire() and release() calls.

This is not evidence that TTS cleanup caused the original token behavior. It is a separate hardening point that can prevent a disconnect or playback exception from leaving the character visually or locally stuck.

Optional cleanup for a reusable public example

The Ali and Hara clients currently follow slightly different authentication flows.

That is understandable if they developed from different versions of the code, but keeping two nearly identical classes can make future fixes drift apart.

A reusable example could use one client class with per-character configuration:

@dataclass(frozen=True)
class VTSClientConfig:
    character: str
    port: int
    token_path: str
    plugin_name: str
    plugin_developer: str
    expected_model_id: str | None = None

Then:

ali_client = VTSClient(
    VTSClientConfig(
        character="Ali",
        port=8001,
        token_path="tokens/ali_vts_token.json",
        plugin_name="Local AI VTuber - Ali",
        plugin_developer="Bro77xp",
        expected_model_id="ALI_MODEL_ID",
    )
)

hara_client = VTSClient(
    VTSClientConfig(
        character="Hara",
        port=8003,
        token_path="tokens/hara_vts_token.json",
        plugin_name="Local AI VTuber - Hara",
        plugin_developer="Bro77xp",
        expected_model_id="HARA_MODEL_ID",
    )
)

Using different plugin names is useful for human-readable permission screens and logs, but it is not the essential separation mechanism.

The essential separation is:

client object
connection/session
port
token state
expected model
audio source
mouth destination

Other useful public-example improvements would be:

  • log the absolute token-file path;
  • use character-specific request IDs;
  • handle APIError;
  • document the first-run authorization flow;
  • document normal restart behavior;
  • document recovery after permission revocation;
  • use a stop event or graceful shutdown path;
  • prevent multiple reconnect workers from being scheduled;
  • add dependency versions;
  • place forum code inside fenced code blocks so copied quotes and indentation remain valid.

The pyvts project is also useful as a reference for how an existing Python wrapper separates connection and authentication operations. I do not think migration to a wrapper is required, but comparing lifecycle boundaries can be helpful.

Short version

Your two-instance solution is fundamentally the correct direction.

I would keep:

separate VTS instance
separate client object
separate configured port
separate token storage
separate TTS-to-mouth route

Then harden these points first:

1. Read AuthenticationResponse.data.authenticated.
2. Save a newly issued token into self.token.
3. Clear authenticated/READY on disconnect.
4. Re-authenticate every new WebSocket session.
5. Verify the loaded model before enabling output.
6. Reset mouth and TTS state in finally.

The connection-generation and reconnect-guard ideas are useful additional defenses, but I would treat them as the next layer rather than the first required changes.

With those additions, this becomes much easier for another person to copy, debug, and extend without confusing:

token obtained
session authenticated
correct model connected
mouth updates active

as if they were the same state.