If you build AI products, you have probably shipped a spinner that means "the agent is doing something". It is fine for one chat box. It breaks down when a screen shows several agents and the user needs to know which one is waiting on them.
I built Agentfaces to fix that. It is an MIT library of expressive faces for AI agents. You pass a state and the face shows it.
Install
npm install agentfaces
import { AgentFace } from "agentfaces/react";
export function AgentStatus({ agent }) {
return <AgentFace state={agent.state} seed={agent.id} name={agent.name} size={48} />;
}
For React Native, add react-native-svg and import from agentfaces/react-native. The props are the same.
The states
| State | When to use it |
|---|---|
idle |
Nothing in progress |
thinking |
Request sent, waiting for the first token |
working |
Running a tool or a long task |
needs-you |
Waiting for approval or input (body morphs into a ?) |
done |
Finished (body morphs into a check mark) |
alert |
Error or something needs attention (body morphs into a !) |
celebrate |
A milestone, with confetti |
sleeping |
Paused or offline |
There are also extended states (starting, exploring, waiting, handing-off and more) and ornament states that add a typing ellipsis, a spinner, a ripple or a tracker.
Wiring it to the Vercel AI SDK
The AI SDK useChat hook gives you a status and messages with tool parts. One function turns that into a face state:
import { isToolUIPart, type ChatStatus, type UIMessage } from "ai";
import type { AgentState } from "agentfaces";
export function agentStateFor(status: ChatStatus, messages: UIMessage[]): AgentState {
if (status === "error") return "alert";
if (status === "submitted") return "thinking";
const last = messages.at(-1);
const tools = last?.role === "assistant" ? last.parts.filter(isToolUIPart) : [];
if (tools.some((part) => part.state === "approval-requested")) return "needs-you";
if (status === "streaming") {
const toolRunning = tools.some((part) => part.state === "input-streaming" || part.state === "input-available");
return toolRunning ? "working" : "typing";
}
return last?.role === "assistant" ? "done" : "idle";
}
The full example is at https://github.com/ajibadedapo/agentfaces/tree/main/examples/vercel-ai-sdk.
How it works, briefly
- Parametric faces. Each eye and the mouth are a handful of numbers. An expression is one parameter set, so moving between any two expressions is interpolation.
- Life without noise. A seeded director schedules blinks, gaze drift and tiny saccades, so faces feel alive and never blink in sync.
- Glyph morphs. The body and the glyph become signed distance fields, get blended, and are traced back into an outline with marching squares and Taubin smoothing.
- One ticker. Every face shares one requestAnimationFrame loop. Off-screen faces pause.
-
Accessibility. Each face has an accessible name, reduced motion gives a calm resting face per state, and
variant="still"runs no timers at all. - Three shapes. Circle, triangle and square, with color and shape picked from a seed when you do not choose.
Voice agents
Agentfaces 0.2 adds listening and speaking states driven by real audio. The listening face leans in and pulses with the microphone level; the speaking face's mouth opens with loudness and widens or rounds with the sound. Pass any MediaStream, audio element or level callback:
<AgentFace state="speaking" audio={remoteAudioStream} />
There are tiny adapters (under 1 KB each) for LiveKit, OpenAI Realtime and ElevenLabs, so a voice agent gets a face in one line.
Links
- GitHub: https://github.com/ajibadedapo/agentfaces
- Docs and playground: https://ajibadedapo.github.io/agentfaces/
- npm: https://www.npmjs.com/package/agentfaces
Issues and ideas are welcome, especially agent states you show in your product that are missing.

Top comments (1)
Does the accessible name update with the current state too? For a status graphic, exposing both the agent identity and changing state would keep the visual glyph and AT output aligned.