MoholoMotion Holograms
Docs · embed · private beta

Put a live avatar on your site

One iframe or one script tag. The avatar listens on the visitor's microphone, answers out loud through a live language model, and renders as a real-time 3D head in the browser: no video stream, no plugin, no account. Free during the beta with the key free; a key of your own stores your prompt, avatar and voice and removes the limits.

The easiest way: one iframe

If you can paste HTML into your page, you can add the avatar. No script, no account, no code: one line, and the prompt is part of it. Create yours in the editor (create your account, make the avatar yours, copy the line from its Export tab) or write it by hand with the free key:

<iframe src="https://moholo.ai/embed?key=free&avatar=ana&prompt=You%20are%20Ana%2C%20the%20concierge%20of%20Casa%20Solana..."
        allow="microphone; camera; autoplay; fullscreen" allowfullscreen
        style="border:0;width:100%;height:560px;max-width:100%;border-radius:12px"></iframe>

The prompt goes in the address, so use the setup page to encode it (spaces, commas and quotes become %20 and friends). With a paid key the prompt, avatar and voice are stored with the key, and the line shrinks to src="https://moholo.ai/embed?key=YOURKEY"; you change the prompt by writing to us, not by touching the page.

WordPress

  1. Edit the page or post, add a block, choose Custom HTML.
  2. Paste the iframe line. Click Preview: the avatar appears in the editor.
  3. Publish. The page must be served over https for the microphone (every WordPress host does this).

Self-hosted WordPress and WordPress.com Business/Commerce plans accept iframes in Custom HTML. The free and Personal WordPress.com plans strip iframes and scripts; those need the Business plan or a host that allows embeds. Wix, Squarespace, Webflow, Shopify and Framer: use their "Embed" or "Custom code" element and paste the same line. The <script> tag below does the same thing with a little less typing where scripts are allowed.

Your own model, unlimited minutes: the same iframe

In the editor's Export tab, choose "my own model key" and paste your Gemini API key: it is stored on our servers only, the conversation runs on your Google account, and the page still carries only the iframe. Nothing to host. (If you prefer to keep the model key on your own server, the bring-your-own-model API below is the alternative.)

Paste this where the avatar should appear (script version)

Parameters

attributemeaningdefault
data-qualitydetail level: low (25k gaussians), medium (50k), high (100k) or full. Omit it and the avatar picks by device: phones get low, a small frame medium, a normal desktop frame high, a large one full.optional
data-keyyour embed key. free works for everyone during the beta.required
data-promptthe system prompt for the live model: who the avatar is, what it knows, how it should answer. Up to 8000 characters (about three pages).Moholo's own persona
data-avatarwhich avatar to show, by name: ana, selam, arjan, mei, karim, lukas, ingrid, hana, arun, sofia, diego, valeria, nadia (an unknown name shows the first avatar and fires Moholo.on('error') with code: 'unknown_avatar'; this list is read live from https://moholo.ai/api/avatars).the first public avatar
data-width, data-heightsize in pixels or any CSS length. Portrait shapes work best.420 × 560
data-greet0 = the avatar waits for the visitor instead of speaking first.1
data-quiet1 = quiet mode, the same as data-greet="0": nothing is said until the visitor speaks to the avatar, sends a text or starts a video call.0
data-llmclient = bring your own language model and voice; the avatar runs on the device and your page hands it the reply audio (see below).Moholo's hosted model
data-leadplayback buffer in seconds before the first word of each reply (client mode default 0.3, adaptive on slow devices; 0.2 for fast audiences). Hosted mode: 1.0.0.3 / 1.0
data-playback0 = the avatar plays no sound (your page already plays the audio it hands over, see attach). host (advanced) = the avatar owns no audio at all: speak() drives the face only and your page is responsible for keeping voice and face aligned.on
data-captions1 = show the avatar's reply text under it. Off by default: the iframe shows only the head and the input.off
data-voicethe avatar's voice: any Gemini Live voice name, for example Aoede, Kore, Leda, Puck, Charon, Fenrir, Zephyr, Orus, Sulafat, Despina, Alnilam, Achernar. Each avatar's own voice is the voice field of https://moholo.ai/api/avatars.the avatar's own

Quality levels

Every avatar is compacted with Moholo's proprietary geometry, appearance and rig compaction and ships in several detail levels: low (25k gaussians, phones), medium (50k), high (100k) and full. Set one per device with data-quality, or leave the attribute out and the avatar picks automatically from the screen: coarse-pointer phones get low, a small frame medium, a normal desktop frame high, a large one full. A level that is not available for an avatar falls back to the next one up, so a request can never fail.

Two ways to run the conversation

The avatar is always rendered and animated on the visitor's device. What differs is who runs the conversation.

Moholo hosted (default)Bring your own live model (data-llm="client")
who talksMoholo's live model and voices, on Moholo's serversyour live model, your voice, on your servers
what reaches Moholothe visitor's microphone audio and text (the conversation)nothing: the page only serves the avatar files. Your page receives the visitor's audio and text and hands back reply audio
lip synccomputed on Moholo's GPUscomputed on the visitor's device (ONNX, works without WebGPU)
costa Moholo key, billed per conversation minute (below)your model's bill (Gemini Live: about $0.03 per minute) and nothing to Moholo during the beta

Your page already plays the audio (any audio-to-audio model): animate it

If your page already runs the conversation, for example a WebRTC session with a realtime model, the avatar only needs the audio you are already playing. Three lines, nothing else changes, nothing reaches Moholo but the request for the avatar files:

<script src="https://moholo.ai/embed.js" data-key="mk_…" data-avatar="ana" data-llm="client" data-playback="0"></script>
<script>
  Moholo.on('ready', function () { Moholo.attach(remoteStream); });   // a MediaStream, an <audio> element, or a Web Audio node
</script>

Moholo.attach(src) taps the audio inside your page, streams it into the avatar and closes each reply after 700 ms of silence ({gap, threshold} options). data-playback="0" keeps the avatar silent because your page is already playing the sound; Moholo.mute(false) turns its playback back on. The visitor's microphone is yours: the avatar does not open it unless you call Moholo.mic(true). Interrupt with Moholo.stop(), captions with Moholo.caption(text). Your model, your key, your servers; the lips are computed on the visitor's device.

Your own audio server (no JavaScript on your page)

If you run your own speech-to-speech model, point the avatar at it: in the editor's Export tab choose "my own audio server" and enter its wss:// address, or put data-audio="wss://voice.your.site/moholo" on the embed tag. The visitor's browser opens one WebSocket to your server per visit and the avatar's lips are driven on the visitor's device from the audio you send. Moholo never carries the audio: our servers only serve the avatar files.

directionmessagemeaning
browser → youJSON {"type":"hello","avatar":"ana","key":"mk_…","visitor":"…","lang":"en-US","in":{"format":"s16","rate":16000,"channels":1},"out":{"format":"s16","rate":24000,"channels":1}}first message on every connection
browser → youbinary: int16 mono 16 kHz PCM, then JSON {"type":"utterance","rate":16000,"seconds":2.4}one whole spoken phrase from the visitor
browser → youJSON {"type":"text","text":"…"}the visitor typed
you → browserbinary: int16 mono 24 kHz PCM chunks (20 to 200 ms each)reply audio; the lips move as chunks arrive. JSON {"type":"audio","pcm":"<base64>","rate":24000} also works
you → browserJSON {"type":"end"}the reply is complete (required)
you → browserJSON {"type":"stop"}interrupt what is playing
you → browserJSON {"type":"caption","text":"…"}, {"type":"status","text":"…"}, {"type":"format","rate":24000}optional caption, status line, reply sample rate

A complete working server in 60 lines of Python is here: own_audio_server_example.py (pip install aiohttp numpy, then python3 own_audio_server_example.py; it answers with a built-in test sound until you give it a model); replace its reply() with your model. The socket reconnects by itself if your server restarts. With your own audio server the animation always runs on the visitor's device and nothing from the conversation reaches Moholo, so the Pro hosted minutes are not used.

Already have a voice agent? Add a face in three calls

If your page already receives reply audio from a voice agent, the whole integration is Moholo.speak(pcm, rate) for each audio chunk as it arrives, Moholo.end() (or speak(chunk, rate, {end:true})) as soon as the last chunk is known, and Moholo.stop() when the visitor interrupts. Send end() on the event that marks the end of the audio, not of the whole response: for OpenAI Realtime that is response.output_audio.done, which arrives before response.done; a late end() holds the last mouth shape until it arrives. You do not need Moholo.mic() or the utterance event: your agent keeps listening the way it does today. The face starts about 0.3 s after your first chunk and stays within a few tens of milliseconds of the voice for the rest of the reply.

LiveKit or Pipecat: the avatar as a plugin toggle

If your agent runs on LiveKit Agents or Pipecat, nothing above is needed. The agent keeps publishing audio as it does today; a server plugin tells the room which avatar to show, and a browser plugin hands the agent's audio track to the avatar, rendered and lip-synced on the visitor's device. No video track, no rendering minute, no change to your pipeline, tools or turn detection.

# LiveKit Agents (pip install livekit-plugins-moholo)
from livekit.plugins import moholo
avatar = moholo.AvatarSession(avatar="ana")
await avatar.start(session, room=ctx.room)        # before session.start(...)

// browser (npm i @moholo/livekit) - React: <MoholoAvatar/> from @moholo/livekit-react
import { attachMoholoAvatar } from '@moholo/livekit';
attachMoholoAvatar(room, { container: document.querySelector('#avatar') });

# Pipecat (pip install pipecat-moholo): one processor in the pipeline
pipeline = Pipeline([transport.input(), rtvi, stt, llm, tts, MoholoAvatar(avatar="ana"), transport.output()])

// browser (npm i @moholo/pipecat)
attachMoholoAvatar(pipecatClient, { container: document.querySelector('#avatar') });

The avatar plays the agent's voice itself, in sync with the lips, so leave the agent's audio track unattached (no track.attach(), no <RoomAudioRenderer/>); pass playback: 'app' if your app must keep its own player. Typed text in the avatar's box goes to the agent (LiveKit lk.chat, Pipecat sendText); the visitor's microphone is the room's. Packages, examples and the server-to-browser contract: github.com/moholo-founder/moholo-plugins.

Set up: bring your own live model (your server keeps the key)

Three pieces. Whatever model you use must stream audio: a request-and-reply model followed by a text-to-speech call puts 4 to 6 seconds between the question and the first word, a live model puts 0.5 to 1.5 s. The example below is Google's Gemini Live; OpenAI's Realtime API or any other engine that streams 16-bit PCM works the same way.

1. The tag

<script src="https://moholo.ai/embed.js" data-key="free" data-avatar="ana" data-llm="client"
        data-width="100%" data-height="560" data-bg="https://your.site/lobby-blurred.jpg"></script>
<script src="/concierge.js"></script>

2. Your server: one live session per visitor

A websocket endpoint that opens the model session with your key. Up: the visitor's utterances (binary, int16 mono 16 kHz, already cut at pauses by the avatar) and typed text. Down: the model's audio chunks the moment they exist, plus transcripts and turn boundaries. Python with google-genai and aiohttp, trimmed from the Casa Solana example:

import asyncio, json
from google import genai; from google.genai import types
from aiohttp import web, WSMsgType
client = genai.Client(api_key=YOUR_GEMINI_KEY)
CFG = types.LiveConnectConfig(response_modalities=['AUDIO'], system_instruction=YOUR_PROMPT,
    speech_config=types.SpeechConfig(voice_config=types.VoiceConfig(prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name='Aoede'))),
    input_audio_transcription=types.AudioTranscriptionConfig(), output_audio_transcription=types.AudioTranscriptionConfig(),
    realtime_input_config=types.RealtimeInputConfig(automatic_activity_detection=types.AutomaticActivityDetection(disabled=True)))

async def live(request):
    ws = web.WebSocketResponse(); await ws.prepare(request)
    async with client.aio.live.connect(model='gemini-3.1-flash-live-preview', config=CFG) as s:
        await ws.send_json({'type': 'ready'})
        async def pump():                       # model -> browser. receive() ends at every turn: re-enter it
            while not ws.closed:
                async for m in s.receive():
                    sc = m.server_content
                    if not sc: continue
                    if sc.interrupted: await ws.send_json({'type': 'interrupted'})
                    if sc.output_transcription: await ws.send_json({'type': 'transcript', 'who': 'avatar', 'text': sc.output_transcription.text})
                    if sc.input_transcription: await ws.send_json({'type': 'transcript', 'who': 'user', 'text': sc.input_transcription.text})
                    for p in (sc.model_turn.parts if sc.model_turn else []):
                        if p.inline_data: await ws.send_bytes(p.inline_data.data)      # int16 mono 24 kHz
                    if sc.turn_complete: await ws.send_json({'type': 'turn_complete'})
        task = asyncio.create_task(pump())
        async for msg in ws:                     # browser -> model
            if msg.type == WSMsgType.BINARY:     # one utterance from the avatar's microphone
                await s.send_realtime_input(activity_start=types.ActivityStart())
                for i in range(0, len(msg.data), 32000):
                    await s.send_realtime_input(audio=types.Blob(data=msg.data[i:i+32000], mime_type='audio/pcm;rate=16000'))
                await s.send_realtime_input(activity_end=types.ActivityEnd())
            elif msg.type == WSMsgType.TEXT:     # typed question
                await s.send_client_content(turns=types.Content(role='user', parts=[types.Part(text=json.loads(msg.data)['text'])]), turn_complete=True)
        task.cancel()
    return ws

3. Your page: glue between the avatar and your socket

const ws = new WebSocket('wss://your.site/live'); ws.binaryType = 'arraybuffer'; let said = '';
ws.onmessage = e => {
  if (e.data instanceof ArrayBuffer) { Moholo.speak(new Int16Array(e.data), 24000); return; }   // lips follow within ~15 ms
  const j = JSON.parse(e.data);
  if (j.type === 'transcript' && j.who === 'avatar') Moholo.caption(said += j.text);
  if (j.type === 'turn_complete') { Moholo.end(); said = ''; }
  if (j.type === 'interrupted') Moholo.stop();
};
Moholo.on('utterance', m => ws.send(m.pcm));                                    // the visitor spoke
Moholo.on('text', m => ws.send(JSON.stringify({ type: 'text', text: m.text }))); // the visitor typed

The avatar owns audio playback in client mode: stop your own player, or the visitor hears a doubled voice (or set data-playback="0" and keep yours, see attach). The delay the avatar adds is its playback lead, about 0.3 s before the first word of each reply (data-lead; it grows by itself on a device that cannot keep up and is remembered there). Measured with this setup on the example site: typed question to first word 0.55 s, end of a spoken question to first word about 1.5 s on a warm session in the model's own region (the first turn of a new session, or a server far from the model, measures 2.5 to 3.5 s), the avatar adding about 15 ms of lip-sync error on top of the audio, which is accuracy, not delay. Keep the model's key on your server only; the page never sees it. A voice close to the one the avatar was distilled on gives the best lips: use the avatar's own voice from /api/avatars (Ana: "Sulafat", Mei: "Aoede"); any voice works. Complete, deployable source: Casa Solana (live) and casa-solana-example.zip (the whole site and server, 13 files; Cloud Run, key in Secret Manager).

Reference: the window.Moholo API

call / eventmeaning
Moholo.on('ready', fn)avatar on screen, on-device driver loaded (m.driver.engine = onnx, m.driver.ep = wasm or webgpu)
Moholo.on('utterance', fn)one spoken phrase from the visitor, already segmented by silence: m.pcm (int16, 16 kHz), m.seconds
Moholo.on('text', fn)the visitor typed m.text in the box under the avatar
Moholo.speak(audio, rate, {end})reply audio: Int16Array, Float32Array or an int16 ArrayBuffer, mono, any rate; call once per chunk, end: true on the last (or call Moholo.end())
Moholo.stop()interrupt the avatar
Moholo.caption(text)show the reply text under the avatar. Pass either the whole reply so far (it replaces what is shown) or just the new words (they are appended); the caption clears after 4 s of silence
Moholo.mic(true|false)open or close the visitor's microphone from your page (the mic button under the avatar does the same)
Moholo.on('speaking'|'state'|'error', fn)m.on while the avatar talks; m.state = idle, listening, thinking, speaking; m.message

Plans

Everything is set up in the editor: create your account, pick the avatar and make it yours (look, motion, speech, scene), then finish in the Export tab: write the prompt, choose the voice and who runs the conversation, copy the line. No card for the trial.

planwhat you getprice
Trialyour own avatar, prompt and voice on our model; every conversation runs 15 minutes, then stops. Also the key free for a quick test without an account (30 minutes and 60 conversations per site per day)$0
Pro10 hours a month on our model included; plug in your own model key (Gemini today, more soon) for unlimited minutes billed by your provider to you; settings kept; allowed-website lock; no Moholo mark; on our model the animation runs on our servers or, if you choose, on the visitor's device; with your own key it always runs on the visitor's device$10 per month per character
More hosted minuteskeep our model beyond the included 10 hoursper conversation minute, quote from founders@moholo.co
Your own characteran avatar made from your photo or promptone-off, on request

Your model key is stored on our servers only and used to open the conversation for your visitors; it is never sent to the browser or written into your page. The page only ever carries your public embed key (mk_…), which is locked to the websites you list.

How this compares

Every other real-time avatar service renders video on datacentre GPUs and bills every minute the avatar is on screen, with or without your own model. Moholo renders on the visitor's device, which is why unlimited minutes can be a flat fee. Public list prices, September 2026, from the vendors' pricing pages:

serviceper-minute priceplanswhere it renders
Moholo Pro, your own model key$0 to Moholo (your provider bills you: Gemini Live about $0.03 per minute)revenue tiered, from $10 / month / character, unlimited minutesvisitor's device (lip-sync on the device)
Moholo Pro, our model10 hours a month included, then per minute (quote)revenue tiered, from $10 / month / charactervisitor's device (lip-sync on our GPU or the device)
HeyGen LiveAvatar≈ $0.10 (lite) to $0.20 (full)$19 / $99 / $475 per month, session caps 5 to 60 min, 5 to 40 concurrentcloud video
Tavus CVI$0.32 to $0.37$59 / month for 100 min, $397 / month for 1,250 min; replicas $40 to $65 eachcloud video
D-ID Agents≈ $0.35 (15 s rounding)from $5.90 / month; streaming API $18 to $198 / monthcloud video
Anam$0.11 to $0.24free 30 mincloud video
Beyond Presence€0.175 to €0.35€49 to €349 / monthcloud video
Lemon Slice≈ $0.16 to $0.21from $8 / monthcloud video
Hedra Live Avatars$0.05usagecloud video
Simli≈ $0.05 pay as you go, ≈ $0.009 at volumeusagecloud (Gaussian splats, server-rendered)
bitHuman≈ $0.01 to $0.04free 99 credits / month; self-host from 1 credit / minon-device (flat photo puppet)
UneeQenterprisefrom ≈ $899 / month plus $20k to $80k integrationcloud video

Example: a hotel page with 2,000 conversations a month of 3 minutes each (6,000 minutes) costs about $600 to $2,200 per month on the cloud-video services above, and $10 on Moholo with your own model plus your model's own bill (Gemini Live: roughly $0.01 to $0.02 per minute, so about $60 to $120).

Requirements

Your page must be served over HTTPS for the microphone to work. The avatar needs WebGL2 (every current desktop and phone browser, including Android WebView apps); WebGPU is used when present but not required. In the hosted mode nothing from your page is sent to us except the parameters above, and the conversation runs between the visitor's browser and Moholo. In the bring-your-own-model mode the conversation never touches Moholo: only the avatar files are served.

Live example

Questions or a key for your site: founders@moholo.co.