Reference
The <dot-pal> web component
The pal is a dependency-free Web Component that you can drop into your own chat UI, IDE panel or dashboard. Send it your agent's state and it shows thinking dots while the model reasons, a progress bubble while tools run, a talking mouth while text streams, a question bubble when it needs approval, a jump when it's done and a frown when something fails.
Install
From a CDN, with no build step:
<script type="module" src="https://unpkg.com/dotpals"></script>
Or from npm:
npm install dotpals
import 'dotpals'; // registers <dot-pal>
import { connectAgent, registerCustom } from 'dotpals';
Importing the main entry registers the <dot-pal> element. The package also exports dotpals/element, dotpals/characters, dotpals/custom, dotpals/actions and dotpals/agent on their own; those don't register the element. To use another tag name, call define('my-pal'). Types are included.
Quick start
<script type="module" src="https://unpkg.com/dotpals"></script>
<dot-pal id="agent" character="grok"></dot-pal>
<script type="module">
const pal = document.getElementById('agent');
pal.setState('thinking');
pal.setState('working', { text: 'Running tests…' });
pal.setState('done', { text: 'All green!' });
</script>
States and moods
A state is where your agent is in its lifecycle. Each state shows a mood. Use mood directly if you aren't driving an agent.
| State | Mood | What the pal does |
|---|---|---|
idle | neutral | Breathes, blinks and follows the cursor. |
listening | listening | Leans in with wide eyes, for while the user is typing. |
thinking | thinking | Looks up and glances around, with a bubble of bouncing dots. |
working | working | A busy bob, eyes scanning down, with a progress bar or your text. After 90 seconds of work (or thinking), it breaks a sweat now and then. |
speaking | speaking | Its mouth moves, for while tokens stream in. |
waiting | waiting | Hops to get your attention, then keeps bouncing, with wide eyes and a ? bubble or your text (“Allow edit?”). |
done | happy | Jumps with a burst of sparkles, happy eyes and a smile, then settles back to calm after about 2.4 seconds. |
error | sad | Jitters, then looks sad, with × eyes. |
sleeping | sleepy | Eyes closed, with floating zs. |
A soft glow behind the pal shows the state too: its own color while it works or thinks, amber (pulsing) while it waits, red on an error and green when it's done. Moods, idle loops and actions blend into each other instead of snapping. Faces and moves are described in Faces and reactions.
The moods are neutral, happy, sad, surprised, thinking, sleepy, shy, listening, working, speaking and waiting. A state's text stays in the bubble while the pal is thinking, working, waiting, listening or sleeping, and disappears after a moment for done, error, speaking and idle.
Three ways to set a state:
<dot-pal character="muse" state="thinking"></dot-pal>
pal.state = 'speaking';
pal.setState('working', { text: 'web_search' });
Attributes
| Attribute | Values | Default |
|---|---|---|
character | A built-in character or any registered name. | blu |
state | idle · listening · thinking · working · speaking · waiting · done · error · sleeping | idle |
mood | Any mood above. | neutral |
size | A number (pixels) or any CSS length. | 160px |
color | Any CSS color. | The character's color |
idle | breathe · bounce · float · wobble · sway · none | breathe |
look | cursor · none (the eyes stay put) | cursor |
lean | none: the body doesn't lean toward the cursor (the eyes still follow it). | leans a little |
static | Boolean: turns off the hover and click reactions. | off |
label | The accessible name. | The character's name |
tiny | Set by the pal itself while it's drawn smaller than 48 px, so you can style small pals. Don't set it yourself. | – |
Under 48 px a pal becomes an avatar: no fur, bigger eyes and mouth, deeper breathing and more glancing around, so it still reads as alive at that size. Tiny pals don't glow, lean or throw particles.
Properties
character, color, idle, mood, state, look and static mirror their attributes. Setting state is the same as setState(value). Setting mood cancels any temporary mood from flash(), during() or watch(). tiny is read-only: true while the pal is drawn smaller than 48 px.
Static members on DotPal:
| Member | Returns |
|---|---|
DotPal.characters | Every registered character name, including your own. |
DotPal.actions | Every registered action name. |
DotPal.moods, DotPal.states | Every mood and every state. |
DotPal.emotes | Every emote name for emote(). |
DotPal.refresh(name) | Redraws every pal using character name, after you've changed it with registerCharacter() or registerCustom(). |
DotPal.pointAt(x, y) | Tells every pal where the cursor is, in viewport CSS px, so their eyes follow it. Use it when you track the cursor outside the page, like a desktop app watching the whole screen. Points outside the page work too. |
Methods
setState(state, { text? })- Show what your agent is doing.
textappears in the bubble, such as the tool being run or the question being asked. Calling it again with the same state just updates the text. say(text, { duration? })- Show a speech bubble. It hides after
durationmilliseconds (by default, longer for longer text, up to 6 seconds).duration: 0keeps it until you callsay(''). play(action)→Promise- Play a one-shot action: one of the built-in actions (
jump,squish,wiggle,shake,nod,spin,love,hop,jitter,hello,dizzy) or your own. Resolves when it finishes or is interrupted. A new action blends from wherever the pal is, so it never snaps. emote(name, ms = 1600)→Promise- Show a face for
msmilliseconds, such as'love'(heart eyes) or'oops'(× eyes). Resolves when it ends. A new emote replaces one that's showing. greet()→Promise- Say hello: rise up from below the bottom edge, squint happily, hop and blink twice. Resolves when it's done, after about 2 seconds. Called before the pal is on the page, it waits until it is.
burst(kind = 'sparkle', count = 6)- Throw a few particles:
'heart','sparkle','star','sweat'or'z'(drawn as SVG, so they look the same everywhere), or any text or emoji. Tiny pals and reduced motion skip them. flash(mood, ms = 2000)- Show a mood for a moment, then go back to the previous one.
during(task, options?)→Promise- Loading feedback for any promise (or a function that returns one): thinking while it runs, then happy and a jump on success, or sad and a jitter on failure. Returns the task's result, or throws its error. Options:
successanderror(moods, defaulthappyandsad),revert(ms before the previous mood comes back, default 2200),thinkingText,successTextanderrorText. watch(target)→stop()- A form companion.
targetis a form, any container, or a selector. The pal follows the text caret, covers its eyes on password fields, frowns at invalid fields and cheers on submit. Returns a function that stops watching. blink()- Blink once (skipped while the eyes are closed).
lookAt(x, y)- Point the eyes:
xandygo from -1 to 1, andlookAt(0, 0)looks straight ahead.
// Loading feedback for any promise
const data = await pal.during(fetch('/api/save'), { successText: 'Saved!' });
// A form companion
const stop = pal.watch('#login-form');
pal.say('Hi! Ask me anything.');
pal.flash('surprised', 1500);
await pal.play('love');
// Say hello when it first appears, then show a face and some sparkles
await pal.greet();
pal.emote('star', 1200);
pal.burst('sparkle', 8);
DOM events
The element fires these events. They bubble and cross shadow roots.
pal.addEventListener('dotpal-state', (e) => e.detail); // { state, text }
pal.addEventListener('dotpal-mood', (e) => e.detail); // { mood }
pal.addEventListener('dotpal-action', (e) => e.detail); // { action }
pal.addEventListener('dotpal-poke', (e) => e.detail); // { count }
dotpal-poke fires on every click (not on static pals). count is how many quick clicks in a row, within 1.2 seconds; the third one makes the pal dizzy.
Faces and reactions
Expression eyes
Pals swap in expression eyes to match their mood: happy arcs, closed lids, wide eyes, × (“oops”), spinning spirals, hearts and sparkle-stars. The swap happens behind a quick blink. Moods pick them for you:
happy(and thedonestate): happy arcs,sleepy(andsleeping): closed,surprisedandwaiting: wide,- the
errorstate: ×.
Every built-in character and every custom pal has them. Your own characters get them when they say where their eyes are, with eyes; without it, their own eyes just squint for moods, as before.
Emotes
pal.emote(name, ms) shows a face for a moment, on top of the mood:
| Emote | Face |
|---|---|
happy | Happy eyes, a smile and blushing cheeks. |
love | Heart eyes, a smile and blush, and a few hearts float up. |
star | Sparkle-star eyes and a smile, with sparkles. |
wide | Wide eyes and an “o” mouth. |
closed | Closed eyes. |
dizzy | Spinning spiral eyes and a wobbly mouth. |
oops | × eyes and a frown. |
hey | Squeezed-shut “> <” eyes and an “o” mouth. |
sweat | Its own eyes, a flat mouth and a couple of sweat drops. |
DotPal.emotes lists them all.
Reactions
Pals react to you by themselves (unless they're static):
- Hover: a blink, a little squish and slightly bigger eyes.
- Rest the mouse on it for 2 seconds: heart eyes (at most once every 20 seconds).
- Click: its tap action and a “hey” face, and a
dotpal-pokeevent. - Three quick clicks: it gets dizzy. Two fast spins, spiral eyes and a woozy sway for about 3 seconds, then a happy face.
And to state changes, as it enters each one:
waiting: a hop, then its loop bounces until you answer,error: a jitter,done: a jump and a burst of sparkles,workingorthinkingfor more than 90 seconds: a sweat drop now and then.
If a greet() is still rising when the state changes, the entry move waits for it to finish.
Reduced motion
With prefers-reduced-motion: reduce, the pal keeps its faces, blinks and state changes but skips the big moves: idle loops, eye wandering, leaning, particles and floating zs, the state entry moves, the hover squish, the click action, the dizzy spin and the rise in greet(). Actions you start yourself with play() still run.
Characters
| Id | Pal | On click |
|---|---|---|
blu | Blu, a blue cloud in a beret | jump |
hop | Hop, a green frog | jump |
sunny | Sunny, a yellow gumdrop in glasses | wiggle |
lovi | Lovi, a pink heart in sunglasses | love |
muse | Muse, a violet flame with sparkles | spin |
grok | Grok, a slate bot with a glowing visor | nod |
nova | Nova, an orange bot with a light-bulb antenna | jump |
byte | Byte, a teal cat with pixel eyes | wiggle |
Add your own character
Characters are plain SVG, drawn in a 200×200 viewBox. They sit on the bottom edge and peek up over it.
import { registerCharacter } from 'dotpals';
registerCharacter('ghost', {
label: 'Ghost',
color: '#e8e8ff',
tap: 'spin', // the action played on click
look: 6, // how far the eyes follow the cursor
mouth: [100, 170], // where mood mouths are drawn
cheek: 34, // blush distance from the mouth
eyes: { at: [[80, 130], [120, 130]], r: 9 }, // where expression eyes go
render: ({ body }) => ({
body: `<rect fill="${body}" x="30" y="50" width="140" height="220" rx="70"/>`,
face: `
<g class="dp-look">
<g class="dp-blink"><circle cx="80" cy="130" r="9"/></g>
<g class="dp-blink"><circle cx="120" cy="130" r="9"/></g>
</g>`,
}),
});
render({ id, body, fur })returns SVG strings:body(required), and optionallydefs,accessoriesandface.bodyis theurl(#…)of the shaded body gradient, which follows thecolorattribute;id(name)makes ids unique to each pal, for your own gradients.- The body is automatically covered in fur and shaded.
- Put
class="dp-blink"on each eye so it blinks and reacts to moods, andclass="dp-look"on anything that should follow the cursor. eyes(optional) says where the two eyes are, so the pal can swap in expression eyes:at: the centres of the left and right eye,[[x, y], [x, y]], in viewBox units,r: roughly how big an eye is (its radius),ink: the color of the expression eyes (default: near-black),glow: a glow around them,truefor light blue or any CSS color (good on dark visors),own: expressions your own eyes already do well, such as['wide']for big googly eyes. Those aren't swapped.
class="dp-eyes"hide, or the.dp-blinkparts if nothing is marked. Mark withdp-eyeswhen only part of an eye should hide, like the shine on a pair of sunglasses.fur: falsedraws the body smooth instead of furry.- Let bodies run below
y=200, so a jump reveals more body instead of a flat edge. - Defaults for anything you leave out:
labelis the name,color#888888,tapjump,look5,mouth[100, 172],cheek36. - Registering a name again replaces it. Pals already on the page keep the old drawing until you call
DotPal.refresh(name)or set theircharacteragain.
Custom pals
The same pal builder as the dashboard's Make your own pal: pick a body, eyes, something on top, a color and a name.
import { registerCustom } from 'dotpals';
registerCustom({ name: 'Pip', shape: 'bean', eyes: 'googly', top: 'crown', color: '#16c6ae', fur: true });
// then: <dot-pal character="custom"></dot-pal>
| Field | Values | Default |
|---|---|---|
name | Any text, up to 24 characters. | My pal |
shape | round (Round) · square (Boxy) · blob (Fluffy) · tall (Pointy) · heart (Heart) · bean (Frog) | round |
eyes | dots (Dots) · round (Button) · googly · pixel · visor · shades | googly |
top | none · ears (Cat ears) · horns · antenna · sprout · sparkle · bow · crown · beret | sprout |
color | A hex color, #rrggbb. | #ff7a2f |
fur | true for fur, false for smooth. | true |
registerCustom(spec, name = 'custom')registers the pal and returns its name. Pass a name to keep several.cleanCustom(anything)returns a valid spec, with unknown values replaced by the defaults (ornullif it isn't an object). Specs are plain JSON, so you can store them.buildCharacter(spec)returns the character definition without registering it.CUSTOM_OPTIONSlists everyshape,eyesandtopvalue with its label, andDEFAULT_CUSTOMis the default spec.
Actions
One-shot moves for play(). Each character plays its own on click (see Characters).
| Action | What it looks like |
|---|---|
jump | A squash, a jump and a springy landing. |
squish | A quick squash and stretch. |
wiggle | A side-to-side wiggle. |
shake | Shakes its head. |
nod | Two nods. |
spin | One turn around. |
love | A happy pulse, with hearts. |
hop | A quick little hop that lands low and springs back (“hey, over here”). The waiting state starts with it. |
jitter | A fast side-to-side shudder that dies down (“something went wrong”). The error state starts with it. |
hello | Pops up from below the bottom edge, settles with a squash, then one small hop. greet() uses it. |
dizzy | Two fast turns that run out of steam. |
Add your own. It runs with the Web Animations API on the pal's body, on top of its idle loop:
import { registerAction } from 'dotpals';
registerAction('pop', {
keyframes: [{ transform: 'scale(1)' }, { transform: 'scale(1.2)' }, { transform: 'scale(1)' }],
duration: 400, // ms, default 600
easing: 'ease-out', // the default
particles: 'sparkle', // optional: what bursts out
});
pal.play('pop');
particles is a built-in shape (heart, sparkle, star, sweat or z, drawn as SVG so they look the same on every system) or any text or emoji. The glyphs older actions used (♥, ✦, ★) are drawn as the matching shape.
Connect an agent
Stream events straight in
connectAgent(pal, source, options?) accepts an EventSource, a WebSocket, any EventTarget, or an async iterable such as an SDK stream. It maps each event to a state and returns a function that disconnects.
import { connectAgent } from 'dotpals';
// Server-Sent Events from your backend
connectAgent(pal, new EventSource('/agent/events'));
// A WebSocket
connectAgent(pal, new WebSocket('wss://my-harness/agent'));
// An SDK stream (async iterable), such as the Anthropic TypeScript SDK
const stream = client.messages.stream({ model, max_tokens, messages, tools });
connectAgent(pal, stream);
- For event targets it listens for
message(change it with{ event: 'name' }) and readse.dataore.detail. JSON strings are parsed. Anerrorevent sets theerrorstate. - For async iterables, an exception sets the
errorstate with its message.
From your own event loop
import { agentHandler } from 'dotpals';
const onEvent = agentHandler(pal);
for await (const event of myAgent.run(prompt)) {
onEvent(event); // unknown events are ignored
render(event);
}
agentHandler(pal, options?) returns a function that applies one event and returns the { state, text } it chose, or null.
Custom mapping
import { connectAgent, toAgentState } from 'dotpals';
connectAgent(pal, source, {
map: (e) => {
if (e.kind === 'plan') return { state: 'thinking', text: 'Planning…' };
if (e.kind === 'shell') return { state: 'working', text: `$ ${e.cmd}` };
return toAgentState(e); // fall back to the built-in mapping
},
});
toAgentState(event) is the built-in mapping on its own: it returns { state, text? } or null.
Events it understands
| Source | Event | State |
|---|---|---|
| Anthropic Messages API (streaming) | message_start | thinking |
content_block_start with a thinking block | thinking | |
content_block_start with a tool_use block | working, with the tool name | |
content_block_start with a text block | speaking | |
message_delta stopping for tool_use | working | |
message_stop | done | |
| Claude Agent SDK | system / init | thinking |
assistant message with a tool_use | working, with the tool name | |
assistant message with text | speaking | |
user message with a tool_result | thinking | |
result | done, or error if it failed | |
| OpenAI Responses API (streaming) | response.created, response.in_progress, response.reasoning… | thinking |
response.output_item.added with a function or tool call | working, with its name | |
response.output_text.* | speaking | |
response.completed | done | |
response.failed, response.incomplete | error | |
| Claude Code hooks | { hook_event_name: … }: prompts, tool use, permission requests, notifications, subagents, compaction, stop | the matching state |
| Generic | { type: 'start' | 'thinking' | 'tool_call' | 'tool_result' | 'text_delta' | 'permission_request' | 'user_typing' | 'done' | 'error' | 'sleep' | … } | the matching state, with text, message, name or tool as the bubble |
| Your own | { state: 'working', text: 'Deploying…' } | exactly what you send |
Plain strings work too: 'thinking', or a JSON string of any of the above.
A runnable example: npm run example:agent in a checkout opens a Server-Sent Events harness on http://localhost:5174 (see examples/sse-harness). Replace its fake runAgent with your real loop.
Styling
dot-pal {
--dp-size: 200px; /* same as the size attribute */
--dp-color: hotpink; /* same as the color attribute */
--dp-glow: transparent; /* turn off the glow behind the pal */
}
dot-pal::part(bubble) { background: #111; color: #fff; }
dot-pal::part(svg) { filter: drop-shadow(0 10px 20px rgb(0 0 0 / .4)); }
dot-pal[tiny]::part(bubble) { display: none; } /* no bubbles on small avatars */
- The parts you can style are
root,idle,actor,svgandbubble. - The
colorattribute wins over--dp-color, which wins over the character's own color. --dp-glowis the color of the soft light behind the pal, which follows its state. Set it totransparentto turn the glow off.- The
tinyattribute is there while the pal is smaller than 48 px, for styles that only small pals need.
Frameworks
- Plain HTML, Electron and VS Code webviews: import it once and use the tag.
- React 19+:
import 'dotpals', then<dot-pal character="grok" state={agentState} />. - Vue: set
compilerOptions.isCustomElement = (tag) => tag === 'dot-pal'. - Svelte: import it once and use the tag.
- TypeScript: types are included, and
document.querySelector('dot-pal')is typed asDotPal, with typeddotpal-*events. - SSR: importing on the server is safe. The element renders once it reaches the browser.
Accessibility
- Each pal has
role="img"and anaria-labelthat includes its mood, such as “Grok (working)”. - With
prefers-reduced-motion: reduce, the pal keeps its faces and blinks and still shows every state change, but skips the big moves (see Reduced motion). - Speech bubbles are decorative. Keep your own visible status text for screen-reader users.