[This repo was cloned from ChatVRM-jp, which is a fork of @pixiv/ChatVRM.]
ChatVRM is a demo application that allows you to easily talk with a 3D character in your browser.
By importing VRM files, you can adjust the voice to match the character, and generate responses that include emotional expressions.
ChatVRM mainly uses the following technologies.
- Generate response text
- User speech recognition
- Text to speech
- Displaying 3D characters
A demo is available at Vercel.
https://chat-vrm-window.vercel.app/
Clone or download this repository to run locally.
git clone https://github.com/zoan37/ChatVRM.gitPlease install the required packages.
npm installAfter package installation is complete, start the development web server with the following command.
npm run devAfter execution, access the following URL.
The app can get its LLM responses two ways:
- The visitor's own OpenRouter key — entered in Settings, stored in that browser's localStorage, and sent directly from their browser to OpenRouter. Nothing touches your account.
- A shared key you provide — used when the visitor hasn't entered one. This key lives only on the server, in the
/api/chatserverless function, and is never sent to the browser.
To set up the shared key, copy .env.example to .env.local for local dev, and set the same variables in Vercel under Project → Settings → Environment Variables:
cp .env.example .env.local| Variable | Purpose |
|---|---|
OPENROUTER_API_KEY |
Shared key, server-side only. Required for the shared path. |
OPENROUTER_MODEL |
Model for the shared key. Must end in :free unless ALLOW_PAID_MODEL=true. |
ALLOW_PAID_MODEL |
Set to true to permit a paid model. Leave unset so a typo can't spend money. |
ALLOWED_ORIGINS |
Optional comma-separated origin allowlist for /api/chat. |
SITE_URL |
Optional. Sent as HTTP-Referer for OpenRouter's app rankings. |
Never prefix an API key with
NEXT_PUBLIC_. Next.js inlines those values into the JavaScript bundle served to every visitor, so the key becomes public and can be extracted from devtools. This app previously usedNEXT_PUBLIC_OPENROUTER_API_KEYand the key was drained as a result.
Recommended hardening for the shared key:
- Use a
:freemodel (the default), so requests cost nothing. - Create a dedicated key at openrouter.ai/settings/keys and set a credit limit on it. That limit is the real spend ceiling.
- Set
ALLOWED_ORIGINSto your production domain once you're done testing.
/api/chat also enforces a fixed model, a 512-token cap, a 24,000-character conversation cap, and a per-IP rate limit (12/min, 200/day). The rate limit is in-memory, so it resets on cold start and isn't shared across regions — treat it as friction against casual abuse, not as the spend ceiling.
OpenRouter model IDs ending in :free cost $0 per token. The free tier allows 20 requests/minute and 50 requests/day, which rises to 1,000/day once you've purchased $10 of credits at any point. The current list is at openrouter.ai/models?max_price=0.
For a talking avatar the binding constraint is time to first token, since nothing can be spoken until it arrives. Measured on a one-sentence reply (August 2026):
| Model | First token | Notes |
|---|---|---|
nvidia/nemotron-3-nano-30b-a3b:free |
0.9s | Current default. MoE, ~3B active params |
google/gemma-4-31b-it:free |
5.1s | Better prose, too slow to feel conversational |
openai/gpt-oss-20b:free |
5.6s | Free relative of the paid gpt-oss-120b |
google/gemma-4-26b-a4b-it:free |
7.5s | |
nvidia/nemotron-3.5-lightning:free |
60s | Avoid. Spent the whole 512-token budget reasoning and emitted its scratchpad as the reply |
liquid/lfm-2.5-2.6b:free |
— | Avoid. Streamed empty chunks, then hung |
Reasoning models are a poor fit here: their thinking tokens count against max_tokens, delaying speech and risking a truncated reply. reasoning: { effort: "low" } is already sent to limit this.
Note that a static build (npm run export, used by the GitHub Pages workflow) has no serverless functions, so /api/chat won't exist there and visitors must supply their own key.
The default avatar is served from public/AvatarSample_B.vrm on the app's own origin. It used to load from a public IPFS gateway, which gives no availability guarantee and was returning 504s — leaving the page with no avatar and an uncaught error in the console.
The committed model is an optimized build of the original 20 MB VRoid export, produced by scripts/optimize-vrm.mjs:
node scripts/optimize-vrm.mjs original.vrm public/AvatarSample_B.vrm \
--max 1024 --thumb 256 --trim-material HAIR_06 --trim-above 1.545| Original | Committed | |
|---|---|---|
| File | 20.07 MB | 6.46 MB |
| Textures | 12.12 MB | 4.21 MB |
| Morph targets | 6.14 MB | 0.69 MB |
Two things dominate a VRoid export. Textures: the 2048×2048 thumbnail alone was 2.35 MB despite never being rendered in-scene, and one normal map was stored six times. Morph targets: 90.3% of the blend-shape deltas are exactly zero, because each expression moves only a few vertices of a whole-body mesh — storing them as glTF sparse accessors cuts them by 89%.
The --trim-* flags fix a separate, pre-existing cosmetic bug: four tiny black specks appeared to float in the air above the head. They are the tips of the black ribbon bows (HAIR_06), which taper to a point — above y=1.545 those triangles are narrower than a pixel, so the rasterizer can only draw them as scattered dots detached from the bow. Dropping those 64 triangles (0.18% of the model) removes the specks with no visible change to the bows. Only the index buffer shrinks, so vertices, skin weights and morph targets are untouched. This artifact is present in the untouched 20 MB original — it is not caused by the optimization.
The script deliberately does not use a general-purpose glTF optimizer. VRM keeps its data in the VRMC_vrm / VRM extensions, which reference textures, images and materials by array index; tools that reorder or reindex those arrays silently break expressions, spring bones and material settings. This script only rewrites bufferView payloads and appends new bufferViews, so every existing index still resolves. Run --max 2048 for a losslessly recompressed 9.14 MB build if you'd rather not downscale textures at all.
Requires ImageMagick (magick) on PATH.
ChatVRM supports reading chat messages from a livestream and generating responses, via the Restream API. Currently, X and Twitch sources are supported. It uses a batching system so that the LLM is called for each batch of messages, not for each message.
Steps (this is mostly in the Settings UI):
- Set OpenRouter API key. (Optional — leaving it blank uses the deployment's shared server-side key on a free model, which is rate limited. Setting your own key gives you higher limits and your choice of account.)
- Set ElevenLabs API key.
- Choose your desired ElevenLabs voice.
- Choose your desired VRM avatar model.
- Set your custom system prompt for your character.
- Get your Restream authentication tokens JSON from the Restream Token Fetcher. It gives permission for ChatVRM to listen to your chat messages from Restream (currently X and Twitch sources are supported).
- Paste your Restream authentication tokens JSON, and click Start Listening.
- Start your livestream using Restream.
Troubleshooting:
- If you are not seeing messages being received from your livestream, you can try clicking the "Stop Listening" button and then "Start Listening" again, getting new Restream tokens, or refreshing the ChatVRM site.
