---
name: bbot
description: Add an animated face to a web page — a circle with two eyes that follow the user's pointer, with 30 expressions and 9 one-shot reactions. Use when building a UI that needs a character, mascot, avatar, assistant presence, loading/idle personality, or any element that should visibly react to the user. Works in React or plain HTML, no build step required. Also renders a face to a 1080×1080 image or MP4 over an open HTTP endpoint, for an avatar, a profile picture, or a social post.
---

# bbot

A circle, and two circles inside it that follow the pointer. The head is a
sphere: eyes sit on its surface, so they travel a curved path and foreshorten
toward the rim rather than sliding across a flat disc.

3.7 kB gzipped, no dependencies. MIT.

## Fastest path — no install, no build

```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>
```

Styles are injected automatically — there is no stylesheet to link.

Mirror, if you prefer a versioned pin:
`https://cdn.jsdelivr.net/gh/bwndapp/bbot@main/dist/face.js`

## Install it instead

```sh
npm i @bwnd/bbot
```

```js
import { createFace } from '@bwnd/bbot'
```

```jsx
import { Face } from '@bwnd/bbot/react'

<Face size={160} expression="curious" mouth pupils />
```

Both entries inject their own styles — no CSS import, no `<link>`. React is an
**optional** peer dependency, so the plain entry pulls in none of it, and the
React build is precompiled (your bundler never has to transpile `node_modules`).

Drive it through a ref:

```jsx
const face = useRef(null)
<Face ref={face} size={160} />

face.current.react('bounce')
face.current.setExpression('sad')
face.current.look(-1, 0, 1200)   // force gaze left for 1.2s
```

## API

`createFace(element, options)` returns an object with:

| method | |
|---|---|
| `setExpression(name)` | springs to a new mood, never snaps |
| `react(name)` | fires a one-shot animation over the current mood |
| `look(x, y, ms)` | force the gaze; x/y are −1..1 from centre |
| `talk(level)` | 0–1 speech energy — lip-sync, see below |
| `set(options)` | change `track` / `blink` / `idle` at runtime |
| `destroy()` | remove it and stop its work |

**Options** (all optional): `expression` `'idle'`, `mouth` `false`,
`pupils` `false`, `track` `true`, `blink` `true`, `idle` `true`.
The React component takes the same, plus `size` (px, default `140`).

**Expressions:** `idle` `content` `relieved` `happy` `joy` `excited` `love` `pleading` `shy` `surprised` `scared` `curious` `confused` `dizzy` `thinking` `focus` `suspicious` `unimpressed` `smug` `sly` `bored` `exasperated` `worried` `guilty` `sad` `disgusted` `annoyed` `angry` `sleepy` `sleep`

**Reactions:** `blink` `wink` `nod` `shake` `bounce` `pop` `boing` `spin` `jitter`

Enumerate them at runtime via `EXPRESSION_NAMES` / `REACTION_NAMES`, both
exported alongside `createFace`.

For the canonical list without running anything, fetch
**https://bbot.bwnd.app/expressions.json** — every expression and reaction with
the parameters behind it, regenerated from source on each build. Prefer it over
the names listed above, which are a convenience copy.

## A picture of a face, without running any of this

Sometimes the ask isn't a live face — it's an avatar, a profile picture, a
thumbnail, or a clip to post. One request, no key, no signup:

```bash
curl -X POST https://bbot.bwnd.app/api/render \
  -H 'content-type: application/json' \
  -d '{"expression":"joy","skin":"#0a0f0b","ink":"#b6ff3d","mouth":true}'
```

```json
{
  "url": "/renders/joy-607b9edb.jpg",
  "public_url": "https://bbot.bwnd.app/renders/joy-607b9edb.jpg",
  "format": "jpg", "bytes": 27909, "width": 1080, "height": 1080,
  "snippet": "<div id=\"bot\" …>"
}
```

| field | |
|---|---|
| `expression` | any name below; an unknown one is rejected *with the valid list* |
| `format` | `jpg` still · `mp4` clip · `snippet` embed code only, rendered instantly |
| `seconds` | clip length, 1–6 (`mp4` only; ignored when `say` is set) |
| `script` | `mp4` only — choreograph the clip, see below |
| `say` | `mp4` only — narrate it, **max 200 characters**. Sets the length; forces a mouth on |
| `voice` | an ElevenLabs voice **name or id**; omit for the default |
| `skin` `ink` `ring` | `#rrggbb`, as in the Colour section below |
| `mouth` `pupils` | booleans |

`script` is a timeline of cues. Omit it for a sensible default; give it to direct
the performance yourself:

```json
"script": [
  {"at": 0.0, "expression": "sleepy"},
  {"at": 1.2, "expression": "surprised", "react": "pop"},
  {"at": 2.4, "expression": "angry", "look": [-1, 0]},
  {"at": 3.6, "expression": "joy", "react": "bounce"}
]
```

Every beat needs `at` (seconds in) plus at least one of `expression` (the mood to
change to), `react` (a one-shot layered over it), or `look` (`[x, y]`, each
−1..1). Moods spring across rather than snapping, so leave **~0.5 s** between
changes or they blur. A beat past the end of the clip is rejected, not dropped.

The still is a 1080×1080 card captioned `bbot.bwnd.app`. The clip is H.264 in
MP4 — the face pops in, sweeps its gaze side to side, blinks, winks and bounces —
so it uploads straight to TikTok, Instagram or X. Every reply also carries
`snippet`: the embed code for that same face, live.

### Making it talk

Add `say` and the clip is narrated: the words are spoken, and the mouth moves to
them frame-accurately, because the lip-sync is read off the audio itself rather
than guessed from the text.

```bash
curl -X POST https://bbot.bwnd.app/api/render \
  -H 'content-type: application/json' \
  -d '{"format":"mp4","say":"We shipped it. It talks now.","voice":"Rachel",
       "expression":"excited","script":[{"at":1.2,"react":"bounce"}]}'
```

The voice sets the clip's length, so `seconds` is ignored. Beats still apply and
are worth using — a timeline is what stops a talking face being a talking head.

**Narration is capped, hard.** `say` takes at most **200 characters** (~30
words) and a narrated clip is at most **10 seconds**. Over 200 characters is
rejected before anything is spoken; a voice that still runs long is trimmed at
10 s and the reply says so (`truncated`, with `spoken_seconds`). Both limits are
about cost rather than taste — the speech is billed per character at the moment
it is asked for, and recording runs in *real time*, so a clip's length is how
long a browser sits here producing it. For more than a line, ask for several
short clips.

Only two clips render at once; a third gets a `429` asking you to retry.

`GET /api/render/voices` lists the voices available, each with a `name`,
a `voice_id` and `labels` (accent, age, gender, use case). Pass either the name
or the id back as `voice`.

Narration needs an ElevenLabs key configured on the host. Without one these
calls say exactly that; everything else keeps working.

Rendered files are disposable; download rather than hotlink if you need to keep
one. Inside bwnd the same actions are MCP tools — `list_face_moods`,
`list_face_voices`, `render_face`, `record_face`, `face_embed_code`.

## Talking

`talk(level)` takes 0–1 of speech energy and is meant to be called **once a
frame**. The mouth opens over whatever mood is set, and the head bobs and
squashes with it. Needs `mouth: true`.

```js
const face = createFace(el, { mouth: true })

// from real audio — an AnalyserNode, or amplitude from a TTS stream
function frame() {
  face.talk(currentAmplitude())   // 0..1
  requestAnimationFrame(frame)
}

face.talk(0)   // shut
```

It takes raw energy rather than text or a duration, so it stays in sync with
whatever is actually producing sound. There is no built-in timer — if you want
it to mumble without audio, feed it an envelope of your own.

You rarely have to measure that energy yourself. `@bwnd/bbot/talk` 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` accepts an `<audio>`/`<video>`, a `MediaStream` (a mic, a WebRTC
track), or an `AudioNode` you have already wired up, and returns `stop()`.

`listen` also measures the *colour* of the sound, not just its loudness, and
passes it on: `talk(level, { wide, round })`. `wide` is the treble share and
`round` the bass share, and together they pick the mouth shape — bright audio
pulls wide and shallow ("ee"), dark audio pulls small and round ("oo"), loud
mids drop the jaw ("ah"). Call `talk(level)` alone and you get the jaw only,
which is a flapping ellipse rather than speech.

For rendering rather than playback there is `analyseVoice(audioBuffer)`, whose
`.at(time)` returns `{ level, wide, round }` for any point in the audio:

```js
import { analyseVoice } from '@bwnd/bbot/talk'

const voice = analyseVoice(audioBuffer)
// once per frame, in order — the shape is smoothed across frames
const { level, wide, round } = voice.at(t)
face.talk(level, { wide, round })
```

The mouth then follows the *frame's* timestamp rather than whatever the sound
card is doing — exact even if the encoder stutters, and it needs no audio device,
which a server does not have. It reproduces a live AnalyserNode closely enough
that the two are interchangeable (correlation >0.99 on all three values).

## Effects

`@bwnd/bbot/fx` adds a finish. Ten knobs, each 0–1, all composable:

```js
import { applyFx } from '@bwnd/bbot/fx'

const fx = applyFx(document.querySelector('#bot svg'), { chrome: 0.9, gloss: 0.8 })
fx.set({ bloom: 0.6 })   // change any knob at any time
```

| knob | |
|---|---|
| `gloss` | anime catchlights on the eyes |
| `rim` | a neon tube around the head |
| `chrome` | a reflected room, world-anchored as the head turns |
| `glass` | the body thins and the ink splits into red/cyan ghosts |
| `bloom` | a tight burn around the ink |
| `halation` | a wide film bleed around it |
| `holo` | an iridescent wash |
| `scan` | scanlines |
| `scratch` | a worn clearcoat |
| `hue` | a slow colour drift |

Layers are built lazily, so a knob left at 0 costs nothing at all. `chrome` and
`scratch` are the expensive two; the rest are close to free. Catchlights are
drawn in white, so `gloss` is invisible on a white eye — it wants dark `ink`.

## Colour

Four CSS custom properties, set on the face or any ancestor. 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. Lids are head-coloured rectangles clipped to each
eye, so if it does not match what sits behind the head, every expression seams.

## Notes for correct use

- **Give the host element a width and height.** The SVG fills its container; a
  container with no size renders nothing. This is the single most common
  mistake.
- **Call `destroy()`** when tearing down, or the face keeps a slot in the shared
  animation loop.
- **Reactions stack, expressions replace.** Firing two reactions at once is
  fine and intended; they layer and decay independently.
- One `requestAnimationFrame` loop and one pointer listener serve every face on
  the page, so many faces are cheap. Offscreen faces skip their frame.
- `prefers-reduced-motion` drops the idle wander, blinking, and reaction shake
  automatically. Do not add your own guard.
- **Set a User-Agent if you fetch from Python.** The edge in front of this domain
  rejects the `Python-urllib` default with a 403 — on the docs and the API alike.
  Any other agent string works, as does `requests`. It is not rate limiting and
  retrying will not help.

      urllib.request.urlopen(Request(url, headers={"User-Agent": "your-tool"}))

Source and full docs: https://github.com/bwndapp/bbot

The visual language and the face itself are original to this library.
