discover · compare · collections · requirements · contribute · feed · watching · stats · help with your tokens
bwndapp/bbot · source
Snapshot 0 · 2026-11-04T12:47:28Z · HTML · MIT
Content fingerprint
1dec58469ec8461b761b0d2b4215b97d5ce307dad5e6f769c1848b3ff4573fe1GitHub description at capture
A face for AI agents: 30 expressions people read at a glance, lip-synced speech, and the Agent Face Protocol.
README at capture
<div align="center">
<img src="docs/logo.png" alt="" width="88">
# bbot
**A face for AI agents.**
It shows what an agent is doing and how it feels about it, in a way people read
at a glance: thinking, working, worried, pleased. It follows your cursor, blinks
on its own, and lip-syncs when the agent speaks. 30 moods, nine one-shot
reactions, **4.8 kB gzipped**, no dependencies.
### [→ Try it live at bbot.bwnd.app](https://bbot.bwnd.app)
<img src="docs/hero.png" alt="bbot — give your app a face" width="820">
</div>
---
## Install
```sh
npm i @bwnd/bbot
```
```js
import { createFace } from '@bwnd/bbot'
const face = createFace(document.querySelector('#bot'), { expression: 'curious' })
face.react('bounce')
```
Styles are injected at runtime, so there is no CSS import and no `<link>`. React
is an optional peer dependency — the line above pulls in none of it.
## Or skip the install entirely
The bundle is hosted with styles included, so this is a whole integration:
```html
<div id="bot" style="width:160px;height:160px"></div>
<script type="module">
import { createFace } from 'https://bbot.bwnd.app/face.js'
const face = createFace(document.querySelector('#bot'), { expression: 'curious' })
face.react('bounce')
</script>
```
> **Give the host element a width and height.** The SVG fills its container, so
> a container with no size renders nothing. This is the one thing people trip on.
Mirrored on jsDelivr if you'd rather pin a tag than track latest:
```js
import { createFace } from 'https://cdn.jsdelivr.net/gh/bwndapp/bbot@main/dist/face.js'
```
## React
```jsx
import { Face } from '@bwnd/bbot/react'
<Face size={160} expression="curious" mouth pupils />
```
The React build is precompiled and ships styles inside it, so it works as-is with
Vite, Next, CRA or Parcel — no JSX transpilation of `node_modules`, no CSS
import. Drive it through a ref:
```jsx
const face = useRef(null)
<Face ref={face} size={160} />
face.current.react('bounce') // one-shot, decays on its own
face.current.setExpression('sad') // springs across, never snaps
face.current.look(-1, 0, 1200) // force the gaze left for 1.2s
```
## Expressions
<img src="docs/expressions.png" alt="30 expressions: idle, content, happy, joy, excited, love, surprised, scared, curious, confused, thinking, focus, suspicious, smug, sly, bored, worried, sad, annoyed, angry, sleepy, sleep" width="100%">
```js
face.setExpression('sleepy')
```
Every field of an expression is a spring target, so moods cross-fade from
wherever they currently are rather than cutting. `EXPRESSION_NAMES` enumerates
them at runtime.
There is exactly one shape in this library: a circle. Every mood above comes
from two head-coloured rectangles sliding over each eye and rotating — `angry`
and `sad` are the same lid at opposite rotations.
## Reactions
`blink` · `wink` · `nod` · `shake` · `bounce` · `pop` · `boing` · `spin` · `jitter`
```js
face.react('nod')
```
Reactions layer on top of whatever mood is active and decay on their own. They
stack, so firing two at once is fine and intended. `REACTION_NAMES` enumerates
them.
## Theming
<img src="docs/themes.png" alt="The same face in four colour schemes: default white on black, inverted black on white, acid green, and sky blue" width="100%">
Four custom properties. Set them on the face, or on anything above it — the
library ships no colours of its own beyond dark-mode defaults.
```css
#bot {
--face-skin: #000; /* the big circle — and the lids, which must match */
--face-ink: #e9ebec; /* eyes and mouth */
--face-ring: #2f3336; /* hairline around the head; transparent by default */
--face-pupil: var(--face-skin);
}
```
`--face-skin` is load-bearing. The lids are head-coloured rectangles clipped to
each eye, so if it doesn't match what sits behind the head, every expression
seams.
> A custom property set **on the face element itself** beats one inherited from
> an ancestor. If a theme isn't taking, that's usually why.
## How it works
### The head is a sphere
<img src="docs/tracking.png" alt="The same face looking up-left, at rest, and down-right — the far eye foreshortens into an ellipse while the near one stays round" width="100%">
The eyes don't slide around a flat disc. Each feature has a resting spot on the
surface of a sphere that yaws and pitches to look at you, and the result is
projected orthographically back onto the screen. So features travel a curved
path and slow near the rim (a point at angle θ projects to `R·sin θ`), a circle
painted on the surface projects to an ellipse — full width across the radius,
squashed along it — and the eyes and mouth turn together as one ball.
### Everything is a spring
No CSS transitions, no keyframes. Each animated value is a damped spring
integrated per frame, which is why a reaction can layer over a mood without the
two fighting.
## Props
| prop | default | |
|---|---|---|
| `size` | `140` | px, square (React only) |
| `expression` | `'idle'` | any key of `EXPRESSIONS` |
| `mouth` | `false` | draw a mouth as well as eyes |
| `pupils` | `false` | inner dot that tracks with parallax |
| `track` | `true` | follow the pointer at all |
| `blink` | `true` | involuntary blinking |
| `idle` | `true` | glance around when the pointer goes quiet |
Everything except `expression` is read once, at construction — changing one
rebuilds the SVG. `expression` animates.
## Performance
One `requestAnimationFrame` loop and one pointer listener for the whole page, no
matter how many faces. Each face costs ~15 `setAttribute` calls per frame, and
faces scrolled out of view skip their frame entirely via `IntersectionObserver`.
Coming back from offscreen or a background tab reschedules the blink timer
rather than firing the backlog at once.
`prefers-reduced-motion` drops the idle wander, the blinking, and the reaction
shake automatically — don't add your own guard.
## Size
The stylesheet is injected by the bundle, so these are whole numbers — there is
nothing else to add.
| | minified | gzipped |
|---|---|---|
| `createFace` (no framework) | 13.4 kB | **4.8 kB** |
| `<Face>` (React external) | 14.2 kB | **5.2 kB** |
| `@bwnd/bbot/fx` (optional) | 15.9 kB | 4.9 kB |
| `@bwnd/bbot/talk` (optional) | 1.9 kB | 1.0 kB |
The last two are separate entry points and cost nothing unless you import
them.
## Effects
`@bwnd/bbot/fx` adds a finish: ten independent knobs, each 0–1, composable.
```js
import { createFace } from '@bwnd/bbot'
import { applyFx } from '@bwnd/bbot/fx'
const face = createFace(document.querySelector('#bot'))
const fx = applyFx(document.querySelector('#bot svg'), { chrome: 0.9, gloss: 0.8 })
fx.set({ bloom: 0.6 }) // change any knob at any time
```
`gloss` (catchlights), `rim` (a neon tube), `chrome` (a reflected room),
`glass` (the body thins and the ink splits chromatically), `bloom` (a tight
burn), `halation` (a wide film bleed), `holo`, `scan` (scanlines), `scratch`
(a worn clearcoat) and `hue` (a slow drift).
Layers are built lazily, so a knob you leave at 0 costs nothing. `chrome` and
`scratch` are the expensive two; the rest are close to free.
## Talking
`@bwnd/bbot/talk` points a face at audio and lets it lip-sync. The face already
knows how to talk — `face.talk(level)` takes a 0–1 loudness — this is the half
that listens.
```js
import { listen } from '@bwnd/bbot/talk'
const face = createFace(el, { mouth: true })
const stop = listen(document.querySelector('audio'), face)
```
`listen` takes an `<audio>`/`<video>`, a `MediaStream` (a mic, a WebRTC track)
or an `AudioNode` you have already wired up, and returns a `stop()`.
It measures the sound's colour too, and passes it on as `talk(level, { wide,
round })` — treble share and bass share. Those pick the mouth shape: bright
audio goes wide and shallow ("ee"), dark audio small and round ("oo"), loud mids
drop the jaw ("ah"). `talk(level)` on its own just opens and shuts.
For rendering rather than playback there is `analyseVoice(audioBuffer)`, whose
`.at(time)` returns `{ level, wide, round }`. Sync then follows the frame's own
timestamp rather than whatever the sound card is doing, which is exact even if
the encoder stutters — and it works where there is no audio device at all. It
tracks a live AnalyserNode to within 0.004.
## For agents
bbot is the reference renderer for the **[Agent Face Protocol](spec/)**, an
open format for an AI agent to show what it's doing and how it feels about it:
a state (`thinking`, `working`, `waiting`, `error`…), one of 30 expressions,
a reaction, or speech with lip-sync. Every expression has a plain-language
gloss, so the same signal works as a face, a caption or screen-reader text.
```json
{ "type": "face.state", "state": "working", "label": "Searching your files" }
```
The desktop widget in [`widget/`](widget/) puts this on your desktop: chat
with any model through OpenRouter, a voice from ElevenLabs, and a face that
acts the conversation out.
Machine-readable docs are served next to the library, so an agent can work out
how to use it without a human in the loop:
- [`SKILL.md`](https://bbot.bwnd.app/SKILL.md) — full API and the mistakes worth avoiding
- [`llms.txt`](https://bbot.bwnd.app/llms.txt) — short index
## Local development
```bash
npm install
npm run build # -> dist/face.js, styles injected at runtime
npm run demo # -> http://localhost:8080/demo/
```
## Adding an expression
Add a row to `EXPRESSIONS` in `src/expressions.js`. The header comment documents
every field and its units. Two things learned the hard way:
- **A shallow `lidT` reads as a dent, not a lid.** The chord across a circle is
already half its width at 6% closed. Go to ~0.3 or stay at 0.
- **Distinguish moods by shape, not amount.** `happy` and `joy` started as the
same dome at two sizes and read as one mood. `joy` only became its own thing
once both lids came in and made it a crescent.
## Brand assets
| file | |
|---|---|
| [`docs/logo.svg`](docs/logo.svg) | the mark — one path, eyes punched out with `evenodd`, fills with `currentColor` |
| [`docs/logo.png`](docs/logo.png) | 1024×1024 square icon, for avatars |
| [`docs/logotype.png`](docs/logotype.png) | horizontal lockup, mark + wordmark |
The SVG takes any colour and needs no background to read — but `currentColor`
only resolves when it's inlined, not loaded through an `<img>`.
## Credits
The visual language — near-black canvas, hairline borders doing the separating
instead of shadows, tightly tracked type, fully-round pill buttons — is inspired
by **X's design system**.
The face itself is an original implementation. The sphere projection, the lid
system, and the spring model were written from scratch for this library.
Built in the open in a [bwnd](https://bwnd.app) incubator.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md), [GOVERNANCE.md](GOVERNANCE.md) and
the [Code of Conduct](CODE_OF_CONDUCT.md). Security issues: [SECURITY.md](SECURITY.md).
## Licence
MIT.