<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ghostvessel</title>
    <description>The latest articles on DEV Community by ghostvessel (@ghostvessel).</description>
    <link>https://dev.to/ghostvessel</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3970416%2F9f228f91-4496-4b9c-bea3-307705776b96.png</url>
      <title>DEV Community: ghostvessel</title>
      <link>https://dev.to/ghostvessel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ghostvessel"/>
    <language>en</language>
    <item>
      <title>I replaced the chat window for my local AI agent with a face</title>
      <dc:creator>ghostvessel</dc:creator>
      <pubDate>Wed, 08 Jul 2026 21:41:56 +0000</pubDate>
      <link>https://dev.to/ghostvessel/i-replaced-the-chat-window-for-my-local-ai-agent-with-a-face-3e1k</link>
      <guid>https://dev.to/ghostvessel/i-replaced-the-chat-window-for-my-local-ai-agent-with-a-face-3e1k</guid>
      <description>&lt;p&gt;I run a local LLM agent (Hermes) on my own machine. The problem was never the model — it was the &lt;em&gt;interface&lt;/em&gt;. I had a Telegram tab open all day just to talk to it: type a command, wait, read a wall of text back, scroll. It felt like texting a very capable stranger.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://github.com/ghdtjrtka/ghost-vessel" rel="noopener noreferrer"&gt;Ghost Vessel&lt;/a&gt;&lt;/strong&gt; — a monitor-resident, video-call-style avatar that fronts the agent. The name is the whole idea: the &lt;em&gt;ghost&lt;/em&gt; is your agent, the &lt;em&gt;vessel&lt;/em&gt; is the body it borrows. It's not a waifu toy; it's a real agent client that happens to have a face.&lt;/p&gt;

&lt;p&gt;Here's what actually turned out to be interesting to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reply is a script, not a string
&lt;/h2&gt;

&lt;p&gt;The core idea is an &lt;strong&gt;output contract&lt;/strong&gt;. Instead of treating the agent's reply as text to print, I split every reply into three planes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;dialogue&lt;/strong&gt; → spoken via local TTS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;data&lt;/strong&gt; → code, logs, files → rendered as chat cards, &lt;em&gt;never read aloud&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;action&lt;/strong&gt; → &lt;em&gt;emotion beats&lt;/em&gt; that drive the avatar&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Emotion beats are inline tags the model emits in-band with its answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[working]&lt;/code&gt; — the avatar puts on glasses and takes notes while a task runs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[confirm] deploy to prod?&lt;/code&gt; — pops a human-in-the-loop approve/cancel, and the agent blocks on your keypress&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[happy]&lt;/code&gt; / &lt;code&gt;[concerned]&lt;/code&gt; / … — fine-grained facial expressions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "run the build, and if it passes, deploy" becomes a little &lt;em&gt;performance&lt;/em&gt;: it looks busy while working, shows you the log as a card, then leans in and asks before the irreversible step. The text you'd have skim-read becomes something you glance at.&lt;/p&gt;

&lt;h2&gt;
  
  
  No runtime GPU for the avatar
&lt;/h2&gt;

&lt;p&gt;The obvious way to animate a face is live inference. I didn't want that — the GPU is busy running the actual model.&lt;/p&gt;

&lt;p&gt;Instead the avatar is &lt;strong&gt;~30 pre-rendered clips&lt;/strong&gt;, and the emotion beats just select and blend between them (blink-aligned seamless idle loops, a head-pose "settle gate" so an expression only reveals when the head is frontal). The avatar's runtime cost is basically video playback. Your GPU stays 100% on your LLM.&lt;/p&gt;

&lt;p&gt;The tradeoff: no real-time lip-sync. I decided a believable &lt;em&gt;talking mouth loop&lt;/em&gt; + expressive face reads as "a person on a call" far more than perfect phoneme-matching does, and it costs nothing at runtime. That single decision collapsed most of the hard engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hooking a real agent without the agent knowing
&lt;/h2&gt;

&lt;p&gt;The agent integration is the part I was most unsure would work. It turned out clean: the app registers as a &lt;strong&gt;connector&lt;/strong&gt;. The agent's gateway dials &lt;em&gt;out&lt;/em&gt; to a local WebSocket the app hosts, and exchanges frames — so from the agent's side, the avatar is just "another channel," indistinguishable from Telegram.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inbound (you → agent): your typed text as a message event&lt;/li&gt;
&lt;li&gt;Outbound (agent → you): the reply, which the connector parses for emotion beats&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adapters for Hermes and OpenClaw are included, plus a demo responder so it runs with zero setup. And the chat pane serves the agent's &lt;strong&gt;live slash-command menu&lt;/strong&gt; — type &lt;code&gt;/&lt;/code&gt; and you get the same 52 commands you'd see in the messenger, passed straight through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bits that make it feel alive
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mood &amp;amp; affinity.&lt;/strong&gt; A short-term mood decays toward a long-term relationship baseline. Scold it repeatedly and it settles into a subdued idle; praise it and it brightens. Persists across restarts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice both ways.&lt;/strong&gt; Edge TTS out (swappable to local Qwen3-TTS / MeloTTS / Piper), Silero VAD + faster-whisper in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor-resident.&lt;/strong&gt; Two frameless, always-on-top windows (avatar + chat) you drag anywhere, built with &lt;strong&gt;Tauri v2&lt;/strong&gt; (Rust shell, Python servers, JS player).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Open-core
&lt;/h2&gt;

&lt;p&gt;The engine is &lt;strong&gt;MIT&lt;/strong&gt; and usable on its own: clone it, drop a folder of clips named by emotion (&lt;code&gt;happy.mp4&lt;/code&gt;, &lt;code&gt;working.mp4&lt;/code&gt;, &lt;code&gt;idle.mp4&lt;/code&gt;, …), point it at your agent. Avatars are &lt;strong&gt;pure-data bundles&lt;/strong&gt; — no code runs when you install one — so you can build your own; the full reproducible method is in the repo.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo + 45s demo:&lt;/strong&gt; &lt;a href="https://github.com/ghdtjrtka/ghost-vessel" rel="noopener noreferrer"&gt;https://github.com/ghdtjrtka/ghost-vessel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows build:&lt;/strong&gt; &lt;a href="https://ghostvessel.space" rel="noopener noreferrer"&gt;https://ghostvessel.space&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'm curious about
&lt;/h2&gt;

&lt;p&gt;The part I'd most like feedback on is the &lt;strong&gt;emotion-beat output format&lt;/strong&gt; — the contract that turns LLM text into a UI performance. Has anyone else built output contracts to drive an interface from model output? What broke, and what did models reliably get right vs. wrong? I'd genuinely like to compare notes.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>opensource</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
