<?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: Ariel Alejandro</title>
    <description>The latest articles on DEV Community by Ariel Alejandro (@aralde).</description>
    <link>https://dev.to/aralde</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%2F3988766%2Fdef3f3d8-c431-4fe4-9f76-be5efbb79418.jpeg</url>
      <title>DEV Community: Ariel Alejandro</title>
      <link>https://dev.to/aralde</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aralde"/>
    <language>en</language>
    <item>
      <title>Replacing LiteLLM in Docker (500 MB RAM) with an 11 MB Go binary: fast LLM fallbacks and OS keyring</title>
      <dc:creator>Ariel Alejandro</dc:creator>
      <pubDate>Mon, 02 Nov 2026 17:41:15 +0000</pubDate>
      <link>https://dev.to/aralde/replacing-litellm-in-docker-500-mb-ram-with-an-11-mb-go-binary-fast-llm-fallbacks-and-os-keyring-c6d</link>
      <guid>https://dev.to/aralde/replacing-litellm-in-docker-500-mb-ram-with-an-11-mb-go-binary-fast-llm-fallbacks-and-os-keyring-c6d</guid>
      <description>&lt;p&gt;When I started building against the OpenAI API, I iterated a lot. Run the test, change something, run it again. Then it clicked that every word coming back from the model was money.&lt;/p&gt;

&lt;p&gt;So I went looking at the free tiers of other providers. I went deep enough that I built a page about them: &lt;a href="https://trymodels.aralde.dev" rel="noopener noreferrer"&gt;trymodels.aralde.dev&lt;/a&gt;. They helped, but each one was limited in its own way, and keeping a stable service on top of them was hard.&lt;/p&gt;

&lt;p&gt;This post is about what I built to solve this once and for all. The short version: you don't need a 500 MB Docker container running Python just to route local LLM requests. A single 11 MB Go binary can configure multi-provider fallbacks instantly, store your API keys securely in your OS keyring instead of scattered &lt;code&gt;.env&lt;/code&gt; files, and dramatically simplify your developer life.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two problems with existing tools
&lt;/h2&gt;

&lt;p&gt;The first problem was cost, stability, and resource waste. Because these providers speak the OpenAI-compatible API, in theory you swap a base URL and keep going. In practice you want something smarter: one name for the model, several providers behind it, and when one hits a rate limit or drops, the same call seamlessly falls back to the next.&lt;/p&gt;

&lt;p&gt;LiteLLM does this, but running it in Docker took about 500 MB of RAM on my machine just to route local HTTP traffic. It felt overkill to keep a full container and Python runtime alive just for proxying requests. I wanted an instant, lightweight tool that does fast multi-provider fallbacks without the bloat.&lt;/p&gt;

&lt;p&gt;The second problem was keys. Every new project started the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# project-a/.env
OPENAI_API_KEY=sk-...
OPENROUTER_API_KEY=sk-or-...
GROQ_API_KEY=gsk_...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;project-b/.env&lt;/code&gt;, with the same three lines. Then &lt;code&gt;project-c&lt;/code&gt;. Each copy is one more file with a live key in it, sitting in one more folder.&lt;/p&gt;

&lt;p&gt;I decided to solve both in one place. The result is OperatorLM, a local OpenAI-compatible proxy. It's one Go binary of about 11 MB. No Docker, no Python, no &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6p255qqbe6memj3kf4i4.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6p255qqbe6memj3kf4i4.gif" alt="Demo of OperatorLM: one model name mapped to three accounts, a test request returning 200 with the circuit-breaker settings beside it, a config file that holds only a keyring reference, the ~11 MB binary, and local models in the admin UI" width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The client side is a base URL. Anything that speaks the OpenAI API can point at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://127.0.0.1:11434/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer anything"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "fast-llama", "messages": [{"role": "user", "content": "Hi"}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API key can be any non-empty string. OperatorLM injects the real one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fast-llama&lt;/code&gt; is not a model. It's an alias. In the README example it points at Groq first (30 requests per minute), then OpenRouter as the paid fallback. The client asks for one name. The alias walks its targets in order until one answers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn9m1cmq6uxj0ducgyenk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn9m1cmq6uxj0ducgyenk.png" alt="Aliases tab in the admin UI: one alias with strategy &amp;quot;order&amp;quot; and five targets across OpenRouter, Google, NVIDIA and Groq, each with an order and an RPM limit" width="800" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each alias decides which provider takes the first request. OpenAI, OpenRouter, Gemini, whatever you put at the top of the list.&lt;/p&gt;

&lt;p&gt;It listens on &lt;code&gt;127.0.0.1:11434&lt;/code&gt;, the same port Ollama uses. So it works as a drop-in for tools that already talk to that address: Cursor, Continue, Cline, Zed and similar.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned about failover
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Retrying the same target is not failover
&lt;/h3&gt;

&lt;p&gt;If a provider says you're rate limited, retrying it 500 ms later mostly gets you the same answer. The quota didn't reset because you waited half a second.&lt;/p&gt;

&lt;p&gt;So an alias has a strategy, &lt;code&gt;order&lt;/code&gt;, that walks the target list until one succeeds. Retries still exist (2 by default, backoff starting at 500 ms with jitter, capped at 10 s).&lt;/p&gt;

&lt;h3&gt;
  
  
  Not every failure is the same failure
&lt;/h3&gt;

&lt;p&gt;This is the part that surprised me. You have to tell apart network failures, streaming timeouts and quota limits (RPM). If you treat them the same, you block an alias that didn't need blocking.&lt;/p&gt;

&lt;p&gt;The circuit breaker has three states and opens after 3 failures. The cooldown depends on what failed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;429 (rate limited): 15 s&lt;/li&gt;
&lt;li&gt;5xx (the provider is broken): 60 s&lt;/li&gt;
&lt;li&gt;network error: 90 s&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also honors &lt;code&gt;Retry-After&lt;/code&gt; when the provider sends it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Streams fail quietly
&lt;/h3&gt;

&lt;p&gt;A stream that stops sending tokens doesn't return an error code. The connection is open and nothing arrives. So there are two separate clocks: a stream idle timeout (30 s) and a total request timeout (180 s).&lt;/p&gt;

&lt;h3&gt;
  
  
  Skip a target you already know is full
&lt;/h3&gt;

&lt;p&gt;If a free account allows 30 requests per minute, sending the 31st is a wasted round trip. The RPM limiter keeps a sliding 60 s window per target. When a target is full, it gets skipped instead of making the request wait.&lt;/p&gt;

&lt;p&gt;All of this is tunable live from the Reliability tab, without a restart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8fnsz882o0vpjn77mhya.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8fnsz882o0vpjn77mhya.png" alt="Reliability tab: retry policy, circuit breaker thresholds and cooldowns per failure type, and timeouts" width="800" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Keys belong in the OS keyring
&lt;/h2&gt;

&lt;p&gt;The fix for the &lt;code&gt;.env&lt;/code&gt; problem was boring, which is why I like it. Keys go into the keyring your OS already has: Windows Credential Manager, macOS Keychain, or Linux Secret Service.&lt;/p&gt;

&lt;p&gt;The config file at &lt;code&gt;~/.operatorlm/config.toml&lt;/code&gt; only holds references, like &lt;code&gt;operatorlm:openai_work&lt;/code&gt;. There's no secret in it. Your projects don't hold keys either. They hold a base URL.&lt;/p&gt;

&lt;p&gt;A few more things in the same spirit. The audit log is JSONL at &lt;code&gt;~/.operatorlm/audit.log&lt;/code&gt;, with &lt;code&gt;Authorization&lt;/code&gt; headers redacted. The proxy only listens on loopback, with host-header validation on the admin API to block DNS rebinding. And v0.2.1 has zero telemetry.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frku3w1w3s4yxppkh072b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frku3w1w3s4yxppkh072b.png" alt="Architecture diagram: editors and SDKs send requests to OperatorLM on 127.0.0.1:11434, which runs alias resolution, retry with jitter, a circuit breaker, an RPM limiter, keyring key injection and an audit log, then forwards to OpenAI, OpenRouter, Groq, Gemini, Azure OpenAI and other providers" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Receive&lt;/strong&gt; an OpenAI-compatible request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolve&lt;/strong&gt; the model name, by prefix match or alias.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inject&lt;/strong&gt; the key from the OS keyring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Try, retry or break&lt;/strong&gt;, following the rules above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit&lt;/strong&gt; the result to the JSONL log.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The admin UI is embedded in the binary with &lt;code&gt;go:embed&lt;/code&gt; and lives at &lt;code&gt;http://127.0.0.1:11434/admin/&lt;/code&gt;. The codebase is small enough to audit in an afternoon. That's the point.&lt;/p&gt;

&lt;p&gt;The project kept growing after that. The same binary can now run local GGUF models through a built-in llama.cpp engine (exposed as &lt;code&gt;local/&amp;lt;model&amp;gt;&lt;/code&gt;), plus Whisper for speech-to-text and Piper for text-to-speech, all offline. One experiment chains them: a local model answers, and Piper turns the answer into voice, so it behaves a bit like a realtime voice model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The binary is not signed. Windows SmartScreen shows "Windows protected your PC" (More info, Run anyway). macOS Gatekeeper blocks the first launch (right-click, Open).&lt;/li&gt;
&lt;li&gt;It's built for local development on &lt;code&gt;127.0.0.1&lt;/code&gt;, for one user. No distributed production setup.&lt;/li&gt;
&lt;li&gt;Port 11434 clashes with a running Ollama. Change one of them in &lt;code&gt;~/.operatorlm/config.toml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On headless Linux, the keyring needs a running Secret Service daemon and a D-Bus session. Running it as a proper Linux service on a server is the next thing I plan to do.&lt;/li&gt;
&lt;li&gt;Local models need &lt;code&gt;llama-server&lt;/code&gt; and run as a second process. Speed depends entirely on your GPU and RAM.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;It's Apache 2.0, for Windows x64, macOS (Apple silicon and Intel) and Linux x64. The code is at &lt;a href="https://github.com/aralde/operatorlm" rel="noopener noreferrer"&gt;github.com/aralde/operatorlm&lt;/a&gt; and the site is &lt;a href="https://operatorlm.aralde.dev/" rel="noopener noreferrer"&gt;operatorlm.aralde.dev&lt;/a&gt;. If a provider fails over badly for you (an error that looks like one thing and is another), an issue with the response attached helps the most.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>go</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
    <item>
      <title>An animated AI agent avatar face, React library (openSource)</title>
      <dc:creator>Ariel Alejandro</dc:creator>
      <pubDate>Wed, 12 Aug 2026 12:37:13 +0000</pubDate>
      <link>https://dev.to/aralde/how-i-gave-my-ai-agent-a-responsive-face-with-pure-react-svg-3d-zero-backend-3dgk</link>
      <guid>https://dev.to/aralde/how-i-gave-my-ai-agent-a-responsive-face-with-pure-react-svg-3d-zero-backend-3dgk</guid>
      <description>&lt;p&gt;Text transcripts tell you what an LLM is doing, but they feel static and heavy to read. I built a React component that transforms AI agent interactions by adding a responsive face — supporting custom SVGs, built-in vector avatars, and optional 3D heads — that visibly listens, thinks, speaks, and reports tool usage, with zero backend setup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqkrdi3amxemxbmiyfm4a.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqkrdi3amxemxbmiyfm4a.gif" alt="react-ai-avatar" width="213" height="218"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why text transcripts feel incomplete for AI interactions
&lt;/h2&gt;

&lt;p&gt;If you build apps with LLMs, user interaction usually boils down to a chat window and a stream of text.&lt;/p&gt;

&lt;p&gt;When the agent runs long tasks, modern UIs show status text: &lt;code&gt;Thinking…&lt;/code&gt;, &lt;code&gt;Searching the web&lt;/code&gt;, or &lt;code&gt;Running bash command&lt;/code&gt;. That's a huge improvement over a static spinner, but it still leaves the interaction feeling like reading a terminal log.&lt;/p&gt;

&lt;p&gt;Every status update competes for the exact same column as the final answer you're waiting to read.&lt;/p&gt;

&lt;p&gt;I wanted to see if I could transform that interaction: &lt;strong&gt;What if your AI agent had a visual face that reports its condition out of the corner of your eye?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not a heavy 3D game character, and not a $99/month SaaS widget — just a clean, provider-agnostic React component that you drop into your existing frontend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: One React component, any avatar style
&lt;/h2&gt;

&lt;p&gt;The goal was to make adding an avatar to an AI app as simple as installing the package and rendering a component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;react-ai-avatar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RealtimeAvatar&lt;/span&gt;
  &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;streaming&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;speaking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;streamingText&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;lastAssistantMessage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API keys, no backend proxy, and no mandatory external libraries.&lt;/p&gt;

&lt;p&gt;Depending on your product's design system, you can choose how the avatar renders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Built-in 2D Vector (Default):&lt;/strong&gt; A clean SVG face with zero dependencies that keeps your bundle tiny.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your Own Custom SVG:&lt;/strong&gt; Pass custom React SVG elements to match your product's brand mascot or design system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3D Head Adapter:&lt;/strong&gt; An optional Three.js adapter if you want a 3D model, lazy-loaded so it won't inflate bundle size if you don't use it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whether your app connects to OpenAI, Claude, Ollama, Gemini, or a custom WebSocket server, the avatar doesn't care. You bring the connection; it brings the face.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5 agent states: What the avatar reports
&lt;/h2&gt;

&lt;p&gt;Instead of guessing your backend logic, the component relies on your app passing one of five clear execution states:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;What the avatar visibly shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;idle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Neutral, relaxed posture; periodic natural blinking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;listening&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Eyes tracking user cursor / input focus&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;thinking&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Animated thought indicator while waiting for the first token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;speaking&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reactive mouth movements synced to incoming content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;working&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tool-use mode (displays active tool labels like "searching" or "executing script")&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because the host application controls the state, you never get weird race conditions or false assumptions about what the LLM is doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lip-sync without audio: Rhythm from token streams
&lt;/h2&gt;

&lt;p&gt;The hardest technical part of making an avatar feel alive is the mouth. A mouth that flaps at a fixed speed while text streams looks fake immediately.&lt;/p&gt;

&lt;p&gt;If your app uses voice, an &lt;code&gt;AnalyserNode&lt;/code&gt; can pass volume levels to drive mouth height. But most LLM applications stream plain text over SSE (Server-Sent Events).&lt;/p&gt;

&lt;p&gt;I realized that &lt;strong&gt;text streams already carry natural speech rhythm.&lt;/strong&gt; Tokens arrive in bursts, pause when the model processes, and speed up during continuous generation.&lt;/p&gt;

&lt;p&gt;To capture that cadence without requiring an audio stream or TTS backend, I built a leaky accumulator that converts raw text arrivals into an energy signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;chargePerChar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;decayMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;140&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxChargePerPush&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createSpeechActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SpeechActivityOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chargePerChar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chargePerChar&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;DEFAULTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chargePerChar&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decayMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decayMs&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;DEFAULTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decayMs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maxChargePerPush&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxChargePerPush&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;DEFAULTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxChargePerPush&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;energy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decayTo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;lastT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;energy&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;decayMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;textChunk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;textChunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nf"&gt;decayTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="nx"&gt;energy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;energy&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxChargePerPush&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;textChunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;chargePerChar&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Energy decays naturally on the next sample().&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;energy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;lastT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="cm"&gt;/** Current decayed energy (0 to 1). Read once per frame. */&lt;/span&gt;
    &lt;span class="nf"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;decayTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;energy&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;energy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key choices that make the animation feel natural:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Charge cap (&lt;code&gt;maxChargePerPush&lt;/code&gt;):&lt;/strong&gt; Prevents large text chunks from locking the mouth open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exponential decay (&lt;code&gt;Math.exp&lt;/code&gt;):&lt;/strong&gt; Mimics acoustic energy falloff instead of linear mechanical closing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sampled timing:&lt;/strong&gt; Calculates decay dynamically on animation frames, avoiding timer overhead and staying smooth even if tab execution slows down.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For React hooks that pass full accumulated strings (&lt;code&gt;"Hello world"&lt;/code&gt;), a light helper (&lt;code&gt;diffStreamingText&lt;/code&gt;) calculates new character pushes cleanly without extra state tracking in your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  State, not simulated emotion
&lt;/h2&gt;

&lt;p&gt;A critical decision when adding an avatar to AI software is drawing a boundary between &lt;strong&gt;system state&lt;/strong&gt; and &lt;strong&gt;simulated emotion&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The avatar shows &lt;em&gt;what the software is doing&lt;/em&gt; (&lt;code&gt;thinking&lt;/code&gt;, &lt;code&gt;speaking&lt;/code&gt;, &lt;code&gt;working&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;It does &lt;em&gt;not&lt;/em&gt; attempt to pretend the AI has feelings, simulated warmth, or fake expressions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping the avatar focused on feedback rather than fake personality avoids the uncanny valley and keeps the tool honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it out
&lt;/h2&gt;

&lt;p&gt;All of this is packed into &lt;strong&gt;&lt;a href="https://github.com/aralde/react-ai-avatar" rel="noopener noreferrer"&gt;react-ai-avatar&lt;/a&gt;&lt;/strong&gt; (MIT license):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;npm:&lt;/strong&gt; &lt;code&gt;npm install react-ai-avatar&lt;/code&gt; — &lt;a href="https://www.npmjs.com/package/react-ai-avatar" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/react-ai-avatar&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/aralde/react-ai-avatar" rel="noopener noreferrer"&gt;https://github.com/aralde/react-ai-avatar&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live Demos (Client-side):&lt;/strong&gt; &lt;a href="https://react-ai-avatar.vercel.app/" rel="noopener noreferrer"&gt;https://react-ai-avatar.vercel.app/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How are you currently handling visual interactions with LLM agent runs?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>ai</category>
      <category>webdev</category>
      <category>ux</category>
    </item>
  </channel>
</rss>
