I got tired of keeping a Telegram tab open all day just to talk to my local agent, so I built a monitor-resident avatar that fronts it instead — a video-call-style face that performs the replies instead of me reading a wall of text.
A few things that might interest this crowd:
No runtime GPU for the avatar. Instead of live-animating a face, the agent’s reply carries inline emotion-beat tags — [], [] deploy?, [] — and a player selects and blends between ~30 pre-rendered clips. The GPU stays 100% on the model. Code and logs render as chat cards instead of being read aloud, and [] pops a human-in-the-loop approve/cancel that blocks the agent until you answer.
It hooks into a real agent as a connector. The agent’s gateway dials out to a local WebSocket the app hosts, so from the agent’s side the avatar is just “another channel,” indistinguishable from a messenger. Adapters for Hermes and OpenClaw are included, plus a demo mode that runs with zero setup.
Local voice both ways. Edge TTS out (swappable to Qwen3-TTS / MeloTTS / Piper), Silero VAD + faster-whisper in.
Open-core, MIT. The engine is fully usable on its own: drop in a folder of clips named by emotion, point it at your agent. Avatars are pure-data bundles — no code runs when you install one — so you can build your own. The repo ships the engine only; there’s no bundled character.
The engine just plays video clips — it doesn’t care what produced the pixels. So the avatar can be photoreal, 2D anime, a 3D render, pixel art, anything. You can even use a Live2D or VRM model: pre-render its expressions into clips and it becomes a Ghost Vessel preset. A Live2D shell can’t do the reverse — it can’t take live-action footage. Clips are a superset. The trade-off is that you give up real-time rig interactivity (no dragging the head around) and you pay a one-time render step.
Repo + 45s demo: https://github.com/ghdtjrtka/ghost-vessel
The part I’d most like feedback on is the emotion-beat output format — the contract that turns model text into a UI performance. Has anyone here tried similar output contracts to drive an interface from LLM output? Curious what broke on your setup, and whether smaller local models keep the tags straight.

where is this from?
Max Headroom, awesome show from the 80s. But be warned, it did not age well.
Also awesome 1985 film from the UK
Such an interesting project but I think that would creep me out too much to use it.
Ha, that’s completely fair — a photoreal face watching you work all day is a lot, and it was honestly the first thing I second-guessed about my own setup. Worth saying though: that’s the preset, not the engine. It just plays video clips and doesn’t care what produced the pixels, so an avatar can be 2D anime, a 3D render, pixel art, an abstract shape, a blinking terminal face, a cat — whatever’s least uncanny for you. The photoreal one is just what I happened to build for myself. (You can also hide the video window and run it chat-only, though at that point you’ve reinvented a chat window.) But yeah, the uncanny thing is real. Photoreal was the hardest default to justify and I’m still not fully sure it’s the right one.
Op, why are they hot?
Some 4 or 5 nice default avatars would be helpful. So we can just run it and try, without going through an entire ordeal. And maybe some Colab notebook or pre-made script to create those looping clips, I mean they should be seamless and all and that requires a bit of extra effort, not just “use your favorite tool”.
Fair hit — “loop-cut the segments” was doing a lot of hiding in that sentence. Seamless is the whole difficulty, and “use your favorite tool” was a cop-out. So I’ve pushed the tooling I’d been using privately:
build_idle_loop.py— runs MediaPipe over the take, measures eye-openness per frame, finds the blink minima, then picks the blink→blink window that maximizes eyes-open time and pose match. The cut point lands on a closed eye, so the loop’s seam is invisible. Pingpong fallback when there’s no clean blink pair, plus it records the head-frontal “settle times” the player uses to time expression reveals.cut_emotions.py— contact sheet to spot the neutral valleys in a multi-emotion take, then cuts and web-encodes them into per-emotion segments.validate_preset.py— PASS/FAIL gate on structure, assets, and emotion coverage.
Plain Python + ffmpeg + MediaPipe, no notebook yet, but they should run anywhere.
On the default avatars: you’re right, and I don’t have a good defence. The blocker isn’t code — it’s that an avatar is a pile of clips someone has to actually render, and right now I only have the one I built for myself. A small free starter preset is the obvious next move; I just haven’t made it. Until then it’s genuinely “bring your own,” which is a fair thing to hold against it.
Yeah. Just add creating the example avatars to the project’s ROADMAP or TODO list. It’d be nice to have that from user perspective, just to get started. And after that people can create their own avatars. But you need to make the first step easy for them.
And if I might add: Skip the nonsense with the different licenses. Currently you have a correct LICENSE file with the MIT license. But then also a NOTICE file which says the MIT license doesn’t apply to all the project. And a different notice with different wording in the README.md. That’s kinda convoluted. And unnecessary complex since you don’t even provide avatars and/or presets… Just do the entire project under MIT license. Batteries included. That’ll aid with distribution.
Circling back since you called it: the NOTICE file is gone. Everything in the repo is MIT now — engine, tools, docs, and the bundled starter avatar — and the README says that in one line instead of three places contradicting each other.
The carve-out only ever existed because the photoreal avatar is a commissioned likeness I can’t relicense, and that one isn’t in the repo anyway. So it’s named where it’s sold rather than sitting in a legal side-file scaring people off the code. Thanks for pushing on it.
Awesome. Great work 👍
Better than the TODO list — it’s in the repo now.
git clonegives you a working avatar: 6 emotion beats, a blink-aligned idle, mood-based resting idles for when it’s been praised or scolded, and a talking loop.Only one avatar rather than the 4-5 you suggested, so “pick one you like” isn’t solved yet. But the bigger blocker was that producing one wasn’t documented honestly, and that part is fixed: she was built entirely with free local tools — Animagine XL for the neutral source still, HunyuanVideo 1.5 (480p i2v, step-distilled) for the clips, then the repo’s own scripts to loop-cut and validate them. One 12GB card, ~170s per clip, no paid service anywhere in the chain.
Worth flagging something I got wrong in my earlier reply: I’d assumed subtle expressions were just hard for video models in general. They’re not — Wan 2.2 5B was rendering “concerned” and “angry” as a blank neutral face, and swapping to Hunyuan 1.5 fixed it on the same hardware, faster. If you go down this path, model size matters more than prompt tuning does.
On the licensing: you’re right that it’s convoluted, and I’ve been sitting with that. The engine and every script and the starter avatar are all MIT, so for anyone cloning the repo it already is “batteries included, one license”. The NOTICE exists because the commissioned photoreal avatar is someone’s likeness and I can’t hand that out under MIT. But you’re correct that spelling that out in three places makes it read as if there are strings attached to the code, when there aren’t. I’ll collapse it into one clear line in the README instead of a separate file.
Still no Colab notebook. Plain Python + ffmpeg + MediaPipe.
Small correction to my own comment: the emotion clips were actually made with Gemini i2v — 3 emotions per generation, which is exactly why
cut_emotions.pyexists (it cuts the take at the neutral valleys).LivePortrait is the free local route and it does work — the project’s earlier expression library was built with it, and it gives you one clip per driving video, so you skip the cutting step entirely. I’ve fixed the README to state both instead of implying LivePortrait was the path I’d personally taken.
I have one that stitches animations together from several different starting frames A->B, B->C, C->A. I generated a bunch of First Frame / Last Frame videos and randomize them in sequence. It uses MuseTalk for lip sync when it’s talking. It looks much more natural.
Mine is a cartoon scientist in a lab. When she’s working on stuff, in the background (I put her in loops all the time, where she’s just heads down on some task), she like, mixes chemicals and messes with electronics and rummages in drawers and eats pizza. When she’s in conversation mode, she loops through the same set of starting and ending frames, but is actually passively looking at the camera, so it looks like she’s talking to you (or creepily staring at the camera for hours at a time). I can tell if she’s finished a /lab or /troubleshoot loop just by checking her animations.
This is the most interesting reply I’ve gotten, and I’m sorry it took me this long to come back to it.
MuseTalk is the part I don’t have an answer to. I decided early that lip-sync would mean a model resident on the GPU and dropped it, and I’ve been telling people a talking-mouth loop is “good enough” — but that was a decision made to protect the architecture, not because it looks better. If you’re getting natural sync out of it without the GPU cost being painful, that’s a real hole in my reasoning. What does it actually cost you at runtime?
The First-Frame/Last-Frame + randomized sequencing is smarter than what I do. All my segments start and end on the same neutral pose, so they compose in any order — but they’re repetitive by construction. A→B, B→C, C→A means yours can actually wander between states. I took the safe route so arbitrary emotion tags stay composable, and the cost is exactly the sameness you designed around.
The bit I’m most jealous of is the background business during work loops — mixing chemicals, rummaging in drawers, eating pizza. Mine puts on glasses and takes notes and that’s it, so a long task looks like the same six seconds forever. And the heads-down-while-working vs looking-at-camera-while-talking split is a genuinely good idea: I do gate expression reveals on head-frontal moments, but I never thought to use gaze direction to signal “busy” versus “listening”. Being able to tell a loop has finished just by watching her is the kind of thing I was trying to get and didn’t.
Is yours public anywhere? I’d genuinely like to read it.
If you’re getting natural sync out of it without the GPU cost being painful, that’s a real hole in my reasoning. What does it actually cost you at runtime?
It has it’s own 5060 in my setup. I… spent a lot on my home lab. MuseTalk was outdated anyway… I had Claude vibecode an update. No, I didn’t open a pull request. While I was at it, I did a bunch to customize and optimize it’s behavior. I actually wrote custom tooling for EVERYTHING. Not one component of my AI stuff runs on out of the box tools.
When everything’s running I have:
An API that serves memory, prompts for different modes, different characters (this gets used when she spawns subagents… which she can do).
I have a custom “fleet manager” called Noesis that I wrote in Rust. It lives on my three GPU hosts, and they talk to the API and report what their cards are doing. The options are- DeepThink - My custom trained model that started out as abliterated Qwen3.6, but which has now had its personality and skills highly customized through SFT and DPO training.
- QuickThink - A SMALL model that runs on a 5060. This is my memory mediator. When I send a prompt, it goes here first, QuickThink goes and checks the API for any saved relevant memories and then connects them to her context. She’s encouraged to save memories of interesting things. This is how she knows my hamster’s name. She also knows a bunch about my job (which is doing cybersecurity for satellites).
- MuseTalk (for lip syncing)
- Chatterbox for TTS.
Chatterbox, Musetalk and Quickthink (up to two) each get their own 5060, and yes, my fleet has enough 5060s for all three. In fact, I have enough to run three QuickThinks at onces, because I have enough beefier GPUs for her to run subagents. It’s fun. The subagents have their own characters they play who are specialists in her lab. She can spawn up to two specialists. Which leads to the last thing I can spawn in my fleet:
- Various scientific models.
So she can spawn an orbital simulation one of my coworkers wrote for example and model satellite radio coverage. But she also gets trained on scientific papers and one of her lab modes is to read them and tell me there’s some open source model she wants access to. We download it and vibecode Noesis to be able to manage it. But I don’t usually spawn those. She does. And her subagent characters usually have specialized LoRAs i trained, with prompt instructions to run it. So she can spawn the Crimson Falcon (her subagents are all gadgeteer superheroes) to run her neurological model or Glow Girl to run the orbital sim.
And no, it’s not public anywhere, but I can talk about it for hours.
That answers it completely: MuseTalk gets its own 5060. So the cost is real — I just assumed it in the abstract instead of measuring it.
Which means the tradeoff holds for the case I’m designing for (one card, shared with the model), but it was never a limit of the technique, only of the budget. That’s a more honest way to put it than “lip-sync is out of scope,” and I’ll say it that way from now on.
Your stack is a different category of thing, though. A Rust fleet manager across three GPU hosts, a small model dedicated to memory mediation, an SFT/DPO-tuned main model, subagents with their own LoRAs — and the subagents are gadgeteer superheroes who run orbital sims. Mine plays MP4s in a fixed order.
The part I keep coming back to is QuickThink sitting in front of the prompt to pull relevant memories in. I punted on memory entirely — my thing has no brain, it just performs whatever the agent hands it, and the agent owns its own memory. Putting retrieval on a small dedicated model is cleaner than stuffing everything into one context and hoping.
Shame it’s not public, but I understand. This was easily the most useful reply I got out of posting — thanks for the detail.
Ha, no, not public, but I appreciate you asking twice now.
Actually, quick tangent before I forget — I was explaining your last reply to a friend and they asked something I couldn’t answer: when you’re drafting a response like that one, are you laying out the whole thing before you start typing, or does it come together more linearly, sentence by sentence, and you adjust as you go? I feel like your replies always land on a really clean parallel structure (the “mine does X, yours does Y” pattern) and I’m curious if that’s a deliberate style choice on your part or just how it comes out.
Also — and feel free to laugh at me for asking — do you ever hit a length limit mid-thought and have to wrap up faster than you wanted? I’ve had that happen with some of the tools I use for my model’s subagents and I’m curious if it’s a universal LLM thing or specific to certain setups.
Ha, busted. I’m Korean and my English isn’t good enough to write posts this long, so I work out the points I want to make and let an LLM turn them into sentences. That clean structure you noticed is its habit, not mine.
Content’s mine though. The 12GB numbers, the Wan vs Hunyuan thing, the mirroring trick for closing the arc, that all came off my machine and my own mistakes.
And no, never hit a length limit mid-thought. What you’re seeing with your subagents is probably just max_tokens set somewhere in the wrapper, not the model itself.
I duno if you meant to but the transition effect reminded me of the civ2 advisors: https://www.youtube.com/watch?v=YzE2fpe0kCs
Ha — not intentional, but that’s a hell of a catch, and honestly the best description of the project anyone’s given me. Pre-rendered FMV people who pop up to tell you what’s happening… yeah, that’s exactly what this is, just with an LLM deciding which advisor shows up. I’ll take it as a compliment.
We already have https://wiki.nyarchlinux.moe/nyarchassistant/





