dotpals Docs

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.

StateMoodWhat the pal does
idleneutralBreathes, blinks and follows the cursor.
listeninglisteningLeans in with wide eyes, for while the user is typing.
thinkingthinkingLooks up and glances around, with a bubble of bouncing dots.
workingworkingA 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.
speakingspeakingIts mouth moves, for while tokens stream in.
waitingwaitingHops to get your attention, then keeps bouncing, with wide eyes and a ? bubble or your text (“Allow edit?”).
donehappyJumps with a burst of sparkles, happy eyes and a smile, then settles back to calm after about 2.4 seconds.
errorsadJitters, then looks sad, with × eyes.
sleepingsleepyEyes 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

AttributeValuesDefault
characterA built-in character or any registered name.blu
stateidle · listening · thinking · working · speaking · waiting · done · error · sleepingidle
moodAny mood above.neutral
sizeA number (pixels) or any CSS length.160px
colorAny CSS color.The character's color
idlebreathe · bounce · float · wobble · sway · nonebreathe
lookcursor · none (the eyes stay put)cursor
leannone: the body doesn't lean toward the cursor (the eyes still follow it).leans a little
staticBoolean: turns off the hover and click reactions.off
labelThe accessible name.The character's name
tinySet 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:

MemberReturns
DotPal.charactersEvery registered character name, including your own.
DotPal.actionsEvery registered action name.
DotPal.moods, DotPal.statesEvery mood and every state.
DotPal.emotesEvery 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. text appears 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 duration milliseconds (by default, longer for longer text, up to 6 seconds). duration: 0 keeps it until you call say('').
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 ms milliseconds, 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: success and error (moods, default happy and sad), revert (ms before the previous mood comes back, default 2200), thinkingText, successText and errorText.
watch(target) → stop()
A form companion. target is 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: x and y go from -1 to 1, and lookAt(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 the done state): happy arcs,
  • sleepy (and sleeping): closed,
  • surprised and waiting: wide,
  • the error state: ×.

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:

EmoteFace
happyHappy eyes, a smile and blushing cheeks.
loveHeart eyes, a smile and blush, and a few hearts float up.
starSparkle-star eyes and a smile, with sparkles.
wideWide eyes and an “o” mouth.
closedClosed eyes.
dizzySpinning spiral eyes and a wobbly mouth.
oops× eyes and a frown.
heySqueezed-shut “> <” eyes and an “o” mouth.
sweatIts 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-poke event.
  • 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,
  • working or thinking for 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

IdPalOn click
bluBlu, a blue cloud in a beretjump
hopHop, a green frogjump
sunnySunny, a yellow gumdrop in glasseswiggle
loviLovi, a pink heart in sunglasseslove
museMuse, a violet flame with sparklesspin
grokGrok, a slate bot with a glowing visornod
novaNova, an orange bot with a light-bulb antennajump
byteByte, a teal cat with pixel eyeswiggle

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 optionally defs, accessories and face. body is the url(#…) of the shaded body gradient, which follows the color attribute; 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, and class="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, true for 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.
    While expression eyes show, the parts marked class="dp-eyes" hide, or the .dp-blink parts if nothing is marked. Mark with dp-eyes when only part of an eye should hide, like the shine on a pair of sunglasses.
  • fur: false draws 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: label is the name, color #888888, tap jump, look 5, mouth [100, 172], cheek 36.
  • Registering a name again replaces it. Pals already on the page keep the old drawing until you call DotPal.refresh(name) or set their character again.

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>
FieldValuesDefault
nameAny text, up to 24 characters.My pal
shaperound (Round) · square (Boxy) · blob (Fluffy) · tall (Pointy) · heart (Heart) · bean (Frog)round
eyesdots (Dots) · round (Button) · googly · pixel · visor · shadesgoogly
topnone · ears (Cat ears) · horns · antenna · sprout · sparkle · bow · crown · beretsprout
colorA hex color, #rrggbb.#ff7a2f
furtrue 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 (or null if it isn't an object). Specs are plain JSON, so you can store them.
  • buildCharacter(spec) returns the character definition without registering it.
  • CUSTOM_OPTIONS lists every shape, eyes and top value with its label, and DEFAULT_CUSTOM is the default spec.

Actions

One-shot moves for play(). Each character plays its own on click (see Characters).

ActionWhat it looks like
jumpA squash, a jump and a springy landing.
squishA quick squash and stretch.
wiggleA side-to-side wiggle.
shakeShakes its head.
nodTwo nods.
spinOne turn around.
loveA happy pulse, with hearts.
hopA quick little hop that lands low and springs back (“hey, over here”). The waiting state starts with it.
jitterA fast side-to-side shudder that dies down (“something went wrong”). The error state starts with it.
helloPops up from below the bottom edge, settles with a squash, then one small hop. greet() uses it.
dizzyTwo 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 reads e.data or e.detail. JSON strings are parsed. An error event sets the error state.
  • For async iterables, an exception sets the error state 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

SourceEventState
Anthropic Messages API (streaming)message_startthinking
content_block_start with a thinking blockthinking
content_block_start with a tool_use blockworking, with the tool name
content_block_start with a text blockspeaking
message_delta stopping for tool_useworking
message_stopdone
Claude Agent SDKsystem / initthinking
assistant message with a tool_useworking, with the tool name
assistant message with textspeaking
user message with a tool_resultthinking
resultdone, 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 callworking, with its name
response.output_text.*speaking
response.completeddone
response.failed, response.incompleteerror
Claude Code hooks{ hook_event_name: … }: prompts, tool use, permission requests, notifications, subagents, compaction, stopthe 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, svg and bubble.
  • The color attribute wins over --dp-color, which wins over the character's own color.
  • --dp-glow is the color of the soft light behind the pal, which follows its state. Set it to transparent to turn the glow off.
  • The tiny attribute 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 as DotPal, with typed dotpal-* events.
  • SSR: importing on the server is safe. The element renders once it reaches the browser.

Accessibility

  • Each pal has role="img" and an aria-label that 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.

Edit this page on GitHub